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/new_access_token_app.vue2
-rw-r--r--app/assets/javascripts/activities.js2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/actions.js2
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue41
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue109
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/app.vue63
-rw-r--r--app/assets/javascripts/admin/abuse_reports/constants.js81
-rw-r--r--app/assets/javascripts/admin/abuse_reports/index.js31
-rw-r--r--app/assets/javascripts/admin/abuse_reports/utils.js6
-rw-r--r--app/assets/javascripts/admin/application_settings/network_outbound.js28
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/base.vue2
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue39
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/constants.js1
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue2
-rw-r--r--app/assets/javascripts/admin/statistics_panel/store/actions.js2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/activate.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/reject.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue2
-rw-r--r--app/assets/javascripts/airflow/dags/components/dags.vue111
-rw-r--r--app/assets/javascripts/alert.js (renamed from app/assets/javascripts/flash.js)2
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_form.vue35
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue2
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js2
-rw-r--r--app/assets/javascripts/alerts_settings/utils/cache_updates.js2
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/base.vue30
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue17
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue6
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/constants.js8
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/actions.js23
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/getters.js4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/mutations.js7
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/state.js6
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/utils.js26
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue5
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue20
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue30
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js19
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue2
-rw-r--r--app/assets/javascripts/api.js2
-rw-r--r--app/assets/javascripts/api/analytics_api.js58
-rw-r--r--app/assets/javascripts/api/user_api.js2
-rw-r--r--app/assets/javascripts/artifacts/components/artifact_row.vue24
-rw-r--r--app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue182
-rw-r--r--app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue11
-rw-r--r--app/assets/javascripts/artifacts/components/job_artifacts_table.vue83
-rw-r--r--app/assets/javascripts/artifacts/components/job_checkbox.vue52
-rw-r--r--app/assets/javascripts/artifacts/constants.js39
-rw-r--r--app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql7
-rw-r--r--app/assets/javascripts/artifacts/index.js10
-rw-r--r--app/assets/javascripts/authentication/mount_2fa.js26
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue6
-rw-r--r--app/assets/javascripts/authentication/u2f/authenticate.js106
-rw-r--r--app/assets/javascripts/authentication/u2f/error.js26
-rw-r--r--app/assets/javascripts/authentication/u2f/index.js17
-rw-r--r--app/assets/javascripts/authentication/u2f/register.js102
-rw-r--r--app/assets/javascripts/authentication/u2f/util.js40
-rw-r--r--app/assets/javascripts/authentication/webauthn/authenticate.js3
-rw-r--r--app/assets/javascripts/authentication/webauthn/components/registration.vue226
-rw-r--r--app/assets/javascripts/authentication/webauthn/constants.js46
-rw-r--r--app/assets/javascripts/authentication/webauthn/error.js7
-rw-r--r--app/assets/javascripts/authentication/webauthn/index.js18
-rw-r--r--app/assets/javascripts/authentication/webauthn/register.js3
-rw-r--r--app/assets/javascripts/authentication/webauthn/registration.js22
-rw-r--r--app/assets/javascripts/authentication/webauthn/util.js8
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue2
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue4
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue13
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue22
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue2
-rw-r--r--app/assets/javascripts/batch_comments/index.js8
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js4
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js2
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js3
-rw-r--r--app/assets/javascripts/behaviors/copy_code.js2
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js4
-rw-r--r--app/assets/javascripts/behaviors/date_picker.js5
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_json_table.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_observability.js30
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/schema.js2
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts.js6
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js5
-rw-r--r--app/assets/javascripts/blame/blame_redirect.js2
-rw-r--r--app/assets/javascripts/blame/streaming/index.js56
-rw-r--r--app/assets/javascripts/blob/csv/index.js1
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js2
-rw-r--r--app/assets/javascripts/blob/notebook/notebook_viewer.vue5
-rw-r--r--app/assets/javascripts/blob/viewer/index.js2
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js2
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js4
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue116
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue101
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue28
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue9
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue24
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue40
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue22
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue144
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue44
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue37
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue9
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue22
-rw-r--r--app/assets/javascripts/boards/constants.js27
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js7
-rw-r--r--app/assets/javascripts/boards/issue_board_filters.js21
-rw-r--r--app/assets/javascripts/boards/stores/actions.js27
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js11
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js2
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue8
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue74
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue43
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js5
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql11
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql11
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql10
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/settings.js49
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js9
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue11
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue109
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue39
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue89
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js36
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue109
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js22
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/event_hub.js5
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/index.js8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue21
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/index.js12
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/mutations.js10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/store/state.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue22
-rw-r--r--app/assets/javascripts/ci/pipeline_new/index.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue4
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue3
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue9
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue5
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue14
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql6
-rw-r--r--app/assets/javascripts/ci/reports/constants.js14
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue37
-rw-r--r--app/assets/javascripts/ci/runner/admin_register_runner/admin_register_runner_app.vue69
-rw-r--r--app/assets/javascripts/ci/runner/admin_register_runner/index.js36
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue2
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/cli_command.vue42
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue135
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue241
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh12
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh11
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps113
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/utils.js109
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_create_form.vue71
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue4
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_button.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_projects.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_update_form.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue2
-rw-r--r--app/assets/javascripts/ci/runner/constants.js62
-rw-r--r--app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql9
-rw-r--r--app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql8
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue2
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue2
-rw-r--r--app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js2
-rw-r--r--app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/revoke_token_button.vue11
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js2
-rw-r--r--app/assets/javascripts/clusters/constants.js16
-rw-r--r--app/assets/javascripts/clusters_list/components/delete_agent_button.vue11
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js2
-rw-r--r--app/assets/javascripts/commit/components/signature_badge.vue94
-rw-r--r--app/assets/javascripts/commit/components/x509_certificate_details.vue45
-rw-r--r--app/assets/javascripts/commit/constants.js104
-rw-r--r--app/assets/javascripts/commit_merge_requests.js2
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue35
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue86
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue168
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue10
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue2
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js1
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/color_chip.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/drawio_diagram.js41
-rw-r--r--app/assets/javascripts/content_editor/extensions/external_keydown_handler.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js2
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js8
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js7
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js2
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js5
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js38
-rw-r--r--app/assets/javascripts/contextual_sidebar.js3
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue42
-rw-r--r--app/assets/javascripts/contributors/stores/actions.js2
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue16
-rw-r--r--app/assets/javascripts/deploy_freeze/store/actions.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue2
-rw-r--r--app/assets/javascripts/deploy_keys/components/confirm_modal.vue4
-rw-r--r--app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue9
-rw-r--r--app/assets/javascripts/deploy_tokens/deploy_token_translations.js5
-rw-r--r--app/assets/javascripts/deprecated_notes.js59
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue139
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue91
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue112
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue25
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue3
-rw-r--r--app/assets/javascripts/design_management/constants.js5
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql8
-rw-r--r--app/assets/javascripts/design_management/index.js10
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js2
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue81
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js2
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js8
-rw-r--r--app/assets/javascripts/diff.js2
-rw-r--r--app/assets/javascripts/diffs/components/app.vue36
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue5
-rw-r--r--app/assets/javascripts/diffs/components/file_row_stats.vue2
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue107
-rw-r--r--app/assets/javascripts/diffs/constants.js8
-rw-r--r--app/assets/javascripts/diffs/index.js17
-rw-r--r--app/assets/javascripts/diffs/store/actions.js36
-rw-r--r--app/assets/javascripts/diffs/store/getters.js8
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js15
-rw-r--r--app/assets/javascripts/diffs/store/utils.js43
-rw-r--r--app/assets/javascripts/diffs/utils/tree_worker_utils.js5
-rw-r--r--app/assets/javascripts/diffs/workers/tree_worker.js19
-rw-r--r--app/assets/javascripts/drawio/constants.js15
-rw-r--r--app/assets/javascripts/drawio/content_editor_facade.js80
-rw-r--r--app/assets/javascripts/drawio/drawio_editor.js277
-rw-r--r--app/assets/javascripts/drawio/markdown_field_editor_facade.js72
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar.vue43
-rw-r--r--app/assets/javascripts/editor/constants.js26
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js45
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js29
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js6
-rw-r--r--app/assets/javascripts/editor/schema/ci.json14
-rw-r--r--app/assets/javascripts/editor/utils.js9
-rw-r--r--app/assets/javascripts/emoji/components/emoji_group.vue5
-rw-r--r--app/assets/javascripts/entrypoints/super_sidebar.js5
-rw-r--r--app/assets/javascripts/environments/components/canary_update_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue4
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue2
-rw-r--r--app/assets/javascripts/environments/components/edit_environment.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue2
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_agent_info.vue101
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_overview.vue73
-rw-r--r--app/assets/javascripts/environments/components/new_environment.vue2
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue30
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue2
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql19
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue2
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/details/actions.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js2
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js2
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue22
-rw-r--r--app/assets/javascripts/feature_flags/components/environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/feature_flags/constants.js8
-rw-r--r--app/assets/javascripts/feature_flags/store/edit/actions.js2
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js2
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js36
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js5
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js5
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js2
-rw-r--r--app/assets/javascripts/gpg_badges.js2
-rw-r--r--app/assets/javascripts/grafana_integration/store/actions.js4
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js1
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json3
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql9
-rw-r--r--app/assets/javascripts/group.js2
-rw-r--r--app/assets/javascripts/groups/components/app.vue4
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue2
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue6
-rw-r--r--app/assets/javascripts/groups/constants.js1
-rw-r--r--app/assets/javascripts/groups/settings/components/access_dropdown.vue2
-rw-r--r--app/assets/javascripts/groups/store/utils.js6
-rw-r--r--app/assets/javascripts/header.js2
-rw-r--r--app/assets/javascripts/header_search/components/app.vue60
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue14
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue6
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue6
-rw-r--r--app/assets/javascripts/header_search/constants.js48
-rw-r--r--app/assets/javascripts/header_search/store/getters.js20
-rw-r--r--app/assets/javascripts/ide/components/ide.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue6
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue26
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/stores/actions.js2
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js2
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js7
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/constants.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/state.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js2
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/import_entities/components/group_dropdown.vue2
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue25
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue22
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js1
-rw-r--r--app/assets/javascripts/import_entities/import_groups/services/status_poller.js2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js5
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/actions.js10
-rw-r--r--app/assets/javascripts/incidents/constants.js1
-rw-r--r--app/assets/javascripts/incidents_settings/incidents_settings_service.js2
-rw-r--r--app/assets/javascripts/init_deprecated_notes.js4
-rw-r--r--app/assets/javascripts/integrations/constants.js6
-rw-r--r--app/assets/javascripts/integrations/edit/components/confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_forms/section.vue8
-rw-r--r--app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue73
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/google_play.vue75
-rw-r--r--app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue143
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_table.vue14
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue7
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue50
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue41
-rw-r--r--app/assets/javascripts/invite_members/constants.js5
-rw-r--r--app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js2
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue13
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue4
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue8
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue72
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue11
-rw-r--r--app/assets/javascripts/issuable/constants.js10
-rw-r--r--app/assets/javascripts/issuable/issuable_bulk_update_actions.js2
-rw-r--r--app/assets/javascripts/issuable/issuable_label_selector.js11
-rw-r--r--app/assets/javascripts/issuable/popover/components/mr_popover.vue24
-rw-r--r--app/assets/javascripts/issuable/popover/constants.js13
-rw-r--r--app/assets/javascripts/issues/constants.js29
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js123
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue41
-rw-r--r--app/assets/javascripts/issues/index.js8
-rw-r--r--app/assets/javascripts/issues/issue.js2
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue41
-rw-r--r--app/assets/javascripts/issues/list/constants.js1
-rw-r--r--app/assets/javascripts/issues/list/utils.js40
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js2
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue32
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/actions.js2
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue40
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue162
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue12
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue1
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue16
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue41
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue24
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/router.js20
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue6
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js2
-rw-r--r--app/assets/javascripts/issues/show/components/locked_warning.vue12
-rw-r--r--app/assets/javascripts/issues/show/components/task_list_item_actions.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue28
-rw-r--r--app/assets/javascripts/issues/show/constants.js1
-rw-r--r--app/assets/javascripts/issues/show/index.js15
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql6
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql11
-rw-r--r--app/assets/javascripts/jobs/components/job/job_log_controllers.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/manual_variables_form.vue148
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue16
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue2
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js1
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/cache_config.js60
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql8
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue46
-rw-r--r--app/assets/javascripts/jobs/constants.js2
-rw-r--r--app/assets/javascripts/jobs/store/actions.js4
-rw-r--r--app/assets/javascripts/labels/components/promote_label_modal.vue4
-rw-r--r--app/assets/javascripts/labels/create_label_dropdown.js22
-rw-r--r--app/assets/javascripts/labels/group_label_subscription.js5
-rw-r--r--app/assets/javascripts/labels/label_manager.js2
-rw-r--r--app/assets/javascripts/labels/labels.js12
-rw-r--r--app/assets/javascripts/labels/labels_select.js2
-rw-r--r--app/assets/javascripts/labels/project_label_subscription.js5
-rw-r--r--app/assets/javascripts/layout_nav.js9
-rw-r--r--app/assets/javascripts/lib/utils/constants.js5
-rw-r--r--app/assets/javascripts/lib/utils/error_message.js20
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js7
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js49
-rw-r--r--app/assets/javascripts/lib/utils/ref_validator.js145
-rw-r--r--app/assets/javascripts/lib/utils/resize_observer.js10
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js58
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js43
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js14
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/mark_raw.js9
-rw-r--r--app/assets/javascripts/locale/sprintf.js4
-rw-r--r--app/assets/javascripts/main.js10
-rw-r--r--app/assets/javascripts/members/constants.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue2
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js2
-rw-r--r--app/assets/javascripts/merge_request.js5
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/merge_requests/components/compare_dropdown.vue8
-rw-r--r--app/assets/javascripts/milestones/components/delete_milestone_modal.vue4
-rw-r--r--app/assets/javascripts/milestones/components/promote_milestone_modal.vue6
-rw-r--r--app/assets/javascripts/milestones/milestone.js2
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js2
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js2
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/constants.js17
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue (renamed from app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue)52
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js11
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js21
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue (renamed from app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue)181
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js16
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue2
-rw-r--r--app/assets/javascripts/monitoring/constants.js6
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js2
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js7
-rw-r--r--app/assets/javascripts/namespaces/leave_by_url.js2
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue30
-rw-r--r--app/assets/javascripts/nav/components/top_nav_new_dropdown.vue17
-rw-r--r--app/assets/javascripts/notebook/cells/output/error.vue40
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue10
-rw-r--r--app/assets/javascripts/notebook/index.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_field_layout.vue2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue127
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue46
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue15
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue6
-rw-r--r--app/assets/javascripts/notes/constants.js23
-rw-r--r--app/assets/javascripts/notes/i18n.js5
-rw-r--r--app/assets/javascripts/notes/index.js6
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js2
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js35
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js5
-rw-r--r--app/assets/javascripts/observability/components/observability_app.vue60
-rw-r--r--app/assets/javascripts/observability/components/skeleton/embed.vue15
-rw-r--r--app/assets/javascripts/observability/components/skeleton/index.vue21
-rw-r--r--app/assets/javascripts/observability/constants.js12
-rw-r--r--app/assets/javascripts/observability/index.js32
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue8
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue45
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue20
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue16
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue9
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue2
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue21
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/network/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_downloader.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js2
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue4
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue2
-rw-r--r--app/assets/javascripts/pages/admin/runners/register/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue25
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue9
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_error_details.vue2
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_history_app.vue2
-rw-r--r--app/assets/javascripts/pages/profiles/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue2
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/airflow/dags/index/index.js27
-rw-r--r--app/assets/javascripts/pages/projects/blame/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js30
-rw-r--r--app/assets/javascripts/pages/projects/boards/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue2
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue2
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/ml/candidates/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js30
-rw-r--r--app/assets/javascripts/pages/projects/project.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js4
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue4
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js7
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue1
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js11
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js19
-rw-r--r--app/assets/javascripts/pages/users/show/index.js19
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js12
-rw-r--r--app/assets/javascripts/persistent_user_callout.js2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js2
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/editor.vue13
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue6
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutations.js2
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue11
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue9
-rw-r--r--app/assets/javascripts/profile/components/activity_calendar.vue100
-rw-r--r--app/assets/javascripts/profile/components/followers_tab.vue15
-rw-r--r--app/assets/javascripts/profile/components/following_tab.vue15
-rw-r--r--app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql21
-rw-r--r--app/assets/javascripts/profile/components/overview_tab.vue5
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue9
-rw-r--r--app/assets/javascripts/profile/components/user_achievements.vue100
-rw-r--r--app/assets/javascripts/profile/constants.js7
-rw-r--r--app/assets/javascripts/profile/index.js36
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue2
-rw-r--r--app/assets/javascripts/profile/profile.js2
-rw-r--r--app/assets/javascripts/profile/utils.js13
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue33
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue24
-rw-r--r--app/assets/javascripts/projects/commit/components/projects_dropdown.vue32
-rw-r--r--app/assets/javascripts/projects/commit/init_revert_commit_modal.js1
-rw-r--r--app/assets/javascripts/projects/commit/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue2
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown.vue4
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue4
-rw-r--r--app/assets/javascripts/projects/components/shared/delete_button.vue10
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue24
-rw-r--r--app/assets/javascripts/projects/new/index.js6
-rw-r--r--app/assets/javascripts/projects/project_find_file.js2
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js15
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js24
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue75
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue7
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js7
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue7
-rw-r--r--app/assets/javascripts/projects/settings/constants.js1
-rw-r--r--app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js31
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue2
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue11
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js12
-rw-r--r--app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue64
-rw-r--r--app/assets/javascripts/projects/settings/utils.js21
-rw-r--r--app/assets/javascripts/projects/star.js2
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue2
-rw-r--r--app/assets/javascripts/protected_branches/constants.js9
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js2
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js2
-rw-r--r--app/assets/javascripts/protected_tags/constants.js11
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js81
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js112
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit_list.js4
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue117
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue12
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue2
-rw-r--r--app/assets/javascripts/related_issues/constants.js31
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue2
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue2
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue9
-rw-r--r--app/assets/javascripts/releases/constants.js5
-rw-r--r--app/assets/javascripts/releases/release_notification_service.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js11
-rw-r--r--app/assets/javascripts/repository/commits_service.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_controls.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue19
-rw-r--r--app/assets/javascripts/repository/components/delete_blob_modal.vue20
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue197
-rw-r--r--app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue137
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue7
-rw-r--r--app/assets/javascripts/repository/components/new_directory_modal.vue22
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue15
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue22
-rw-r--r--app/assets/javascripts/repository/constants.js8
-rw-r--r--app/assets/javascripts/repository/index.js9
-rw-r--r--app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql11
-rw-r--r--app/assets/javascripts/repository/queries/fork_details.query.graphql2
-rw-r--r--app/assets/javascripts/right_sidebar.js2
-rw-r--r--app/assets/javascripts/saved_replies/components/app.vue2
-rw-r--r--app/assets/javascripts/saved_replies/components/form.vue182
-rw-r--r--app/assets/javascripts/saved_replies/components/list.vue56
-rw-r--r--app/assets/javascripts/saved_replies/components/list_item.vue84
-rw-r--r--app/assets/javascripts/saved_replies/pages/edit.vue68
-rw-r--r--app/assets/javascripts/saved_replies/pages/index.vue54
-rw-r--r--app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql10
-rw-r--r--app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql5
-rw-r--r--app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql10
-rw-r--r--app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql4
-rw-r--r--app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql10
-rw-r--r--app/assets/javascripts/saved_replies/routes.js7
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue11
-rw-r--r--app/assets/javascripts/search/sidebar/components/checkbox_filter.vue19
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter.vue50
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue7
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue14
-rw-r--r--app/assets/javascripts/search/sidebar/utils.js27
-rw-r--r--app/assets/javascripts/search/store/actions.js31
-rw-r--r--app/assets/javascripts/search/store/getters.js12
-rw-r--r--app/assets/javascripts/search/store/mutations.js2
-rw-r--r--app/assets/javascripts/search/store/utils.js32
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue79
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue11
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js3
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue34
-rw-r--r--app/assets/javascripts/sentry/legacy_constants.js (renamed from app/assets/javascripts/sentry/constants.js)4
-rw-r--r--app/assets/javascripts/sentry/legacy_sentry_config.js2
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js4
-rw-r--r--app/assets/javascripts/service_ping_consent.js2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js5
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issue_button.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issues_button.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue5
-rw-r--r--app/assets/javascripts/sidebar/constants.js54
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js4
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js31
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/single_file_diff.js2
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue8
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue26
-rw-r--r--app/assets/javascripts/streaming/chunk_writer.js144
-rw-r--r--app/assets/javascripts/streaming/constants.js9
-rw-r--r--app/assets/javascripts/streaming/handle_streamed_anchor_link.js26
-rw-r--r--app/assets/javascripts/streaming/html_stream.js33
-rw-r--r--app/assets/javascripts/streaming/polyfills.js5
-rw-r--r--app/assets/javascripts/streaming/rate_limit_stream_requests.js87
-rw-r--r--app/assets/javascripts/streaming/render_balancer.js36
-rw-r--r--app/assets/javascripts/streaming/render_html_streams.js40
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue158
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue26
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue2
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue1
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue77
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue320
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue167
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue58
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue87
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js33
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/actions.js45
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/getters.js220
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/index.js25
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js30
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/state.js19
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue78
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue29
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/merge_request_menu.vue16
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue122
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue79
-rw-r--r--app/assets/javascripts/super_sidebar/components/search_results.vue49
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue24
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_portal.vue30
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue17
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue85
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue73
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue291
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue77
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js14
-rw-r--r--app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql24
-rw-r--r--app/assets/javascripts/super_sidebar/mock_data.js7
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js19
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js51
-rw-r--r--app/assets/javascripts/super_sidebar/utils.js83
-rw-r--r--app/assets/javascripts/syntax_highlight.js4
-rw-r--r--app/assets/javascripts/task_list.js2
-rw-r--r--app/assets/javascripts/terraform/components/init_command_modal.vue2
-rw-r--r--app/assets/javascripts/terraform/components/states_table_actions.vue4
-rw-r--r--app/assets/javascripts/toggles/index.js16
-rw-r--r--app/assets/javascripts/token_access/components/inbound_token_access.vue2
-rw-r--r--app/assets/javascripts/token_access/components/opt_in_jwt.vue2
-rw-r--r--app/assets/javascripts/token_access/components/outbound_token_access.vue2
-rw-r--r--app/assets/javascripts/token_access/components/token_access_app.vue9
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue7
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js1
-rw-r--r--app/assets/javascripts/user_lists/components/add_user_modal.vue4
-rw-r--r--app/assets/javascripts/users_select/index.js7
-rw-r--r--app/assets/javascripts/validators/length_validator.js (renamed from app/assets/javascripts/pages/sessions/new/length_validator.js)15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue70
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue72
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql (renamed from app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql)10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue212
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue77
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js6
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue109
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql12
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue120
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/actions.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue4
-rw-r--r--app/assets/javascripts/vue_shared/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js16
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js73
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js13
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue5
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue4
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue58
-rw-r--r--app/assets/javascripts/vue_shared/plugins/global_toast.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue2
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue2
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue1
-rw-r--r--app/assets/javascripts/work_items/components/notes/activity_filter.vue113
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue116
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue72
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue62
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue6
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue164
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue61
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue161
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue61
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_body.vue5
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue20
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue91
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue455
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue25
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue17
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue24
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue140
-rw-r--r--app/assets/javascripts/work_items/constants.js31
-rw-r--r--app/assets/javascripts/work_items/graphql/cache_utils.js130
-rw-r--r--app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql17
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql39
-rw-r--r--app/assets/javascripts/work_items/index.js2
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue2
-rw-r--r--app/assets/javascripts/work_items/router/index.js2
-rw-r--r--app/assets/javascripts/zen_mode.js8
923 files changed, 14887 insertions, 5211 deletions
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
index d24285af5c3..02159d4d524 100644
--- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { __, n__, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 6fc37e9331f..d6fdcea468a 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
index a41ff42df20..c66b595ffdc 100644
--- a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
+++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue
@@ -2,7 +2,7 @@
import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js
index d4c9db2fa33..de9c7488ace 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/actions.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js
@@ -1,6 +1,6 @@
import _ from 'lodash';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
new file mode 100644
index 00000000000..a4211002f71
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -0,0 +1,41 @@
+<script>
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+
+export default {
+ name: 'AbuseReportRow',
+ components: {
+ ListItem,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ updatedAt() {
+ const template = __('Updated %{timeAgo}');
+ return sprintf(template, { timeAgo: getTimeago().format(this.report.updatedAt) });
+ },
+ title() {
+ const { reportedUser, reporter, category } = this.report;
+ const template = __('%{reported} reported for %{category} by %{reporter}');
+ return sprintf(template, { reported: reportedUser.name, reporter: reporter.name, category });
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item data-testid="abuse-report-row">
+ <template #left-primary>
+ <div class="gl-font-weight-normal" data-testid="title">{{ title }}</div>
+ </template>
+
+ <template #right-secondary>
+ <div data-testid="updated-at">{{ updatedAt }}</div>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
new file mode 100644
index 00000000000..b60fe3ae9b8
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_reports_filtered_search_bar.vue
@@ -0,0 +1,109 @@
+<script>
+import { setUrlParams, redirectTo, queryToObject, updateHistory } from '~/lib/utils/url_utility';
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import {
+ FILTERED_SEARCH_TOKENS,
+ DEFAULT_SORT,
+ SORT_OPTIONS,
+ isValidSortKey,
+} from '~/admin/abuse_reports/constants';
+import { buildFilteredSearchCategoryToken } from '~/admin/abuse_reports/utils';
+
+export default {
+ name: 'AbuseReportsFilteredSearchBar',
+ components: { FilteredSearchBar },
+ sortOptions: SORT_OPTIONS,
+ inject: ['categories'],
+ data() {
+ return {
+ initialFilterValue: [],
+ initialSortBy: DEFAULT_SORT,
+ };
+ },
+ computed: {
+ tokens() {
+ return [...FILTERED_SEARCH_TOKENS, buildFilteredSearchCategoryToken(this.categories)];
+ },
+ },
+ created() {
+ const query = queryToObject(window.location.search);
+
+ // Backend shows open reports by default if status param is not specified.
+ // To match that behavior, update the current URL to include status=open
+ // query when no status query is specified on load.
+ if (!query.status) {
+ query.status = 'open';
+ updateHistory({ url: setUrlParams(query), replace: true });
+ }
+
+ const sort = this.currentSortKey();
+ if (sort) {
+ this.initialSortBy = query.sort;
+ }
+
+ const tokens = this.tokens
+ .filter((token) => query[token.type])
+ .map((token) => ({
+ type: token.type,
+ value: {
+ data: query[token.type],
+ operator: '=',
+ },
+ }));
+
+ this.initialFilterValue = tokens;
+ },
+ methods: {
+ currentSortKey() {
+ const { sort } = queryToObject(window.location.search);
+
+ return isValidSortKey(sort) ? sort : undefined;
+ },
+ handleFilter(tokens) {
+ let params = tokens.reduce((accumulator, token) => {
+ const { type, value } = token;
+
+ // We don't support filtering reports by search term for now
+ if (!value || !type || type === FILTERED_SEARCH_TERM) {
+ return accumulator;
+ }
+
+ return {
+ ...accumulator,
+ [type]: value.data,
+ };
+ }, {});
+
+ const sort = this.currentSortKey();
+ if (sort) {
+ params = { ...params, sort };
+ }
+
+ redirectTo(setUrlParams(params, window.location.href, true));
+ },
+ handleSort(sort) {
+ const { page, ...query } = queryToObject(window.location.search);
+
+ redirectTo(setUrlParams({ ...query, sort }, window.location.href, true));
+ },
+ },
+ filteredSearchNamespace: 'abuse_reports',
+ recentSearchesStorageKey: 'abuse_reports',
+};
+</script>
+
+<template>
+ <filtered-search-bar
+ :namespace="$options.filteredSearchNamespace"
+ :tokens="tokens"
+ :recent-searches-storage-key="$options.recentSearchesStorageKey"
+ :search-input-placeholder="__('Filter reports')"
+ :initial-filter-value="initialFilterValue"
+ :initial-sort-by="initialSortBy"
+ :sort-options="$options.sortOptions"
+ data-testid="abuse-reports-filtered-search-bar"
+ @onFilter="handleFilter"
+ @onSort="handleSort"
+ />
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/app.vue b/app/assets/javascripts/admin/abuse_reports/components/app.vue
new file mode 100644
index 00000000000..e1e75a4f8d0
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/app.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlEmptyState, GlPagination } from '@gitlab/ui';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import FilteredSearchBar from './abuse_reports_filtered_search_bar.vue';
+import AbuseReportRow from './abuse_report_row.vue';
+
+export default {
+ name: 'AbuseReportsApp',
+ components: {
+ AbuseReportRow,
+ FilteredSearchBar,
+ GlEmptyState,
+ GlPagination,
+ },
+ props: {
+ abuseReports: {
+ type: Array,
+ required: true,
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showPagination() {
+ return this.pagination.totalItems > this.pagination.perPage;
+ },
+ },
+ methods: {
+ paginationLinkGenerator(page) {
+ return mergeUrlParams({ page }, window.location.href);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <filtered-search-bar />
+
+ <gl-empty-state v-if="abuseReports.length == 0" :title="s__('AbuseReports|No reports found')" />
+ <abuse-report-row
+ v-for="(report, index) in abuseReports"
+ v-else
+ :key="index"
+ :report="report"
+ />
+
+ <gl-pagination
+ v-if="showPagination"
+ :value="pagination.currentPage"
+ :per-page="pagination.perPage"
+ :total-items="pagination.totalItems"
+ :link-gen="paginationLinkGenerator"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ :label-next-page="__('Go to next page')"
+ :label-prev-page="__('Go to previous page')"
+ align="center"
+ class="gl-mt-3"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js
new file mode 100644
index 00000000000..ee2e9ab2cbf
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -0,0 +1,81 @@
+import { getUsers } from '~/rest_api';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue';
+import {
+ OPERATORS_IS,
+ TOKEN_TITLE_STATUS,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import { __ } from '~/locale';
+
+const STATUS_OPTIONS = [
+ { value: 'closed', title: __('Closed') },
+ { value: 'open', title: __('Open') },
+];
+
+export const FILTERED_SEARCH_TOKEN_USER = {
+ type: 'user',
+ icon: 'user',
+ title: __('User'),
+ token: UserToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ fetchUsers: getUsers,
+ defaultUsers: [],
+};
+
+export const FILTERED_SEARCH_TOKEN_REPORTER = {
+ ...FILTERED_SEARCH_TOKEN_USER,
+ type: 'reporter',
+ title: __('Reporter'),
+};
+
+export const FILTERED_SEARCH_TOKEN_STATUS = {
+ type: 'status',
+ icon: 'status',
+ title: TOKEN_TITLE_STATUS,
+ token: BaseToken,
+ unique: true,
+ options: STATUS_OPTIONS,
+ operators: OPERATORS_IS,
+};
+
+export const DEFAULT_SORT = 'created_at_desc';
+
+export const SORT_OPTIONS = [
+ {
+ id: 10,
+ title: __('Created date'),
+ sortDirection: {
+ descending: DEFAULT_SORT,
+ ascending: 'created_at_asc',
+ },
+ },
+ {
+ id: 20,
+ title: __('Updated date'),
+ sortDirection: {
+ descending: 'updated_at_desc',
+ ascending: 'updated_at_asc',
+ },
+ },
+];
+
+export const isValidSortKey = (key) =>
+ SORT_OPTIONS.some(
+ (sort) => sort.sortDirection.ascending === key || sort.sortDirection.descending === key,
+ );
+
+export const FILTERED_SEARCH_TOKEN_CATEGORY = {
+ type: 'category',
+ icon: 'label',
+ title: __('Category'),
+ token: BaseToken,
+ unique: true,
+ operators: OPERATORS_IS,
+};
+
+export const FILTERED_SEARCH_TOKENS = [
+ FILTERED_SEARCH_TOKEN_USER,
+ FILTERED_SEARCH_TOKEN_REPORTER,
+ FILTERED_SEARCH_TOKEN_STATUS,
+];
diff --git a/app/assets/javascripts/admin/abuse_reports/index.js b/app/assets/javascripts/admin/abuse_reports/index.js
new file mode 100644
index 00000000000..dbc466af2d2
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AbuseReportsApp from './components/app.vue';
+
+export const initAbuseReportsApp = () => {
+ const el = document.querySelector('#js-abuse-reports-list-app');
+
+ if (!el) {
+ return null;
+ }
+
+ const { abuseReportsData } = el.dataset;
+ const { categories, reports, pagination } = convertObjectPropsToCamelCase(
+ JSON.parse(abuseReportsData),
+ {
+ deep: true,
+ },
+ );
+
+ return new Vue({
+ el,
+ provide: { categories },
+ render: (createElement) =>
+ createElement(AbuseReportsApp, {
+ props: {
+ abuseReports: reports,
+ pagination,
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/admin/abuse_reports/utils.js b/app/assets/javascripts/admin/abuse_reports/utils.js
new file mode 100644
index 00000000000..84221901089
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/utils.js
@@ -0,0 +1,6 @@
+import { FILTERED_SEARCH_TOKEN_CATEGORY } from './constants';
+
+export const buildFilteredSearchCategoryToken = (categories) => {
+ const options = categories.map((c) => ({ value: c, title: c }));
+ return { ...FILTERED_SEARCH_TOKEN_CATEGORY, options };
+};
diff --git a/app/assets/javascripts/admin/application_settings/network_outbound.js b/app/assets/javascripts/admin/application_settings/network_outbound.js
new file mode 100644
index 00000000000..ad7ed85131c
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/network_outbound.js
@@ -0,0 +1,28 @@
+export default () => {
+ const denyAllRequests = document.querySelector('.js-deny-all-requests');
+
+ if (!denyAllRequests) {
+ return;
+ }
+
+ denyAllRequests.addEventListener('change', () => {
+ const denyAll = denyAllRequests.checked;
+ const allowLocalRequests = document.querySelectorAll('.js-allow-local-requests');
+ const denyAllRequestsWarning = document.querySelector('.js-deny-all-requests-warning');
+
+ if (denyAll) {
+ denyAllRequestsWarning.classList.remove('gl-display-none');
+ } else {
+ denyAllRequestsWarning.classList.add('gl-display-none');
+ }
+
+ allowLocalRequests.forEach((allowLocalRequest) => {
+ /* eslint-disable no-param-reassign */
+ if (denyAll) {
+ allowLocalRequest.checked = false;
+ }
+ allowLocalRequest.disabled = denyAll;
+ /* eslint-enable no-param-reassign */
+ });
+ });
+};
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/base.vue b/app/assets/javascripts/admin/broadcast_messages/components/base.vue
index f869d21d55f..c28cd266617 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/base.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue
@@ -2,7 +2,7 @@
import { GlPagination } from '@gitlab/ui';
import { redirectTo } from '~/lib/utils/url_utility';
import { buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-import { createAlert, VARIANT_DANGER } from '~/flash';
+import { createAlert, VARIANT_DANGER } from '~/alert';
import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { NEW_BROADCAST_MESSAGE } from '../constants';
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
index 36796708e78..65aa4cba074 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
@@ -12,16 +12,25 @@ import {
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
-import { createAlert, VARIANT_DANGER } from '~/flash';
+import { createAlert, VARIANT_DANGER } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { BROADCAST_MESSAGES_PATH, THEMES, TYPES, TYPE_BANNER } from '../constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import {
+ BROADCAST_MESSAGES_PATH,
+ MESSAGES_PREVIEW_PATH,
+ THEMES,
+ TYPES,
+ TYPE_BANNER,
+} from '../constants';
import MessageFormGroup from './message_form_group.vue';
import DatetimePicker from './datetime_picker.vue';
const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } };
export default {
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
name: 'MessageForm',
components: {
DatetimePicker,
@@ -36,6 +45,9 @@ export default {
GlFormTextarea,
MessageFormGroup,
},
+ directives: {
+ SafeHtml,
+ },
mixins: [glFeatureFlagsMixin()],
inject: ['targetAccessLevelOptions'],
i18n: {
@@ -81,6 +93,7 @@ export default {
})),
startsAt: new Date(this.broadcastMessage.startsAt.getTime()),
endsAt: new Date(this.broadcastMessage.endsAt.getTime()),
+ renderedMessage: '',
};
},
computed: {
@@ -91,7 +104,7 @@ export default {
return this.message.trim() === '';
},
messagePreview() {
- return this.messageBlank ? this.$options.i18n.messagePlaceholder : this.message;
+ return this.messageBlank ? this.$options.i18n.messagePlaceholder : this.renderedMessage;
},
isAddForm() {
return !this.broadcastMessage.id;
@@ -114,6 +127,11 @@ export default {
});
},
},
+ watch: {
+ message() {
+ this.renderPreview();
+ },
+ },
methods: {
async onSubmit() {
this.loading = true;
@@ -140,13 +158,25 @@ export default {
}
return true;
},
+
+ async renderPreview() {
+ try {
+ const res = await axios.post(MESSAGES_PREVIEW_PATH, this.formPayload, FORM_HEADERS);
+ this.renderedMessage = res.data;
+ } catch (e) {
+ this.renderedMessage = '';
+ }
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['use'],
},
};
</script>
<template>
<gl-form @submit.prevent="onSubmit">
<gl-broadcast-message class="gl-my-6" :type="type" :theme="theme" :dismissible="dismissable">
- {{ messagePreview }}
+ <div v-safe-html:[$options.safeHtmlConfig]="messagePreview"></div>
</gl-broadcast-message>
<message-form-group :label="$options.i18n.message" label-for="message-textarea">
@@ -154,6 +184,7 @@ export default {
id="message-textarea"
v-model="message"
size="sm"
+ :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS"
:placeholder="$options.i18n.messagePlaceholder"
/>
</message-form-group>
diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js
index 6250d5a943d..323ac6857f6 100644
--- a/app/assets/javascripts/admin/broadcast_messages/constants.js
+++ b/app/assets/javascripts/admin/broadcast_messages/constants.js
@@ -1,6 +1,7 @@
import { s__ } from '~/locale';
export const BROADCAST_MESSAGES_PATH = '/admin/broadcast_messages';
+export const MESSAGES_PREVIEW_PATH = '/admin/broadcast_messages/preview';
export const TYPE_BANNER = 'banner';
export const TYPE_NOTIFICATION = 'notification';
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index be85ee43891..134498af348 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -5,7 +5,7 @@ import { __ } from '~/locale';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import csrf from '~/lib/utils/csrf';
export default {
diff --git a/app/assets/javascripts/admin/statistics_panel/store/actions.js b/app/assets/javascripts/admin/statistics_panel/store/actions.js
index 4f952698d7a..7372f03ec0b 100644
--- a/app/assets/javascripts/admin/statistics_panel/store/actions.js
+++ b/app/assets/javascripts/admin/statistics_panel/store/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue
index 3a54035c587..0099c8da8e6 100644
--- a/app/assets/javascripts/admin/users/components/actions/activate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/activate.vue
@@ -41,7 +41,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.activate,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue
index 5a8c675822d..52560ebe5b1 100644
--- a/app/assets/javascripts/admin/users/components/actions/approve.vue
+++ b/app/assets/javascripts/admin/users/components/actions/approve.vue
@@ -43,7 +43,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.approve,
- attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }],
+ attributes: { variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' },
},
messageHtml,
},
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index 898a688c203..203d076914f 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -56,7 +56,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.ban,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue
index d25dd400f9b..d50b76aaa92 100644
--- a/app/assets/javascripts/admin/users/components/actions/block.vue
+++ b/app/assets/javascripts/admin/users/components/actions/block.vue
@@ -42,7 +42,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.block,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
index c85f3f01675..ab1069601d2 100644
--- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
@@ -51,7 +51,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.deactivate,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue
index bac08de1d5e..2b9c4acfcb5 100644
--- a/app/assets/javascripts/admin/users/components/actions/reject.vue
+++ b/app/assets/javascripts/admin/users/components/actions/reject.vue
@@ -54,7 +54,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.reject,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
messageHtml,
},
diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue
index beede2d37d7..42b6fb3bdd4 100644
--- a/app/assets/javascripts/admin/users/components/actions/unban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unban.vue
@@ -37,7 +37,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unban,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index 720f2efd932..f94e128a945 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -32,7 +32,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unblock,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
},
});
diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue
index 55ea3e0aba7..c78c260b4fe 100644
--- a/app/assets/javascripts/admin/users/components/actions/unlock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue
@@ -31,7 +31,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unlock,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
},
});
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index f569cda0a4b..e55622d40ba 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -1,6 +1,6 @@
<script>
import { GlSkeletonLoader, GlTable } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
import { thWidthPercent } from '~/lib/utils/table_utility';
import { s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/airflow/dags/components/dags.vue b/app/assets/javascripts/airflow/dags/components/dags.vue
deleted file mode 100644
index 88eb3fd5aba..00000000000
--- a/app/assets/javascripts/airflow/dags/components/dags.vue
+++ /dev/null
@@ -1,111 +0,0 @@
-<script>
-import { GlTableLite, GlEmptyState, GlPagination, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import { setUrlParams } from '~/lib/utils/url_utility';
-import { formatDate } from '~/lib/utils/datetime/date_format_utility';
-import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
-
-export default {
- name: 'AirflowDags',
- components: {
- GlTableLite,
- GlEmptyState,
- IncubationAlert,
- GlPagination,
- TimeAgo,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- dags: {
- type: Array,
- required: true,
- },
- pagination: {
- type: Object,
- required: true,
- },
- },
- computed: {
- fields() {
- return [
- { key: 'dag_name', label: this.$options.i18n.dagLabel },
- { key: 'schedule', label: this.$options.scheduleLabel },
- { key: 'next_run', label: this.$options.nextRunLabel },
- { key: 'is_active', label: this.$options.isActiveLabel },
- { key: 'is_paused', label: this.$options.isPausedLabel },
- { key: 'fileloc', label: this.$options.fileLocLabel },
- ];
- },
- hasPagination() {
- return this.dags.length > 0;
- },
- prevPage() {
- return this.pagination.page > 1 ? this.pagination.page - 1 : null;
- },
- nextPage() {
- return !this.pagination.isLastPage ? this.pagination.page + 1 : null;
- },
- emptyState() {
- return {
- svgPath: '/assets/illustrations/empty-state/empty-dag-md.svg',
- };
- },
- },
- methods: {
- generateLink(page) {
- return setUrlParams({ page });
- },
- formatDate(dateString) {
- return formatDate(new Date(dateString));
- },
- },
- i18n: {
- emptyStateLabel: s__('Airflow|There are no DAGs to show'),
- emptyStateDescription: s__(
- 'Airflow|Either the Airflow instance does not contain DAGs or has yet to be configured',
- ),
- dagLabel: s__('Airflow|DAG'),
- scheduleLabel: s__('Airflow|Schedule'),
- nextRunLabel: s__('Airflow|Next run'),
- isActiveLabel: s__('Airflow|Is active'),
- isPausedLabel: s__('Airflow|Is paused'),
- fileLocLabel: s__('Airflow|DAG file location'),
- featureName: s__('Airflow|GitLab Airflow integration'),
- },
- linkToFeedbackIssue:
- 'https://gitlab.com/gitlab-org/incubation-engineering/airflow/meta/-/issues/2',
-};
-</script>
-
-<template>
- <div>
- <incubation-alert
- :feature-name="$options.i18n.featureName"
- :link-to-feedback-issue="$options.linkToFeedbackIssue"
- />
- <gl-empty-state
- v-if="!dags.length"
- :title="$options.i18n.emptyStateLabel"
- :description="$options.i18n.emptyStateDescription"
- :svg-path="emptyState.svgPath"
- />
- <gl-table-lite v-else :items="dags" :fields="fields" class="gl-mt-0!">
- <template #cell(next_run)="data">
- <time-ago v-gl-tooltip.hover :time="data.value" :title="formatDate(data.value)" />
- </template>
- </gl-table-lite>
- <gl-pagination
- v-if="hasPagination"
- :value="pagination.page"
- :prev-page="prevPage"
- :next-page="nextPage"
- :total-items="pagination.totalItems"
- :per-page="pagination.perPage"
- :link-gen="generateLink"
- align="center"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/alert.js
index 483f1d2c7a0..006c4f50d09 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/alert.js
@@ -15,7 +15,7 @@ export const VARIANT_TIP = 'tip';
*
* @example
* // Render a new alert
- * import { createAlert, VARIANT_WARNING } from '~/flash';
+ * import { createAlert, VARIANT_WARNING } from '~/alert';
*
* createAlert({ message: 'My error message' });
* createAlert({ message: 'My warning message', variant: VARIANT_WARNING });
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
index 38bcdef3e04..b9e37b9ede7 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
@@ -5,8 +5,7 @@ import {
GlLink,
GlFormGroup,
GlFormCheckbox,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
} from '@gitlab/ui';
import {
I18N_ALERT_SETTINGS_FORM,
@@ -22,8 +21,7 @@ export default {
GlLink,
GlFormGroup,
GlFormCheckbox,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
},
inject: ['service', 'alertSettings'],
data() {
@@ -40,9 +38,6 @@ export default {
TAKING_INCIDENT_ACTION_DOCS_LINK,
ISSUE_TEMPLATES_DOCS_LINK,
computed: {
- issueTemplateHeader() {
- return this.issueTemplate || NO_ISSUE_TEMPLATE_SELECTED.name;
- },
formData() {
return {
create_issue: this.createIssueEnabled,
@@ -53,12 +48,6 @@ export default {
},
},
methods: {
- selectIssueTemplate(templateKey) {
- this.issueTemplate = templateKey;
- },
- isTemplateSelected(templateKey) {
- return templateKey === this.issueTemplate;
- },
updateAlertsIntegrationSettings() {
this.loading = true;
@@ -99,23 +88,13 @@ export default {
<span class="gl-font-weight-normal gl-pl-2">{{ $options.i18n.introLinkText }}</span>
</gl-link>
</label>
- <gl-dropdown
+ <gl-collapsible-listbox
id="alert-integration-settings-issue-template"
+ v-model="issueTemplate"
+ :items="templates"
+ block
data-qa-selector="incident_templates_dropdown"
- :text="issueTemplateHeader"
- :block="true"
- >
- <gl-dropdown-item
- v-for="template in templates"
- :key="template.key"
- data-qa-selector="incident_templates_item"
- is-check-item
- :is-checked="isTemplateSelected(template.key)"
- @click="selectIssueTemplate(template.key)"
- >
- {{ template.name }}
- </gl-dropdown-item>
- </gl-dropdown>
+ />
</gl-form-group>
<gl-form-group class="gl-pl-0 gl-mb-5">
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 7dd33da435a..cc8913c2f45 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -2,7 +2,7 @@
import { GlButton, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { fetchPolicies } from '~/lib/graphql';
import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import { typeSet, i18n, tabIndices } from '../constants';
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index b93119d6e6a..de7240009bc 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -183,7 +183,7 @@ export const I18N_ALERT_SETTINGS_FORM = {
},
};
-export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') };
+export const NO_ISSUE_TEMPLATE_SELECTED = { value: '', text: __('No template selected') };
export const TAKING_INCIDENT_ACTION_DOCS_LINK =
'/help/operations/metrics/alerts#trigger-actions-from-alerts';
export const ISSUE_TEMPLATES_DOCS_LINK =
diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
index 2e64312b0e0..e03ebffd17a 100644
--- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js
+++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
@@ -1,5 +1,5 @@
import produce from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DELETE_INTEGRATION_ERROR, ADD_INTEGRATION_ERROR } from './error_messages';
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
index a688e2f497b..704b4ce9c8a 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
@@ -4,7 +4,7 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import { VSA_METRICS_GROUPS } from '~/analytics/shared/constants';
-import { toYmd } from '~/analytics/shared/utils';
+import { toYmd, generateValueStreamsDashboardLink } from '~/analytics/shared/utils';
import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
@@ -48,12 +48,13 @@ export default {
'selectedStageEvents',
'selectedStageError',
'stageCounts',
- 'endpoints',
'features',
'createdBefore',
'createdAfter',
'pagination',
'hasNoAccessError',
+ 'groupPath',
+ 'namespace',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
isLoaded() {
@@ -98,8 +99,25 @@ export default {
}
return 0;
},
+ hasCycleAnalyticsForGroups() {
+ return this.features?.cycleAnalyticsForGroups;
+ },
metricsRequests() {
- return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
+ return this.hasCycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
+ },
+ showLinkToDashboard() {
+ return Boolean(
+ this.features?.groupLevelAnalyticsDashboard && this.features?.groupAnalyticsDashboardsPage,
+ );
+ },
+ dashboardsPath() {
+ const {
+ namespace: { fullPath },
+ groupPath,
+ } = this;
+ return this.showLinkToDashboard
+ ? generateValueStreamsDashboardLink(groupPath, [fullPath])
+ : null;
},
query() {
return {
@@ -150,8 +168,7 @@ export default {
<div>
<h3>{{ $options.i18n.pageTitle }}</h3>
<value-stream-filters
- :group-id="endpoints.groupId"
- :group-path="endpoints.groupPath"
+ :group-path="groupPath"
:has-project-filter="false"
:start-date="createdAfter"
:end-date="createdBefore"
@@ -169,10 +186,11 @@ export default {
/>
</div>
<value-stream-metrics
- :request-path="endpoints.fullPath"
+ :request-path="namespace.fullPath"
:request-params="filterParams"
:requests="metricsRequests"
:group-by="$options.VSA_METRICS_GROUPS"
+ :dashboards-path="dashboardsPath"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
<stage-table
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue
index ac41bc4917c..d305132ae33 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue
@@ -66,33 +66,38 @@ export default {
<template #title>{{ pathItem.title }}</template>
<div class="gl-px-4">
<div class="gl-display-flex gl-justify-content-space-between">
- <div class="gl-pr-4 gl-pb-4">
+ <div class="gl-pr-4 gl-pb-3">
{{ s__('ValueStreamEvent|Stage time (median)') }}
</div>
- <div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div>
+ <div class="gl-pb-3 gl-font-weight-bold">{{ pathItem.metric }}</div>
</div>
</div>
<div class="gl-px-4">
<div class="gl-display-flex gl-justify-content-space-between">
- <div class="gl-pr-4 gl-pb-4">
+ <div class="gl-pr-4 gl-pb-3">
{{ s__('ValueStreamEvent|Items in stage') }}
</div>
- <div class="gl-pb-4 gl-font-weight-bold">
+ <div class="gl-pb-3 gl-font-weight-bold">
<formatted-stage-count :stage-count="pathItem.stageCount" />
</div>
</div>
</div>
+ <div class="gl-px-4">
+ <div class="gl-pb-3 gl-font-style-italic">
+ {{ s__('ValueStreamEvent|Only items that reached their stop event.') }}
+ </div>
+ </div>
<div class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50">
<div
v-if="pathItem.startEventHtmlDescription"
class="gl-display-flex gl-flex-direction-row"
>
- <div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label">
+ <div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-3 metric-label">
{{ s__('ValueStreamEvent|Start') }}
</div>
<div
v-safe-html="pathItem.startEventHtmlDescription"
- class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description"
+ class="gl-display-flex gl-flex-direction-column gl-pb-3 stage-event-description"
></div>
</div>
<div
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
index 17decb6b448..4c7e18f9895 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue
@@ -31,10 +31,6 @@ export default {
required: false,
default: true,
},
- groupId: {
- type: Number,
- required: true,
- },
groupPath: {
type: String,
required: true,
@@ -82,9 +78,7 @@ export default {
<div>
<projects-dropdown-filter
v-if="hasProjectFilter"
- :key="groupId"
class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0"
- :group-id="groupId"
:group-namespace="groupPath"
:query-params="projectsQueryParams"
:multi-select="$options.multiProjectSelect"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js
index 2758d686fb1..ebb2775b378 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/constants.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js
@@ -32,11 +32,6 @@ export const I18N_VSA_ERROR_SELECTED_STAGE = __(
'There was an error fetching data for the selected stage',
);
-export const OVERVIEW_METRICS = {
- TIME_SUMMARY: 'TIME_SUMMARY',
- RECENT_ACTIVITY: 'RECENT_ACTIVITY',
-};
-
export const SUMMARY_METRICS_REQUEST = [
{ endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
];
@@ -45,3 +40,6 @@ export const METRICS_REQUESTS = [
{ endpoint: METRIC_TYPE_TIME_SUMMARY, name: __('time summary'), request: getValueStreamMetrics },
...SUMMARY_METRICS_REQUEST,
];
+
+export const MILESTONES_ENDPOINT = '/-/milestones.json';
+export const LABELS_ENDPOINT = '/-/labels.json';
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/actions.js b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js
index 4a201e00582..32fe0abe83e 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js
@@ -6,9 +6,15 @@ import {
getValueStreamStageCounts,
} from '~/api/analytics_api';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
-import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants';
+import {
+ DEFAULT_VALUE_STREAM,
+ I18N_VSA_ERROR_STAGE_MEDIAN,
+ LABELS_ENDPOINT,
+ MILESTONES_ENDPOINT,
+} from '../constants';
+import { constructPathWithNamespace } from '../utils';
import * as types from './mutation_types';
export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => {
@@ -18,7 +24,7 @@ export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => {
export const fetchValueStreamStages = ({ commit, state }) => {
const {
- endpoints: { fullPath },
+ namespace: { fullPath },
selectedValueStream: { id },
} = state;
commit(types.REQUEST_VALUE_STREAM_STAGES);
@@ -41,7 +47,7 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => {
export const fetchValueStreams = ({ commit, dispatch, state }) => {
const {
- endpoints: { fullPath },
+ namespace: { fullPath },
} = state;
commit(types.REQUEST_VALUE_STREAMS);
@@ -180,7 +186,8 @@ export const initializeVsa = async ({ commit, dispatch }, initialData = {}) => {
commit(types.INITIALIZE_VSA, initialData);
const {
- endpoints: { fullPath, groupPath, milestonesPath = '', labelsPath = '' },
+ groupPath,
+ namespace,
selectedAuthor,
selectedMilestone,
selectedAssigneeList,
@@ -189,10 +196,10 @@ export const initializeVsa = async ({ commit, dispatch }, initialData = {}) => {
} = initialData;
dispatch('filters/setEndpoints', {
- labelsEndpoint: labelsPath,
- milestonesEndpoint: milestonesPath,
+ labelsEndpoint: constructPathWithNamespace(namespace, LABELS_ENDPOINT),
+ milestonesEndpoint: constructPathWithNamespace(namespace, MILESTONES_ENDPOINT),
groupEndpoint: groupPath,
- projectEndpoint: fullPath,
+ projectEndpoint: namespace.fullPath,
});
dispatch('filters/initialize', {
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/getters.js b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js
index 83068cabf0f..f5ed922c602 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js
@@ -15,11 +15,11 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage
export const requestParams = (state) => {
const {
- endpoints: { fullPath },
+ namespace: { fullPath },
selectedValueStream: { id: valueStreamId },
selectedStage: { id: stageId = null },
} = state;
- return { requestPath: fullPath, valueStreamId, stageId };
+ return { namespacePath: fullPath, valueStreamId, stageId };
};
export const paginationParams = ({ pagination: { page, sort, direction } }) => ({
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js
index 8567529caf2..4af96fc96e3 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js
@@ -1,15 +1,16 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import { formatMedianValues } from '../utils';
+import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import * as types from './mutation_types';
export default {
[types.INITIALIZE_VSA](
state,
- { endpoints, features, createdBefore, createdAfter, pagination = {} },
+ { groupPath, features, createdBefore, createdAfter, pagination = {}, namespace = {} },
) {
- state.endpoints = endpoints;
+ state.groupPath = groupPath;
+ state.namespace = namespace;
state.createdBefore = createdBefore;
state.createdAfter = createdAfter;
state.features = features;
diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/state.js b/app/assets/javascripts/analytics/cycle_analytics/store/state.js
index 00dd2e53883..0c51656c59f 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/store/state.js
@@ -6,7 +6,11 @@ import {
export default () => ({
id: null,
features: {},
- endpoints: {},
+ groupPath: {},
+ namespace: {
+ name: null,
+ fullPath: null,
+ },
createdAfter: null,
createdBefore: null,
stages: [],
diff --git a/app/assets/javascripts/analytics/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js
index 428bb11b950..9265ff952e0 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/utils.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js
@@ -1,5 +1,6 @@
import { parseSeconds } from '~/lib/utils/datetime_utility';
import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
+import { joinPaths } from '~/lib/utils/url_utility';
/**
* Takes the stages and median data, combined with the selected stage, to build an
@@ -77,7 +78,11 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
*/
const extractFeatures = (gon) => ({
+ // licensed feature toggles
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
+ groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard),
+ // feature flags
+ groupAnalyticsDashboardsPage: Boolean(gon?.features?.groupAnalyticsDashboardsPage),
});
/**
@@ -87,27 +92,21 @@ const extractFeatures = (gon) => ({
* @returns {Object} - The initial data to load the app with
*/
export const buildCycleAnalyticsInitialData = ({
- fullPath,
- requestPath,
projectId,
- groupId,
groupPath,
- labelsPath,
- milestonesPath,
stage,
createdAfter,
createdBefore,
+ namespaceName,
+ namespaceFullPath,
gon,
} = {}) => {
return {
projectId: parseInt(projectId, 10),
- endpoints: {
- requestPath,
- fullPath,
- labelsPath,
- milestonesPath,
- groupId: parseInt(groupId, 10),
- groupPath,
+ groupPath: `groups/${groupPath}`,
+ namespace: {
+ name: namespaceName,
+ fullPath: namespaceFullPath,
},
createdAfter: new Date(createdAfter),
createdBefore: new Date(createdBefore),
@@ -115,3 +114,6 @@ export const buildCycleAnalyticsInitialData = ({
features: extractFeatures(gon),
};
};
+
+export const constructPathWithNamespace = ({ fullPath }, endpoint) =>
+ joinPaths('/', fullPath, endpoint);
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index 5bb60d91f1e..98193de4a12 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -32,11 +32,6 @@ export default {
GlTruncate,
},
props: {
- groupId: {
- type: Number,
- required: false,
- default: null,
- },
groupNamespace: {
type: String,
required: true,
diff --git a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
index cc7b554f32c..3082897af76 100644
--- a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
@@ -1,9 +1,10 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { isEqual, keyBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sprintf, s__ } from '~/locale';
import { fetchMetricsData, removeFlash } from '../utils';
+import ValueStreamsDashboardLink from './value_streams_dashboard_link.vue';
import MetricTile from './metric_tile.vue';
const extractMetricsGroupData = (keyList = [], data = []) => {
@@ -28,6 +29,7 @@ export default {
components: {
GlSkeletonLoader,
MetricTile,
+ ValueStreamsDashboardLink,
},
props: {
requestPath: {
@@ -52,6 +54,11 @@ export default {
required: false,
default: () => [],
},
+ dashboardsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -76,6 +83,10 @@ export default {
this.fetchData();
},
methods: {
+ shouldDisplayDashboardLink(index) {
+ // When we have groups of metrics, we should only display the link for the first group
+ return index === 0 && this.dashboardsPath;
+ },
fetchData() {
removeFlash();
this.isLoading = true;
@@ -110,7 +121,7 @@ export default {
<template v-else>
<div v-if="hasGroupedMetrics" class="gl-flex-direction-column">
<div
- v-for="group in groupedMetrics"
+ v-for="(group, groupIndex) in groupedMetrics"
:key="group.key"
class="gl-mb-7"
data-testid="vsa-metrics-group"
@@ -123,6 +134,11 @@ export default {
:metric="metric"
class="gl-mt-5 gl-pr-10"
/>
+ <value-streams-dashboard-link
+ v-if="shouldDisplayDashboardLink(groupIndex)"
+ class="gl-mt-5"
+ :request-path="dashboardsPath"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
new file mode 100644
index 00000000000..95a6447ebaf
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'ValueStreamsDashboardLink',
+ components: { GlIcon, GlLink },
+ props: {
+ requestPath: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ title: __('Related'),
+ linkText: __('Value Streams Dashboard | DORA'),
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-flex-direction-column" data-testid="vsd-link">
+ <div class="gl-display-flex gl-mb-2">
+ <span>{{ $options.i18n.title }}</span>
+ </div>
+ <div class="gl-display-flex gl-align-items-baseline">
+ <gl-link :href="requestPath">{{ $options.i18n.linkText }}</gl-link
+ >&nbsp;<gl-icon name="dashboard" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index aafbf642766..a85f3fb3730 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,6 +1,7 @@
import { flatten } from 'lodash';
import dateFormat from '~/lib/dateformat';
import { slugify } from '~/lib/utils/text_utility';
+import { joinPaths } from '~/lib/utils/url_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats, METRICS_POPOVER_CONTENT } from './constants';
@@ -119,3 +120,21 @@ export const fetchMetricsData = (requests = [], requestPath, params) => {
prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
);
};
+
+/**
+ * Generates a URL link to the VSD dashboard based on the group
+ * and project paths passed into the method.
+ *
+ * @param {String} groupPath - Path of the specified group
+ * @param {Array} projectPaths - Array of project paths to include in the `query` parameter
+ * @returns a URL or blank string if there is no groupPath set
+ */
+export const generateValueStreamsDashboardLink = (groupPath, projectPaths = []) => {
+ if (groupPath.length) {
+ const query = projectPaths.length ? `?query=${projectPaths.join(',')}` : '';
+ const dashboardsSlug = '/-/analytics/dashboards/value_streams_dashboard';
+ const segments = [gon.relative_url_root || '', '/', groupPath, dashboardsSlug];
+ return joinPaths(...segments).concat(query);
+ }
+ return '';
+};
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 5651789e2c7..1fd1f91bda3 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -1,7 +1,7 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { number } from '~/lib/utils/unit_format';
import { __, s__ } from '~/locale';
import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql';
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index b02dd9321b3..87c74438d00 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import { joinPaths } from './lib/utils/url_utility';
diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js
index 66ed30130bb..35c9fa20e56 100644
--- a/app/assets/javascripts/api/analytics_api.js
+++ b/app/assets/javascripts/api/analytics_api.js
@@ -2,8 +2,8 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { buildApiUrl } from './api_utils';
-const PROJECT_VSA_METRICS_BASE = '/:request_path/-/analytics/value_stream_analytics';
-const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams';
+const PROJECT_VSA_METRICS_BASE = '/:namespace_path/-/analytics/value_stream_analytics';
+const PROJECT_VSA_PATH_BASE = '/:namespace_path/-/analytics/value_stream_analytics/value_streams';
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`;
@@ -15,71 +15,77 @@ export const DEPLOYS_METRIC_TYPE = 'deploys';
export const METRIC_TYPE_SUMMARY = 'summary';
export const METRIC_TYPE_TIME_SUMMARY = 'time_summary';
-const buildProjectMetricsPath = (requestPath) =>
- buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':request_path', requestPath);
+const buildProjectMetricsPath = (namespacePath) =>
+ buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':namespace_path', namespacePath);
-const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => {
+const buildProjectValueStreamPath = (namespacePath, valueStreamId = null) => {
if (valueStreamId) {
return buildApiUrl(PROJECT_VSA_STAGES_PATH)
- .replace(':request_path', requestPath)
+ .replace(':namespace_path', namespacePath)
.replace(':value_stream_id', valueStreamId);
}
- return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':request_path', requestPath);
+ return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':namespace_path', namespacePath);
};
-const buildValueStreamStageDataPath = ({ requestPath, valueStreamId = null, stageId = null }) =>
+const buildValueStreamStageDataPath = ({ namespacePath, valueStreamId = null, stageId = null }) =>
buildApiUrl(PROJECT_VSA_STAGE_DATA_PATH)
- .replace(':request_path', requestPath)
+ .replace(':namespace_path', namespacePath)
.replace(':value_stream_id', valueStreamId)
.replace(':stage_id', stageId);
-export const getProjectValueStreams = (requestPath) => {
- const url = buildProjectValueStreamPath(requestPath);
+export const getProjectValueStreams = (namespacePath) => {
+ const url = buildProjectValueStreamPath(namespacePath);
return axios.get(url);
};
-export const getProjectValueStreamStages = (requestPath, valueStreamId) => {
- const url = buildProjectValueStreamPath(requestPath, valueStreamId);
+export const getProjectValueStreamStages = (namespacePath, valueStreamId) => {
+ const url = buildProjectValueStreamPath(namespacePath, valueStreamId);
return axios.get(url);
};
// NOTE: legacy VSA request use a different path
-// the `requestPath` provides a full url for the request
-export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) =>
- axios.get(joinPaths(requestPath, 'events', stageId), { params });
+// the `namespacePath` provides a full url for the request
+export const getProjectValueStreamStageData = ({ namespacePath, stageId, params }) =>
+ axios.get(joinPaths(namespacePath, 'events', stageId), { params });
/**
* Dedicated project VSA paths
*/
-export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
- const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
+export const getValueStreamStageMedian = (
+ { namespacePath, valueStreamId, stageId },
+ params = {},
+) => {
+ const stageBase = buildValueStreamStageDataPath({ namespacePath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'median'), { params });
};
export const getValueStreamStageRecords = (
- { requestPath, valueStreamId, stageId },
+ { namespacePath, valueStreamId, stageId },
params = {},
) => {
- const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
+ const stageBase = buildValueStreamStageDataPath({ namespacePath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'records'), { params });
};
-export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId }, params = {}) => {
- const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
+export const getValueStreamStageCounts = (
+ { namespacePath, valueStreamId, stageId },
+ params = {},
+) => {
+ const stageBase = buildValueStreamStageDataPath({ namespacePath, valueStreamId, stageId });
return axios.get(joinPaths(stageBase, 'count'), { params });
};
export const getValueStreamMetrics = ({
endpoint = METRIC_TYPE_SUMMARY,
- requestPath,
+ requestPath: namespacePath,
params = {},
}) => {
- const metricBase = buildProjectMetricsPath(requestPath);
+ const metricBase = buildProjectMetricsPath(namespacePath);
return axios.get(joinPaths(metricBase, endpoint), { params });
};
-export const getValueStreamSummaryMetrics = (requestPath, params = {}) => {
- const metricBase = buildProjectMetricsPath(requestPath);
+export const getValueStreamSummaryMetrics = (namespacePath, params = {}) => {
+ const metricBase = buildProjectMetricsPath(namespacePath);
return axios.get(joinPaths(metricBase, 'summary'), { params });
};
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 45fddc3a696..bcb0f079d3d 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -1,5 +1,5 @@
import { DEFAULT_PER_PAGE } from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/artifacts/components/artifact_row.vue
index fffdfce60a7..f37c4c6f107 100644
--- a/app/assets/javascripts/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/artifacts/components/artifact_row.vue
@@ -1,7 +1,8 @@
<script>
-import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE, BULK_DELETE_FEATURE_FLAG } from '../constants';
export default {
name: 'ArtifactRow',
@@ -10,13 +11,19 @@ export default {
GlButton,
GlBadge,
GlFriendlyWrap,
+ GlFormCheckbox,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['canDestroyArtifacts'],
props: {
artifact: {
type: Object,
required: true,
},
+ isSelected: {
+ type: Boolean,
+ required: true,
+ },
isLastRow: {
type: Boolean,
required: true,
@@ -32,6 +39,16 @@ export default {
artifactSize() {
return numberToHumanSize(this.artifact.size);
},
+ canBulkDestroyArtifacts() {
+ return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ },
+ },
+ methods: {
+ handleInput(checked) {
+ if (checked === this.isSelected) return;
+
+ this.$emit('selectArtifact', this.artifact, checked);
+ },
},
i18n: {
expired: I18N_EXPIRED,
@@ -46,6 +63,9 @@ export default {
:class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': !isLastRow }"
>
<div class="gl-display-inline-flex gl-align-items-center gl-w-full">
+ <span v-if="canBulkDestroyArtifacts" class="gl-pl-5">
+ <gl-form-checkbox :checked="isSelected" @input="handleInput" />
+ </span>
<span
class="gl-w-half gl-pl-8 gl-display-flex gl-align-items-center"
data-testid="job-artifact-row-name"
diff --git a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
new file mode 100644
index 00000000000..cc08551fdb7
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
@@ -0,0 +1,182 @@
+<script>
+import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
+import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
+import { removeArtifactFromStore } from '../graphql/cache_update';
+import {
+ I18N_BULK_DELETE_BANNER,
+ I18N_BULK_DELETE_CLEAR_SELECTION,
+ I18N_BULK_DELETE_DELETE_SELECTED,
+ I18N_BULK_DELETE_MODAL_TITLE,
+ I18N_BULK_DELETE_BODY,
+ I18N_BULK_DELETE_ACTION,
+ I18N_BULK_DELETE_PARTIAL_ERROR,
+ I18N_BULK_DELETE_ERROR,
+ I18N_MODAL_CANCEL,
+ BULK_DELETE_MODAL_ID,
+} from '../constants';
+
+export default {
+ name: 'ArtifactsBulkDelete',
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ },
+ inject: ['projectId'],
+ props: {
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isModalVisible: false,
+ isDeleting: false,
+ };
+ },
+ computed: {
+ checkedCount() {
+ return this.selectedArtifacts.length || 0;
+ },
+ modalActionPrimary() {
+ return {
+ text: I18N_BULK_DELETE_ACTION(this.checkedCount),
+ attributes: {
+ loading: this.isDeleting,
+ variant: 'danger',
+ },
+ };
+ },
+ modalActionCancel() {
+ return {
+ text: I18N_MODAL_CANCEL,
+ attributes: {
+ loading: this.isDeleting,
+ },
+ };
+ },
+ },
+ methods: {
+ async onConfirmDelete(e) {
+ // don't close modal until deletion is complete
+ if (e) {
+ e.preventDefault();
+ }
+ this.isDeleting = true;
+
+ try {
+ await this.$apollo.mutate({
+ mutation: bulkDestroyJobArtifactsMutation,
+ variables: {
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
+ ids: this.selectedArtifacts,
+ },
+ update: (store, { data }) => {
+ const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts;
+ if (errors?.length) {
+ createAlert({
+ message: I18N_BULK_DELETE_PARTIAL_ERROR,
+ captureError: true,
+ error: new Error(errors.join(' ')),
+ });
+ }
+ if (destroyedIds?.length) {
+ this.$emit('deleted', destroyedCount);
+
+ // Remove deleted artifacts from the cache
+ destroyedIds.forEach((id) => {
+ removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
+ });
+ store.gc();
+
+ this.$emit('clearSelectedArtifacts');
+ }
+ },
+ });
+ } catch (error) {
+ this.onError(error);
+ } finally {
+ this.isDeleting = false;
+ this.isModalVisible = false;
+ }
+ },
+ onError(error) {
+ createAlert({
+ message: I18N_BULK_DELETE_ERROR,
+ captureError: true,
+ error,
+ });
+ },
+ handleClearSelection() {
+ this.$emit('clearSelectedArtifacts');
+ },
+ handleModalShow() {
+ this.isModalVisible = true;
+ },
+ handleModalHide() {
+ this.isModalVisible = false;
+ },
+ },
+ i18n: {
+ banner: I18N_BULK_DELETE_BANNER,
+ clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
+ deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
+ modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
+ modalBody: I18N_BULK_DELETE_BODY,
+ },
+ BULK_DELETE_MODAL_ID,
+};
+</script>
+<template>
+ <div class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
+ <div class="gl-display-flex gl-align-items-center">
+ <div>
+ <gl-sprintf :message="$options.i18n.banner(checkedCount)">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-ml-auto">
+ <gl-button
+ variant="default"
+ data-testid="bulk-delete-clear-button"
+ @click="handleClearSelection"
+ >
+ {{ $options.i18n.clearSelection }}
+ </gl-button>
+ <gl-button
+ variant="danger"
+ data-testid="bulk-delete-delete-button"
+ @click="handleModalShow"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+ </div>
+ <gl-modal
+ size="sm"
+ :modal-id="$options.BULK_DELETE_MODAL_ID"
+ :visible="isModalVisible"
+ :title="$options.i18n.modalTitle(checkedCount)"
+ :action-primary="modalActionPrimary"
+ :action-cancel="modalActionCancel"
+ @hide="handleModalHide"
+ @primary="onConfirmDelete"
+ >
+ <gl-sprintf
+ data-testid="bulk-delete-modal-content"
+ :message="$options.i18n.modalBody(checkedCount)"
+ />
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
index 4a826d0d462..7d675251ffd 100644
--- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
+++ b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import destroyArtifactMutation from '../graphql/mutations/destroy_artifact.mutation.graphql';
@@ -25,6 +25,10 @@ export default {
type: Object,
required: true,
},
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
queryVariables: {
type: Object,
required: true,
@@ -52,6 +56,9 @@ export default {
isLastRow(index) {
return index === this.artifacts.nodes.length - 1;
},
+ isSelected(item) {
+ return this.selectedArtifacts.includes(item.id);
+ },
showModal(item) {
this.deletingArtifactId = item.id;
this.deletingArtifactName = item.name;
@@ -98,7 +105,9 @@ export default {
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
<artifact-row
:artifact="item"
+ :is-selected="isSelected(item)"
:is-last-row="isLastRow(index)"
+ v-on="$listeners"
@delete="showModal(item)"
/>
</dynamic-scroller-item>
diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
index 5743ff3ec9e..ba4026190a2 100644
--- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
@@ -8,11 +8,13 @@ import {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils';
import {
@@ -33,7 +35,11 @@ import {
INITIAL_NEXT_PAGE_CURSOR,
JOBS_PER_PAGE,
INITIAL_LAST_PAGE_SIZE,
+ BULK_DELETE_FEATURE_FLAG,
+ I18N_BULK_DELETE_CONFIRMATION_TOAST,
} from '../constants';
+import JobCheckbox from './job_checkbox.vue';
+import ArtifactsBulkDelete from './artifacts_bulk_delete.vue';
import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
import FeedbackBanner from './feedback_banner.vue';
@@ -56,11 +62,15 @@ export default {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
CiIcon,
TimeAgo,
+ JobCheckbox,
+ ArtifactsBulkDelete,
ArtifactsTableRowDetails,
FeedbackBanner,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
@@ -68,9 +78,8 @@ export default {
variables() {
return this.queryVariables;
},
- update({ project: { jobs: { nodes = [], pageInfo = {}, count = 0 } = {} } }) {
+ update({ project: { jobs: { nodes = [], pageInfo = {} } = {} } }) {
this.pageInfo = pageInfo;
- this.count = count;
return nodes
.map(mapArchivesToJobNodes)
.map(mapBooleansToJobNodes)
@@ -93,9 +102,9 @@ export default {
data() {
return {
jobArtifacts: [],
- count: 0,
pageInfo: {},
expandedJobs: [],
+ selectedArtifacts: [],
pagination: INITIAL_PAGINATION_STATE,
};
},
@@ -110,7 +119,9 @@ export default {
};
},
showPagination() {
- return this.count > JOBS_PER_PAGE;
+ const { hasNextPage, hasPreviousPage } = this.pageInfo;
+
+ return hasNextPage || hasPreviousPage;
},
prevPage() {
return Number(this.pageInfo.hasPreviousPage);
@@ -118,6 +129,21 @@ export default {
nextPage() {
return Number(this.pageInfo.hasNextPage);
},
+ fields() {
+ return [
+ this.canBulkDestroyArtifacts && {
+ key: 'checkbox',
+ label: '',
+ },
+ ...this.$options.fields,
+ ];
+ },
+ anyArtifactsSelected() {
+ return Boolean(this.selectedArtifacts.length);
+ },
+ canBulkDestroyArtifacts() {
+ return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ },
},
methods: {
refetchArtifacts() {
@@ -158,6 +184,19 @@ export default {
this.expandedJobs.splice(this.expandedJobs.indexOf(id), 1);
}
},
+ selectArtifact(artifactNode, checked) {
+ if (checked) {
+ this.selectedArtifacts.push(artifactNode.id);
+ } else {
+ this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1);
+ }
+ },
+ clearSelectedArtifacts() {
+ this.selectedArtifacts = [];
+ },
+ showDeletedToast(deletedCount) {
+ this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(deletedCount));
+ },
downloadPath(job) {
return job.archive?.downloadPath;
},
@@ -217,9 +256,16 @@ export default {
<template>
<div>
<feedback-banner />
+ <artifacts-bulk-delete
+ v-if="canBulkDestroyArtifacts && anyArtifactsSelected"
+ :selected-artifacts="selectedArtifacts"
+ :query-variables="queryVariables"
+ @clearSelectedArtifacts="clearSelectedArtifacts"
+ @deleted="showDeletedToast"
+ />
<gl-table
:items="jobArtifacts"
- :fields="$options.fields"
+ :fields="fields"
:busy="$apollo.queries.jobArtifacts.loading"
stacked="sm"
details-td-class="gl-bg-gray-10! gl-p-0! gl-overflow-auto"
@@ -227,6 +273,29 @@ export default {
<template #table-busy>
<gl-loading-icon size="lg" />
</template>
+ <template v-if="canBulkDestroyArtifacts" #head(checkbox)>
+ <gl-form-checkbox
+ :disabled="!anyArtifactsSelected"
+ :checked="anyArtifactsSelected"
+ :indeterminate="anyArtifactsSelected"
+ @change="clearSelectedArtifacts"
+ />
+ </template>
+ <template
+ v-if="canBulkDestroyArtifacts"
+ #cell(checkbox)="{ item: { hasArtifacts, artifacts } }"
+ >
+ <job-checkbox
+ :has-artifacts="hasArtifacts"
+ :selected-artifacts="
+ artifacts.nodes.filter((node) => selectedArtifacts.includes(node.id))
+ "
+ :unselected-artifacts="
+ artifacts.nodes.filter((node) => !selectedArtifacts.includes(node.id))
+ "
+ @selectArtifact="selectArtifact"
+ />
+ </template>
<template
#cell(artifacts)="{ item: { id, artifacts, hasArtifacts }, toggleDetails, detailsShowing }"
>
@@ -323,8 +392,10 @@ export default {
<template #row-details="{ item: { artifacts } }">
<artifacts-table-row-details
:artifacts="artifacts"
+ :selected-artifacts="selectedArtifacts"
:query-variables="queryVariables"
@refetch="refetchArtifacts"
+ @selectArtifact="selectArtifact"
/>
</template>
</gl-table>
diff --git a/app/assets/javascripts/artifacts/components/job_checkbox.vue b/app/assets/javascripts/artifacts/components/job_checkbox.vue
new file mode 100644
index 00000000000..ce49b3f8678
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/job_checkbox.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+
+export default {
+ name: 'JobCheckbox',
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ hasArtifacts: {
+ type: Boolean,
+ required: true,
+ },
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ unselectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ disabled() {
+ return !this.hasArtifacts;
+ },
+ checked() {
+ return this.hasArtifacts && this.unselectedArtifacts.length === 0;
+ },
+ indeterminate() {
+ return this.selectedArtifacts.length > 0 && this.unselectedArtifacts.length > 0;
+ },
+ },
+ methods: {
+ handleInput(checked) {
+ if (checked) {
+ this.unselectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, true));
+ } else {
+ this.selectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, false));
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-checkbox
+ :disabled="disabled"
+ :checked="checked"
+ :indeterminate="indeterminate"
+ @input="handleInput"
+ />
+</template>
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js
index da562b03bf8..4ac20d963d1 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/artifacts/constants.js
@@ -54,6 +54,45 @@ export const I18N_FEEDBACK_BANNER_BODY = s__(
export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey');
export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8';
+export const BULK_DELETE_FEATURE_FLAG = 'ciJobArtifactBulkDestroy';
+export const I18N_BULK_DELETE_BANNER = (count) =>
+ sprintf(
+ n__(
+ 'Artifacts|%{strongStart}%{count}%{strongEnd} artifact selected',
+ 'Artifacts|%{strongStart}%{count}%{strongEnd} artifacts selected',
+ count,
+ ),
+ {
+ count,
+ },
+ );
+export const I18N_BULK_DELETE_CLEAR_SELECTION = s__('Artifacts|Clear selection');
+export const I18N_BULK_DELETE_DELETE_SELECTED = s__('Artifacts|Delete selected');
+
+export const BULK_DELETE_MODAL_ID = 'artifacts-bulk-delete-modal';
+export const I18N_BULK_DELETE_MODAL_TITLE = (count) =>
+ n__('Artifacts|Delete %d artifact?', 'Artifacts|Delete %d artifacts?', count);
+export const I18N_BULK_DELETE_BODY = (count) =>
+ sprintf(
+ n__(
+ 'Artifacts|The selected artifact will be permanently deleted. Any reports generated from these artifacts will be empty.',
+ 'Artifacts|The selected artifacts will be permanently deleted. Any reports generated from these artifacts will be empty.',
+ count,
+ ),
+ { count },
+ );
+export const I18N_BULK_DELETE_ACTION = (count) =>
+ n__('Artifacts|Delete %d artifact', 'Artifacts|Delete %d artifacts', count);
+
+export const I18N_BULK_DELETE_PARTIAL_ERROR = s__(
+ 'Artifacts|An error occurred while deleting. Some artifacts may not have been deleted.',
+);
+export const I18N_BULK_DELETE_ERROR = s__(
+ 'Artifacts|Something went wrong while deleting. Please refresh the page to try again.',
+);
+export const I18N_BULK_DELETE_CONFIRMATION_TOAST = (count) =>
+ n__('Artifacts|%d selected artifact deleted', 'Artifacts|%d selected artifacts deleted', count);
+
export const INITIAL_CURRENT_PAGE = 1;
export const INITIAL_PREVIOUS_PAGE_CURSOR = '';
export const INITIAL_NEXT_PAGE_CURSOR = '';
diff --git a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
new file mode 100644
index 00000000000..421b9258ca0
--- /dev/null
+++ b/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
@@ -0,0 +1,7 @@
+mutation bulkDestroyJobArtifacts($projectId: ProjectID!, $ids: [CiJobArtifactID!]!) {
+ bulkDestroyJobArtifacts(input: { projectId: $projectId, ids: $ids }) {
+ destroyedCount
+ destroyedIds
+ errors
+ }
+}
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js
index a62b3daa961..6e795fd9bd7 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/artifacts/index.js
@@ -1,3 +1,4 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
@@ -5,6 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import App from './components/app.vue';
Vue.use(VueApollo);
+Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -17,13 +19,19 @@ export const initArtifactsTable = () => {
return false;
}
- const { projectPath, canDestroyArtifacts, artifactsManagementFeedbackImagePath } = el.dataset;
+ const {
+ projectPath,
+ projectId,
+ canDestroyArtifacts,
+ artifactsManagementFeedbackImagePath,
+ } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
+ projectId,
canDestroyArtifacts: parseBoolean(canDestroyArtifacts),
artifactsManagementFeedbackImagePath,
},
diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js
index 52ed67b8c7b..29dcab9ed4d 100644
--- a/app/assets/javascripts/authentication/mount_2fa.js
+++ b/app/assets/javascripts/authentication/mount_2fa.js
@@ -1,29 +1,9 @@
-import $ from 'jquery';
-import initU2F from './u2f';
-import U2FRegister from './u2f/register';
-import initWebauthn from './webauthn';
-import WebAuthnRegister from './webauthn/register';
+import { initWebauthnAuthenticate, initWebauthnRegister } from './webauthn';
export const mount2faAuthentication = () => {
- if (gon.webauthn) {
- initWebauthn();
- } else {
- initU2F();
- }
+ initWebauthnAuthenticate();
};
export const mount2faRegistration = () => {
- const el = $('#js-register-token-2fa');
-
- if (!el.length) {
- return;
- }
-
- if (gon.webauthn) {
- const webauthnRegister = new WebAuthnRegister(el, gon.webauthn);
- webauthnRegister.start();
- } else {
- const u2fRegister = new U2FRegister(el, gon.u2f);
- u2fRegister.start();
- }
+ initWebauthnRegister();
};
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
index 484c6524d0e..98ed2a31730 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue
@@ -6,10 +6,8 @@ import { __ } from '~/locale';
export const i18n = {
currentPassword: __('Current password'),
confirmTitle: __('Are you sure?'),
- confirmWebAuthn: __(
- 'This will invalidate your registered applications and U2F / WebAuthn devices.',
- ),
- confirm: __('This will invalidate your registered applications and U2F devices.'),
+ confirmWebAuthn: __('This will invalidate your registered applications and WebAuthn devices.'),
+ confirm: __('This will invalidate your registered applications and WebAuthn devices.'),
disableTwoFactor: __('Disable two-factor authentication'),
disable: __('Disable'),
cancel: __('Cancel'),
diff --git a/app/assets/javascripts/authentication/u2f/authenticate.js b/app/assets/javascripts/authentication/u2f/authenticate.js
deleted file mode 100644
index 22eca904f32..00000000000
--- a/app/assets/javascripts/authentication/u2f/authenticate.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import $ from 'jquery';
-import { template as lodashTemplate, omit } from 'lodash';
-import U2FError from './error';
-import importU2FLibrary from './util';
-
-// Authenticate U2F (universal 2nd factor) devices for users to authenticate with.
-//
-// State Flow #1: setup -> in_progress -> authenticated -> POST to server
-// State Flow #2: setup -> in_progress -> error -> setup
-export default class U2FAuthenticate {
- constructor(container, form, u2fParams, fallbackButton, fallbackUI) {
- this.u2fUtils = null;
- this.container = container;
- this.renderAuthenticated = this.renderAuthenticated.bind(this);
- this.renderError = this.renderError.bind(this);
- this.renderInProgress = this.renderInProgress.bind(this);
- this.renderTemplate = this.renderTemplate.bind(this);
- this.authenticate = this.authenticate.bind(this);
- this.start = this.start.bind(this);
- this.appId = u2fParams.app_id;
- this.challenge = u2fParams.challenge;
- this.form = form;
- this.fallbackButton = fallbackButton;
- this.fallbackUI = fallbackUI;
- if (this.fallbackButton) {
- this.fallbackButton.addEventListener('click', this.switchToFallbackUI.bind(this));
- }
-
- // The U2F Javascript API v1.1 requires a single challenge, with
- // _no challenges per-request_. The U2F Javascript API v1.0 requires a
- // challenge per-request, which is done by copying the single challenge
- // into every request.
- //
- // In either case, we don't need the per-request challenges that the server
- // has generated, so we can remove them.
- //
- // Note: The server library fixes this behaviour in (unreleased) version 1.0.0.
- // This can be removed once we upgrade.
- // https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4
- this.signRequests = u2fParams.sign_requests.map((request) => omit(request, 'challenge'));
-
- this.templates = {
- inProgress: '#js-authenticate-token-2fa-in-progress',
- error: '#js-authenticate-token-2fa-error',
- authenticated: '#js-authenticate-token-2fa-authenticated',
- };
- }
-
- start() {
- return importU2FLibrary()
- .then((utils) => {
- this.u2fUtils = utils;
- this.renderInProgress();
- })
- .catch(() => this.switchToFallbackUI());
- }
-
- authenticate() {
- return this.u2fUtils.sign(
- this.appId,
- this.challenge,
- this.signRequests,
- (response) => {
- if (response.errorCode) {
- const error = new U2FError(response.errorCode, 'authenticate');
- return this.renderError(error);
- }
- return this.renderAuthenticated(JSON.stringify(response));
- },
- 10,
- );
- }
-
- renderTemplate(name, params) {
- const templateString = $(this.templates[name]).html();
- const template = lodashTemplate(templateString);
- return this.container.html(template(params));
- }
-
- renderInProgress() {
- this.renderTemplate('inProgress');
- return this.authenticate();
- }
-
- renderError(error) {
- this.renderTemplate('error', {
- error_message: error.message(),
- error_name: error.errorCode,
- });
- return this.container.find('#js-token-2fa-try-again').on('click', this.renderInProgress);
- }
-
- renderAuthenticated(deviceResponse) {
- this.renderTemplate('authenticated');
- const container = this.container[0];
- container.querySelector('#js-device-response').value = deviceResponse;
- container.querySelector(this.form).submit();
- this.fallbackButton.classList.add('hidden');
- }
-
- switchToFallbackUI() {
- this.fallbackButton.classList.add('hidden');
- this.container[0].classList.add('hidden');
- this.fallbackUI.classList.remove('hidden');
- }
-}
diff --git a/app/assets/javascripts/authentication/u2f/error.js b/app/assets/javascripts/authentication/u2f/error.js
deleted file mode 100644
index ca0fc0700ad..00000000000
--- a/app/assets/javascripts/authentication/u2f/error.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { __ } from '~/locale';
-
-export default class U2FError {
- constructor(errorCode, u2fFlowType) {
- this.errorCode = errorCode;
- this.message = this.message.bind(this);
- this.httpsDisabled = window.location.protocol !== 'https:';
- this.u2fFlowType = u2fFlowType;
- }
-
- message() {
- if (this.errorCode === window.u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled) {
- return __(
- 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.',
- );
- } else if (this.errorCode === window.u2f.ErrorCodes.DEVICE_INELIGIBLE) {
- if (this.u2fFlowType === 'authenticate') {
- return __('This device has not been registered with us.');
- }
- if (this.u2fFlowType === 'register') {
- return __('This device has already been registered with us.');
- }
- }
- return __('There was a problem communicating with your device.');
- }
-}
diff --git a/app/assets/javascripts/authentication/u2f/index.js b/app/assets/javascripts/authentication/u2f/index.js
deleted file mode 100644
index f129acca1c3..00000000000
--- a/app/assets/javascripts/authentication/u2f/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import $ from 'jquery';
-import U2FAuthenticate from './authenticate';
-
-export default () => {
- if (!gon.u2f) return;
-
- const u2fAuthenticate = new U2FAuthenticate(
- $('#js-authenticate-token-2fa'),
- '#js-login-token-2fa-form',
- gon.u2f,
- document.querySelector('#js-login-2fa-device'),
- document.querySelector('.js-2fa-form'),
- );
- u2fAuthenticate.start();
- // needed in rspec (FakeU2fDevice)
- gl.u2fAuthenticate = u2fAuthenticate;
-};
diff --git a/app/assets/javascripts/authentication/u2f/register.js b/app/assets/javascripts/authentication/u2f/register.js
deleted file mode 100644
index 6c98f0978bc..00000000000
--- a/app/assets/javascripts/authentication/u2f/register.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import $ from 'jquery';
-import { template as lodashTemplate } from 'lodash';
-import { __ } from '~/locale';
-import U2FError from './error';
-import importU2FLibrary from './util';
-
-// Register U2F (universal 2nd factor) devices for users to authenticate with.
-//
-// State Flow #1: setup -> in_progress -> registered -> POST to server
-// State Flow #2: setup -> in_progress -> error -> setup
-export default class U2FRegister {
- constructor(container, u2fParams) {
- this.u2fUtils = null;
- this.container = container;
- this.renderNotSupported = this.renderNotSupported.bind(this);
- this.renderRegistered = this.renderRegistered.bind(this);
- this.renderError = this.renderError.bind(this);
- this.renderInProgress = this.renderInProgress.bind(this);
- this.renderSetup = this.renderSetup.bind(this);
- this.renderTemplate = this.renderTemplate.bind(this);
- this.register = this.register.bind(this);
- this.start = this.start.bind(this);
- this.appId = u2fParams.app_id;
- this.registerRequests = u2fParams.register_requests;
- this.signRequests = u2fParams.sign_requests;
-
- this.templates = {
- message: '#js-register-2fa-message',
- setup: '#js-register-token-2fa-setup',
- error: '#js-register-token-2fa-error',
- registered: '#js-register-token-2fa-registered',
- };
- }
-
- start() {
- return importU2FLibrary()
- .then((utils) => {
- this.u2fUtils = utils;
- this.renderSetup();
- })
- .catch(() => this.renderNotSupported());
- }
-
- register() {
- return this.u2fUtils.register(
- this.appId,
- this.registerRequests,
- this.signRequests,
- (response) => {
- if (response.errorCode) {
- const error = new U2FError(response.errorCode, 'register');
- return this.renderError(error);
- }
- return this.renderRegistered(JSON.stringify(response));
- },
- 10,
- );
- }
-
- renderTemplate(name, params) {
- const templateString = $(this.templates[name]).html();
- const template = lodashTemplate(templateString);
- return this.container.html(template(params));
- }
-
- renderSetup() {
- this.renderTemplate('setup');
- return this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress);
- }
-
- renderInProgress() {
- this.renderTemplate('message', {
- message: __(
- 'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
- ),
- });
- return this.register();
- }
-
- renderError(error) {
- this.renderTemplate('error', {
- error_message: error.message(),
- error_name: error.errorCode,
- });
- return this.container.find('#js-token-2fa-try-again').on('click', this.renderSetup);
- }
-
- renderRegistered(deviceResponse) {
- this.renderTemplate('registered');
- // Prefer to do this instead of interpolating using Underscore templates
- // because of JSON escaping issues.
- return this.container.find('#js-device-response').val(deviceResponse);
- }
-
- renderNotSupported() {
- return this.renderTemplate('message', {
- message: __(
- "Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).",
- ),
- });
- }
-}
diff --git a/app/assets/javascripts/authentication/u2f/util.js b/app/assets/javascripts/authentication/u2f/util.js
deleted file mode 100644
index b706481c02f..00000000000
--- a/app/assets/javascripts/authentication/u2f/util.js
+++ /dev/null
@@ -1,40 +0,0 @@
-function isOpera(userAgent) {
- return userAgent.indexOf('Opera') >= 0 || userAgent.indexOf('OPR') >= 0;
-}
-
-function getOperaVersion(userAgent) {
- const match = userAgent.match(/OPR[^0-9]*([0-9]+)[^0-9]+/);
- return match ? parseInt(match[1], 10) : false;
-}
-
-function isChrome(userAgent) {
- return userAgent.indexOf('Chrom') >= 0 && !isOpera(userAgent);
-}
-
-function getChromeVersion(userAgent) {
- const match = userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\./);
- return match ? parseInt(match[1], 10) : false;
-}
-
-export function canInjectU2fApi(userAgent) {
- const isSupportedChrome = isChrome(userAgent) && getChromeVersion(userAgent) >= 41;
- const isSupportedOpera = isOpera(userAgent) && getOperaVersion(userAgent) >= 40;
- const isMobile =
- userAgent.indexOf('droid') >= 0 ||
- userAgent.indexOf('CriOS') >= 0 ||
- /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
- return (isSupportedChrome || isSupportedOpera) && !isMobile;
-}
-
-export default function importU2FLibrary() {
- if (window.u2f) {
- return Promise.resolve(window.u2f);
- }
-
- const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
- if (canInjectU2fApi(userAgent) || (gon && gon.test_env)) {
- return import(/* webpackMode: "eager" */ 'vendor/u2f').then(() => window.u2f);
- }
-
- return Promise.reject();
-}
diff --git a/app/assets/javascripts/authentication/webauthn/authenticate.js b/app/assets/javascripts/authentication/webauthn/authenticate.js
index 47cb7a40f76..748945a680b 100644
--- a/app/assets/javascripts/authentication/webauthn/authenticate.js
+++ b/app/assets/javascripts/authentication/webauthn/authenticate.js
@@ -1,3 +1,4 @@
+import { WEBAUTHN_AUTHENTICATE } from './constants';
import WebAuthnError from './error';
import WebAuthnFlow from './flow';
import { supported, convertGetParams, convertGetResponse } from './util';
@@ -44,7 +45,7 @@ export default class WebAuthnAuthenticate {
this.renderAuthenticated(JSON.stringify(convertedResponse));
})
.catch((err) => {
- this.flow.renderError(new WebAuthnError(err, 'authenticate'));
+ this.flow.renderError(new WebAuthnError(err, WEBAUTHN_AUTHENTICATE));
});
}
diff --git a/app/assets/javascripts/authentication/webauthn/components/registration.vue b/app/assets/javascripts/authentication/webauthn/components/registration.vue
new file mode 100644
index 00000000000..84132a7d062
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/components/registration.vue
@@ -0,0 +1,226 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormInput,
+ GlFormGroup,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+} from '@gitlab/ui';
+import {
+ I18N_BUTTON_REGISTER,
+ I18N_BUTTON_SETUP,
+ I18N_BUTTON_TRY_AGAIN,
+ I18N_DEVICE_NAME,
+ I18N_DEVICE_NAME_DESCRIPTION,
+ I18N_DEVICE_NAME_PLACEHOLDER,
+ I18N_ERROR_HTTP,
+ I18N_ERROR_UNSUPPORTED_BROWSER,
+ I18N_INFO_TEXT,
+ I18N_NOTICE,
+ I18N_PASSWORD,
+ I18N_PASSWORD_DESCRIPTION,
+ I18N_STATUS_SUCCESS,
+ I18N_STATUS_WAITING,
+ STATE_ERROR,
+ STATE_READY,
+ STATE_SUCCESS,
+ STATE_UNSUPPORTED,
+ STATE_WAITING,
+ WEBAUTHN_DOCUMENTATION_PATH,
+ WEBAUTHN_REGISTER,
+} from '~/authentication/webauthn/constants';
+import WebAuthnError from '~/authentication/webauthn/error';
+import {
+ convertCreateParams,
+ convertCreateResponse,
+ isHTTPS,
+ supported,
+} from '~/authentication/webauthn/util';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ name: 'WebAuthnRegistration',
+ components: {
+ GlAlert,
+ GlButton,
+ GlForm,
+ GlFormInput,
+ GlFormGroup,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ I18N_BUTTON_REGISTER,
+ I18N_BUTTON_SETUP,
+ I18N_BUTTON_TRY_AGAIN,
+ I18N_DEVICE_NAME,
+ I18N_DEVICE_NAME_DESCRIPTION,
+ I18N_DEVICE_NAME_PLACEHOLDER,
+ I18N_ERROR_HTTP,
+ I18N_ERROR_UNSUPPORTED_BROWSER,
+ I18N_INFO_TEXT,
+ I18N_NOTICE,
+ I18N_PASSWORD,
+ I18N_PASSWORD_DESCRIPTION,
+ I18N_STATUS_SUCCESS,
+ I18N_STATUS_WAITING,
+ STATE_ERROR,
+ STATE_READY,
+ STATE_SUCCESS,
+ STATE_UNSUPPORTED,
+ STATE_WAITING,
+ WEBAUTHN_DOCUMENTATION_PATH,
+ inject: ['initialError', 'passwordRequired', 'targetPath'],
+ data() {
+ return {
+ csrfToken: csrf.token,
+ form: { deviceName: '', password: '' },
+ state: STATE_UNSUPPORTED,
+ errorMessage: this.initialError,
+ credentials: null,
+ };
+ },
+ computed: {
+ disabled() {
+ const isEmptyDeviceName = this.form.deviceName.trim() === '';
+ const isEmptyPassword = this.form.password.trim() === '';
+
+ if (this.passwordRequired === false) {
+ return isEmptyDeviceName;
+ }
+
+ return isEmptyDeviceName || isEmptyPassword;
+ },
+ },
+ created() {
+ if (this.errorMessage) {
+ this.state = STATE_ERROR;
+ return;
+ }
+
+ if (supported()) {
+ this.state = STATE_READY;
+ return;
+ }
+
+ this.errorMessage = isHTTPS() ? I18N_ERROR_UNSUPPORTED_BROWSER : I18N_ERROR_HTTP;
+ },
+ methods: {
+ isCurrentState(state) {
+ return this.state === state;
+ },
+ async onRegister() {
+ this.state = STATE_WAITING;
+
+ try {
+ const credentials = await navigator.credentials.create({
+ publicKey: convertCreateParams(gon.webauthn.options),
+ });
+
+ this.credentials = JSON.stringify(convertCreateResponse(credentials));
+ this.state = STATE_SUCCESS;
+ } catch (error) {
+ this.errorMessage = new WebAuthnError(error, WEBAUTHN_REGISTER).message();
+ this.state = STATE_ERROR;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <template v-if="isCurrentState($options.STATE_UNSUPPORTED)">
+ <gl-alert variant="danger" :dismissible="false">{{ errorMessage }}</gl-alert>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_READY)">
+ <div class="row">
+ <div class="col-md-5">
+ <gl-button variant="confirm" @click="onRegister">{{
+ $options.I18N_BUTTON_SETUP
+ }}</gl-button>
+ </div>
+ <div class="col-md-7">
+ <p>{{ $options.I18N_INFO_TEXT }}</p>
+ </div>
+ </div>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_WAITING)">
+ <gl-alert :dismissible="false">
+ {{ $options.I18N_STATUS_WAITING }}
+ <gl-loading-icon />
+ </gl-alert>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_SUCCESS)">
+ <p>{{ $options.I18N_STATUS_SUCCESS }}</p>
+ <gl-alert :dismissible="false" class="gl-mb-5">
+ <gl-sprintf :message="$options.I18N_NOTICE">
+ <template #link="{ content }">
+ <gl-link :href="$options.WEBAUTHN_DOCUMENTATION_PATH" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <div class="row">
+ <gl-form method="post" :action="targetPath" class="col-md-9" data-testid="create-webauthn">
+ <gl-form-group
+ v-if="passwordRequired"
+ :description="$options.I18N_PASSWORD_DESCRIPTION"
+ :label="$options.I18N_PASSWORD"
+ label-for="webauthn-registration-current-password"
+ >
+ <gl-form-input
+ id="webauthn-registration-current-password"
+ v-model="form.password"
+ name="current_password"
+ type="password"
+ autocomplete="current-password"
+ data-testid="current-password-input"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ :description="$options.I18N_DEVICE_NAME_DESCRIPTION"
+ :label="$options.I18N_DEVICE_NAME"
+ label-for="device-name"
+ >
+ <gl-form-input
+ id="device-name"
+ v-model="form.deviceName"
+ name="device_registration[name]"
+ :placeholder="$options.I18N_DEVICE_NAME_PLACEHOLDER"
+ data-testid="device-name-input"
+ />
+ </gl-form-group>
+
+ <input type="hidden" name="device_registration[device_response]" :value="credentials" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+
+ <gl-button type="submit" :disabled="disabled" variant="confirm">{{
+ $options.I18N_BUTTON_REGISTER
+ }}</gl-button>
+ </gl-form>
+ </div>
+ </template>
+
+ <template v-else-if="isCurrentState($options.STATE_ERROR)">
+ <gl-alert
+ variant="danger"
+ :dismissible="false"
+ class="gl-mb-5"
+ :secondary-button-text="$options.I18N_BUTTON_TRY_AGAIN"
+ @secondaryAction="onRegister"
+ >
+ {{ errorMessage }}
+ </gl-alert>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/authentication/webauthn/constants.js b/app/assets/javascripts/authentication/webauthn/constants.js
new file mode 100644
index 00000000000..c41e6d2bd58
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/constants.js
@@ -0,0 +1,46 @@
+import { __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const I18N_BUTTON_REGISTER = __('Register device');
+export const I18N_BUTTON_SETUP = __('Set up new device');
+export const I18N_BUTTON_TRY_AGAIN = __('Try again?');
+export const I18N_DEVICE_NAME = __('Device name');
+export const I18N_DEVICE_NAME_DESCRIPTION = __(
+ 'Excluding USB security keys, you should include the browser name together with the device name.',
+);
+export const I18N_DEVICE_NAME_PLACEHOLDER = __('Macbook Touch ID on Edge');
+export const I18N_ERROR_HTTP = __(
+ 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.',
+);
+export const I18N_ERROR_UNSUPPORTED_BROWSER = __(
+ "Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+).",
+);
+export const I18N_INFO_TEXT = __(
+ 'Your device needs to be set up. Plug it in (if needed) and click the button on the left.',
+);
+export const I18N_NOTICE = __(
+ 'You must save your recovery codes after you first register a two-factor authenticator, so you do not lose access to your account. %{linkStart}See the documentation on managing your WebAuthn device for more information.%{linkEnd}',
+);
+export const I18N_PASSWORD = __('Current password');
+export const I18N_PASSWORD_DESCRIPTION = __(
+ 'Your current password is required to register a new device.',
+);
+export const I18N_STATUS_SUCCESS = __(
+ 'Your device was successfully set up! Give it a name and register it with the GitLab server.',
+);
+export const I18N_STATUS_WAITING = __(
+ 'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
+);
+
+export const STATE_ERROR = 'error';
+export const STATE_READY = 'ready';
+export const STATE_SUCCESS = 'success';
+export const STATE_UNSUPPORTED = 'unsupported';
+export const STATE_WAITING = 'waiting';
+
+export const WEBAUTHN_AUTHENTICATE = 'authenticate';
+export const WEBAUTHN_REGISTER = 'register';
+export const WEBAUTHN_DOCUMENTATION_PATH = helpPagePath(
+ 'user/profile/account/two_factor_authentication',
+ { anchor: 'set-up-a-webauthn-device' },
+);
diff --git a/app/assets/javascripts/authentication/webauthn/error.js b/app/assets/javascripts/authentication/webauthn/error.js
index a1a3f861c25..40dbecd8bc9 100644
--- a/app/assets/javascripts/authentication/webauthn/error.js
+++ b/app/assets/javascripts/authentication/webauthn/error.js
@@ -1,5 +1,6 @@
import { __ } from '~/locale';
-import { isHTTPS, FLOW_AUTHENTICATE, FLOW_REGISTER } from './util';
+import { WEBAUTHN_AUTHENTICATE, WEBAUTHN_REGISTER } from './constants';
+import { isHTTPS } from './util';
export default class WebAuthnError {
constructor(error, flowType) {
@@ -13,9 +14,9 @@ export default class WebAuthnError {
message() {
if (this.errorName === 'NotSupportedError') {
return __('Your device is not compatible with GitLab. Please try another device');
- } else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_AUTHENTICATE) {
+ } else if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_AUTHENTICATE) {
return __('This device has not been registered with us.');
- } else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_REGISTER) {
+ } else if (this.errorName === 'InvalidStateError' && this.flowType === WEBAUTHN_REGISTER) {
return __('This device has already been registered with us.');
} else if (this.errorName === 'SecurityError' && this.httpsDisabled) {
return __(
diff --git a/app/assets/javascripts/authentication/webauthn/index.js b/app/assets/javascripts/authentication/webauthn/index.js
index bbf694c7698..1fbe89d1097 100644
--- a/app/assets/javascripts/authentication/webauthn/index.js
+++ b/app/assets/javascripts/authentication/webauthn/index.js
@@ -1,7 +1,12 @@
import $ from 'jquery';
import WebAuthnAuthenticate from './authenticate';
+import WebAuthnRegister from './register';
+
+export const initWebauthnAuthenticate = () => {
+ if (!gon.webauthn) {
+ return;
+ }
-export default () => {
const webauthnAuthenticate = new WebAuthnAuthenticate(
$('#js-authenticate-token-2fa'),
'#js-login-token-2fa-form',
@@ -11,3 +16,14 @@ export default () => {
);
webauthnAuthenticate.start();
};
+
+export const initWebauthnRegister = () => {
+ const el = $('#js-register-token-2fa');
+
+ if (!el.length) {
+ return;
+ }
+
+ const webauthnRegister = new WebAuthnRegister(el, gon.webauthn);
+ webauthnRegister.start();
+};
diff --git a/app/assets/javascripts/authentication/webauthn/register.js b/app/assets/javascripts/authentication/webauthn/register.js
index 62ebf85abe4..c00d3ede2c1 100644
--- a/app/assets/javascripts/authentication/webauthn/register.js
+++ b/app/assets/javascripts/authentication/webauthn/register.js
@@ -2,6 +2,7 @@ import { __ } from '~/locale';
import WebAuthnError from './error';
import WebAuthnFlow from './flow';
import { supported, isHTTPS, convertCreateParams, convertCreateResponse } from './util';
+import { WEBAUTHN_REGISTER } from './constants';
// Register WebAuthn devices for users to authenticate with.
//
@@ -40,7 +41,7 @@ export default class WebAuthnRegister {
publicKey: this.webauthnOptions,
})
.then((cred) => this.renderRegistered(JSON.stringify(convertCreateResponse(cred))))
- .catch((err) => this.flow.renderError(new WebAuthnError(err, 'register')));
+ .catch((err) => this.flow.renderError(new WebAuthnError(err, WEBAUTHN_REGISTER)));
}
renderSetup() {
diff --git a/app/assets/javascripts/authentication/webauthn/registration.js b/app/assets/javascripts/authentication/webauthn/registration.js
new file mode 100644
index 00000000000..67906a24857
--- /dev/null
+++ b/app/assets/javascripts/authentication/webauthn/registration.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import WebAuthnRegistration from '~/authentication/webauthn/components/registration.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export const initWebAuthnRegistration = () => {
+ const el = document.querySelector('#js-device-registration');
+
+ if (!el) {
+ return null;
+ }
+
+ const { initialError, passwordRequired, targetPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'WebAuthnRegistrationRoot',
+ provide: { initialError, passwordRequired: parseBoolean(passwordRequired), targetPath },
+ render(h) {
+ return h(WebAuthnRegistration);
+ },
+ });
+};
diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js
index 2a0740cf488..0ff0f0e6a29 100644
--- a/app/assets/javascripts/authentication/webauthn/util.js
+++ b/app/assets/javascripts/authentication/webauthn/util.js
@@ -1,9 +1,6 @@
export function supported() {
return Boolean(
- navigator.credentials &&
- navigator.credentials.create &&
- navigator.credentials.get &&
- window.PublicKeyCredential,
+ navigator.credentials?.create && navigator.credentials?.get && window.PublicKeyCredential,
);
}
@@ -11,9 +8,6 @@ export function isHTTPS() {
return window.location.protocol.startsWith('https');
}
-export const FLOW_AUTHENTICATE = 'authenticate';
-export const FLOW_REGISTER = 'register';
-
/**
* Converts a base64 string to an ArrayBuffer
*
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 1855fb9ed8c..de67e01d650 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -7,7 +7,7 @@ import { getEmojiScoreWithIntent } from '~/emoji/utils';
import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils';
import * as Emoji from '~/emoji';
import { dispose, fixTitle } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index c95c90d5daf..1a80030c7e6 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -3,7 +3,7 @@ import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
import { escape, debounce } from 'lodash';
import { mapActions, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { s__, sprintf } from '~/locale';
import createEmptyBadge from '../empty_badge';
import { PLACEHOLDERS } from '../constants';
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
index a7a21d65475..09f997d73aa 100644
--- a/app/assets/javascripts/badges/components/badge_settings.vue
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -1,7 +1,7 @@
<script>
import { GlSprintf, GlModal } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { __, s__ } from '~/locale';
import Badge from './badge.vue';
import BadgeForm from './badge_form.vue';
@@ -26,7 +26,7 @@ export default {
primaryProps() {
return {
text: __('Delete badge'),
- attributes: [{ category: 'primary' }, { variant: 'danger' }],
+ attributes: { category: 'primary', variant: 'danger' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index cc524c71c1e..b78874d372c 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import NoteableNote from '~/notes/components/noteable_note.vue';
@@ -11,6 +11,7 @@ export default {
},
directives: {
SafeHtml,
+ GlTooltip: GlTooltipDirective,
},
props: {
draft: {
@@ -95,7 +96,15 @@ export default {
@mouseleave.native="handleMouseLeave(draft)"
>
<template #note-header-info>
- <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge>
+ <gl-badge
+ v-gl-tooltip
+ variant="warning"
+ size="sm"
+ class="gl-mr-2"
+ :title="__('Pending comments are hidden until you submit your review.')"
+ >
+ {{ __('Pending') }}
+ </gl-badge>
</template>
<template v-if="!isEditingDraft" #after-note-body>
<div
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index 4ac0c8c4894..ca9cb03ca37 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -55,21 +55,25 @@ export default {
<template>
<gl-disclosure-dropdown :items="listItems" dropup data-qa-selector="review_preview_dropdown">
<template #toggle>
- <gl-button
- >{{ __('Pending comments') }} <drafts-count variant="neutral" /><gl-icon
- class="dropdown-chevron"
- name="chevron-up"
- /></gl-button>
+ <gl-button>
+ {{ __('Pending comments') }}
+ <drafts-count variant="neutral" />
+ <gl-icon class="dropdown-chevron" name="chevron-up" />
+ </gl-button>
</template>
<template #header>
- <p class="gl-dropdown-header-top">
- {{ n__('%d pending comment', '%d pending comments', draftsCount) }}
- </p>
+ <div
+ class="gl-display-flex gl-align-items-center gl-p-4! gl-min-h-8 gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"
+ >
+ <span class="gl-flex-grow-1 gl-font-weight-bold gl-font-sm gl-pr-2">
+ {{ n__('%d pending comment', '%d pending comments', draftsCount) }}
+ </span>
+ </div>
</template>
<template #list-item="{ item }">
- <preview-item :draft="item" :is-last="item.last" @click="onClickDraft(item)" />
+ <preview-item :draft="item" :is-last="item.last" />
</template>
</gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index ed0481e7a48..beda251aa1e 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -9,7 +9,7 @@ import {
GlFormCheckbox,
} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import Autosave from '~/autosave';
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
index 65fd34dcb00..2a8786134cc 100644
--- a/app/assets/javascripts/batch_comments/index.js
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -1,17 +1,25 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mapActions, mapGetters } from 'vuex';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import store from '~/mr_notes/stores';
export const initReviewBar = () => {
const el = document.getElementById('js-review-bar');
+ Vue.use(VueApollo);
+
// eslint-disable-next-line no-new
new Vue({
el,
store,
+ apolloProvider,
components: {
ReviewBar: () => import('./components/review_bar.vue'),
},
+ provide: {
+ newSavedRepliesPath: el.dataset.savedRepliesNewPath,
+ },
computed: {
...mapGetters('batchComments', ['draftsCount']),
},
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index feac6f10b1e..f6eae7c0c83 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -1,5 +1,5 @@
import { isEmpty } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
@@ -167,3 +167,5 @@ export const expandAllDiscussions = ({ dispatch, state }) =>
export const toggleResolveDiscussion = ({ commit }, draftId) => {
commit(types.TOGGLE_RESOLVE_DISCUSSION, draftId);
};
+
+export const clearDrafts = ({ commit }) => commit(types.CLEAR_DRAFTS);
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
index df523a692d3..67bcc53ac7d 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js
@@ -14,3 +14,5 @@ export const RECEIVE_PUBLISH_REVIEW_ERROR = 'RECEIVE_PUBLISH_REVIEW_ERROR';
export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS';
export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION';
+
+export const CLEAR_DRAFTS = 'CLEAR_DRAFTS';
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
index dabfe864575..384d7904ac7 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js
@@ -62,4 +62,7 @@ export default {
return draft;
});
},
+ [types.CLEAR_DRAFTS](state) {
+ state.drafts = [];
+ },
};
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js
index 970864eef74..218a402772f 100644
--- a/app/assets/javascripts/behaviors/copy_code.js
+++ b/app/assets/javascripts/behaviors/copy_code.js
@@ -5,6 +5,8 @@ import { setAttributes } from '~/lib/utils/dom_utils';
class CopyCodeButton extends HTMLElement {
connectedCallback() {
+ if (this.querySelector('.btn')) return;
+
this.for = uniqueId('code-');
const target = this.parentNode.querySelector('pre');
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 4b337dce8f3..834defe336b 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -10,10 +10,10 @@ const CLIPBOARD_ERROR_EVENT = 'clipboard-error';
const I18N_ERROR_MESSAGE = __('Copy failed. Please manually copy the value.');
function showTooltip(target, title) {
- const { title: originalTitle } = target.dataset;
+ const { originalTitle } = target.dataset;
once('hidden', (tooltip) => {
- if (tooltip.target === target) {
+ if (originalTitle && tooltip.target === target) {
target.setAttribute('title', originalTitle);
target.setAttribute('aria-label', originalTitle);
fixTitle(target);
diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js
index efd89ec4330..11fe01ca48d 100644
--- a/app/assets/javascripts/behaviors/date_picker.js
+++ b/app/assets/javascripts/behaviors/date_picker.js
@@ -27,7 +27,10 @@ export default function initDatePickers() {
$('.js-clear-due-date,.js-clear-start-date').on('click', (e) => {
e.preventDefault();
- const calendar = $(e.target).siblings('.datepicker').data('pikaday');
+ const calendar = $(e.target)
+ .siblings('.issuable-form-select-holder')
+ .children('.datepicker')
+ .data('pikaday');
calendar.setDate(null);
});
}
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 19ebab36481..36317444af9 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -164,7 +164,7 @@ export class CopyAsGFM {
static nodeToGFM(node) {
return Promise.all([
- import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ '@tiptap/pm/model'),
import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'),
import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'),
])
diff --git a/app/assets/javascripts/behaviors/markdown/render_json_table.js b/app/assets/javascripts/behaviors/markdown/render_json_table.js
index 4d9ac1d266b..aa0e7d38113 100644
--- a/app/assets/javascripts/behaviors/markdown/render_json_table.js
+++ b/app/assets/javascripts/behaviors/markdown/render_json_table.js
@@ -1,7 +1,7 @@
import { memoize } from 'lodash';
import Vue from 'vue';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
// Async import component since we might not need it...
const JSONTable = memoize(() =>
diff --git a/app/assets/javascripts/behaviors/markdown/render_observability.js b/app/assets/javascripts/behaviors/markdown/render_observability.js
index 704d85cf22e..6346fb8ab48 100644
--- a/app/assets/javascripts/behaviors/markdown/render_observability.js
+++ b/app/assets/javascripts/behaviors/markdown/render_observability.js
@@ -1,25 +1,19 @@
import Vue from 'vue';
-import { darkModeEnabled } from '~/lib/utils/color_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
-
-export function getFrameSrc(url) {
- return `${setUrlParams({ theme: darkModeEnabled() ? 'dark' : 'light' }, url)}&kiosk`;
-}
+import ObservabilityApp from '~/observability/components/observability_app.vue';
+import { SKELETON_VARIANT_EMBED, INLINE_EMBED_DIMENSIONS } from '~/observability/constants';
const mountVueComponent = (element) => {
- const url = [element.dataset.frameUrl];
-
+ const url = element.dataset.frameUrl;
return new Vue({
el: element,
render(h) {
- return h('iframe', {
- style: {
- height: '366px',
- width: '768px',
- },
- attrs: {
- src: getFrameSrc(url),
- frameBorder: '0',
+ return h(ObservabilityApp, {
+ props: {
+ observabilityIframeSrc: url,
+ inlineEmbed: true,
+ skeletonVariant: SKELETON_VARIANT_EMBED,
+ height: INLINE_EMBED_DIMENSIONS.HEIGHT,
+ width: INLINE_EMBED_DIMENSIONS.WIDTH,
},
});
},
@@ -27,7 +21,5 @@ const mountVueComponent = (element) => {
};
export default function renderObservability(elements) {
- elements.forEach((element) => {
- mountVueComponent(element);
- });
+ return elements.map(mountVueComponent);
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
index 66007aa9e3d..bd9e41ac0ba 100644
--- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
@@ -8,7 +8,7 @@ import {
} from '~/lib/utils/url_utility';
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { setAttributes, isElementVisible } from '~/lib/utils/dom_utils';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { unrestrictedPages } from './constants';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js
index 1b0f46ff4cb..31bab23c8b0 100644
--- a/app/assets/javascripts/behaviors/markdown/schema.js
+++ b/app/assets/javascripts/behaviors/markdown/schema.js
@@ -1,4 +1,4 @@
-import { Schema } from 'prosemirror-model';
+import { Schema } from '@tiptap/pm/model';
import editorExtensions from './editor_extensions';
const nodes = editorExtensions.nodes.reduce(
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 32e395e4f3c..dc408f5a950 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/behaviors/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts.js
index 12fdb2e2981..22a8be92e52 100644
--- a/app/assets/javascripts/behaviors/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts.js
@@ -26,12 +26,10 @@ export default function initPageShortcuts() {
// the pages above have their own shortcuts sub-classes instantiated elsewhere
// TODO: replace this whitelist with something more automated/maintainable
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/392845
if (page && !pagesWithCustomShortcuts.includes(page)) {
import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts/shortcuts')
- .then(({ default: Shortcuts }) => {
- const shortcuts = new Shortcuts();
- window.toggleShortcutsHelp = shortcuts.onToggleHelp;
- })
+ .then(({ default: Shortcuts }) => new Shortcuts())
.catch(() => {});
}
return false;
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 7a1577e97d5..6a7ce4f1c41 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -124,8 +124,11 @@ export default class Shortcuts {
e.preventDefault();
});
+ const shortcutsModalTriggerEvent = 'click.shortcutsModalTrigger';
// eslint-disable-next-line @gitlab/no-global-event-off
- $('.js-shortcuts-modal-trigger').off('click').on('click', this.onToggleHelp);
+ $(document)
+ .off(shortcutsModalTriggerEvent)
+ .on(shortcutsModalTriggerEvent, '.js-shortcuts-modal-trigger', this.onToggleHelp);
if (shouldDisableShortcuts()) {
disableShortcuts();
diff --git a/app/assets/javascripts/blame/blame_redirect.js b/app/assets/javascripts/blame/blame_redirect.js
index 155e2a3a2cd..f528fdb1f69 100644
--- a/app/assets/javascripts/blame/blame_redirect.js
+++ b/app/assets/javascripts/blame/blame_redirect.js
@@ -1,5 +1,5 @@
import { setUrlParams } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
export default function redirectToCorrectBlamePage() {
diff --git a/app/assets/javascripts/blame/streaming/index.js b/app/assets/javascripts/blame/streaming/index.js
new file mode 100644
index 00000000000..935343cca2e
--- /dev/null
+++ b/app/assets/javascripts/blame/streaming/index.js
@@ -0,0 +1,56 @@
+import { renderHtmlStreams } from '~/streaming/render_html_streams';
+import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
+import { toPolyfillReadable } from '~/streaming/polyfills';
+
+export async function renderBlamePageStreams(firstStreamPromise) {
+ const element = document.querySelector('#blame-stream-container');
+
+ if (!element || !firstStreamPromise) return;
+
+ const stopAnchorObserver = handleStreamedAnchorLink(element);
+ const { dataset } = document.querySelector('#blob-content-holder');
+ const totalExtraPages = parseInt(dataset.totalExtraPages, 10);
+ const { pagesUrl } = dataset;
+
+ const remainingStreams = rateLimitStreamRequests({
+ factory: (index) => {
+ const url = new URL(pagesUrl);
+ // page numbers start with 1
+ // the first page is already rendered in the document
+ // the second page is passed with the 'firstStreamPromise'
+ url.searchParams.set('page', index + 3);
+ return fetch(url).then((response) => toPolyfillReadable(response.body));
+ },
+ // we don't want to overload gitaly with concurrent requests
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/391842#note_1281695095
+ // using 5 as a good starting point
+ maxConcurrentRequests: 5,
+ total: totalExtraPages,
+ });
+
+ try {
+ await renderHtmlStreams(
+ [firstStreamPromise.then(toPolyfillReadable), ...remainingStreams],
+ element,
+ );
+ } catch (error) {
+ createAlert({
+ message: __('Blame could not be loaded as a single page.'),
+ primaryButton: {
+ text: __('View blame as separate pages'),
+ clickHandler() {
+ const newUrl = new URL(window.location);
+ newUrl.searchParams.delete('streaming');
+ window.location.href = newUrl;
+ },
+ },
+ });
+ throw error;
+ } finally {
+ stopAnchorObserver();
+ document.querySelector('#blame-stream-loading').remove();
+ }
+}
diff --git a/app/assets/javascripts/blob/csv/index.js b/app/assets/javascripts/blob/csv/index.js
index 4cf6c169c68..ed8e1ffa318 100644
--- a/app/assets/javascripts/blob/csv/index.js
+++ b/app/assets/javascripts/blob/csv/index.js
@@ -10,6 +10,7 @@ export default () => {
return createElement(CsvViewer, {
props: {
csv: el.dataset.data,
+ remoteFile: true,
},
});
},
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 2ea3c93625d..7ccb66f18a9 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import Api from '~/api';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
index ade92f2562b..acbd231c94a 100644
--- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue
+++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
@@ -83,4 +83,9 @@ export default {
.output img {
min-width: 0; /* https://www.w3.org/TR/css-flexbox-1/#min-size-auto */
}
+
+.output .markdown {
+ display: block;
+ width: 100%;
+}
</style>
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 5e85e4cea38..bdaefe8383c 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import {
REPO_BLOB_LOAD_VIEWER_START,
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 509d399273d..01d35a0980f 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
import NewCommitForm from '../new_commit_form';
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index a3d11d90ed2..f021553ae98 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -4,7 +4,7 @@ import { SourceEditorExtension } from '~/editor/extensions/source_editor_extensi
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext';
import SourceEditor from '~/editor/source_editor';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
import { insertFinalNewline } from '~/lib/utils/text_utility';
@@ -68,9 +68,9 @@ export default class EditBlob {
blobContent: editorEl.innerText,
});
this.editor.use([
+ { definition: ToolbarExtension },
{ definition: SourceEditorExtension },
{ definition: FileTemplateExtension },
- { definition: ToolbarExtension },
]);
fileNameEl.addEventListener('change', () => {
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index c5411ec313a..90f7059da86 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -1,13 +1,24 @@
<script>
-import { GlFormRadio, GlFormRadioGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import {
+ GlTooltipDirective as GlTooltip,
+ GlButton,
+ GlCollapsibleListbox,
+ GlIcon,
+} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
+import { __ } from '~/locale';
export default {
+ i18n: {
+ value: __('Value'),
+ noResults: __('No matching results'),
+ },
components: {
BoardAddNewColumnForm,
- GlFormRadio,
- GlFormRadioGroup,
+ GlButton,
+ GlCollapsibleListbox,
+ GlIcon,
},
directives: {
GlTooltip,
@@ -17,6 +28,7 @@ export default {
return {
selectedId: null,
selectedLabel: null,
+ selectedIdValid: true,
};
},
computed: {
@@ -25,6 +37,15 @@ export default {
columnForSelected() {
return this.getListByLabelId(this.selectedId);
},
+ items() {
+ return (
+ this.labels.map((i) => ({
+ ...i,
+ text: i.title,
+ value: i.id,
+ })) || []
+ );
+ },
},
created() {
this.filterItems();
@@ -33,6 +54,7 @@ export default {
...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
addList() {
if (!this.selectedLabel) {
+ this.selectedIdValid = false;
return;
}
@@ -61,53 +83,67 @@ export default {
this.selectedLabel = { ...label };
}
},
+ onHide() {
+ this.searchValue = '';
+ this.$emit('filter-items', '');
+ this.$emit('hide');
+ },
},
};
</script>
<template>
<board-add-new-column-form
- :loading="labelsLoading"
- :none-selected="__('Select a label')"
- :search-placeholder="__('Search labels')"
- :selected-id="selectedId"
+ :selected-id-valid="selectedIdValid"
@filter-items="filterItems"
@add-list="addList"
>
- <template #selected>
- <template v-if="selectedLabel">
- <span
- class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
- :style="{
- backgroundColor: selectedLabel.color,
- }"
- ></span>
- <div class="gl-text-truncate">{{ selectedLabel.title }}</div>
- </template>
- </template>
-
- <template #items>
- <gl-form-radio-group
- v-if="labels.length > 0"
- class="gl-overflow-y-auto gl-px-5 gl-pt-3"
- :checked="selectedId"
- @change="setSelectedItem"
+ <template #dropdown>
+ <gl-collapsible-listbox
+ class="gl-mb-3 gl-max-w-full"
+ :items="items"
+ searchable
+ :search-placeholder="__('Search labels')"
+ :searching="labelsLoading"
+ :selected="selectedId"
+ :no-results-text="$options.i18n.noResults"
+ @select="setSelectedItem"
+ @search="filterItems"
+ @hidden="onHide"
>
- <label
- v-for="label in labels"
- :key="label.id"
- class="gl-display-flex gl-mb-5 gl-font-weight-normal gl-overflow-break-word"
- >
- <gl-form-radio :value="label.id" />
- <span
- class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
- :style="{
- backgroundColor: label.color,
- }"
- ></span>
- <span>{{ label.title }}</span>
- </label>
- </gl-form-radio-group>
+ <template #toggle>
+ <gl-button
+ class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-truncate"
+ :class="{ 'gl-inset-border-1-red-400!': !selectedIdValid }"
+ button-text-classes="gl-display-flex"
+ >
+ <template v-if="selectedLabel">
+ <span
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
+ :style="{
+ backgroundColor: selectedLabel.color,
+ }"
+ ></span>
+ <div class="gl-text-truncate">{{ selectedLabel.title }}</div>
+ </template>
+
+ <template v-else>{{ __('Select a label') }}</template>
+ <gl-icon class="dropdown-chevron gl-ml-2" name="chevron-down" />
+ </gl-button>
+ </template>
+
+ <template #list-item="{ item }">
+ <label class="gl-display-flex gl-font-weight-normal gl-overflow-break-word gl-mb-0">
+ <span
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
+ :style="{
+ backgroundColor: item.color,
+ }"
+ ></span>
+ <span>{{ item.title }}</span>
+ </label>
+ </template>
+ </gl-collapsible-listbox>
</template>
</board-add-new-column-form>
</template>
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index 1899d42fa4d..259423df07f 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -1,12 +1,5 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlFormGroup,
- GlIcon,
- GlSearchBoxByType,
- GlSkeletonLoader,
-} from '@gitlab/ui';
+import { GlButton, GlFormGroup } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
@@ -15,81 +8,34 @@ export default {
add: __('Add to board'),
cancel: __('Cancel'),
newList: __('New list'),
- noResults: __('No matching results'),
scope: __('Scope'),
scopeDescription: __('Issues must match this scope to appear in this list.'),
- selected: __('Selected'),
requiredFieldFeedback: __('This field is required.'),
},
components: {
GlButton,
- GlDropdown,
GlFormGroup,
- GlIcon,
- GlSearchBoxByType,
- GlSkeletonLoader,
},
props: {
- loading: {
- type: Boolean,
- required: true,
- },
searchLabel: {
type: String,
required: false,
default: null,
},
- noneSelected: {
- type: String,
- required: true,
- },
- searchPlaceholder: {
- type: String,
+ selectedIdValid: {
+ type: Boolean,
required: true,
},
- selectedId: {
- type: [Number, String],
- required: false,
- default: null,
- },
},
data() {
return {
searchValue: '',
- selectedIdValid: true,
};
},
- computed: {
- toggleClassList() {
- return `gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate ${
- this.selectedIdValid ? '' : 'gl-inset-border-1-red-400!'
- }`;
- },
- },
- watch: {
- selectedId(val) {
- if (val) {
- this.$refs.dropdown.hide(true);
- this.selectedIdValid = true;
- }
- },
- },
methods: {
...mapActions(['setAddColumnFormVisibility']),
- setFocus() {
- this.$refs.searchBox.focusInput();
- },
- onHide() {
- this.searchValue = '';
- this.$emit('filter-items', '');
- this.$emit('hide');
- },
onSubmit() {
- if (!this.selectedId) {
- this.selectedIdValid = false;
- } else {
- this.$emit('add-list');
- }
+ this.$emit('add-list');
},
},
};
@@ -126,44 +72,7 @@ export default {
:state="selectedIdValid"
:invalid-feedback="$options.i18n.requiredFieldFeedback"
>
- <gl-dropdown
- ref="dropdown"
- class="gl-mb-3 gl-max-w-full"
- :toggle-class="toggleClassList"
- boundary="viewport"
- @shown="setFocus"
- @hide="onHide"
- >
- <template #button-content>
- <slot name="selected">
- <div>{{ noneSelected }}</div>
- </slot>
- <gl-icon class="dropdown-chevron gl-flex-shrink-0" name="chevron-down" />
- </template>
-
- <template #header>
- <gl-search-box-by-type
- ref="searchBox"
- v-model="searchValue"
- debounce="250"
- class="gl-mt-0!"
- :placeholder="searchPlaceholder"
- @input="$emit('filter-items', $event)"
- />
- </template>
-
- <div v-if="loading" class="gl-px-5">
- <gl-skeleton-loader :width="400" :height="172">
- <rect width="380" height="20" x="10" y="15" rx="4" />
- <rect width="280" height="20" x="10" y="50" rx="4" />
- <rect width="330" height="20" x="10" y="85" rx="4" />
- </gl-skeleton-loader>
- </div>
-
- <slot v-else name="items">
- <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
- </slot>
- </gl-dropdown>
+ <slot name="dropdown"></slot>
</gl-form-group>
</div>
<div class="gl-display-flex gl-mb-4">
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index d41fc1e9300..48dfcf81f1e 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
-import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility';
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';
@@ -11,14 +11,19 @@ export default {
BoardSettingsSidebar,
BoardTopBar,
},
- inject: ['initialBoardId'],
+ inject: ['initialBoardId', 'initialFilterParams'],
data() {
return {
boardId: this.initialBoardId,
+ filterParams: { ...this.initialFilterParams },
+ isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
};
},
computed: {
...mapGetters(['isSidebarOpen']),
+ isSwimlanesOn() {
+ return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false;
+ },
},
created() {
window.addEventListener('popstate', refreshCurrentPage);
@@ -30,14 +35,29 @@ export default {
switchBoard(id) {
this.boardId = id;
},
+ setFilters(filters) {
+ const filterParams = { ...filters };
+ if (filterParams.groupBy) delete filterParams.groupBy;
+ this.filterParams = filterParams;
+ },
},
};
</script>
<template>
<div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
- <board-top-bar :board-id="boardId" @switchBoard="switchBoard" />
- <board-content :board-id="boardId" />
+ <board-top-bar
+ :board-id="boardId"
+ :is-swimlanes-on="isSwimlanesOn"
+ @switchBoard="switchBoard"
+ @setFilters="setFilters"
+ @toggleSwimlanes="isShowingEpicsSwimlanes = $event"
+ />
+ <board-content
+ :board-id="boardId"
+ :is-swimlanes-on="isSwimlanesOn"
+ :filter-params="filterParams"
+ />
<board-settings-sidebar />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 708e1539c6e..83ba538168a 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -20,6 +20,10 @@ export default {
type: String,
required: true,
},
+ filters: {
+ type: Object,
+ required: true,
+ },
},
computed: {
...mapState(['filterParams', 'highlightedLists']),
@@ -33,11 +37,14 @@ export default {
isListDraggable() {
return isListDraggable(this.list);
},
+ filtersToUse() {
+ return this.isApolloBoard ? this.filters : this.filterParams;
+ },
},
watch: {
filterParams: {
handler() {
- if (this.list.id && !this.list.collapsed) {
+ if (!this.isApolloBoard && this.list.id && !this.list.collapsed) {
this.fetchItemsForList({ listId: this.list.id });
}
},
@@ -46,7 +53,7 @@ export default {
},
'list.id': {
handler(id) {
- if (id) {
+ if (!this.isApolloBoard && id) {
this.fetchItemsForList({ listId: this.list.id });
}
},
@@ -83,13 +90,13 @@ export default {
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50"
:class="{ 'board-column-highlighted': highlighted }"
>
- <board-list-header :list="list" />
+ <board-list-header :list="list" :filter-params="filtersToUse" />
<board-list
ref="board-list"
:board-id="boardId"
:board-items="listItems"
:list="list"
- :filter-params="filterParams"
+ :filter-params="filtersToUse"
/>
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 8a37719eae8..84a8781db1c 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -3,9 +3,10 @@ import { GlAlert } from '@gitlab/ui';
import { breakpoints } from '@gitlab/ui/dist/utils';
import { sortBy, throttle } from 'lodash';
import Draggable from 'vuedraggable';
-import { mapState, mapGetters, mapActions } from 'vuex';
+import { mapState, mapActions } from 'vuex';
import { contentTop } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
+import eventHub from '~/boards/eventhub';
import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { defaultSortableOptions } from '~/sortable/constants';
@@ -44,6 +45,14 @@ export default {
type: String,
required: true,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
+ isSwimlanesOn: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -80,7 +89,6 @@ export default {
},
computed: {
...mapState(['boardLists', 'error', 'addColumnForm']),
- ...mapGetters(['isSwimlanesOn']),
addColumnFormVisible() {
return this.addColumnForm?.visible;
},
@@ -92,7 +100,7 @@ export default {
}),
fullPath: this.fullPath,
boardId: this.boardId,
- filterParams: this.filterParams,
+ filters: this.filterParams,
};
},
boardListsToUse() {
@@ -126,6 +134,12 @@ export default {
return this.isApolloBoard ? this.apolloError : this.error;
},
},
+ created() {
+ eventHub.$on('updateBoard', this.refetchLists);
+ },
+ beforeDestroy() {
+ eventHub.$off('updateBoard', this.refetchLists);
+ },
mounted() {
this.setBoardHeight();
@@ -152,6 +166,9 @@ export default {
this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`;
}
},
+ refetchLists() {
+ this.$apollo.queries.boardListsApollo.refetch();
+ },
},
};
</script>
@@ -176,6 +193,7 @@ export default {
ref="board"
:board-id="boardId"
:list="list"
+ :filters="filterParams"
:data-draggable-item-type="$options.draggableItemTypes.list"
:class="{ 'gl-xs-display-none!': addColumnFormVisible }"
/>
@@ -190,6 +208,7 @@ export default {
ref="swimlanes"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
+ :filters="filterParams"
:style="{ height: boardHeight }"
/>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 6227f185eda..675878683ab 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -6,9 +6,9 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow
import { __, sprintf } from '~/locale';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
-import { BoardType, ISSUABLE, INCIDENT } from '~/boards/constants';
+import { ISSUABLE, INCIDENT } from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
@@ -16,7 +16,6 @@ import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severit
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
-import { LabelType } from '~/sidebar/components/labels/labels_select_widget/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -98,7 +97,7 @@ export default {
return this.activeBoardItem?.referencePath?.split('#')[0] || '';
},
parentType() {
- return this.isGroupBoard ? BoardType.group : BoardType.project;
+ return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
createLabelTitle() {
return sprintf(__('Create %{workspace} label'), {
@@ -114,7 +113,7 @@ export default {
return this.isGroupBoard ? this.groupPathForActiveIssue : this.projectPathForActiveIssue;
},
labelType() {
- return this.isGroupBoard ? LabelType.group : LabelType.project;
+ return this.isGroupBoard ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
labelsFilterPath() {
return this.isGroupBoard
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 1bc5d910561..2e14afad963 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -1,7 +1,7 @@
<script>
import { pickBy, isEmpty, mapValues } from 'lodash';
import { mapActions } from 'vuex';
-import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId, isGid, convertToGraphQLId } from '~/graphql_shared/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -23,6 +23,7 @@ import {
} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { AssigneeFilterType } from '~/boards/constants';
+import { TYPENAME_ITERATION } from '~/graphql_shared/constants';
import eventHub from '../eventhub';
export default {
@@ -30,7 +31,7 @@ export default {
search: __('Search'),
},
components: { FilteredSearch },
- inject: ['initialFilterParams'],
+ inject: ['initialFilterParams', 'isApolloBoard'],
props: {
tokens: {
type: Array,
@@ -334,11 +335,23 @@ export default {
},
);
},
+ formattedFilterParams() {
+ const filtersCopy = { ...this.filterParams };
+ if (this.filterParams?.iterationId) {
+ filtersCopy.iterationId = convertToGraphQLId(
+ TYPENAME_ITERATION,
+ this.filterParams.iterationId,
+ );
+ }
+
+ return filtersCopy;
+ },
},
created() {
eventHub.$on('updateTokens', this.updateTokens);
if (!isEmpty(this.eeFilters)) {
this.filterParams = this.eeFilters;
+ this.$emit('setFilters', this.formattedFilterParams);
}
},
beforeDestroy() {
@@ -349,6 +362,7 @@ export default {
updateTokens() {
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
this.filterParams = convertObjectPropsToCamelCase(rawFilterParams, {});
+ this.$emit('setFilters', this.formattedFilterParams);
this.filteredSearchKey += 1;
},
handleFilter(filters) {
@@ -360,7 +374,11 @@ export default {
replace: true,
});
- this.performSearch();
+ if (this.isApolloBoard) {
+ this.$emit('setFilters', this.formattedFilterParams);
+ } else {
+ this.performSearch();
+ }
},
getFilterParams(filters = []) {
const notFilters = filters.filter((item) => item.value.operator === '!=');
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index a71bde54a8f..9ea801dc9a2 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -4,6 +4,7 @@ import { mapActions, mapState } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
+import eventHub from '~/boards/eventhub';
import { formType } from '../constants';
import createBoardMutation from '../graphql/board_create.mutation.graphql';
@@ -57,6 +58,9 @@ export default {
isProjectBoard: {
default: false,
},
+ isApolloBoard: {
+ default: false,
+ },
},
props: {
canAdminBoard: {
@@ -124,14 +128,12 @@ export default {
primaryProps() {
return {
text: this.buttonText,
- attributes: [
- {
- variant: this.buttonKind,
- disabled: this.submitDisabled,
- loading: this.isLoading,
- 'data-qa-selector': 'save_changes_button',
- },
- ],
+ attributes: {
+ variant: this.buttonKind,
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
+ 'data-qa-selector': 'save_changes_button',
+ },
};
},
cancelProps() {
@@ -213,13 +215,23 @@ export default {
} else {
try {
const board = await this.createOrUpdateBoard();
- this.setBoard(board);
+ if (this.isApolloBoard) {
+ if (this.board.id) {
+ eventHub.$emit('updateBoard', board);
+ } else {
+ this.$emit('addBoard', board);
+ }
+ } else {
+ this.setBoard(board);
+ }
this.cancel();
- const param = getParameterByName('group_by')
- ? `?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 {
@@ -278,7 +290,7 @@ export default {
@hide.prevent
>
<gl-alert
- v-if="error"
+ v-if="!isApolloBoard && error"
class="gl-mb-3"
variant="danger"
:dismissible="true"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 6f2b35f5191..a47db661445 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -59,6 +59,10 @@ export default {
type: Array,
required: true,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -108,7 +112,7 @@ export default {
},
},
computed: {
- ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']),
+ ...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']),
boardListItems() {
return this.isApolloBoard
? this.currentList?.[`${this.issuableType}s`].nodes || []
@@ -125,7 +129,7 @@ export default {
};
},
listItemsCount() {
- return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
+ return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount;
},
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {
@@ -260,6 +264,10 @@ export default {
this.showIssueForm = !this.showIssueForm;
}
},
+ isObservableItem(index) {
+ // observe every 6 item of 10 to achieve smooth loading state
+ return index !== 0 && index % 6 === 0;
+ },
onReachingListBottom() {
if (!this.loadingMore && this.hasNextPage) {
this.showCount = true;
@@ -393,8 +401,14 @@ export default {
:list="list"
:list-items-length="boardListItems.length"
/>
+ <gl-intersection-observer
+ v-if="isObservableItem(index)"
+ data-testid="board-card-gl-io"
+ @appear="onReachingListBottom"
+ />
</board-card>
- <gl-intersection-observer @appear="onReachingListBottom">
+ <div>
+ <!-- for supporting previous structure with intersection observer -->
<li
v-if="showCount"
class="board-list-count gl-text-center gl-text-secondary gl-py-4"
@@ -409,7 +423,7 @@ export default {
<span v-if="showingAllItems">{{ showingAllItemsText }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
- </gl-intersection-observer>
+ </div>
</component>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 749fae0c426..f4358315d45 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -1,18 +1,18 @@
<script>
import {
GlButton,
- GlButtonGroup,
GlLabel,
GlTooltip,
GlIcon,
GlSprintf,
GlTooltipDirective,
+ GlDisclosureDropdown,
} from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { isListDraggable } from '~/boards/boards_util';
import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { n__, s__, __ } from '~/locale';
+import { n__, s__ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
import Tracking from '~/tracking';
import { formatDate } from '~/lib/utils/datetime_utility';
@@ -25,14 +25,15 @@ import ItemCount from './item_count.vue';
export default {
i18n: {
- newIssue: __('New issue'),
- newEpic: s__('Boards|New epic'),
- listSettings: __('List settings'),
+ newIssue: s__('Boards|Create new issue'),
+ listActions: s__('Boards|List actions'),
+ newEpic: s__('Boards|Create new epic'),
+ listSettings: s__('Boards|Edit list settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
},
components: {
- GlButtonGroup,
+ GlDisclosureDropdown,
GlButton,
GlLabel,
GlTooltip,
@@ -75,16 +76,22 @@ export default {
required: false,
default: false,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
},
computed: {
- ...mapState(['activeId', 'filterParams', 'boardId']),
- ...mapGetters(['isSwimlanesOn']),
+ ...mapState(['activeId', 'boardId']),
isLoggedIn() {
return Boolean(this.currentUserId);
},
listType() {
return this.list.listType;
},
+ itemsCount() {
+ return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount;
+ },
listAssignee() {
return this.list?.assignee?.username || '';
},
@@ -111,7 +118,10 @@ export default {
},
showListHeaderActions() {
if (this.isLoggedIn) {
- return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown;
+ return (
+ (this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown) &&
+ !this.list.collapsed
+ );
}
return false;
},
@@ -162,6 +172,50 @@ export default {
canShowTotalWeight() {
return this.weightFeatureAvailable && !this.isLoading;
},
+ actionListItems() {
+ const items = [];
+
+ if (this.isNewIssueShown) {
+ const newIssueText = this.$options.i18n.newIssue;
+ items.push({
+ text: newIssueText,
+ action: this.showNewIssueForm,
+ extraAttrs: {
+ 'data-testid': 'newIssueBtn',
+ title: newIssueText,
+ 'aria-label': newIssueText,
+ },
+ });
+ }
+
+ if (this.isNewEpicShown) {
+ const newEpicText = this.$options.i18n.newEpic;
+ items.push({
+ text: newEpicText,
+ action: this.showNewEpicForm,
+ extraAttrs: {
+ 'data-testid': 'newEpicBtn',
+ title: newEpicText,
+ 'aria-label': newEpicText,
+ },
+ });
+ }
+
+ if (this.isSettingsShown) {
+ const listSettingsText = this.$options.i18n.listSettings;
+ items.push({
+ text: listSettingsText,
+ action: this.openSidebarSettings,
+ extraAttrs: {
+ 'data-testid': 'settingsBtn',
+ title: listSettingsText,
+ 'aria-label': listSettingsText,
+ },
+ });
+ }
+
+ return items;
+ },
},
apollo: {
boardList: {
@@ -188,6 +242,9 @@ export default {
},
methods: {
...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']),
+ closeListActions() {
+ this.$refs.headerListActions?.close();
+ },
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
@@ -196,13 +253,14 @@ export default {
this.setActiveId({ id: this.list.id, sidebarType: LIST });
this.track('click_button', { label: 'list_settings' });
+
+ this.closeListActions();
},
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
-
showNewIssueForm() {
- if (this.isSwimlanesOn) {
+ if (this.isSwimlanesHeader) {
eventHub.$emit('open-unassigned-lane');
this.$nextTick(() => {
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
@@ -210,9 +268,13 @@ export default {
} else {
eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`);
}
+
+ this.closeListActions();
},
showNewEpicForm() {
eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`);
+
+ this.closeListActions();
},
toggleExpanded() {
const collapsed = !this.list.collapsed;
@@ -392,7 +454,7 @@ export default {
<gl-icon class="gl-mr-2" :name="countIcon" :size="14" />
<item-count
v-if="!isLoading"
- :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount"
+ :items-size="itemsCount"
:max-issue-count="list.maxIssueCount"
/>
</span>
@@ -407,44 +469,24 @@ export default {
<!-- EE end -->
</span>
</div>
- <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2">
- <gl-button
- v-if="isNewIssueShown"
- v-show="!list.collapsed"
- ref="newIssueBtn"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.newIssue"
- :title="$options.i18n.newIssue"
- class="no-drag"
- size="small"
- icon="plus"
- @click="showNewIssueForm"
- />
-
- <gl-button
- v-if="isNewEpicShown"
- v-show="!list.collapsed"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.newEpic"
- :title="$options.i18n.newEpic"
- class="no-drag"
- size="small"
- icon="plus"
- @click="showNewEpicForm"
- />
-
- <gl-button
- v-if="isSettingsShown"
- ref="settingsBtn"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.listSettings"
- class="no-drag"
- size="small"
- :title="$options.i18n.listSettings"
- icon="settings"
- @click="openSidebarSettings"
- />
- </gl-button-group>
+ <gl-disclosure-dropdown
+ v-if="showListHeaderActions"
+ ref="headerListActions"
+ v-gl-tooltip.hover.top="{
+ title: $options.i18n.listActions,
+ boundary: 'viewport',
+ }"
+ data-testid="header-list-actions"
+ class="gl-py-2 gl-ml-3"
+ :aria-label="$options.i18n.listActions"
+ :title="$options.i18n.listActions"
+ category="tertiary"
+ icon="ellipsis_v"
+ :text-sr-only="true"
+ :items="actionListItems"
+ no-caret
+ placement="right"
+ />
</h3>
</header>
</template>
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index c0c2699b63d..afa20f63913 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -136,11 +136,11 @@ export default {
size="sm"
:action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: $options.i18n.modalAction,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
:action-secondary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: $options.i18n.modalCancel,
- attributes: [{ variant: 'default' }],
+ attributes: { variant: 'default' },
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
@primary="handleModalPrimary"
>
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index 2e20ed70bb0..fad57758be1 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -35,6 +35,10 @@ export default {
type: String,
required: true,
},
+ isSwimlanesOn: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -56,10 +60,28 @@ export default {
return !this.isApolloBoard;
},
update(data) {
- return data.workspace.board;
+ const { board } = data.workspace;
+ return {
+ ...board,
+ labels: board.labels?.nodes,
+ };
},
},
},
+ computed: {
+ hasScope() {
+ if (this.board.labels?.length > 0) {
+ return true;
+ }
+ let hasScope = false;
+ ['assignee', 'iterationCadence', 'iteration', 'milestone', 'weight'].forEach((attr) => {
+ if (this.board[attr] !== null && this.board[attr] !== undefined) {
+ hasScope = true;
+ }
+ });
+ return hasScope;
+ },
+ },
};
</script>
@@ -73,15 +95,27 @@ export default {
>
<boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" />
<new-board-button />
- <issue-board-filtered-search v-if="isIssueBoard" />
- <epic-board-filtered-search v-else />
+ <issue-board-filtered-search
+ v-if="isIssueBoard"
+ :board="board"
+ @setFilters="$emit('setFilters', $event)"
+ />
+ <epic-board-filtered-search
+ v-else
+ :board="board"
+ @setFilters="$emit('setFilters', $event)"
+ />
</div>
<div
class="filter-dropdown-container gl-md-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-align-items-flex-start"
>
<toggle-labels />
- <toggle-epics-swimlanes v-if="swimlanesFeatureAvailable && isSignedIn" />
- <config-toggle />
+ <toggle-epics-swimlanes
+ v-if="swimlanesFeatureAvailable && isSignedIn"
+ :is-swimlanes-on="isSwimlanesOn"
+ @toggleSwimlanes="$emit('toggleSwimlanes', $event)"
+ />
+ <config-toggle :board-has-scope="hasScope" />
<board-add-new-column-trigger v-if="canAdminList" />
<toggle-focus />
</div>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index a1a49386b37..4aec286a5f4 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -8,6 +8,7 @@ import {
GlDropdownItem,
GlModalDirective,
} from '@gitlab/ui';
+import { produce } from 'immer';
import { throttle } from 'lodash';
import { mapActions, mapState } from 'vuex';
@@ -89,6 +90,9 @@ export default {
parentType() {
return this.boardType;
},
+ boardQuery() {
+ return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
+ },
loading() {
return this.loadingRecentBoards || this.loadingBoards;
},
@@ -155,9 +159,6 @@ export default {
name: node.name,
}));
},
- boardQuery() {
- return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
- },
recentBoardsQuery() {
return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery;
},
@@ -191,6 +192,29 @@ export default {
},
});
},
+ addBoard(board) {
+ const { defaultClient: store } = this.$apollo.provider.clients;
+
+ const sourceData = store.readQuery({
+ query: this.boardQuery,
+ variables: { fullPath: this.fullPath },
+ });
+
+ const newData = produce(sourceData, (draftState) => {
+ draftState[this.parentType].boards.edges = [
+ ...draftState[this.parentType].boards.edges,
+ { node: board },
+ ];
+ });
+
+ store.writeQuery({
+ query: this.boardQuery,
+ variables: { fullPath: this.fullPath },
+ data: newData,
+ });
+
+ this.$emit('switchBoard', board.id);
+ },
isScrolledUp() {
const { content } = this.$refs;
@@ -226,14 +250,12 @@ export default {
boardType: this.boardType,
});
},
- fullBoardId(boardId) {
- return fullBoardId(boardId);
- },
async switchBoard(boardId, e) {
if (isMetaKey(e)) {
window.open(`${this.boardBaseUrl}/${boardId}`, '_blank');
} else if (this.isApolloBoard) {
- this.$emit('switchBoard', this.fullBoardId(boardId));
+ this.$emit('switchBoard', fullBoardId(boardId));
+ updateHistory({ url: `${this.boardBaseUrl}/${boardId}` });
} else {
this.unsetActiveId();
this.fetchCurrentBoard(boardId);
@@ -357,6 +379,7 @@ export default {
:weights="weights"
:current-board="boardToUse"
:current-page="currentPage"
+ @addBoard="addBoard"
@cancel="cancel"
/>
</span>
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index 7002fd44294..dd3b9472879 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -16,6 +16,13 @@ export default {
},
mixins: [Tracking.mixin()],
inject: ['canAdminList'],
+ props: {
+ boardHasScope: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
...mapGetters(['hasScope']),
buttonText() {
@@ -40,7 +47,7 @@ export default {
v-gl-modal-directive="'board-config-modal'"
v-gl-tooltip
:title="tooltipTitle"
- :class="{ 'dot-highlight': hasScope }"
+ :class="{ 'dot-highlight': hasScope || boardHasScope }"
data-qa-selector="boards_config_button"
@click.prevent="showPage"
>
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index 7749391ec6f..cdcc7b8e5a6 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -1,12 +1,11 @@
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { mapActions } from 'vuex';
import { orderBy } from 'lodash';
import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
-import issueBoardFilters from '~/boards/issue_board_filters';
+import issueBoardFilters from 'ee_else_ce/boards/issue_board_filters';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
@@ -47,11 +46,18 @@ export default {
},
components: { BoardFilteredSearch },
inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'isGroupBoard'],
+ props: {
+ board: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
computed: {
tokensCE() {
const { issue, incident } = this.$options.i18n;
const { types } = this.$options;
- const { fetchUsers, fetchLabels } = issueBoardFilters(
+ const { fetchUsers, fetchLabels, fetchMilestones } = issueBoardFilters(
this.$apollo,
this.fullPath,
this.isGroupBoard,
@@ -135,7 +141,7 @@ export default {
token: MilestoneToken,
unique: true,
shouldSkipSort: true,
- fetchMilestones: this.fetchMilestones,
+ fetchMilestones,
},
{
icon: 'issues',
@@ -176,7 +182,6 @@ export default {
},
},
methods: {
- ...mapActions(['fetchMilestones']),
preloadedUsers() {
return gon?.current_user_id
? [
@@ -194,5 +199,10 @@ export default {
</script>
<template>
- <board-filtered-search data-testid="issue-board-filtered-search" :tokens="tokens" />
+ <board-filtered-search
+ data-testid="issue-board-filtered-search"
+ :tokens="tokens"
+ :board="board"
+ @setFilters="$emit('setFilters', $event)"
+ />
</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 712e3e1ac4a..b557dc9205e 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,5 +1,5 @@
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { s__, __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
@@ -12,19 +12,6 @@ import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
-/* eslint-disable-next-line @gitlab/require-i18n-strings */
-export const AssigneeIdParamValues = ['Any', 'None'];
-
-export const issuableTypes = {
- issue: 'issue',
- epic: 'epic',
-};
-
-export const BoardType = {
- project: 'project',
- group: 'group',
-};
-
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
@@ -64,10 +51,10 @@ export const INCIDENT = 'INCIDENT';
export const flashAnimationDuration = 2000;
export const boardQuery = {
- [BoardType.group]: {
+ [WORKSPACE_GROUP]: {
query: groupBoardQuery,
},
- [BoardType.project]: {
+ [WORKSPACE_PROJECT]: {
query: projectBoardQuery,
},
};
@@ -94,7 +81,7 @@ export const titleQueries = {
[TYPE_ISSUE]: {
mutation: issueSetTitleMutation,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
mutation: updateEpicTitleMutation,
},
};
@@ -103,7 +90,7 @@ export const subscriptionQueries = {
[TYPE_ISSUE]: {
mutation: issueSetSubscriptionMutation,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
mutation: updateEpicSubscriptionMutation,
},
};
@@ -143,6 +130,7 @@ export const MilestoneFilterType = {
started: 'Started',
upcoming: 'Upcoming',
};
+/* eslint-enable @gitlab/require-i18n-strings */
export const DraggableItemTypes = {
card: 'card',
@@ -155,7 +143,6 @@ export const MilestoneIDs = {
};
export default {
- BoardType,
ListType,
};
@@ -178,3 +165,5 @@ export const BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS = [
action: () => {},
},
];
+
+export const GroupByParamType = {};
diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
index 9e6c26063e9..14811b435e1 100644
--- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql
@@ -1,5 +1,5 @@
query GroupBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
- group(fullPath: $fullPath) {
+ workspace: group(fullPath: $fullPath) {
id
milestones(
includeAncestors: true
diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
index 02aa08f90ef..9af92a6ff2d 100644
--- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql
@@ -1,5 +1,5 @@
query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String, $state: MilestoneStateEnum) {
- project(fullPath: $fullPath) {
+ workspace: project(fullPath: $fullPath) {
id
milestones(
searchTitle: $searchTerm
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 4c6f341828c..67388284d31 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -3,9 +3,8 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
-import { BoardType } from '~/boards/constants';
import store from '~/boards/stores';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import {
NavigationType,
isLoggedIn,
@@ -68,8 +67,8 @@ function mountBoardApp(el) {
initialFilterParams,
boardBaseUrl: el.dataset.boardBaseUrl,
boardType,
- isGroupBoard: boardType === BoardType.group,
- isProjectBoard: boardType === BoardType.project,
+ isGroupBoard: boardType === WORKSPACE_GROUP,
+ isProjectBoard: boardType === WORKSPACE_PROJECT,
currentUserId: gon.current_user_id || null,
boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
labelsManagePath: el.dataset.labelsManagePath,
diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js
index 7e9b68778d5..27efb3f775c 100644
--- a/app/assets/javascripts/boards/issue_board_filters.js
+++ b/app/assets/javascripts/boards/issue_board_filters.js
@@ -1,5 +1,7 @@
import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql';
import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql';
+import groupBoardMilestonesQuery from './graphql/group_board_milestones.query.graphql';
+import projectBoardMilestonesQuery from './graphql/project_board_milestones.query.graphql';
import boardLabels from './graphql/board_labels.query.graphql';
export default function issueBoardFilters(apollo, fullPath, isGroupBoard) {
@@ -37,8 +39,27 @@ export default function issueBoardFilters(apollo, fullPath, isGroupBoard) {
.then(transformLabels);
};
+ const fetchMilestones = (searchTerm) => {
+ const variables = {
+ fullPath,
+ searchTerm,
+ };
+
+ const query = isGroupBoard ? groupBoardMilestonesQuery : projectBoardMilestonesQuery;
+
+ return apollo
+ .query({
+ query,
+ variables,
+ })
+ .then(({ data }) => {
+ return data.workspace?.milestones.nodes;
+ });
+ };
+
return {
fetchLabels,
fetchUsers,
+ fetchMilestones,
};
}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 1b4e6334723..a144054d680 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,7 +1,6 @@
import * as Sentry from '@sentry/browser';
import { sortBy } from 'lodash';
import {
- BoardType,
ListType,
inactiveId,
flashAnimationDuration,
@@ -34,7 +33,7 @@ import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_defe
import { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -61,7 +60,7 @@ export default {
return gqlClient
.query({
- query: boardType === BoardType.group ? groupBoardQuery : projectBoardQuery,
+ query: boardType === WORKSPACE_GROUP ? groupBoardQuery : projectBoardQuery,
variables,
})
.then(({ data }) => {
@@ -139,8 +138,8 @@ export default {
boardId: fullBoardId,
filters: filterParams,
...(issuableType === TYPE_ISSUE && {
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
+ isGroup: boardType === WORKSPACE_GROUP,
+ isProject: boardType === WORKSPACE_PROJECT,
}),
};
@@ -234,8 +233,8 @@ export default {
const variables = {
fullPath,
searchTerm,
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
+ isGroup: boardType === WORKSPACE_GROUP,
+ isProject: boardType === WORKSPACE_PROJECT,
};
commit(types.RECEIVE_LABELS_REQUEST);
@@ -268,10 +267,10 @@ export default {
};
let query;
- if (boardType === BoardType.project) {
+ if (boardType === WORKSPACE_PROJECT) {
query = projectBoardMilestonesQuery;
}
- if (boardType === BoardType.group) {
+ if (boardType === WORKSPACE_GROUP) {
query = groupBoardMilestonesQuery;
}
@@ -286,8 +285,8 @@ export default {
variables,
})
.then(({ data }) => {
- const errors = data[boardType]?.errors;
- const milestones = data[boardType]?.milestones.nodes;
+ const errors = data.workspace?.errors;
+ const milestones = data.workspace?.milestones.nodes;
if (errors?.[0]) {
throw new Error(errors[0]);
@@ -431,8 +430,8 @@ export default {
boardId: fullBoardId,
id: listId,
filters: filterParams,
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
+ isGroup: boardType === WORKSPACE_GROUP,
+ isProject: boardType === WORKSPACE_PROJECT,
first: DEFAULT_BOARD_LIST_ITEMS_SIZE,
after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
};
@@ -710,7 +709,7 @@ export default {
) => {
const input = formatIssueInput(issueInput, boardConfig);
- if (boardType === BoardType.project) {
+ if (boardType === WORKSPACE_PROJECT) {
input.projectPath = fullPath;
}
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index fef5862f319..505c011b034 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -1,18 +1,19 @@
import { cloneDeep, pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_EPIC } from '~/issues/constants';
import { s__, __ } from '~/locale';
import { formatIssue } from '../boards_util';
-import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
const updateListItemsCount = ({ state, listId, value }) => {
const list = state.boardLists[listId];
- if (state.issuableType === issuableTypes.epic) {
- Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
- } else {
- Vue.set(state.boardLists, listId, { ...list });
+ if (state.issuableType === TYPE_EPIC) {
+ const listItem = cloneDeep(state.boardLists[listId]);
+ listItem.metadataepicsCount += value;
+ Vue.set(state.boardLists[listId], listId, listItem);
}
+ Vue.set(state.boardLists, listId, { ...list });
};
export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => {
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index d05b53f1a50..54abc9c45a7 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import DivergenceGraph from './components/divergence_graph.vue';
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue
index 3c6114b38ce..257c3309e10 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
@@ -38,6 +38,10 @@ export default {
required: false,
default: 0,
},
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
variables: {
type: Array,
required: true,
@@ -87,8 +91,12 @@ export default {
:entity="entity"
:is-loading="isLoading"
:max-variable-limit="maxVariableLimit"
+ :page-info="pageInfo"
:variables="variables"
+ @handle-prev-page="$emit('handle-prev-page')"
+ @handle-next-page="$emit('handle-next-page')"
@set-selected-variable="setSelectedVariable"
+ @sort-changed="(val) => $emit('sort-changed', val)"
/>
<ci-variable-modal
v-if="showModal"
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
index 6e39bda0b07..9db9bea63b2 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue
@@ -1,10 +1,12 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapEnvironmentNames, reportMessageToSentry } from '../utils';
import {
ADD_MUTATION_ACTION,
DELETE_MUTATION_ACTION,
+ SORT_DIRECTIONS,
UPDATE_MUTATION_ACTION,
environmentFetchErrorText,
genericMutationErrorText,
@@ -16,6 +18,7 @@ export default {
components: {
CiVariableSettings,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['endpoint'],
props: {
areScopedVariablesAvailable: {
@@ -97,6 +100,7 @@ export default {
loadingCounter: 0,
maxVariableLimit: 0,
pageInfo: {},
+ sortDirection: SORT_DIRECTIONS.ASC,
};
},
apollo: {
@@ -107,6 +111,8 @@ export default {
variables() {
return {
fullPath: this.fullPath || undefined,
+ first: this.pageSize,
+ sort: this.sortDirection,
};
},
update(data) {
@@ -116,21 +122,23 @@ export default {
this.maxVariableLimit = this.queryData.ciVariables.lookup(data)?.limit || 0;
this.pageInfo = this.queryData.ciVariables.lookup(data)?.pageInfo || this.pageInfo;
- this.hasNextPage = this.pageInfo?.hasNextPage || false;
- // Because graphQL has a limit of 100 items,
- // we batch load all the variables by making successive queries
- // to keep the same UX. As a safeguard, we make sure that we cannot go over
- // 20 consecutive API calls, which means 2000 variables loaded maximum.
- if (!this.hasNextPage) {
- this.isLoadingMoreItems = false;
- } else if (this.loadingCounter < 20) {
- this.hasNextPage = false;
- this.fetchMoreVariables();
- this.loadingCounter += 1;
- } else {
- createAlert({ message: this.$options.tooManyCallsError });
- reportMessageToSentry(this.componentName, this.$options.tooManyCallsError, {});
+ if (!this.glFeatures?.ciVariablesPages) {
+ this.hasNextPage = this.pageInfo?.hasNextPage || false;
+ // Because graphQL has a limit of 100 items,
+ // we batch load all the variables by making successive queries
+ // to keep the same UX. As a safeguard, we make sure that we cannot go over
+ // 20 consecutive API calls, which means 2000 variables loaded maximum.
+ if (!this.hasNextPage) {
+ this.isLoadingMoreItems = false;
+ } else if (this.loadingCounter < 20) {
+ this.hasNextPage = false;
+ this.fetchMoreVariables();
+ this.loadingCounter += 1;
+ } else {
+ createAlert({ message: this.$options.tooManyCallsError });
+ reportMessageToSentry(this.componentName, this.$options.tooManyCallsError, {});
+ }
}
},
error() {
@@ -172,6 +180,9 @@ export default {
this.isLoadingMoreItems
);
},
+ pageSize() {
+ return this.glFeatures?.ciVariablesPages ? 20 : 100;
+ },
},
methods: {
addVariable(variable) {
@@ -189,6 +200,31 @@ export default {
},
});
},
+ handlePrevPage() {
+ this.$apollo.queries.ciVariables.fetchMore({
+ variables: {
+ before: this.pageInfo.startCursor,
+ first: null,
+ last: this.pageSize,
+ },
+ });
+ },
+ handleNextPage() {
+ this.$apollo.queries.ciVariables.fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ first: this.pageSize,
+ last: null,
+ },
+ });
+ },
+ async handleSortChanged({ sortDesc }) {
+ this.sortDirection = sortDesc ? SORT_DIRECTIONS.DESC : SORT_DIRECTIONS.ASC;
+
+ // Wait for the new sort direction to be updated and then refetch
+ await this.$nextTick();
+ this.$apollo.queries.ciVariables.refetch();
+ },
updateVariable(variable) {
this.variableMutation(UPDATE_MUTATION_ACTION, variable);
},
@@ -230,13 +266,17 @@ export default {
<ci-variable-settings
:are-scoped-variables-available="areScopedVariablesAvailable"
:entity="entity"
+ :environments="environments"
:hide-environment-scope="hideEnvironmentScope"
:is-loading="isLoading"
- :variables="ciVariables"
:max-variable-limit="maxVariableLimit"
- :environments="environments"
+ :page-info="pageInfo"
+ :variables="ciVariables"
@add-variable="addVariable"
@delete-variable="deleteVariable"
+ @handle-prev-page="handlePrevPage"
+ @handle-next-page="handleNextPage"
+ @sort-changed="handleSortChanged"
@update-variable="updateVariable"
/>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
index 345a8def49d..5e367ff33b2 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue
@@ -4,6 +4,7 @@ import {
GlButton,
GlLoadingIcon,
GlModalDirective,
+ GlKeysetPagination,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -56,6 +57,7 @@ export default {
components: {
GlAlert,
GlButton,
+ GlKeysetPagination,
GlLoadingIcon,
GlTable,
},
@@ -78,6 +80,10 @@ export default {
type: Number,
required: true,
},
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
variables: {
type: Array,
required: true,
@@ -165,6 +171,28 @@ export default {
>
{{ exceedsVariableLimitText }}
</gl-alert>
+ <div
+ v-if="glFeatures.ciVariablesPages"
+ class="ci-variable-actions gl-display-flex gl-justify-content-end gl-my-3"
+ >
+ <gl-button
+ v-if="!isTableEmpty"
+ data-qa-selector="reveal_ci_variable_value_button"
+ @click="toggleHiddenState"
+ >{{ valuesButtonText }}</gl-button
+ >
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ class="gl-mx-3"
+ data-qa-selector="add_ci_variable_button"
+ variant="confirm"
+ category="primary"
+ :aria-label="__('Add')"
+ :disabled="exceedsVariableLimit"
+ @click="setSelectedVariable()"
+ >{{ __('Add variable') }}</gl-button
+ >
+ </div>
<gl-table
v-if="!isLoading"
:fields="fields"
@@ -174,11 +202,13 @@ export default {
sort-by="key"
sort-direction="asc"
stacked="lg"
- table-class="text-secondary"
+ table-class="gl-border-t"
fixed
show-empty
sort-icon-left
no-sort-reset
+ no-local-sorting
+ @sort-changed="(val) => $emit('sort-changed', val)"
>
<template #table-colgroup="scope">
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
@@ -275,7 +305,7 @@ export default {
>
{{ exceedsVariableLimitText }}
</gl-alert>
- <div class="ci-variable-actions gl-display-flex gl-mt-5">
+ <div v-if="!glFeatures.ciVariablesPages" class="ci-variable-actions gl-display-flex gl-mt-5">
<gl-button
v-gl-modal-directive="$options.modalId"
class="gl-mr-3"
@@ -294,5 +324,14 @@ export default {
>{{ valuesButtonText }}</gl-button
>
</div>
+ <div v-else class="gl-display-flex gl-justify-content-center gl-mt-6">
+ <gl-keyset-pagination
+ v-bind="pageInfo"
+ :prev-text="__('Previous')"
+ :next-text="__('Next')"
+ @prev="$emit('handle-prev-page')"
+ @next="$emit('handle-next-page')"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index 627ace1b28e..c77d8c67bc8 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -2,6 +2,11 @@ import { __, s__ } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
+export const SORT_DIRECTIONS = {
+ ASC: 'KEY_ASC',
+ DESC: 'KEY_DESC',
+};
+
// This const will be deprecated once we remove VueX from the section
export const displayText = {
variableText: __('Variable'),
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
index 538502fdd3b..4a64a24573e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/group_variables.query.graphql
@@ -1,10 +1,17 @@
#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) {
+query getGroupVariables(
+ $after: String
+ $before: String
+ $first: Int
+ $fullPath: ID!
+ $last: Int
+ $sort: CiVariableSort = KEY_ASC
+) {
group(fullPath: $fullPath) {
id
- ciVariables(after: $after, first: $first) {
+ ciVariables(after: $after, before: $before, first: $first, last: $last, sort: $sort) {
limit
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
index af0cd2d0b2c..03a7142080b 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_variables.query.graphql
@@ -1,10 +1,17 @@
#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) {
+query getProjectVariables(
+ $after: String
+ $before: String
+ $first: Int
+ $fullPath: ID!
+ $last: Int
+ $sort: CiVariableSort = KEY_ASC
+) {
project(fullPath: $fullPath) {
id
- ciVariables(after: $after, first: $first) {
+ ciVariables(after: $after, before: $before, first: $first, last: $last, sort: $sort) {
limit
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
index b8dd6f5f562..adf539a44ae 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/variables.query.graphql
@@ -1,8 +1,14 @@
#import "~/ci/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-query getVariables($after: String, $first: Int = 100) {
- ciVariables(after: $after, first: $first) {
+query getVariables(
+ $after: String
+ $before: String
+ $first: Int
+ $last: Int
+ $sort: CiVariableSort = KEY_ASC
+) {
+ ciVariables(after: $after, before: $before, first: $first, last: $last, sort: $sort) {
pageInfo {
...PageInfo
}
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
index cafe3df35d0..7ed0418d5f4 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
@@ -205,33 +205,40 @@ export const mergeVariables = (existing, incoming, { args }) => {
return result;
};
-export const cacheConfig = {
- cacheConfig: {
- typePolicies: {
- Query: {
- fields: {
- ciVariables: {
- keyArgs: false,
- merge: mergeVariables,
+export const mergeOnlyIncomings = (_, incoming) => {
+ return incoming;
+};
+
+export const generateCacheConfig = (isVariablePagesEnabled = false) => {
+ const merge = isVariablePagesEnabled ? mergeOnlyIncomings : mergeVariables;
+ return {
+ cacheConfig: {
+ typePolicies: {
+ Query: {
+ fields: {
+ ciVariables: {
+ keyArgs: false,
+ merge,
+ },
},
},
- },
- Project: {
- fields: {
- ciVariables: {
- keyArgs: ['fullPath', 'endpoint', 'id'],
- merge: mergeVariables,
+ Project: {
+ fields: {
+ ciVariables: {
+ keyArgs: ['fullPath'],
+ merge,
+ },
},
},
- },
- Group: {
- fields: {
- ciVariables: {
- keyArgs: ['fullPath'],
- merge: mergeVariables,
+ Group: {
+ fields: {
+ ciVariables: {
+ keyArgs: ['fullPath'],
+ merge,
+ },
},
},
},
},
- },
+ };
};
diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index 4270c3c67fc..3ed56201f0d 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -5,7 +5,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import CiAdminVariables from './components/ci_admin_variables.vue';
import CiGroupVariables from './components/ci_group_variables.vue';
import CiProjectVariables from './components/ci_project_variables.vue';
-import { cacheConfig, resolvers } from './graphql/settings';
+import { generateCacheConfig, resolvers } from './graphql/settings';
const mountCiVariableListApp = (containerEl) => {
const {
@@ -42,8 +42,13 @@ const mountCiVariableListApp = (containerEl) => {
Vue.use(VueApollo);
+ // If the feature flag `ci_variables_pages` is enabled,
+ // we are using the default cache config with pages.
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers, cacheConfig),
+ defaultClient: createDefaultClient(
+ resolvers,
+ generateCacheConfig(window.gon?.features?.ciVariablesPages),
+ ),
});
return new Vue({
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
index 891c40482d3..1192f0bf418 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
@@ -2,6 +2,7 @@
import { EDITOR_READY_EVENT } from '~/editor/constants';
import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
import { SOURCE_EDITOR_DEBOUNCE } from '../../constants';
export default {
@@ -16,6 +17,12 @@ export default {
},
inject: ['ciConfigPath'],
inheritAttrs: false,
+ created() {
+ eventHub.$on(SCROLL_EDITOR_TO_BOTTOM, this.scrollEditorToBottom);
+ },
+ beforeDestroy() {
+ eventHub.$off(SCROLL_EDITOR_TO_BOTTOM, this.scrollEditorToBottom);
+ },
methods: {
onCiConfigUpdate(content) {
this.$emit('updateCiConfig', content);
@@ -24,6 +31,10 @@ export default {
instance.use({ definition: CiSchemaExtension });
instance.registerCiSchema();
},
+ scrollEditorToBottom() {
+ const editor = this.$refs.editor.getEditor();
+ editor.setScrollTop(editor.getScrollHeight());
+ },
},
readyEvent: EDITOR_READY_EVENT,
};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
index 84c0eef441f..8553256f13a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue
@@ -1,8 +1,7 @@
<script>
-import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import {
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_LINT_UNAVAILABLE,
@@ -11,15 +10,20 @@ import {
} from '../../constants';
export const i18n = {
- empty: __(
- "We'll continuously validate your pipeline configuration. The validation results will appear here.",
+ empty: s__(
+ "Pipelines|We'll continuously validate your pipeline configuration. The validation results will appear here.",
),
- learnMore: __('Learn more'),
loading: s__('Pipelines|Validating GitLab CI configuration…'),
- invalid: s__('Pipelines|This GitLab CI configuration is invalid.'),
- invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'),
- unavailableValidation: s__('Pipelines|Configuration validation currently not available.'),
- valid: s__('Pipelines|Pipeline syntax is correct.'),
+ invalid: s__(
+ 'Pipelines|This GitLab CI configuration is invalid. %{linkStart}Learn more%{linkEnd}',
+ ),
+ invalidWithReason: s__(
+ 'Pipelines|This GitLab CI configuration is invalid: %{reason}. %{linkStart}Learn more%{linkEnd}',
+ ),
+ unavailableValidation: s__(
+ 'Pipelines|Unable to validate CI/CD configuration. See the %{linkStart}GitLab CI/CD troubleshooting guide%{linkEnd} for more details.',
+ ),
+ valid: s__('Pipelines|Pipeline syntax is correct. %{linkStart}Learn more%{linkEnd}'),
};
export default {
@@ -28,10 +32,10 @@ export default {
GlIcon,
GlLink,
GlLoadingIcon,
- TooltipOnTruncate,
+ GlSprintf,
},
inject: {
- lintUnavailableHelpPagePath: {
+ ciTroubleshootingPath: {
default: '',
},
ymlHelpPagePath: {
@@ -54,49 +58,48 @@ export default {
},
},
computed: {
- helpPath() {
- return this.isLintUnavailable ? this.lintUnavailableHelpPagePath : this.ymlHelpPagePath;
+ APP_STATUS_CONFIG() {
+ return {
+ [EDITOR_APP_STATUS_EMPTY]: {
+ icon: 'check',
+ message: this.$options.i18n.empty,
+ },
+ [EDITOR_APP_STATUS_LINT_UNAVAILABLE]: {
+ icon: 'time-out',
+ link: this.ciTroubleshootingPath,
+ message: this.$options.i18n.unavailableValidation,
+ },
+ [EDITOR_APP_STATUS_VALID]: {
+ icon: 'check',
+ message: this.$options.i18n.valid,
+ },
+ };
},
- isEmpty() {
- return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ currentAppStatusConfig() {
+ return this.APP_STATUS_CONFIG[this.appStatus] || {};
},
- isLintUnavailable() {
- return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE;
+ hasLink() {
+ return this.appStatus !== EDITOR_APP_STATUS_EMPTY;
+ },
+ helpPath() {
+ return this.currentAppStatusConfig.link || this.ymlHelpPagePath;
},
isLoading() {
return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
- isValid() {
- return this.appStatus === EDITOR_APP_STATUS_VALID;
- },
icon() {
- switch (this.appStatus) {
- case EDITOR_APP_STATUS_EMPTY:
- return 'check';
- case EDITOR_APP_STATUS_LINT_UNAVAILABLE:
- return 'time-out';
- case EDITOR_APP_STATUS_VALID:
- return 'check';
- default:
- return 'warning-solid';
- }
+ return this.currentAppStatusConfig.icon || 'warning-solid';
},
message() {
const [reason] = this.ciConfig?.errors || [];
- switch (this.appStatus) {
- case EDITOR_APP_STATUS_EMPTY:
- return this.$options.i18n.empty;
- case EDITOR_APP_STATUS_LINT_UNAVAILABLE:
- return this.$options.i18n.unavailableValidation;
- case EDITOR_APP_STATUS_VALID:
- return this.$options.i18n.valid;
- default:
- // Only display first error as a reason
- return this.ciConfig?.errors?.length > 0
- ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
- : this.$options.i18n.invalid;
- }
+ return (
+ this.currentAppStatusConfig.message ||
+ // Only display first error as a reason
+ (reason
+ ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
+ : this.$options.i18n.invalid)
+ );
},
},
};
@@ -108,18 +111,14 @@ export default {
<gl-loading-icon size="sm" inline />
{{ $options.i18n.loading }}
</template>
-
- <span v-else class="gl-display-inline-flex gl-white-space-nowrap gl-max-w-full">
- <tooltip-on-truncate :title="message" class="gl-text-truncate">
+ <span v-else data-testid="validation-segment">
+ <span class="gl-max-w-full" data-qa-selector="validation_message_content">
<gl-icon :name="icon" />
- <span data-qa-selector="validation_message_content" data-testid="validationMsg">
- {{ message }}
- </span>
- </tooltip-on-truncate>
- <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2">
- <gl-link data-testid="learnMoreLink" :href="helpPath">
- {{ $options.i18n.learnMore }}
- </gl-link>
+ <gl-sprintf :message="message">
+ <template v-if="hasLink" #link="{ content }">
+ <gl-link :href="helpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</span>
</span>
</div>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
new file mode 100644
index 00000000000..c2ae7d7be49
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlFormGroup, GlAccordionItem, GlFormInput } from '@gitlab/ui';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ components: {
+ GlAccordionItem,
+ GlFormInput,
+ GlFormGroup,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.IMAGE">
+ <div class="gl-display-flex">
+ <gl-form-group class="gl-flex-grow-1 gl-mr-3" :label="$options.i18n.IMAGE_NAME">
+ <gl-form-input
+ :value="job.image.name"
+ data-testid="image-name-input"
+ @input="$emit('update-job', 'image.name', $event)"
+ />
+ </gl-form-group>
+ <gl-form-group class="gl-flex-grow-1" :label="$options.i18n.IMAGE_ENTRYPOINT">
+ <gl-form-input
+ :value="job.image.entrypoint.join(' ')"
+ data-testid="image-entrypoint-input"
+ @input="$emit('update-job', 'image.entrypoint', $event.split(' '))"
+ />
+ </gl-form-group>
+ </div>
+ </gl-accordion-item>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
new file mode 100644
index 00000000000..a25b3ca09fd
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
@@ -0,0 +1,89 @@
+<script>
+import {
+ GlAccordionItem,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlTokenSelector,
+ GlFormCombobox,
+} from '@gitlab/ui';
+import { mapState } from 'vuex';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ components: {
+ GlAccordionItem,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormCombobox,
+ GlTokenSelector,
+ },
+ props: {
+ tagOptions: {
+ type: Array,
+ required: true,
+ },
+ job: {
+ type: Object,
+ required: true,
+ },
+ isNameValid: {
+ type: Boolean,
+ required: true,
+ },
+ isScriptValid: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['availableStages']),
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.JOB_SETUP" visible>
+ <gl-form-group
+ :invalid-feedback="$options.i18n.THIS_FIELD_IS_REQUIRED"
+ :state="isNameValid"
+ :label="$options.i18n.JOB_NAME"
+ >
+ <gl-form-input
+ :value="job.name"
+ :state="isNameValid"
+ data-testid="job-name-input"
+ @input="$emit('update-job', 'name', $event)"
+ />
+ </gl-form-group>
+ <gl-form-combobox
+ :value="job.stage"
+ :token-list="availableStages"
+ :label-text="$options.i18n.STAGE"
+ data-testid="job-stage-input"
+ @input="$emit('update-job', 'stage', $event)"
+ />
+ <gl-form-group
+ :invalid-feedback="$options.i18n.THIS_FIELD_IS_REQUIRED"
+ :state="isScriptValid"
+ :label="$options.i18n.SCRIPT"
+ >
+ <gl-form-textarea
+ :value="job.script"
+ :state="isScriptValid"
+ :no-resize="false"
+ data-testid="job-script-input"
+ @input="$emit('update-job', 'script', $event)"
+ />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.TAGS">
+ <gl-token-selector
+ :dropdown-items="tagOptions"
+ :selected-tokens="job.tags"
+ data-testid="job-tags-input"
+ @input="$emit('update-job', 'tags', $event)"
+ />
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
index 1c122fd5e38..994a6e719fe 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
@@ -1,7 +1,41 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const DRAWER_CONTAINER_CLASS = '.content-wrapper';
+export const JOB_TEMPLATE = {
+ name: '',
+ stage: '',
+ script: '',
+ tags: [],
+ image: {
+ name: '',
+ entrypoint: [''],
+ },
+ services: [
+ {
+ name: '',
+ entrypoint: [''],
+ },
+ ],
+ artifacts: {
+ paths: [''],
+ exclude: [''],
+ },
+ cache: {
+ paths: [''],
+ key: '',
+ },
+};
+
export const i18n = {
ADD_JOB: s__('JobAssistant|Add job'),
+ SCRIPT: s__('JobAssistant|Script'),
+ JOB_NAME: s__('JobAssistant|Job name'),
+ JOB_SETUP: s__('JobAssistant|Job Setup'),
+ STAGE: s__('JobAssistant|Stage (optional)'),
+ TAGS: s__('JobAssistant|Tags (optional)'),
+ IMAGE: s__('JobAssistant|Image'),
+ IMAGE_NAME: s__('JobAssistant|Image name (optional)'),
+ IMAGE_ENTRYPOINT: s__('JobAssistant|Image entrypoint (optional)'),
+ THIS_FIELD_IS_REQUIRED: __('This field is required'),
};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
index 65c87df21cb..9f68b97b329 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
@@ -1,13 +1,25 @@
<script>
-import { GlDrawer, GlButton } from '@gitlab/ui';
+import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui';
+import { stringify } from 'yaml';
+import { mapMutations, mapState } from 'vuex';
+import { set, omit, trim } from 'lodash';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
-import { DRAWER_CONTAINER_CLASS, i18n } from './constants';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
+import { UPDATE_CI_CONFIG } from '~/ci/pipeline_editor/store/mutation_types';
+import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
+import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants';
+import { removeEmptyObj, trimFields } from './utils';
+import JobSetupItem from './accordion_items/job_setup_item.vue';
+import ImageItem from './accordion_items/image_item.vue';
export default {
i18n,
components: {
GlDrawer,
+ GlAccordion,
GlButton,
+ JobSetupItem,
+ ImageItem,
},
props: {
isVisible: {
@@ -21,15 +33,84 @@ export default {
default: 200,
},
},
+ data() {
+ return {
+ isNameValid: true,
+ isScriptValid: true,
+ job: JSON.parse(JSON.stringify(JOB_TEMPLATE)),
+ };
+ },
+ apollo: {
+ runners: {
+ query: getAllRunners,
+ update(data) {
+ return data?.runners?.nodes || [];
+ },
+ },
+ },
computed: {
+ ...mapState(['currentCiFileContent']),
+ tagOptions() {
+ const options = [];
+ this.runners?.forEach((runner) => options.push(...runner.tagList));
+ return [...new Set(options)].map((tag) => {
+ return {
+ id: tag,
+ name: tag,
+ };
+ });
+ },
drawerHeightOffset() {
return getContentWrapperHeight(DRAWER_CONTAINER_CLASS);
},
},
methods: {
+ ...mapMutations({
+ updateCiConfig: UPDATE_CI_CONFIG,
+ }),
closeDrawer() {
+ this.clearJob();
this.$emit('close-job-assistant-drawer');
},
+ addCiConfig() {
+ this.isNameValid = this.validate(this.job.name);
+ this.isScriptValid = this.validate(this.job.script);
+
+ if (!this.isNameValid || !this.isScriptValid) {
+ return;
+ }
+
+ const newJobString = this.generateYmlString();
+ this.updateCiConfig(`${this.currentCiFileContent}\n${newJobString}`);
+ eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
+
+ this.closeDrawer();
+ },
+ generateYmlString() {
+ let job = JSON.parse(JSON.stringify(this.job));
+ const jobName = job.name;
+ job = omit(job, ['name']);
+ job.tags = job.tags.map((tag) => tag.name); // Tag item is originally an option object, we need a string here to match `.gitlab-ci.yml` rules
+ const cleanedJob = trimFields(removeEmptyObj(job));
+ return stringify({ [jobName]: cleanedJob });
+ },
+ clearJob() {
+ this.job = JSON.parse(JSON.stringify(JOB_TEMPLATE));
+ this.isNameValid = true;
+ this.isScriptValid = true;
+ },
+ updateJob(key, value) {
+ set(this.job, key, value);
+ if (key === 'name') {
+ this.isNameValid = this.validate(this.job.name);
+ }
+ if (key === 'script') {
+ this.isScriptValid = this.validate(this.job.script);
+ }
+ },
+ validate(value) {
+ return trim(value) !== '';
+ },
},
};
</script>
@@ -44,6 +125,16 @@ export default {
<template #title>
<h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.ADD_JOB }}</h2>
</template>
+ <gl-accordion :header-level="3">
+ <job-setup-item
+ :tag-options="tagOptions"
+ :job="job"
+ :is-name-valid="isNameValid"
+ :is-script-valid="isScriptValid"
+ @update-job="updateJob"
+ />
+ <image-item :job="job" @update-job="updateJob" />
+ </gl-accordion>
<template #footer>
<div class="gl-display-flex gl-justify-content-end">
<gl-button
@@ -51,11 +142,15 @@ export default {
class="gl-mr-3"
data-testid="cancel-button"
@click="closeDrawer"
- >{{ __('Cancel') }}</gl-button
- >
- <gl-button category="primary" variant="confirm" data-testid="confirm-button">{{
- __('Add')
- }}</gl-button>
+ >{{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ data-testid="confirm-button"
+ @click="addCiConfig"
+ >{{ __('Add') }}
+ </gl-button>
</div>
</template>
</gl-drawer>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
new file mode 100644
index 00000000000..83e7574c4de
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
@@ -0,0 +1,22 @@
+import { isEmpty, isObject, isArray, isString, reject, omitBy, mapValues, map, trim } from 'lodash';
+
+const isEmptyValue = (val) => (isObject(val) || isString(val)) && isEmpty(val);
+const trimText = (val) => (isString(val) ? trim(val) : val);
+
+export const removeEmptyObj = (obj) => {
+ if (isArray(obj)) {
+ return reject(map(obj, removeEmptyObj), isEmptyValue);
+ } else if (isObject(obj)) {
+ return omitBy(mapValues(obj, removeEmptyObj), isEmptyValue);
+ }
+ return obj;
+};
+
+export const trimFields = (data) => {
+ if (isArray(data)) {
+ return data.map(trimFields);
+ } else if (isObject(data)) {
+ return mapValues(data, trimFields);
+ }
+ return trimText(data);
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/event_hub.js b/app/assets/javascripts/ci/pipeline_editor/event_hub.js
new file mode 100644
index 00000000000..c64eaf5ef5c
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/event_hub.js
@@ -0,0 +1,5 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
+
+export const SCROLL_EDITOR_TO_BOTTOM = Symbol('scrollEditorToBottom');
diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js
index 6d91c339833..d65a7c321ce 100644
--- a/app/assets/javascripts/ci/pipeline_editor/index.js
+++ b/app/assets/javascripts/ci/pipeline_editor/index.js
@@ -12,6 +12,7 @@ import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphq
import { resolvers } from './graphql/resolvers';
import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
+import createStore from './store';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const el = document.querySelector(selector);
@@ -29,12 +30,12 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciExamplesHelpPagePath,
ciHelpPagePath,
ciLintPath,
+ ciTroubleshootingPath,
defaultBranch,
emptyStateIllustrationPath,
helpPaths,
includesHelpPagePath,
lintHelpPagePath,
- lintUnavailableHelpPagePath,
needsHelpPagePath,
newMergeRequestPath,
pipelinePagePath,
@@ -111,14 +112,18 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
},
});
+ const store = createStore();
+
return new Vue({
el,
+ store,
apolloProvider,
provide: {
ciConfigPath,
ciExamplesHelpPagePath,
ciHelpPagePath,
ciLintPath,
+ ciTroubleshootingPath,
configurationPaths,
dataMethod: 'graphql',
defaultBranch,
@@ -126,7 +131,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
helpPaths,
includesHelpPagePath,
lintHelpPagePath,
- lintUnavailableHelpPagePath,
needsHelpPagePath,
newMergeRequestPath,
pipelinePagePath,
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
index ff848a973e3..7b3c4d6f74f 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
@@ -1,10 +1,12 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { mapState, mapMutations } from 'vuex';
+import { parse } from 'yaml';
import { fetchPolicies } from '~/lib/graphql';
import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
-
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
+import { UPDATE_CI_CONFIG, UPDATE_AVAILABLE_STAGES } from './store/mutation_types';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
@@ -44,7 +46,6 @@ export default {
data() {
return {
ciConfigData: {},
- currentCiFileContent: '',
failureType: null,
failureReasons: [],
hasBranchLoaded: false,
@@ -94,7 +95,7 @@ export default {
const fileContent = rawBlob ?? '';
this.lastCommittedContent = fileContent;
- this.currentCiFileContent = fileContent;
+ this.updateCiConfig(fileContent);
// If rawBlob is defined and returns a string, it means that there is
// a CI config file with empty content. If `rawBlob` is not defined
@@ -155,6 +156,10 @@ export default {
this.isLintUnavailable = false;
}
}
+
+ if (data?.ciConfig?.mergedYaml) {
+ this.updateAvailableStages(parse(data.ciConfig.mergedYaml).stages);
+ }
},
error() {
// We are not using `reportFailure` here because we don't
@@ -231,6 +236,7 @@ export default {
},
},
computed: {
+ ...mapState(['currentCiFileContent']),
hasUnsavedChanges() {
return this.lastCommittedContent !== this.currentCiFileContent;
},
@@ -294,6 +300,10 @@ export default {
this.checkShouldSkipStartScreen();
},
methods: {
+ ...mapMutations({
+ updateCiConfig: UPDATE_CI_CONFIG,
+ updateAvailableStages: UPDATE_AVAILABLE_STAGES,
+ }),
checkShouldSkipStartScreen() {
const params = queryToObject(window.location.search);
this.shouldSkipStartScreen = Boolean(params?.add_new_config_file);
@@ -344,7 +354,7 @@ export default {
},
resetContent() {
this.showResetConfirmationModal = false;
- this.currentCiFileContent = this.lastCommittedContent;
+ this.updateCiConfig(this.lastCommittedContent);
},
setAppStatus(appStatus) {
if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) {
@@ -361,9 +371,6 @@ export default {
showErrorAlert({ type, reasons = [] }) {
this.reportFailure(type, reasons);
},
- updateCiConfig(ciFileContent) {
- this.currentCiFileContent = ciFileContent;
- },
updateCommitSha() {
this.isFetchingCommitSha = true;
this.$apollo.queries.commitSha.refetch();
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/index.js b/app/assets/javascripts/ci/pipeline_editor/store/index.js
new file mode 100644
index 00000000000..d7d5aed79e2
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/store/index.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ mutations,
+ state: state(),
+ });
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js
new file mode 100644
index 00000000000..035d3c90c14
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js
@@ -0,0 +1,2 @@
+export const UPDATE_CI_CONFIG = 'UPDATE_CI_CONFIG';
+export const UPDATE_AVAILABLE_STAGES = 'UPDATE_AVAILABLE_STAGES';
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js
new file mode 100644
index 00000000000..552c1df9a2c
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js
@@ -0,0 +1,10 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.UPDATE_CI_CONFIG](state, content) {
+ state.currentCiFileContent = content;
+ },
+ [types.UPDATE_AVAILABLE_STAGES](state, stages) {
+ state.availableStages = stages || [];
+ },
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/store/state.js b/app/assets/javascripts/ci/pipeline_editor/store/state.js
new file mode 100644
index 00000000000..34146cd54c4
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/store/state.js
@@ -0,0 +1,4 @@
+export default () => ({
+ currentCiFileContent: '',
+ availableStages: [],
+});
diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
index 8837b7a1917..5337d0da80c 100644
--- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
@@ -43,6 +43,7 @@ const i18n = {
defaultError: __('Something went wrong on our end. Please try again.'),
refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
+ configButtonTitle: s__('Pipelines|Go to the pipeline editor'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
removeVariableLabel: s__('CiVariables|Remove variable'),
@@ -81,6 +82,14 @@ export default {
type: String,
required: true,
},
+ pipelinesEditorPath: {
+ type: String,
+ required: true,
+ },
+ canViewPipelineEditor: {
+ type: Boolean,
+ required: true,
+ },
defaultBranch: {
type: String,
required: true,
@@ -373,9 +382,18 @@ export default {
:dismissible="false"
variant="danger"
class="gl-mb-4"
- data-testid="run-pipeline-error-alert"
>
- <span v-safe-html="error"></span>
+ <span v-safe-html="error" data-testid="run-pipeline-error-alert" class="block"></span>
+ <gl-button
+ v-if="canViewPipelineEditor"
+ class="gl-my-3"
+ data-testid="ci-cd-pipeline-configuration"
+ variant="confirm"
+ :aria-label="$options.i18n.configButtonTitle"
+ :href="pipelinesEditorPath"
+ >
+ {{ $options.i18n.configButtonTitle }}
+ </gl-button>
</gl-alert>
<gl-alert
v-if="shouldShowWarning"
diff --git a/app/assets/javascripts/ci/pipeline_new/index.js b/app/assets/javascripts/ci/pipeline_new/index.js
index 71c76aeab36..a466313a6cd 100644
--- a/app/assets/javascripts/ci/pipeline_new/index.js
+++ b/app/assets/javascripts/ci/pipeline_new/index.js
@@ -14,6 +14,8 @@ const mountPipelineNewForm = (el) => {
fileParam,
maxWarnings,
pipelinesPath,
+ pipelinesEditorPath,
+ canViewPipelineEditor,
projectId,
projectPath,
refParam,
@@ -43,6 +45,8 @@ const mountPipelineNewForm = (el) => {
fileParams,
maxWarnings: Number(maxWarnings),
pipelinesPath,
+ pipelinesEditorPath,
+ canViewPipelineEditor,
projectId,
projectPath,
refParam,
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue b/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue
index 16bfc7f3abe..92c824fb5a1 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/delete_pipeline_schedule_modal.vue
@@ -10,11 +10,11 @@ export default {
),
actionPrimary: {
text: s__('PipelineSchedules|Delete pipeline schedule'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
actionCancel: {
text: __('Cancel'),
- attributes: [],
+ attributes: {},
},
},
components: {
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
index d03de91ea07..6695c6179cf 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue
@@ -70,10 +70,12 @@ export default {
},
update(data) {
const { pipelineSchedules: { nodes: list = [], count } = {} } = data.project || {};
+ const currentUser = data.currentUser || {};
return {
list,
count,
+ currentUser,
};
},
error() {
@@ -279,6 +281,7 @@ export default {
<pipeline-schedules-table
v-else
:schedules="schedules.list"
+ :current-user="schedules.currentUser"
@showTakeOwnershipModal="setTakeOwnershipModal"
@showDeleteModal="setDeleteModal"
@playPipelineSchedule="playPipelineSchedule"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
index 45b4f618e17..5bd58bfd95d 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue
@@ -23,13 +23,20 @@ export default {
type: Object,
required: true,
},
+ currentUser: {
+ type: Object,
+ required: true,
+ },
},
computed: {
canPlay() {
return this.schedule.userPermissions.playPipelineSchedule;
},
+ isCurrentUserOwner() {
+ return this.schedule.owner.username === this.currentUser.username;
+ },
canTakeOwnership() {
- return this.schedule.userPermissions.takeOwnershipPipelineSchedule;
+ return !this.isCurrentUserOwner && this.schedule.userPermissions.adminPipelineSchedule;
},
canUpdate() {
return this.schedule.userPermissions.updatePipelineSchedule;
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
index e8cfc5b29f3..0b95e2037e8 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue
@@ -59,6 +59,10 @@ export default {
type: Array,
required: true,
},
+ currentUser: {
+ type: Object,
+ required: true,
+ },
},
};
</script>
@@ -94,6 +98,7 @@ export default {
<template #cell(actions)="{ item }">
<pipeline-schedule-actions
:schedule="item"
+ :current-user="currentUser"
@showTakeOwnershipModal="$emit('showTakeOwnershipModal', $event)"
@showDeleteModal="$emit('showDeleteModal', $event)"
@playPipelineSchedule="$emit('playPipelineSchedule', $event)"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue
index 3ac52d4735d..7863b0e3ef0 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal.vue
@@ -27,12 +27,10 @@ export default {
actionPrimary() {
return {
text: this.$options.i18n.takeOwnership,
- attributes: [
- {
- variant: 'confirm',
- category: 'primary',
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ category: 'primary',
+ },
};
},
},
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
index 7ded3945a32..b4d84309c5f 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue
@@ -27,14 +27,12 @@ export default {
actionPrimary() {
return {
text: this.$options.i18n.takeOwnership,
- attributes: [
- {
- variant: 'confirm',
- category: 'primary',
- href: this.ownershipUrl,
- 'data-method': 'post',
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ category: 'primary',
+ href: this.ownershipUrl,
+ 'data-method': 'post',
+ },
};
},
},
diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
index 9f6cb429cca..6167c7dc577 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
+++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql
@@ -1,4 +1,8 @@
query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStatus) {
+ currentUser {
+ id
+ username
+ }
project(fullPath: $projectPath) {
id
pipelineSchedules(status: $status) {
@@ -25,13 +29,13 @@ query getPipelineSchedulesQuery($projectPath: ID!, $status: PipelineScheduleStat
realNextRun
owner {
id
+ username
avatarUrl
name
webPath
}
userPermissions {
playPipelineSchedule
- takeOwnershipPipelineSchedule
updatePipelineSchedule
adminPipelineSchedule
}
diff --git a/app/assets/javascripts/ci/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js
index bad6fa1e7b9..1137236d355 100644
--- a/app/assets/javascripts/ci/reports/constants.js
+++ b/app/assets/javascripts/ci/reports/constants.js
@@ -1,10 +1,3 @@
-export const fieldTypes = {
- codeBlock: 'codeBlock',
- link: 'link',
- seconds: 'seconds',
- text: 'text',
-};
-
export const LOADING = 'LOADING';
export const ERROR = 'ERROR';
export const SUCCESS = 'SUCCESS';
@@ -15,10 +8,6 @@ export const STATUS_NEUTRAL = 'neutral';
export const STATUS_NOT_FOUND = 'not_found';
export const ICON_WARNING = 'warning';
-export const ICON_SUCCESS = 'success';
-export const ICON_NOTFOUND = 'notfound';
-export const ICON_PENDING = 'pending';
-export const ICON_FAILED = 'failed';
export const status = {
LOADING,
@@ -26,9 +15,6 @@ export const status = {
SUCCESS,
};
-export const ACCESSIBILITY_ISSUE_ERROR = 'error';
-export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
-
/**
* Slot names for the ReportSection component, corresponding to the success,
* loading and error statuses.
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
index 5401c7c1c28..79600012838 100644
--- a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
@@ -1,9 +1,13 @@
<script>
import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
-import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
-import { DEFAULT_PLATFORM, DEFAULT_ACCESS_LEVEL } from '../constants';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
export default {
name: 'AdminNewRunnerApp',
@@ -12,7 +16,7 @@ export default {
GlSprintf,
RunnerInstructionsModal,
RunnerPlatformsRadioGroup,
- RunnerFormFields,
+ RunnerCreateForm,
},
directives: {
GlModal: GlModalDirective,
@@ -26,17 +30,24 @@ export default {
data() {
return {
platform: DEFAULT_PLATFORM,
- runner: {
- description: '',
- maintenanceNote: '',
- paused: false,
- accessLevel: DEFAULT_ACCESS_LEVEL,
- runUntagged: false,
- tagList: '',
- maximumTimeout: ' ',
- },
};
},
+ methods: {
+ onSaved(runner) {
+ const registerUrl = setUrlParams(
+ { [PARAM_KEY_PLATFORM]: this.platform },
+ runner.registerAdminUrl,
+ );
+ saveAlertToLocalStorage({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ redirectTo(registerUrl);
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
modalId: 'runners-legacy-registration-instructions-modal',
};
</script>
@@ -73,6 +84,6 @@ export default {
<hr aria-hidden="true" />
- <runner-form-fields v-model="runner" />
+ <runner-create-form @saved="onSaved" @error="onError" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/admin_register_runner/admin_register_runner_app.vue b/app/assets/javascripts/ci/runner/admin_register_runner/admin_register_runner_app.vue
new file mode 100644
index 00000000000..cd38dc07157
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/admin_register_runner/admin_register_runner_app.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { getParameterByName, updateHistory, mergeUrlParams } from '~/lib/utils/url_utility';
+import { PARAM_KEY_PLATFORM, DEFAULT_PLATFORM } from '../constants';
+import RegistrationInstructions from '../components/registration/registration_instructions.vue';
+import PlatformsDrawer from '../components/registration/platforms_drawer.vue';
+
+export default {
+ name: 'AdminRegisterRunnerApp',
+ components: {
+ GlButton,
+ RegistrationInstructions,
+ PlatformsDrawer,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ runnersPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: getParameterByName(PARAM_KEY_PLATFORM) || DEFAULT_PLATFORM,
+ isDrawerOpen: false,
+ };
+ },
+ watch: {
+ platform(platform) {
+ updateHistory({
+ url: mergeUrlParams({ [PARAM_KEY_PLATFORM]: platform }, window.location.href),
+ });
+ },
+ },
+ methods: {
+ onSelectPlatform(platform) {
+ this.platform = platform;
+ },
+ onToggleDrawer(val = !this.isDrawerOpen) {
+ this.isDrawerOpen = val;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <registration-instructions
+ :runner-id="runnerId"
+ :platform="platform"
+ @toggleDrawer="onToggleDrawer"
+ >
+ <template #runner-list-name>{{ s__('Runners|Admin area › Runners') }}</template>
+ </registration-instructions>
+
+ <platforms-drawer
+ :platform="platform"
+ :open="isDrawerOpen"
+ @selectPlatform="onSelectPlatform"
+ @close="onToggleDrawer(false)"
+ />
+
+ <gl-button :href="runnersPath" variant="confirm">{{
+ s__('Runners|Go to runners page')
+ }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/admin_register_runner/index.js b/app/assets/javascripts/ci/runner/admin_register_runner/index.js
new file mode 100644
index 00000000000..bd43a5e8ce9
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/admin_register_runner/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
+import AdminRegisterRunnerApp from './admin_register_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initAdminRegisterRunner = (selector = '#js-admin-register-runner') => {
+ showAlertFromLocalStorage();
+
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId, runnersPath } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(AdminRegisterRunnerApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
index 8d4303778af..36fb1cee525 100644
--- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
index ce2c511ddd4..d452adb34d9 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -128,8 +128,8 @@ export default {
return isSearchFiltered(this.search);
},
shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow feature flag
- return this.glFeatures.createRunnerWorkflow;
+ // create_runner_workflow_for_admin feature flag
+ return this.glFeatures.createRunnerWorkflowForAdmin;
},
},
watch: {
diff --git a/app/assets/javascripts/ci/runner/components/registration/cli_command.vue b/app/assets/javascripts/ci/runner/components/registration/cli_command.vue
new file mode 100644
index 00000000000..95b135c83a7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/cli_command.vue
@@ -0,0 +1,42 @@
+<script>
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ components: {
+ ClipboardButton,
+ },
+ props: {
+ prompt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ command: {
+ type: [Array, String],
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ lines() {
+ if (typeof this.command === 'string') {
+ return [this.command];
+ }
+ return this.command;
+ },
+ clipboard() {
+ return this.lines.join('');
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-gap-3 gl-align-items-flex-start">
+ <!-- eslint-disable vue/require-v-for-key-->
+ <pre
+ class="gl-w-full"
+ ><span v-if="prompt" class="gl-user-select-none">{{ prompt }} </span><template v-for="line in lines">{{ line }}<br class="gl-user-select-none"/></template></pre>
+ <!-- eslint-enable vue/require-v-for-key-->
+ <clipboard-button :text="clipboard" :title="__('Copy')" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue
new file mode 100644
index 00000000000..ff182c61ccf
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/platforms_drawer.vue
@@ -0,0 +1,135 @@
+<script>
+import { GlDrawer, GlFormGroup, GlFormSelect, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+
+import {
+ DEFAULT_PLATFORM,
+ MACOS_PLATFORM,
+ LINUX_PLATFORM,
+ WINDOWS_PLATFORM,
+ INSTALL_HELP_URL,
+} from '../../constants';
+import { installScript, platformArchitectures } from './utils';
+
+import CliCommand from './cli_command.vue';
+
+export default {
+ components: {
+ GlDrawer,
+ GlFormGroup,
+ GlFormSelect,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ CliCommand,
+ },
+ props: {
+ open: {
+ type: Boolean,
+ required: true,
+ },
+ platform: {
+ type: String,
+ required: false,
+ default: DEFAULT_PLATFORM,
+ },
+ },
+ data() {
+ return {
+ selectedPlatform: this.platform,
+ selectedArchitecture: null,
+ };
+ },
+ computed: {
+ drawerHeightOffset() {
+ return getContentWrapperHeight('.content-wrapper');
+ },
+ architectureOptions() {
+ return platformArchitectures({ platform: this.selectedPlatform });
+ },
+ script() {
+ return installScript({
+ platform: this.selectedPlatform,
+ architecture: this.selectedArchitecture,
+ });
+ },
+ },
+ watch: {
+ selectedPlatform() {
+ this.selectedArchitecture =
+ this.architectureOptions.find((value) => value === this.selectedArchitecture) ||
+ this.architectureOptions[0];
+
+ this.$emit('selectPlatform', this.selectedPlatform);
+ },
+ },
+ created() {
+ [this.selectedArchitecture] = this.architectureOptions;
+ },
+ methods: {
+ onClose() {
+ this.$emit('close');
+ },
+ },
+ platformOptions: [
+ /* eslint-disable @gitlab/require-i18n-strings */
+ { value: LINUX_PLATFORM, text: 'Linux' },
+ { value: MACOS_PLATFORM, text: 'macOS' },
+ { value: WINDOWS_PLATFORM, text: 'Windows' },
+ /* eslint-enable @gitlab/require-i18n-strings */
+ ],
+ INSTALL_HELP_URL,
+ DRAWER_Z_INDEX,
+};
+</script>
+<template>
+ <gl-drawer
+ :open="open"
+ :header-height="drawerHeightOffset"
+ :z-index="$options.DRAWER_Z_INDEX"
+ data-testid="runner-platforms-drawer"
+ @close="onClose"
+ >
+ <template #title>
+ <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">
+ {{ s__('Runners|Install GitLab Runner') }}
+ </h2>
+ </template>
+ <div>
+ <p>{{ s__('Runners|Select platform specifications to install GitLab Runner.') }}</p>
+
+ <gl-form-group :label="s__('Runners|Environment')" label-for="runner-environment-select">
+ <gl-form-select
+ id="runner-environment-select"
+ v-model="selectedPlatform"
+ :options="$options.platformOptions"
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="s__('Runners|Architecture')" label-for="runner-architecture-select">
+ <gl-form-select
+ id="runner-architecture-select"
+ v-model="selectedArchitecture"
+ :options="architectureOptions"
+ />
+ </gl-form-group>
+
+ <cli-command :command="script" />
+
+ <p>
+ <gl-sprintf
+ :message="
+ s__('Runners|See more %{linkStart}installation methods and architectures%{linkEnd}.')
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.INSTALL_HELP_URL">
+ {{ content }} <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
new file mode 100644
index 00000000000..2f3c172666d
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
@@ -0,0 +1,241 @@
+<script>
+import { GlIcon, GlLink, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { createAlert } from '~/alert';
+import { s__, sprintf } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
+
+import runnerForRegistrationQuery from '../../graphql/register/runner_for_registration.query.graphql';
+import {
+ STATUS_ONLINE,
+ EXECUTORS_HELP_URL,
+ SERVICE_COMMANDS_HELP_URL,
+ RUNNER_REGISTRATION_POLLING_INTERVAL_MS,
+ I18N_FETCH_ERROR,
+ I18N_REGISTRATION_SUCCESS,
+} from '../../constants';
+import { captureException } from '../../sentry_utils';
+
+import CliCommand from './cli_command.vue';
+import { commandPrompt, registerCommand, runCommand } from './utils';
+
+export default {
+ name: 'RegistrationInstructions',
+ components: {
+ GlIcon,
+ GlLink,
+ GlSkeletonLoader,
+ GlSprintf,
+ ClipboardButton,
+ CliCommand,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ platform: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ runner: null,
+ token: null,
+ };
+ },
+ apollo: {
+ runner: {
+ query: runnerForRegistrationQuery,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId),
+ };
+ },
+ manual: true,
+ result({ data }) {
+ if (data?.runner) {
+ const { ephemeralAuthenticationToken, ...runner } = data.runner;
+ this.runner = runner;
+
+ // The token is available in the API for a limited amount of time
+ // preserve its original value if it is missing after polling.
+ this.token = ephemeralAuthenticationToken || this.token;
+ }
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+ captureException({ error, component: this.$options.name });
+ },
+ pollInterval() {
+ if (this.runner?.status === STATUS_ONLINE) {
+ // stop polling
+ return 0;
+ }
+ return RUNNER_REGISTRATION_POLLING_INTERVAL_MS;
+ },
+ },
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.runner.loading;
+ },
+ description() {
+ return this.runner?.description;
+ },
+ heading() {
+ if (this.description) {
+ return sprintf(
+ s__('Runners|Register "%{runnerDescription}" runner'),
+ {
+ runnerDescription: this.description,
+ },
+ false,
+ );
+ }
+ return s__('Runners|Register runner');
+ },
+ status() {
+ return this.runner?.status;
+ },
+ tokenMessage() {
+ if (this.token) {
+ return s__(
+ 'Runners|The %{boldStart}runner token%{boldEnd} %{token} displays %{boldStart}only for a short time%{boldEnd}, and is stored in the %{codeStart}config.toml%{codeEnd} after you register the runner. It will not be visible once the runner is registered.',
+ );
+ }
+ return s__(
+ 'Runners|The %{boldStart}runner token%{boldEnd} is no longer visible, it is stored in the %{codeStart}config.toml%{codeEnd} if you have registered the runner.',
+ );
+ },
+ commandPrompt() {
+ return commandPrompt({ platform: this.platform });
+ },
+ registerCommand() {
+ return registerCommand({
+ platform: this.platform,
+ registrationToken: this.token,
+ description: this.description,
+ });
+ },
+ runCommand() {
+ return runCommand({ platform: this.platform });
+ },
+ },
+ methods: {
+ toggleDrawer() {
+ this.$emit('toggleDrawer');
+ },
+ },
+ EXECUTORS_HELP_URL,
+ SERVICE_COMMANDS_HELP_URL,
+ STATUS_ONLINE,
+ I18N_REGISTRATION_SUCCESS,
+};
+</script>
+<template>
+ <div>
+ <h1 class="gl-font-size-h1">{{ heading }}</h1>
+
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|GitLab Runner must be installed before you can register a runner. %{linkStart}How do I install GitLab Runner?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link data-testid="runner-install-link" @click="toggleDrawer">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <section>
+ <h2 class="gl-font-size-h2">{{ s__('Runners|Step 1') }}</h2>
+ <p>
+ {{
+ s__(
+ 'Runners|Copy and paste the following command into your command line to register the runner.',
+ )
+ }}
+ </p>
+ <gl-skeleton-loader v-if="loading" />
+ <template v-else>
+ <cli-command :prompt="commandPrompt" :command="registerCommand" />
+ <p>
+ <gl-icon name="information-o" class="gl-text-blue-600!" />
+ <gl-sprintf :message="tokenMessage">
+ <template #token>
+ <code data-testid="runner-token">{{ token }}</code>
+ <clipboard-button
+ :text="token"
+ :title="__('Copy')"
+ size="small"
+ category="tertiary"
+ class="gl-border-none!"
+ />
+ </template>
+ <template #bold="{ content }"
+ ><span class="gl-font-weight-bold">{{ content }}</span></template
+ >
+ <template #code="{ content }"
+ ><code>{{ content }}</code></template
+ >
+ </gl-sprintf>
+ </p>
+ </template>
+ </section>
+ <section>
+ <h2 class="gl-font-size-h2">{{ s__('Runners|Step 2') }}</h2>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Choose an executor when prompted by the command line. Executors run builds in different environments. %{linkStart}Not sure which one to select?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.EXECUTORS_HELP_URL" target="_blank">
+ {{ content }} <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </section>
+ <section>
+ <h2 class="gl-font-size-h2">{{ s__('Runners|Step 3 (optional)') }}</h2>
+ <p>{{ s__('Runners|Manually verify that the runner is available to pick up jobs.') }}</p>
+ <cli-command :prompt="commandPrompt" :command="runCommand" />
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|This may not be needed if you manage your runner as a %{linkStart}system or user service%{linkEnd}.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.SERVICE_COMMANDS_HELP_URL" target="_blank">
+ {{ content }} <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </section>
+ <section v-if="status == $options.STATUS_ONLINE">
+ <h2 class="gl-font-size-h2">🎉 {{ $options.I18N_REGISTRATION_SUCCESS }}</h2>
+
+ <p class="gl-pl-6">
+ <gl-sprintf :message="s__('Runners|To view the runner, go to %{runnerListName}.')">
+ <template #runnerListName>
+ <span class="gl-font-weight-bold"><slot name="runner-list-name"></slot></span>
+ </template>
+ </gl-sprintf>
+ </p>
+ </section>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
index ac2793654c8..6ce88fc54de 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -1,6 +1,6 @@
<script>
import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
@@ -73,13 +73,13 @@ export default {
actionPrimary() {
return {
text: i18n.modalAction,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
actionSecondary() {
return {
text: i18n.modalCancel,
- attributes: [{ variant: 'default' }],
+ attributes: { variant: 'default' },
};
},
},
diff --git a/app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh b/app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh
new file mode 100644
index 00000000000..a8ba2592128
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/scripts/linux/install.sh
@@ -0,0 +1,12 @@
+# Download the binary for your system
+sudo curl -L --output /usr/local/bin/gitlab-runner ${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# Create a GitLab Runner user
+sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
+
+# Install and run as a service
+sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
+sudo gitlab-runner start
diff --git a/app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh b/app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh
new file mode 100644
index 00000000000..76c893bacfc
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/scripts/osx/install.sh
@@ -0,0 +1,11 @@
+# Download the binary for your system
+sudo curl --output /usr/local/bin/gitlab-runner ${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}
+
+# Give it permission to execute
+sudo chmod +x /usr/local/bin/gitlab-runner
+
+# The rest of the commands execute as the user who will run the runner
+# Register the runner (steps below), then run
+cd ~
+gitlab-runner install
+gitlab-runner start
diff --git a/app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps1 b/app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps1
new file mode 100644
index 00000000000..019363fc3f7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/scripts/windows/install.ps1
@@ -0,0 +1,13 @@
+# Run PowerShell: https://docs.microsoft.com/en-us/powershell/scripting/windows-powershell/starting-windows-powershell?view=powershell-7#with-administrative-privileges-run-as-administrator
+# Create a folder somewhere on your system, for example: C:\GitLab-Runner
+New-Item -Path 'C:\GitLab-Runner' -ItemType Directory
+
+# Change to the folder
+cd 'C:\GitLab-Runner'
+
+# Download binary
+Invoke-WebRequest -Uri "${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}" -OutFile "gitlab-runner.exe"
+
+# Register the runner (steps below), then run
+.\gitlab-runner.exe install
+.\gitlab-runner.exe start
diff --git a/app/assets/javascripts/ci/runner/components/registration/utils.js b/app/assets/javascripts/ci/runner/components/registration/utils.js
new file mode 100644
index 00000000000..94d75bc4562
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/utils.js
@@ -0,0 +1,109 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import {
+ DEFAULT_PLATFORM,
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ DOWNLOAD_LOCATIONS,
+} from '../../constants';
+import linuxInstall from './scripts/linux/install.sh?raw';
+import osxInstall from './scripts/osx/install.sh?raw';
+import windowsInstall from './scripts/windows/install.ps1?raw';
+
+const OS = {
+ [LINUX_PLATFORM]: {
+ shell: 'bash',
+ commandPrompt: '$',
+ executable: 'gitlab-runner',
+ },
+ [MACOS_PLATFORM]: {
+ shell: 'bash',
+ commandPrompt: '$',
+ executable: 'gitlab-runner',
+ },
+ [WINDOWS_PLATFORM]: {
+ shell: 'powershell',
+ commandPrompt: '>',
+ executable: '.\\gitlab-runner.exe',
+ },
+};
+
+const escapedParam = (param, shell = 'bash') => {
+ let escaped;
+ if (shell === 'bash') {
+ // replace single-quotes by the sequence '\''
+ escaped = param.replaceAll("'", "'\\''");
+ } else if (shell === 'powershell') {
+ // replace single-quotes by the sequence ''
+ // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.3
+ escaped = param.replaceAll("'", "''");
+ }
+ // surround with single quotes.
+ return `'${escaped}'`;
+};
+
+export const commandPrompt = ({ platform }) => {
+ return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt;
+};
+
+export const executable = ({ platform }) => {
+ return (OS[platform] || OS[DEFAULT_PLATFORM]).executable;
+};
+
+const shell = ({ platform }) => {
+ return (OS[platform] || OS[DEFAULT_PLATFORM]).shell;
+};
+
+export const registerCommand = ({
+ platform,
+ url = gon.gitlab_url,
+ registrationToken,
+ description,
+}) => {
+ const lines = [`${executable({ platform })} register`];
+ if (url) {
+ lines.push(` --url ${url}`);
+ }
+ if (registrationToken) {
+ lines.push(` --registration-token ${registrationToken}`);
+ }
+ if (description) {
+ const escapedDescription = escapedParam(description, shell({ platform }));
+ lines.push(` --description ${escapedDescription}`);
+ }
+ return lines;
+};
+
+export const runCommand = ({ platform }) => {
+ return `${executable({ platform })} run`;
+};
+
+const importInstallScript = ({ platform = DEFAULT_PLATFORM }) => {
+ switch (platform) {
+ case LINUX_PLATFORM:
+ return linuxInstall;
+ case MACOS_PLATFORM:
+ return osxInstall;
+ case WINDOWS_PLATFORM:
+ return windowsInstall;
+ default:
+ return '';
+ }
+};
+
+export const platformArchitectures = ({ platform }) => {
+ return DOWNLOAD_LOCATIONS[platform].map(({ arch }) => arch);
+};
+
+export const installScript = ({ platform, architecture }) => {
+ const downloadLocation = DOWNLOAD_LOCATIONS[platform].find(({ arch }) => arch === architecture)
+ .url;
+
+ return importInstallScript({ platform })
+ .replace(
+ // eslint-disable-next-line no-template-curly-in-string
+ '${GITLAB_CI_RUNNER_DOWNLOAD_LOCATION}',
+ downloadLocation,
+ )
+ .trim();
+};
diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
index 8dde3ac4e19..e7b26ec6d4e 100644
--- a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, n__, sprintf } from '~/locale';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql';
diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
new file mode 100644
index 00000000000..5d2a3c53842
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlForm, GlButton } from '@gitlab/ui';
+import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
+import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql';
+import { modelToUpdateMutationVariables } from 'ee_else_ce/ci/runner/runner_update_form_utils';
+import { captureException } from '../sentry_utils';
+import { DEFAULT_ACCESS_LEVEL } from '../constants';
+
+export default {
+ name: 'RunnerCreateForm',
+ components: {
+ GlForm,
+ GlButton,
+ RunnerFormFields,
+ },
+ data() {
+ return {
+ saving: false,
+ runner: {
+ description: '',
+ maintenanceNote: '',
+ paused: false,
+ accessLevel: DEFAULT_ACCESS_LEVEL,
+ runUntagged: false,
+ tagList: '',
+ maximumTimeout: '',
+ },
+ };
+ },
+ methods: {
+ async onSubmit() {
+ this.saving = true;
+ try {
+ const {
+ data: {
+ runnerCreate: { errors, runner },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerCreateMutation,
+ variables: modelToUpdateMutationVariables(this.runner),
+ });
+
+ if (errors?.length) {
+ this.$emit('error', new Error(errors.join(' ')));
+ } else {
+ this.onSuccess(runner);
+ }
+ } catch (error) {
+ captureException({ error, component: this.$options.name });
+ this.$emit('error', error);
+ } finally {
+ this.saving = false;
+ }
+ },
+ onSuccess(runner) {
+ this.$emit('saved', runner);
+ },
+ },
+};
+</script>
+<template>
+ <gl-form @submit.prevent="onSubmit">
+ <runner-form-fields v-model="runner" />
+
+ <div class="gl-display-flex">
+ <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="saving">
+ {{ __('Submit') }}
+ </gl-button>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
index f02e6bce5c3..020487fc727 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import runnerDeleteMutation from '~/ci/runner/graphql/shared/runner_delete.mutation.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sprintf, s__ } from '~/locale';
import { captureException } from '~/ci/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs.vue b/app/assets/javascripts/ci/runner/components/runner_jobs.vue
index 9003eba3636..f5287f597ab 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs.vue
@@ -1,6 +1,6 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import runnerJobsQuery from '../graphql/show/runner_jobs.query.graphql';
import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
import { captureException } from '../sentry_utils';
diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
index d2f7912fabb..2cff11c1aa1 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
@@ -43,8 +43,8 @@ export default {
},
computed: {
shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow feature flag
- return this.newRunnerPath && this.glFeatures?.createRunnerWorkflow;
+ // create_runner_workflow_for_admin feature flag
+ return this.newRunnerPath && this.glFeatures?.createRunnerWorkflowForAdmin;
},
},
modalId: 'runners-empty-state-instructions-modal',
diff --git a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
index 2c80518e772..a27af232e97 100644
--- a/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_pause_button.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { captureException } from '~/ci/runner/sentry_utils';
import { I18N_PAUSE, I18N_PAUSE_TOOLTIP, I18N_RESUME, I18N_RESUME_TOOLTIP } from '../constants';
diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue
index 4a6e90b44a9..4cfc57340f5 100644
--- a/app/assets/javascripts/ci/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue
@@ -1,7 +1,7 @@
<script>
import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql';
import {
I18N_ASSIGNED_PROJECTS,
diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
index a9790d06ca7..dd8e965cecd 100644
--- a/app/assets/javascripts/ci/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
@@ -13,7 +13,7 @@ import {
modelToUpdateMutationVariables,
runnerToModel,
} from 'ee_else_ce/ci/runner/runner_update_form_utils';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { captureException } from '~/ci/runner/sentry_utils';
diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
index 6e7c41885f8..1de7775090a 100644
--- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
+++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index 318eb7e74bd..6237dcd0c03 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -105,10 +105,15 @@ export const I18N_JOBS = s__('Runners|Jobs');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects');
export const I18N_CLEAR_FILTER_PROJECTS = __('Clear');
-export const I18N_NONE = __('None');
export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.');
export const I18N_NO_PROJECTS_FOUND = __('No projects found');
+// Runner registration
+
+export const I18N_REGISTRATION_SUCCESS = s__("Runners|You've created a new runner!");
+
+export const RUNNER_REGISTRATION_POLLING_INTERVAL_MS = 2000;
+
// Styles
export const RUNNER_TAG_BADGE_VARIANT = 'info';
@@ -129,6 +134,8 @@ export const PARAM_KEY_SORT = 'sort';
export const PARAM_KEY_AFTER = 'after';
export const PARAM_KEY_BEFORE = 'before';
+export const PARAM_KEY_PLATFORM = 'platform';
+
// CiRunnerType
export const INSTANCE_TYPE = 'INSTANCE_TYPE';
@@ -182,9 +189,62 @@ export const MACOS_PLATFORM = 'osx';
export const WINDOWS_PLATFORM = 'windows';
export const AWS_PLATFORM = 'aws';
+export const DOWNLOAD_LOCATIONS = {
+ [LINUX_PLATFORM]: [
+ {
+ arch: 'amd64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64',
+ },
+ {
+ arch: '386',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386',
+ },
+ {
+ arch: 'arm',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm',
+ },
+ {
+ arch: 'arm64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64',
+ },
+ ],
+ [MACOS_PLATFORM]: [
+ {
+ arch: 'amd64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64',
+ },
+ {
+ arch: 'arm64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-arm64',
+ },
+ ],
+ [WINDOWS_PLATFORM]: [
+ {
+ arch: 'amd64',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe',
+ },
+ {
+ arch: '386',
+ url:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe',
+ },
+ ],
+};
+
export const DEFAULT_PLATFORM = LINUX_PLATFORM;
// Runner docs are in a separate repository and are not shipped with GitLab
// they are rendered as external URLs.
+export const INSTALL_HELP_URL = 'https://docs.gitlab.com/runner/install';
+export const EXECUTORS_HELP_URL = 'https://docs.gitlab.com/runner/executors/';
+export const SERVICE_COMMANDS_HELP_URL =
+ 'https://docs.gitlab.com/runner/commands/#service-related-commands';
export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html';
export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html';
diff --git a/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
new file mode 100644
index 00000000000..d14a594e378
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/new/runner_create.mutation.graphql
@@ -0,0 +1,9 @@
+mutation runnerCreate($input: RunnerCreateInput!) {
+ runnerCreate(input: $input) {
+ runner {
+ id
+ registerAdminUrl
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql b/app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql
new file mode 100644
index 00000000000..f6cee807620
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/graphql/register/runner_for_registration.query.graphql
@@ -0,0 +1,8 @@
+query getRunnerForRegistration($id: CiRunnerID!) {
+ runner(id: $id) {
+ id
+ description
+ ephemeralAuthenticationToken
+ status
+ }
+}
diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
index 273a9aa823c..2db3a2f42a7 100644
--- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
index e66a1c7b1aa..294d06a66e7 100644
--- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import { upgradeStatusTokenConfig } from 'ee_else_ce/ci/runner/components/search_tokens/upgrade_status_token_config';
diff --git a/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js b/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js
index d768a06494a..bad3ca6024e 100644
--- a/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js
+++ b/app/assets/javascripts/ci/runner/local_storage_alert/show_alert_from_local_storage.js
@@ -7,7 +7,7 @@ export const showAlertFromLocalStorage = async () => {
if (alertOptions) {
try {
- const { createAlert } = await import('~/flash');
+ const { createAlert } = await import('~/alert');
createAlert(JSON.parse(alertOptions));
} catch {
// ignore when the alert data cannot be parsed
diff --git a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
index 4593c9ae52b..843342b20df 100644
--- a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
+++ b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '../components/runner_header.vue';
diff --git a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
index f0af0da4bb4..697162b50ae 100644
--- a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
+++ b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
@@ -78,16 +78,17 @@ export default {
primaryModalProps() {
return {
text: this.$options.i18n.revokeButton,
- attributes: [
- { disabled: this.loading || this.disableModalSubmit, loading: this.loading },
- { variant: 'danger' },
- ],
+ attributes: {
+ disabled: this.loading || this.disableModalSubmit,
+ loading: this.loading,
+ variant: 'danger',
+ },
};
},
cancelModalProps() {
return {
text: this.$options.i18n.modalCancel,
- attributes: [],
+ attributes: {},
};
},
disableModalSubmit() {
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index a788703fd08..c94c91654fc 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -1,7 +1,7 @@
import { GlToast } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
deleted file mode 100644
index c6ca895778d..00000000000
--- a/app/assets/javascripts/clusters/constants.js
+++ /dev/null
@@ -1,16 +0,0 @@
-// These need to match the enum found in app/models/clusters/cluster.rb
-export const CLUSTER_TYPE = {
- INSTANCE: 'instance_type',
- GROUP: 'group_type',
- PROJECT: 'project_type',
-};
-
-// These need to match the available providers in app/models/clusters/providers/
-export const PROVIDER_TYPE = {
- GCP: 'gcp',
-};
-
-// These are only used client-side
-
-export const LOGGING_MODE = 'logging';
-export const BLOCKING_MODE = 'blocking';
diff --git a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
index 7a028858d10..913db87f019 100644
--- a/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
+++ b/app/assets/javascripts/clusters_list/components/delete_agent_button.vue
@@ -77,16 +77,17 @@ export default {
primaryModalProps() {
return {
text: this.$options.i18n.modalAction,
- attributes: [
- { disabled: this.loading || this.disableModalSubmit, loading: this.loading },
- { variant: 'danger' },
- ],
+ attributes: {
+ disabled: this.loading || this.disableModalSubmit,
+ loading: this.loading,
+ variant: 'danger',
+ },
};
},
cancelModalProps() {
return {
text: this.$options.i18n.modalCancel,
- attributes: [],
+ attributes: {},
};
},
disableModalSubmit() {
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 77d6d5eb009..1ea18dcc97d 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
diff --git a/app/assets/javascripts/commit/components/signature_badge.vue b/app/assets/javascripts/commit/components/signature_badge.vue
new file mode 100644
index 00000000000..344536df093
--- /dev/null
+++ b/app/assets/javascripts/commit/components/signature_badge.vue
@@ -0,0 +1,94 @@
+<script>
+import { GlBadge, GlLink, GlPopover } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { typeConfig, statusConfig } from '../constants';
+import X509CertificateDetails from './x509_certificate_details.vue';
+
+export default {
+ components: {
+ GlBadge,
+ GlPopover,
+ GlLink,
+ X509CertificateDetails,
+ },
+ props: {
+ signature: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ statusConfig() {
+ return this.$options.statusConfig?.[this.signature?.verificationStatus];
+ },
+ typeConfig() {
+ // eslint-disable-next-line no-underscore-dangle
+ return this.$options.typeConfig?.[this.signature?.__typename];
+ },
+ },
+ methods: {
+ helpPagePath,
+ getSubjectKeyIdentifierToDisplay(subjectKeyIdentifier) {
+ // we need to remove : to not trigger secret detection scan
+ return subjectKeyIdentifier.replaceAll(':', ' ');
+ },
+ },
+ typeConfig,
+ statusConfig,
+};
+</script>
+<template>
+ <span
+ v-if="statusConfig && typeConfig"
+ class="gl-display-flex gl-align-items-center gl-hover-cursor-pointer gl-ml-2"
+ >
+ <button
+ id="signature"
+ tabindex="0"
+ data-testid="signature-badge"
+ role="button"
+ variant="link"
+ class="gl-border-0 gl-outline-0! gl-p-0 gl-bg-transparent"
+ :aria-label="statusConfig.label"
+ >
+ <gl-badge :variant="statusConfig.variant" size="md" data-testid="signature-status">
+ {{ statusConfig.label }}
+ </gl-badge>
+ </button>
+ <gl-popover target="signature" triggers="focus" data-testid="signature-info">
+ <template #title>
+ {{ statusConfig.title }}
+ </template>
+ <p data-testid="signature-description">
+ {{ statusConfig.description }}
+ </p>
+ <p v-if="typeConfig.keyLabel" data-testid="signature-key-label">
+ {{ typeConfig.keyLabel }}
+ <span class="gl-font-monospace" data-testid="signature-key">
+ {{ signature[typeConfig.keyNamespace] || __('Unknown') }}
+ </span>
+ </p>
+ <x509-certificate-details
+ v-if="signature.x509Certificate"
+ :title="typeConfig.subjectTitle"
+ :subject="signature.x509Certificate.subject"
+ :subject-key-identifier="
+ getSubjectKeyIdentifierToDisplay(signature.x509Certificate.subjectKeyIdentifier)
+ "
+ />
+ <x509-certificate-details
+ v-if="signature.x509Certificate && signature.x509Certificate.x509Issuer"
+ :title="typeConfig.issuerTitle"
+ :subject="signature.x509Certificate.x509Issuer.subject"
+ :subject-key-identifier="
+ getSubjectKeyIdentifierToDisplay(
+ signature.x509Certificate.x509Issuer.subjectKeyIdentifier,
+ )
+ "
+ />
+ <gl-link :href="helpPagePath(typeConfig.helpLink.path)">
+ {{ typeConfig.helpLink.label }}
+ </gl-link>
+ </gl-popover>
+ </span>
+</template>
diff --git a/app/assets/javascripts/commit/components/x509_certificate_details.vue b/app/assets/javascripts/commit/components/x509_certificate_details.vue
new file mode 100644
index 00000000000..6880fab9043
--- /dev/null
+++ b/app/assets/javascripts/commit/components/x509_certificate_details.vue
@@ -0,0 +1,45 @@
+<script>
+import { X509_CERTIFICATE_KEY_IDENTIFIER_TITLE } from '../constants';
+
+export default {
+ props: {
+ subject: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ subjectKeyIdentifier: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ subjectValues() {
+ return this.subject.split(',');
+ },
+ subjectKeyIdentifierToDisplay() {
+ return this.subjectKeyIdentifier.replaceAll(':', ' ');
+ },
+ },
+ i18n: {
+ keyIdentifierTitle: X509_CERTIFICATE_KEY_IDENTIFIER_TITLE,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <strong>{{ title }}</strong>
+ <ul class="gl-pl-5">
+ <li v-for="value in subjectValues" :key="value" data-testid="subject-value">
+ {{ value }}
+ </li>
+ <li data-testid="key-identifier">
+ {{ $options.i18n.keyIdentifierTitle }} {{ subjectKeyIdentifierToDisplay }}
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/commit/constants.js b/app/assets/javascripts/commit/constants.js
new file mode 100644
index 00000000000..4f865e99e46
--- /dev/null
+++ b/app/assets/javascripts/commit/constants.js
@@ -0,0 +1,104 @@
+import { __, s__ } from '~/locale';
+
+export const X509_CERTIFICATE_KEY_IDENTIFIER_TITLE = __('Subject Key Identifier:');
+
+export const verificationStatuses = {
+ VERIFIED: 'VERIFIED',
+ UNVERIFIED: 'UNVERIFIED',
+ UNVERIFIED_KEY: 'UNVERIFIED_KEY',
+ UNKNOWN_KEY: 'UNKNOWN_KEY',
+ OTHER_USER: 'OTHER_USER',
+ SAME_USER_DIFFERENT_EMAIL: 'SAME_USER_DIFFERENT_EMAIL',
+ MULTIPLE_SIGNATURES: 'MULTIPLE_SIGNATURES',
+ REVOKED_KEY: 'REVOKED_KEY',
+};
+
+export const signatureTypes = {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ GPG: 'GpgSignature',
+ X509: 'X509Signature',
+ SSH: 'SshSignature',
+ /* eslint-enable @gitlab/require-i18n-strings */
+};
+
+const UNVERIFIED_CONFIG = {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __('Unverified signature'),
+ description: __('This commit was signed with an unverified signature.'),
+};
+
+export const statusConfig = {
+ [verificationStatuses.VERIFIED]: {
+ variant: 'success',
+ label: __('Verified'),
+ title: __('Verified commit'),
+ description: __(
+ 'This commit was signed with a verified signature and the committer email was verified to belong to the same user.',
+ ),
+ },
+ [verificationStatuses.UNVERIFIED]: {
+ ...UNVERIFIED_CONFIG,
+ },
+ [verificationStatuses.UNVERIFIED_KEY]: {
+ ...UNVERIFIED_CONFIG,
+ },
+ [verificationStatuses.UNKNOWN_KEY]: {
+ ...UNVERIFIED_CONFIG,
+ },
+ [verificationStatuses.OTHER_USER]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __("Different user's signature"),
+ description: __('This commit was signed with an unverified signature.'),
+ },
+ [verificationStatuses.SAME_USER_DIFFERENT_EMAIL]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __('GPG key mismatch'),
+ description: __(
+ 'This commit was signed with a verified signature, but the committer email is not associated with the GPG Key.',
+ ),
+ },
+ [verificationStatuses.MULTIPLE_SIGNATURES]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: __('Multiple signatures'),
+ description: __('This commit was signed with multiple signatures.'),
+ },
+ [verificationStatuses.REVOKED_KEY]: {
+ variant: 'muted',
+ label: __('Unverified'),
+ title: s__('CommitSignature|Unverified signature'),
+ description: s__('CommitSignature|This commit was signed with a key that was revoked.'),
+ },
+};
+
+export const typeConfig = {
+ [signatureTypes.GPG]: {
+ keyLabel: __('GPG Key ID:'),
+ keyNamespace: 'gpgKeyPrimaryKeyid',
+ helpLink: {
+ label: __('Learn about signing commits'),
+ path: 'user/project/repository/gpg_signed_commits/index.md',
+ },
+ },
+ [signatureTypes.X509]: {
+ keyLabel: '',
+ helpLink: {
+ label: __('Learn more about X.509 signed commits'),
+ path: '/user/project/repository/x509_signed_commits/index.md',
+ },
+ subjectTitle: __('Certificate Subject'),
+ issuerTitle: __('Certificate Issuer'),
+ keyIdentifierTitle: __('Subject Key Identifier:'),
+ },
+ [signatureTypes.SSH]: {
+ keyLabel: __('SSH key fingerprint:'),
+ keyNamespace: 'keyFingerprintSha256',
+ helpLink: {
+ label: __('Learn about signing commits with SSH keys.'),
+ path: '/user/project/repository/ssh_signed_commits/index.md',
+ },
+ },
+};
diff --git a/app/assets/javascripts/commit_merge_requests.js b/app/assets/javascripts/commit_merge_requests.js
index d40cbe589c0..38abb7bebb0 100644
--- a/app/assets/javascripts/commit_merge_requests.js
+++ b/app/assets/javascripts/commit_merge_requests.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { n__, s__ } from './locale';
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index 196f5537a90..97762ff549b 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Api from '~/api';
import { __ } from '~/locale';
import state from '../state';
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
index 354db88f11c..06b80a65528 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
@@ -7,6 +7,7 @@ import Heading from '../../extensions/heading';
import Audio from '../../extensions/audio';
import Video from '../../extensions/video';
import Image from '../../extensions/image';
+import DrawioDiagram from '../../extensions/drawio_diagram';
import ToolbarButton from '../toolbar_button.vue';
import BubbleMenu from './bubble_menu.vue';
@@ -26,7 +27,7 @@ export default {
if (from === to) return false;
const includes = [Paragraph.name, Heading.name];
- const excludes = [Image.name, Audio.name, Video.name];
+ const excludes = [Image.name, Audio.name, Video.name, DrawioDiagram.name];
return (
includes.some((type) => editor.isActive(type)) &&
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
index 310bb1be81f..a14d49922fb 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
@@ -11,23 +11,26 @@ import {
} from '@gitlab/ui';
import { __ } from '~/locale';
import Audio from '../../extensions/audio';
+import DrawioDiagram from '../../extensions/drawio_diagram';
import Image from '../../extensions/image';
import Video from '../../extensions/video';
import EditorStateObserver from '../editor_state_observer.vue';
import { acceptedMimes } from '../../services/upload_helpers';
import BubbleMenu from './bubble_menu.vue';
-const MEDIA_TYPES = [Audio.name, Image.name, Video.name];
+const MEDIA_TYPES = [Audio.name, Image.name, Video.name, DrawioDiagram.name];
export default {
i18n: {
copySourceLabels: {
[Audio.name]: __('Copy audio URL'),
+ [DrawioDiagram.name]: __('Copy diagram URL'),
[Image.name]: __('Copy image URL'),
[Video.name]: __('Copy video URL'),
},
editLabels: {
[Audio.name]: __('Edit audio description'),
+ [DrawioDiagram.name]: __('Edit diagram description'),
[Image.name]: __('Edit image description'),
[Video.name]: __('Edit video description'),
},
@@ -38,6 +41,7 @@ export default {
},
deleteLabels: {
[Audio.name]: __('Delete audio'),
+ [DrawioDiagram.name]: __('Delete diagram'),
[Image.name]: __('Delete image'),
[Video.name]: __('Delete video'),
},
@@ -86,6 +90,9 @@ export default {
showProgressIndicator() {
return this.isUploading || this.isUpdating;
},
+ isDrawioDiagram() {
+ return this.mediaType === DrawioDiagram.name;
+ },
},
methods: {
shouldShow() {
@@ -156,10 +163,21 @@ export default {
this.isUpdating = false;
},
+ resetMediaInfo() {
+ this.mediaTitle = null;
+ this.mediaAlt = null;
+ this.mediaCanonicalSrc = null;
+ this.isUploading = false;
+ },
+
replaceMedia() {
this.$refs.fileSelector.click();
},
+ editDiagram() {
+ this.tiptapEditor.chain().focus().createOrEditDiagram().run();
+ },
+
onFileSelect(e) {
this.tiptapEditor
.chain()
@@ -191,6 +209,8 @@ export default {
class="gl-shadow gl-rounded-base gl-bg-white"
plugin-key="bubbleMenuMedia"
:should-show="shouldShow"
+ @show="updateMediaInfoToState"
+ @hidden="resetMediaInfo"
>
<editor-state-observer @transaction="updateMediaInfoToState">
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
@@ -240,6 +260,19 @@ export default {
@click="startEditingMedia"
/>
<gl-button
+ v-if="isDrawioDiagram"
+ v-gl-tooltip
+ variant="default"
+ category="tertiary"
+ size="medium"
+ data-testid="edit-diagram"
+ :aria-label="replaceLabel"
+ title="Edit diagram"
+ icon="diagram"
+ @click="editDiagram"
+ />
+ <gl-button
+ v-else
v-gl-tooltip
variant="default"
category="tertiary"
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 237808983ee..9e08a257abf 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,7 +1,9 @@
<script>
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
-import { __ } from '~/locale';
-import { VARIANT_DANGER } from '~/flash';
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { VARIANT_DANGER } from '~/alert';
+import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue';
import { createContentEditor } from '../services/create_content_editor';
import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
@@ -16,6 +18,8 @@ import LoadingIndicator from './loading_indicator.vue';
export default {
components: {
+ GlSprintf,
+ GlLink,
LoadingIndicator,
ContentEditorAlert,
ContentEditorProvider,
@@ -26,6 +30,7 @@ export default {
LinkBubbleMenu,
MediaBubbleMenu,
EditorStateObserver,
+ EditorModeDropdown,
},
props: {
renderMarkdown: {
@@ -51,17 +56,32 @@ export default {
required: false,
default: '',
},
+ placeholder: {
+ type: String,
+ required: false,
+ default: '',
+ },
autofocus: {
type: [String, Boolean],
required: false,
default: false,
validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus),
},
- useBottomToolbar: {
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
type: Boolean,
required: false,
default: false,
},
+ editable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -76,9 +96,20 @@ export default {
this.setSerializedContent(markdown);
}
},
+ editable(value) {
+ this.contentEditor.setEditable(value);
+ },
},
created() {
- const { renderMarkdown, uploadsPath, extensions, serializerConfig, autofocus } = this;
+ const {
+ renderMarkdown,
+ uploadsPath,
+ extensions,
+ serializerConfig,
+ autofocus,
+ drawioEnabled,
+ editable,
+ } = this;
// This is a non-reactive attribute intentionally since this is a complex object.
this.contentEditor = createContentEditor({
@@ -86,8 +117,10 @@ export default {
uploadsPath,
extensions,
serializerConfig,
+ drawioEnabled,
tiptapOptions: {
autofocus,
+ editable,
},
});
},
@@ -104,10 +137,10 @@ export default {
try {
await this.contentEditor.setSerializedContent(markdown);
- this.contentEditor.setEditable(true);
this.notifyLoadingSuccess();
this.latestMarkdown = markdown;
} catch {
+ this.contentEditor.setEditable(false);
this.contentEditor.eventHub.$emit(ALERT_EVENT, {
message: __(
'An error occurred while trying to render the content editor. Please try again.',
@@ -115,10 +148,10 @@ export default {
variant: VARIANT_DANGER,
actionLabel: __('Retry'),
action: () => {
+ this.contentEditor.setEditable(true);
this.setSerializedContent(markdown);
},
});
- this.contentEditor.setEditable(false);
this.notifyLoadingError();
}
},
@@ -149,6 +182,16 @@ export default {
markdown: this.latestMarkdown,
});
},
+ handleEditorModeChanged(mode) {
+ if (mode === 'markdown') {
+ this.$emit('enableMarkdownEditor');
+ }
+ },
+ },
+ i18n: {
+ quickActionsText: s__(
+ 'ContentEditor|For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.',
+ ),
},
};
</script>
@@ -168,30 +211,37 @@ export default {
class="md-area"
:class="{ 'is-focused': focused }"
>
- <formatting-toolbar
- v-if="!useBottomToolbar"
- ref="toolbar"
- class="gl-border-b"
- @enableMarkdownEditor="$emit('enableMarkdownEditor')"
- />
+ <formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" />
<div class="gl-relative gl-mt-4">
<formatting-bubble-menu />
<code-block-bubble-menu />
<link-bubble-menu />
<media-bubble-menu />
+ <div v-if="placeholder && !markdown && !focused" class="gl-absolute gl-text-gray-400">
+ {{ placeholder }}
+ </div>
<tiptap-editor-content
class="md"
data-testid="content_editor_editablebox"
:editor="contentEditor.tiptapEditor"
/>
<loading-indicator v-if="isLoading" />
+ <div class="gl-display-flex gl-border-t gl-py-2 gl-text-secondary">
+ <div class="gl-w-full">
+ <template v-if="quickActionsDocsPath">
+ <gl-sprintf :message="$options.i18n.quickActionsText">
+ <template #keyboard="{ content }">
+ <kbd>{{ content }}</kbd>
+ </template>
+ <template #quickActionsDocsLink="{ content }">
+ <gl-link :href="quickActionsDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </div>
+ <editor-mode-dropdown size="small" value="richText" @input="handleEditorModeChanged" />
+ </div>
</div>
- <formatting-toolbar
- v-if="useBottomToolbar"
- ref="toolbar"
- class="gl-border-t"
- @enableMarkdownEditor="$emit('enableMarkdownEditor')"
- />
</div>
</div>
</content-editor-provider>
diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
index 36ca3b8cfb6..a5be63fa89f 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -1,5 +1,5 @@
<script>
-import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue';
+import { GlTabs, GlTab } from '@gitlab/ui';
import trackUIControl from '../services/track_ui_control';
import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue';
@@ -10,7 +10,8 @@ import ToolbarMoreDropdown from './toolbar_more_dropdown.vue';
export default {
components: {
- EditorModeDropdown,
+ GlTabs,
+ GlTab,
ToolbarButton,
ToolbarTextStyleDropdown,
ToolbarLinkButton,
@@ -22,95 +23,88 @@ export default {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ property: contentType, value });
},
- handleEditorModeChanged(mode) {
- if (mode === 'markdown') {
- this.$emit('enableMarkdownEditor');
- }
- },
},
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3">
- <toolbar-text-style-dropdown
- data-testid="text-styles"
- class="gl-mr-3"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="bold"
- content-type="bold"
- icon-name="bold"
- class="gl-mx-2"
- editor-command="toggleBold"
- :label="__('Bold text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="italic"
- content-type="italic"
- icon-name="italic"
- class="gl-mx-2"
- editor-command="toggleItalic"
- :label="__('Italic text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- class="gl-mx-2"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code"
- content-type="code"
- icon-name="code"
- class="gl-mx-2"
- editor-command="toggleCode"
- :label="__('Code')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
- <toolbar-button
- data-testid="bullet-list"
- content-type="bulletList"
- icon-name="list-bulleted"
- class="gl-mx-2 gl-display-none gl-sm-display-inline"
- editor-command="toggleBulletList"
- :label="__('Add a bullet list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="ordered-list"
- content-type="orderedList"
- icon-name="list-numbered"
- class="gl-mx-2 gl-display-none gl-sm-display-inline"
- editor-command="toggleOrderedList"
- :label="__('Add a numbered list')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="task-list"
- content-type="taskList"
- icon-name="list-task"
- class="gl-mx-2 gl-display-none gl-sm-display-inline"
- editor-command="toggleTaskList"
- :label="__('Add a checklist')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-image-button
- ref="imageButton"
- data-testid="image"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
- <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
-
- <editor-mode-dropdown class="gl-ml-auto" value="richText" @input="handleEditorModeChanged" />
- </div>
+ <gl-tabs content-class="gl-display-none">
+ <gl-tab title-link-class="gl-py-4 gl-px-3" :title="__('Write')" />
+ <template #tabs-end>
+ <div class="gl-ml-auto gl-py-2 gl-display-flex gl-flex-wrap gl-align-items-end">
+ <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="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-link-button data-testid="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-image-button
+ ref="imageButton"
+ data-testid="image"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
+ <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
+ </div>
+ </template>
+ </gl-tabs>
</template>
<style>
.gl-spinner-container {
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index ca17443081c..99ba8c51948 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -9,7 +9,7 @@ export default {
GlDisclosureDropdown,
GlTooltip,
},
- inject: ['tiptapEditor'],
+ inject: ['tiptapEditor', 'contentEditor'],
data() {
return {
toggleId: uniqueId('dropdown-toggle-btn-'),
@@ -53,6 +53,14 @@ export default {
text: __('PlantUML diagram'),
action: () => this.insert('diagram', { language: 'plantuml' }),
},
+ ...(this.contentEditor.drawioEnabled
+ ? [
+ {
+ text: __('Create or edit diagram'),
+ action: () => this.execute('createOrEditDiagram', 'drawioDiagram'),
+ },
+ ]
+ : []),
{
text: __('Table of contents'),
action: () => this.execute('insertTableOfContents', 'tableOfContents'),
diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
index 9c1d1faca48..bd30bdcea0c 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
@@ -76,6 +76,8 @@ export default {
:disabled="!activeItem"
:data-qa-text-style="activeItemLabel"
data-qa-selector="text_style_dropdown"
+ size="small"
+ toggle-class="btn-default-tertiary"
@select="execute"
/>
</editor-state-observer>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
index 6456540a0dd..4d948f4ec05 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
-import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables';
+import { selectedRect as getSelectedRect } from '@tiptap/pm/tables';
import { __ } from '~/locale';
const TABLE_CELL_HEADER = 'th';
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index 14862727811..6a3740a5952 100644
--- a/app/assets/javascripts/content_editor/constants/index.js
+++ b/app/assets/javascripts/content_editor/constants/index.js
@@ -47,6 +47,7 @@ export const KEYDOWN_EVENT = 'keydown';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
+export const PARSE_HTML_PRIORITY_HIGH = 75;
export const PARSE_HTML_PRIORITY_HIGHEST = 100;
export const EXTENSION_PRIORITY_LOWER = 75;
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
index 9634730f637..0d5b8e56a6c 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { handleFileEvent } from '../services/upload_helpers';
export default Extension.create({
diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js
index deb5029a1f0..c49b541bbaf 100644
--- a/app/assets/javascripts/content_editor/extensions/color_chip.js
+++ b/app/assets/javascripts/content_editor/extensions/color_chip.js
@@ -1,6 +1,6 @@
import { Node } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
-import { Decoration, DecorationSet } from 'prosemirror-view';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { isValidColorExpression } from '~/lib/utils/color_utils';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
diff --git a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js
new file mode 100644
index 00000000000..8c3012ecf59
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js
@@ -0,0 +1,41 @@
+import { create } from '~/drawio/content_editor_facade';
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import createAssetResolver from '../services/asset_resolver';
+import Image from './image';
+
+export default Image.extend({
+ name: 'drawioDiagram',
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ uploadsPath: null,
+ renderMarkdown: null,
+ };
+ },
+ parseHTML() {
+ return [
+ {
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ tag: 'a.no-attachment-icon[data-canonical-src$="drawio.svg"]',
+ },
+ {
+ tag: 'img[src]',
+ },
+ ];
+ },
+ addCommands() {
+ return {
+ createOrEditDiagram: () => () => {
+ launchDrawioEditor({
+ editorFacade: create({
+ tiptapEditor: this.editor,
+ drawioNodeName: this.name,
+ uploadsPath: this.options.uploadsPath,
+ assetResolver: createAssetResolver({ renderMarkdown: this.options.renderMarkdown }),
+ }),
+ });
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
index e940614083e..e48100c15a7 100644
--- a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
+++ b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
@@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { KEYDOWN_EVENT } from '../constants';
/**
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index fc4c108b773..58c16297886 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,5 +1,5 @@
import { Image } from '@tiptap/extension-image';
-import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import { PARSE_HTML_PRIORITY_HIGH } from '../constants';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
@@ -77,7 +77,7 @@ export default Image.extend({
parseHTML() {
return [
{
- priority: PARSE_HTML_PRIORITY_HIGHEST,
+ priority: PARSE_HTML_PRIORITY_HIGH,
tag: 'a.no-attachment-icon',
},
{
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 848c4c12a9a..0a9a0d8d4c1 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -1,7 +1,7 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { __ } from '~/locale';
-import { VARIANT_DANGER } from '~/flash';
+import { VARIANT_DANGER } from '~/alert';
import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer';
import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index a9628c78add..eb53a3a61b3 100644
--- a/app/assets/javascripts/content_editor/extensions/suggestions.js
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -2,7 +2,7 @@ import { Node } from '@tiptap/core';
import { VueRenderer } from '@tiptap/vue-2';
import tippy from 'tippy.js';
import Suggestion from '@tiptap/suggestion';
-import { PluginKey } from 'prosemirror-state';
+import { PluginKey } from '@tiptap/pm/state';
import { isFunction, uniqueId, memoize } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { initEmojiMap, getAllEmoji } from '~/emoji';
diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js
index d7456ab4094..de8170eff93 100644
--- a/app/assets/javascripts/content_editor/extensions/table.js
+++ b/app/assets/javascripts/content_editor/extensions/table.js
@@ -1,6 +1,6 @@
import { Table } from '@tiptap/extension-table';
import { debounce } from 'lodash';
-import { VARIANT_WARNING } from '~/flash';
+import { VARIANT_WARNING } from '~/alert';
import { __ } from '~/locale';
import { getMarkdownSource } from '../services/markdown_sourcemap';
import { shouldRenderHTMLTable } from '../services/serialization_helpers';
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 514ab9699bc..a988e1df2a6 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,12 +1,14 @@
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) {
+ constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub, drawioEnabled }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub;
this._assetResolver = assetResolver;
this._pristineDoc = null;
+
+ this.drawioEnabled = drawioEnabled;
}
get tiptapEditor() {
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 61c6be574d0..9d536793287 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -16,6 +16,7 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Diagram from '../extensions/diagram';
+import DrawioDiagram from '../extensions/drawio_diagram';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
@@ -74,7 +75,7 @@ const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
extensions: [...extensions],
editorProps: {
attributes: {
- class: 'gl-outline-0!',
+ class: 'gl-shadow-none!',
},
},
...options,
@@ -86,6 +87,7 @@ export const createContentEditor = ({
extensions = [],
serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
+ drawioEnabled = false,
} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
@@ -157,6 +159,9 @@ export const createContentEditor = ({
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
+
+ if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown }));
+
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ serializerConfig });
@@ -173,5 +178,6 @@ export const createContentEditor = ({
eventHub,
deserializer,
assetResolver,
+ drawioEnabled,
});
};
diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
index 796dc06ad93..91f8aaf6324 100644
--- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
@@ -1,4 +1,4 @@
-import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
import { replaceCommentsWith } from '~/lib/utils/dom_utils';
export default ({ render }) => {
@@ -18,10 +18,7 @@ export default ({ render }) => {
*/
return {
deserialize: async ({ schema, markdown }) => {
- const html = await render(markdown);
-
- if (!html) return {};
-
+ const html = markdown ? await render(markdown) : '<p></p>';
const parser = new DOMParser();
const { body } = parser.parseFromString(`<body>${html}</body>`, 'text/html');
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index 28a50adca6b..c8972515c25 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -19,7 +19,7 @@
* visit-parents documentation: https://github.com/syntax-tree/unist-util-visit-parents
*/
-import { Mark } from 'prosemirror-model';
+import { Mark } from '@tiptap/pm/model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
import { isFunction, isString, noop, mapValues } from 'lodash';
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 4e29f85004b..e27a427372c 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -12,6 +12,7 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
+import DrawioDiagram from '../extensions/drawio_diagram';
import Comment from '../extensions/comment';
import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
@@ -134,6 +135,10 @@ const defaultSerializerConfig = {
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
[Comment.name]: renderComment,
[Diagram.name]: preserveUnchanged(renderCodeBlock),
+ [DrawioDiagram.name]: preserveUnchanged({
+ render: renderImage,
+ inline: true,
+ }),
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index 09f0738b51b..de1a187b246 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -1,20 +1,30 @@
-import { VARIANT_DANGER } from '~/flash';
+import { VARIANT_DANGER } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { extractFilename, readFileAsDataURL } from './utils';
export const acceptedMimes = {
- image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
- audio: [
- 'audio/basic',
- 'audio/mid',
- 'audio/mpeg',
- 'audio/x-aiff',
- 'audio/ogg',
- 'audio/vorbis',
- 'audio/vnd.wav',
- ],
- video: ['video/mp4', 'video/quicktime'],
+ drawioDiagram: {
+ mimes: ['image/svg+xml'],
+ ext: 'drawio.svg',
+ },
+ image: {
+ mimes: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
+ },
+ audio: {
+ mimes: [
+ 'audio/basic',
+ 'audio/mid',
+ 'audio/mpeg',
+ 'audio/x-aiff',
+ 'audio/ogg',
+ 'audio/vorbis',
+ 'audio/vnd.wav',
+ ],
+ },
+ video: {
+ mimes: ['video/mp4', 'video/quicktime'],
+ },
};
const extractAttachmentLinkUrl = (html) => {
@@ -128,8 +138,8 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
- for (const [type, mimes] of Object.entries(acceptedMimes)) {
- if (mimes.includes(file?.type)) {
+ for (const [type, { mimes, ext }] of Object.entries(acceptedMimes)) {
+ if (mimes.includes(file?.type) && (!ext || file?.name.endsWith(ext))) {
uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index f2ff77daf02..ea444b5c146 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -38,9 +38,6 @@ export default class ContextualSidebar {
this.toggleCollapsedSidebar(value, true);
}
});
- this.$page.on('transitionstart transitionend', () => {
- $(document).trigger('content.resize');
- });
$(window).on(
'resize',
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index 17e6cc87ff8..ce99d5da3cc 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -9,7 +9,6 @@ import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
-import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { xAxisLabelFormatter, dateFormatter } from '../utils';
const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g;
@@ -26,7 +25,6 @@ export default {
GlAreaChart,
GlButton,
GlLoadingIcon,
- ResizableChartContainer,
RefSelector,
},
props: {
@@ -249,18 +247,15 @@ export default {
<div data-testid="contributors-charts">
<h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4>
<span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- class="gl-mb-5"
- :width="width"
- :data="masterChartData"
- :option="masterChartOptions"
- :height="masterChartHeight"
- @created="onMasterChartCreated"
- />
- </template>
- </resizable-chart-container>
+ <gl-area-chart
+ class="gl-mb-5"
+ responsive
+ width="auto"
+ :data="masterChartData"
+ :option="masterChartOptions"
+ :height="masterChartHeight"
+ @created="onMasterChartCreated"
+ />
<div class="row">
<div
@@ -272,17 +267,14 @@ export default {
<p class="gl-mb-3">
{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})
</p>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- :width="width"
- :data="contributor.dates"
- :option="individualChartOptions"
- :height="individualChartHeight"
- @created="onIndividualChartCreated"
- />
- </template>
- </resizable-chart-container>
+ <gl-area-chart
+ responsive
+ width="auto"
+ :data="contributor.dates"
+ :option="individualChartOptions"
+ :height="individualChartHeight"
+ @created="onIndividualChartCreated"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js
index 3a6f4191031..5a8349aa1fd 100644
--- a/app/assets/javascripts/contributors/stores/actions.js
+++ b/app/assets/javascripts/contributors/stores/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import service from '../services/contributors_service';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
index c67b544eacd..b13b0ede9f0 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -48,15 +48,13 @@ export default {
addDeployFreezeButton() {
return {
text: this.isEditing ? __('Save deploy freeze') : __('Add deploy freeze'),
- attributes: [
- { variant: 'confirm' },
- {
- disabled:
- !isValidCron(this.freezeStartCron) ||
- !isValidCron(this.freezeEndCron) ||
- !this.selectedTimezone,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ disabled:
+ !isValidCron(this.freezeStartCron) ||
+ !isValidCron(this.freezeEndCron) ||
+ !this.selectedTimezone,
+ },
};
},
invalidFreezeStartCron() {
diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js
index 76a4eaaff3f..77d3037ff57 100644
--- a/app/assets/javascripts/deploy_freeze/store/actions.js
+++ b/app/assets/javascripts/deploy_freeze/store/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index db5e9a954cf..5fc15578827 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import eventHub from '../eventhub';
diff --git a/app/assets/javascripts/deploy_keys/components/confirm_modal.vue b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
index 1932435c42a..25551d7b5cb 100644
--- a/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
+++ b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
@@ -22,11 +22,11 @@ export default {
title: __('Do you want to remove this deploy key?'),
actionPrimary: {
text: __('Remove deploy key'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
actionSecondary: {
text: __('Cancel'),
- attributes: [{ category: 'tertiary' }],
+ attributes: { category: 'tertiary' },
},
static: true,
modalId: 'confirm-remove-deploy-key',
diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
index 57fae608efa..c49ab1ac43c 100644
--- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
+++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
GlLink,
} from '@gitlab/ui';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -66,6 +66,11 @@ export default {
},
},
methods: {
+ getWritePackageRegistryHelpText() {
+ return this.tokenType === 'group'
+ ? this.$options.translations.groupWritePackageRegistryHelp
+ : this.$options.translations.projectWritePackageRegistryHelp;
+ },
defaultData() {
return {
expiresAt: null,
@@ -110,7 +115,7 @@ export default {
id: 'deploy_token_write_package_registry',
isShown: this.$props.packagesRegistryEnabled,
value: false,
- helpText: this.$options.translations.writePackageRegistryHelp,
+ helpText: this.getWritePackageRegistryHelpText(),
scopeName: 'write_package_registry',
},
],
diff --git a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
index 3767e9e6170..410864a83a2 100644
--- a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
+++ b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js
@@ -32,9 +32,12 @@ const translations = {
readRegistryHelp: s__('DeployTokens|Allows read-only access to registry images.'),
writeRegistryHelp: s__('DeployTokens|Allows read and write access to registry images.'),
readPackageRegistryHelp: s__('DeployTokens|Allows read-only access to the package registry.'),
- writePackageRegistryHelp: s__(
+ groupWritePackageRegistryHelp: s__(
'DeployTokens|Allows read and write access to the package registry.',
),
+ projectWritePackageRegistryHelp: s__(
+ 'DeployTokens|Allows read, write and delete access to the package registry.',
+ ),
createTokenFailedAlert: s__('DeployTokens|Failed to create a new deployment token'),
};
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 7503df9194b..0008c3504ce 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -16,7 +16,7 @@ import $ from 'jquery';
import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
@@ -53,9 +53,9 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export default class Notes {
- static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM) {
+ static initialize(notes_url, last_fetched_at, view, enableGFM) {
if (!this.instance) {
- this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM);
+ this.instance = new Notes(notes_url, last_fetched_at, view, enableGFM);
}
}
@@ -63,7 +63,7 @@ export default class Notes {
return this.instance;
}
- constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = defaultAutocompleteConfig) {
+ constructor(notes_url, last_fetched_at, view, enableGFM = defaultAutocompleteConfig) {
this.updateTargetButtons = this.updateTargetButtons.bind(this);
this.updateComment = this.updateComment.bind(this);
this.visibilityChange = this.visibilityChange.bind(this);
@@ -85,9 +85,9 @@ export default class Notes {
this.postComment = this.postComment.bind(this);
this.clearAlertWrapper = this.clearAlert.bind(this);
this.onHashChange = this.onHashChange.bind(this);
+ this.note_ids = [];
this.notes_url = notes_url;
- this.note_ids = note_ids;
this.enableGFM = enableGFM;
// Used to keep track of updated notes while people are editing things
this.updatedNotesTrackingMap = {};
@@ -449,8 +449,6 @@ export default class Notes {
return;
}
- this.note_ids.push(noteEntity.id);
-
if ($notesList.length) {
$notesList.find('.system-note.being-posted').remove();
}
@@ -497,7 +495,6 @@ export default class Notes {
if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return;
}
- this.note_ids.push(noteEntity.id);
const form =
$form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
@@ -745,7 +742,7 @@ export default class Notes {
$noteAvatar.append($targetNoteBadge);
this.revertNoteEditForm($targetNote);
- renderGFM($noteEntityEl.get(0));
+ renderGFM(Notes.getNodeToRender($noteEntityEl));
// Find the note's `li` element by ID and replace it with the updated HTML
const $note_li = $(`.note-row-${noteEntity.id}`);
@@ -1396,8 +1393,28 @@ export default class Notes {
/**
* Check if note does not exist on page
*/
- static isNewNote(noteEntity, noteIds) {
- return $.inArray(noteEntity.id, noteIds) === -1;
+ static isNewNote(noteEntity, note_ids) {
+ if (note_ids.length === 0) {
+ Notes.loadNotesIds(note_ids);
+ }
+ const isNewEntry = $.inArray(noteEntity.id, note_ids) === -1;
+ if (isNewEntry) {
+ note_ids.push(noteEntity.id);
+ }
+ return isNewEntry;
+ }
+
+ /**
+ * Load notes ids
+ */
+ static loadNotesIds(note_ids) {
+ const $notesList = $('.main-notes-list li[id^=note_]');
+ for (const $noteItem of $notesList) {
+ if (Notes.isNodeTypeElement($noteItem)) {
+ const noteId = parseInt($noteItem.id.split('_')[1], 10);
+ note_ids.push(noteId);
+ }
+ }
}
/**
@@ -1422,7 +1439,7 @@ export default class Notes {
const $note = $(noteHtml);
$note.addClass('fade-in-full');
- renderGFM($note.get(0));
+ renderGFM(Notes.getNodeToRender($note));
$notesList.append($note);
return $note;
}
@@ -1431,11 +1448,20 @@ export default class Notes {
const $updatedNote = $(noteHtml);
$updatedNote.addClass('fade-in');
- renderGFM($updatedNote.get(0));
+ renderGFM(Notes.getNodeToRender($updatedNote));
$note.replaceWith($updatedNote);
return $updatedNote;
}
+ static getNodeToRender($note) {
+ for (const $item of $note) {
+ if (Notes.isNodeTypeElement($item)) {
+ return $item;
+ }
+ }
+ return '';
+ }
+
/**
* Get data from Form attributes to use for saving/submitting comment.
*/
@@ -1829,4 +1855,11 @@ export default class Notes {
return $closeBtn.text($closeBtn.data('originalText'));
}
+
+ /**
+ * Function to check if node is element to avoid comment and text
+ */
+ static isNodeTypeElement($node) {
+ return $node.nodeType === Node.ELEMENT_NODE;
+ }
}
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 3091c6703b4..680a101b118 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -1,16 +1,19 @@
<script>
import { GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui';
-import { ApolloMutation } from 'vue-apollo';
-import { createAlert } from '~/flash';
-import { s__ } from '~/locale';
+import * as Sentry from '@sentry/browser';
+import { createAlert } from '~/alert';
+import { __, s__ } from '~/locale';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import { updateGlobalTodoCount } from '~/sidebar/utils';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import { isLoggedIn } from '~/lib/utils/common_utils';
-import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+import { TYPENAME_NOTE, TYPENAME_DISCUSSION } from '~/graphql_shared/constants';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES, DELETE_NOTE_ERROR_MSG } from '../../constants';
import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
+import destroyNoteMutation from '../../graphql/mutations/destroy_note.mutation.graphql';
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
import allVersionsMixin from '../../mixins/all_versions';
@@ -23,8 +26,14 @@ import DesignReplyForm from './design_reply_form.vue';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
export default {
+ i18n: {
+ deleteNote: {
+ confirmationText: __('Are you sure you want to delete this comment?'),
+ primaryModalBtnText: __('Delete comment'),
+ errorText: DELETE_NOTE_ERROR_MSG,
+ },
+ },
components: {
- ApolloMutation,
DesignNote,
DesignNotePin,
DesignNoteSignedOut,
@@ -97,9 +106,9 @@ export default {
},
data() {
return {
- discussionComment: '',
isFormRendered: false,
activeDiscussion: {},
+ noteToDelete: null,
isResolving: false,
shouldChangeResolvedStatus: false,
areRepliesCollapsed: this.discussion.resolved,
@@ -107,10 +116,9 @@ export default {
};
},
computed: {
- mutationPayload() {
+ mutationVariables() {
return {
noteableId: this.noteableId,
- body: this.discussionComment,
discussionId: this.discussion.id,
};
},
@@ -156,19 +164,21 @@ export default {
onDone({ data: { createNote } }) {
if (hasErrors(createNote)) {
createAlert({ message: ADD_DISCUSSION_COMMENT_ERROR });
+ } else {
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * Hide the form once the create note mutation is completed.
+ */
+ this.hideForm();
}
- this.discussionComment = '';
- this.hideForm();
+
if (this.shouldChangeResolvedStatus) {
this.toggleResolvedStatus();
}
},
- onCreateNoteError(err) {
- this.$emit('create-note-error', err);
- },
hideForm() {
this.isFormRendered = false;
- this.discussionComment = '';
},
showForm() {
this.$emit('open-form', this.discussion.id);
@@ -219,13 +229,65 @@ export default {
const { source } = activeDiscussion;
return ALLOWED_ACTIVE_DISCUSSION_SOURCES.includes(source) && this.isDiscussionActive;
},
+ async showDeleteNoteConfirmationModal(note) {
+ const isLast = note?.discussion?.notes?.nodes.length === 1;
+ this.noteToDelete = { ...note, isLast };
+
+ const confirmed = await confirmAction(this.$options.i18n.deleteNote.confirmationText, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: this.$options.i18n.deleteNote.primaryModalBtnText,
+ });
+
+ if (confirmed) {
+ await this.deleteNote();
+ }
+ },
+ async deleteNote() {
+ const { id, discussion, isLast } = this.noteToDelete;
+ try {
+ await this.$apollo.mutate({
+ mutation: destroyNoteMutation,
+ variables: {
+ input: {
+ id,
+ },
+ },
+ update: (cache, { data }) => {
+ const { errors } = data.destroyNote;
+
+ if (errors?.length) {
+ this.$emit('delete-note-error', errors[0]);
+ }
+
+ const objectToIdentify = isLast
+ ? { __typename: TYPENAME_DISCUSSION, id: discussion?.id }
+ : { __typename: TYPENAME_NOTE, id };
+
+ cache.modify({
+ id: cache.identify(objectToIdentify),
+ fields: (_, { DELETE }) => DELETE,
+ });
+ },
+ optimisticResponse: {
+ destroyNote: {
+ note: null,
+ errors: [],
+ __typename: 'DestroyNotePayload',
+ },
+ },
+ });
+ } catch (error) {
+ this.$emit('delete-note-error', this.$options.i18n.deleteNote.errorText);
+ Sentry.captureException(error);
+ }
+ },
},
createNoteMutation,
};
</script>
<template>
- <div class="design-discussion-wrapper">
+ <div class="design-discussion-wrapper" @click="$emit('update-active-discussion')">
<design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" />
<ul
class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none"
@@ -235,9 +297,10 @@ export default {
:note="firstNote"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
+ :is-discussion="true"
:noteable-id="noteableId"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
- @error="$emit('update-note-error', $event)"
+ @delete-note="showDeleteNoteConfirmationModal($event)"
>
<template v-if="isLoggedIn && discussion.resolvable" #resolve-discussion>
<gl-button
@@ -279,8 +342,9 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:noteable-id="noteableId"
+ :is-discussion="false"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
- @error="$emit('update-note-error', $event)"
+ @delete-note="showDeleteNoteConfirmationModal($event)"
/>
<li
v-show="isReplyPlaceholderVisible"
@@ -296,33 +360,24 @@ export default {
:placeholder-text="__('Reply…')"
@focus="showForm"
/>
- <apollo-mutation
+ <design-reply-form
v-else
- #default="{ mutate, loading }"
- :mutation="$options.createNoteMutation"
- :variables="{
- input: mutationPayload,
- }"
- @done="onDone"
- @error="onCreateNoteError"
+ :design-note-mutation="$options.createNoteMutation"
+ :mutation-variables="mutationVariables"
+ :markdown-preview-path="markdownPreviewPath"
+ :noteable-id="noteableId"
+ :discussion-id="discussion.id"
+ :is-discussion="false"
+ @note-submit-complete="onDone"
+ @cancel-form="hideForm"
>
- <design-reply-form
- v-model="discussionComment"
- :is-saving="loading"
- :markdown-preview-path="markdownPreviewPath"
- :noteable-id="noteableId"
- :discussion-id="discussion.id"
- @submit-form="mutate"
- @cancel-form="hideForm"
- >
- <template v-if="discussion.resolvable" #resolve-checkbox>
- <label data-testid="resolve-checkbox">
- <input v-model="shouldChangeResolvedStatus" type="checkbox" />
- {{ resolveCheckboxText }}
- </label>
- </template>
- </design-reply-form>
- </apollo-mutation>
+ <template v-if="discussion.resolvable" #resolve-checkbox>
+ <label data-testid="resolve-checkbox">
+ <input v-model="shouldChangeResolvedStatus" type="checkbox" />
+ {{ resolveCheckboxText }}
+ </label>
+ </template>
+ </design-reply-form>
</template>
</li>
</ul>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index af4bf7eb14d..b92a2392948 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -1,6 +1,13 @@
<script>
-import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui';
-import { ApolloMutation } from 'vue-apollo';
+import {
+ GlAvatar,
+ GlAvatarLink,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLink,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
@@ -14,13 +21,16 @@ import DesignReplyForm from './design_reply_form.vue';
export default {
i18n: {
editCommentLabel: __('Edit comment'),
+ moreActionsLabel: __('More actions'),
+ deleteCommentText: __('Delete comment'),
},
components: {
- ApolloMutation,
DesignReplyForm,
GlAvatar,
GlAvatarLink,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlLink,
TimeAgoTooltip,
TimelineEntryItem,
@@ -39,6 +49,11 @@ export default {
required: false,
default: '',
},
+ isDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
noteableId: {
type: String,
required: true,
@@ -46,8 +61,8 @@ export default {
},
data() {
return {
- noteText: this.note.body,
isEditing: false,
+ isError: true,
};
},
computed: {
@@ -63,20 +78,24 @@ export default {
isNoteLinked() {
return extractDesignNoteId(this.$route.hash) === this.noteAnchorId;
},
- mutationPayload() {
+ mutationVariables() {
return {
id: this.note.id,
- body: this.noteText,
};
},
isEditButtonVisible() {
- return !this.isEditing && this.note.userPermissions.adminNote;
+ return !this.isEditing && this.adminPermissions;
+ },
+ isMoreActionsButtonVisible() {
+ return !this.isEditing && this.adminPermissions;
+ },
+ adminPermissions() {
+ return this.note.userPermissions.adminNote;
},
},
methods: {
hideForm() {
this.isEditing = false;
- this.noteText = this.note.body;
},
onDone({ data }) {
this.hideForm();
@@ -132,6 +151,30 @@ export default {
size="small"
@click="isEditing = true"
/>
+ <gl-dropdown
+ v-if="isMoreActionsButtonVisible"
+ v-gl-tooltip.hover
+ class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ icon="ellipsis_v"
+ category="tertiary"
+ data-qa-selector="design_discussion_actions_ellipsis_dropdown"
+ data-testid="more-actions-dropdown"
+ :text="$options.i18n.moreActionsLabel"
+ text-sr-only
+ :title="$options.i18n.moreActionsLabel"
+ :aria-label="$options.i18n.moreActionsLabel"
+ no-caret
+ left
+ >
+ <gl-dropdown-item
+ variant="danger"
+ data-qa-selector="delete_design_note_button"
+ data-testid="delete-note-button"
+ @click="$emit('delete-note', note)"
+ >
+ {{ $options.i18n.deleteCommentText }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</div>
<template v-if="!isEditing">
@@ -143,26 +186,18 @@ export default {
></div>
<slot name="resolved-status"></slot>
</template>
- <apollo-mutation
+ <design-reply-form
v-else
- #default="{ mutate, loading }"
- :mutation="$options.updateNoteMutation"
- :variables="{
- input: mutationPayload,
- }"
- @error="$emit('error', $event)"
- @done="onDone"
- >
- <design-reply-form
- v-model="noteText"
- :is-saving="loading"
- :markdown-preview-path="markdownPreviewPath"
- :is-new-comment="false"
- :noteable-id="noteableId"
- class="gl-mt-5"
- @submit-form="mutate"
- @cancel-form="hideForm"
- />
- </apollo-mutation>
+ :markdown-preview-path="markdownPreviewPath"
+ :design-note-mutation="$options.updateNoteMutation"
+ :mutation-variables="mutationVariables"
+ :value="note.body"
+ :is-new-comment="false"
+ :is-discussion="isDiscussion"
+ :noteable-id="noteableId"
+ class="gl-mt-5"
+ @note-submit-complete="onDone"
+ @cancel-form="hideForm"
+ />
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 830f16b50ee..4fd90130284 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlAlert } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import Autosave from '~/autosave';
@@ -7,6 +7,12 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import {
+ ADD_DISCUSSION_COMMENT_ERROR,
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_NOTE_ERROR,
+} from '../../utils/error_messages';
export default {
name: 'DesignReplyForm',
@@ -23,22 +29,29 @@ export default {
components: {
MarkdownField,
GlButton,
+ GlAlert,
},
props: {
+ designNoteMutation: {
+ type: Object,
+ required: true,
+ },
+ mutationVariables: {
+ type: Object,
+ required: false,
+ default: null,
+ },
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
- value: {
- type: String,
- required: true,
- },
- isSaving: {
+ isNewComment: {
type: Boolean,
- required: true,
+ required: false,
+ default: true,
},
- isNewComment: {
+ isDiscussion: {
type: Boolean,
required: false,
default: true,
@@ -52,16 +65,24 @@ export default {
required: false,
default: 'new',
},
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- formText: this.value,
+ noteText: this.value,
+ saving: false,
+ noteUpdateDirty: false,
isLoggedIn: isLoggedIn(),
+ errorMessage: '',
};
},
computed: {
hasValue() {
- return this.value.trim().length > 0;
+ return this.noteText.length > 0;
},
buttonText() {
return this.isNewComment
@@ -75,18 +96,69 @@ export default {
mounted() {
this.focusInput();
},
+ beforeDestroy() {
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ * Reply form closes and component destroys
+ * only when comment submission was successful,
+ * so we're safe to clear autosave data here conditionally.
+ */
+ this.$nextTick(() => {
+ if (!this.noteUpdateDirty) {
+ this.autosaveDiscussion.reset();
+ }
+ });
+ },
methods: {
+ handleInput() {
+ /**
+ * While the form is saving using ctrl+enter
+ * Do not mark it as dirty.
+ *
+ */
+ if (!this.saving) {
+ this.noteUpdateDirty = true;
+ }
+ },
submitForm() {
if (this.hasValue) {
- this.$emit('submit-form');
- this.autosaveDiscussion.reset();
+ this.saving = true;
+ this.$apollo
+ .mutate({
+ mutation: this.designNoteMutation,
+ variables: {
+ input: {
+ ...this.mutationVariables,
+ body: this.noteText,
+ },
+ },
+ update: () => {
+ this.noteUpdateDirty = false;
+ },
+ })
+ .then((response) => {
+ this.$emit('note-submit-complete', response);
+ })
+ .catch(() => {
+ this.errorMessage = this.getErrorMessage();
+ })
+ .finally(() => {
+ this.saving = false;
+ });
}
},
+ getErrorMessage() {
+ if (this.isNewComment) {
+ return this.isDiscussion ? ADD_IMAGE_DIFF_NOTE_ERROR : ADD_DISCUSSION_COMMENT_ERROR;
+ }
+ return this.isDiscussion ? UPDATE_IMAGE_DIFF_NOTE_ERROR : UPDATE_NOTE_ERROR;
+ },
cancelComment() {
- if (this.hasValue && this.formText !== this.value) {
+ if (this.hasValue && this.noteUpdateDirty) {
this.confirmCancelCommentModal();
} else {
this.$emit('cancel-form');
+ this.noteUpdateDirty = false;
}
},
async confirmCancelCommentModal() {
@@ -130,24 +202,29 @@ export default {
<template>
<form class="new-note common-note-form" @submit.prevent>
+ <div v-if="errorMessage" class="gl-pb-3">
+ <gl-alert variant="danger" @dismiss="errorMessage = null">
+ {{ errorMessage }}
+ </gl-alert>
+ </div>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:enable-autocomplete="true"
- :textarea-value="value"
+ :textarea-value="noteText"
:markdown-docs-path="$options.markdownDocsPath"
class="bordered-box"
>
<template #textarea>
<textarea
ref="textarea"
- :value="value"
+ v-model.trim="noteText"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="false"
data-qa-selector="note_textarea"
:aria-label="__('Description')"
:placeholder="__('Write a comment…')"
- @input="$emit('input', $event.target.value)"
+ @input="handleInput"
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keyup.esc.stop="cancelComment"
@@ -159,7 +236,8 @@ export default {
<div class="note-form-actions gl-display-flex">
<gl-button
ref="submitButton"
- :disabled="!hasValue || isSaving"
+ :disabled="!hasValue"
+ :loading="saving"
class="gl-mr-3 gl-w-auto!"
category="primary"
variant="confirm"
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index 24cc93f5eaf..c34d5cea0c2 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -57,7 +57,6 @@ export default {
},
data() {
return {
- isResolvedDiscussionsExpanded: this.resolvedDiscussionsExpanded,
discussionWithOpenForm: '',
isLoggedIn: isLoggedIn(),
};
@@ -87,13 +86,13 @@ export default {
unresolvedDiscussions() {
return this.discussions.filter((discussion) => !discussion.resolved);
},
- },
- watch: {
- resolvedDiscussionsExpanded(resolvedDiscussionsExpanded) {
- this.isResolvedDiscussionsExpanded = resolvedDiscussionsExpanded;
- },
- isResolvedDiscussionsExpanded() {
- this.$emit('toggleResolvedComments');
+ isResolvedDiscussionsExpanded: {
+ get() {
+ return this.resolvedDiscussionsExpanded;
+ },
+ set(isExpanded) {
+ this.$emit('toggleResolvedComments', isExpanded);
+ },
},
},
mounted() {
@@ -129,7 +128,7 @@ export default {
</script>
<template>
- <div class="image-notes gl-pt-0" @click="handleSidebarClick">
+ <div class="image-notes gl-pt-0" @click.self="handleSidebarClick">
<div
class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
@@ -179,8 +178,9 @@ export default {
data-testid="unresolved-discussion"
@create-note-error="$emit('onDesignDiscussionError', $event)"
@update-note-error="$emit('updateNoteError', $event)"
+ @delete-note-error="$emit('deleteNoteError', $event)"
@resolve-discussion-error="$emit('resolveDiscussionError', $event)"
- @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ @update-active-discussion="updateActiveDiscussion(discussion.notes[0].id)"
@open-form="updateDiscussionWithOpenForm"
/>
<gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5">
@@ -202,9 +202,10 @@ export default {
:discussion-with-open-form="discussionWithOpenForm"
data-testid="resolved-discussion"
@error="$emit('onDesignDiscussionError', $event)"
- @updateNoteError="$emit('updateNoteError', $event)"
+ @update-note-error="$emit('updateNoteError', $event)"
+ @delete-note-error="$emit('deleteNoteError', $event)"
@open-form="updateDiscussionWithOpenForm"
- @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ @update-active-discussion="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-accordion-item>
</gl-accordion>
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index 6d571365306..cd76b6c1885 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -60,7 +60,8 @@ export default {
},
image: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
isLoading: {
type: Boolean,
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
index afe621ac3c5..6720245b5f1 100644
--- a/app/assets/javascripts/design_management/constants.js
+++ b/app/assets/javascripts/design_management/constants.js
@@ -1,3 +1,4 @@
+import { __ } from '~/locale';
// WARNING: replace this with something
// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
export const VALID_DESIGN_FILE_MIMETYPE = {
@@ -14,3 +15,7 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
export const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
+
+export const DELETE_NOTE_ERROR_MSG = __(
+ 'Something went wrong when deleting a comment. Please try again.',
+);
diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql
new file mode 100644
index 00000000000..58fb05e2140
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/destroy_note.mutation.graphql
@@ -0,0 +1,8 @@
+mutation destroyNote($input: DestroyNoteInput!) {
+ destroyNote(input: $input) {
+ errors
+ note {
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index b856ac6c627..80b146c9209 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -8,7 +8,14 @@ import createRouter from './router';
export default () => {
const el = document.querySelector('.js-design-management');
- const { issueIid, projectPath, issuePath, registerPath, signInPath } = el.dataset;
+ const {
+ issueIid,
+ projectPath,
+ issuePath,
+ registerPath,
+ signInPath,
+ savedRepliesNewPath,
+ } = el.dataset;
const router = createRouter(issuePath);
apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -32,6 +39,7 @@ export default () => {
issueIid,
registerPath,
signInPath,
+ newSavedRepliesPath: savedRepliesNewPath,
},
mounted() {
performanceMarkAndMeasure({
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
index b783ec43cd1..b182e68260a 100644
--- a/app/assets/javascripts/design_management/mixins/all_designs.js
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -1,6 +1,6 @@
import { propertyOf } from 'lodash';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { s__ } from '~/locale';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
import allVersionsMixin from './all_versions';
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index f448e2f9e3d..0251ffe28f9 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -2,9 +2,8 @@
import { GlAlert } from '@gitlab/ui';
import { isNull } from 'lodash';
import Mousetrap from 'mousetrap';
-import { ApolloMutation } from 'vue-apollo';
import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { fetchPolicies } from '~/lib/graphql';
import { updateGlobalTodoCount } from '~/sidebar/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -34,13 +33,11 @@ import {
getPageLayoutElement,
} from '../../utils/design_management_utils';
import {
- ADD_DISCUSSION_COMMENT_ERROR,
- ADD_IMAGE_DIFF_NOTE_ERROR,
UPDATE_IMAGE_DIFF_NOTE_ERROR,
DESIGN_NOT_FOUND_ERROR,
DESIGN_VERSION_NOT_EXIST_ERROR,
- UPDATE_NOTE_ERROR,
TOGGLE_TODO_ERROR,
+ DELETE_NOTE_ERROR,
designDeletionError,
} from '../../utils/error_messages';
import { trackDesignDetailView, servicePingDesignDetailView } from '../../utils/tracking';
@@ -50,7 +47,6 @@ const DEFAULT_MAX_SCALE = 2;
export default {
components: {
- ApolloMutation,
DesignReplyForm,
DesignPresentation,
DesignScaler,
@@ -90,7 +86,6 @@ export default {
data() {
return {
design: {},
- comment: '',
annotationCoordinates: null,
errorMessage: '',
scale: DEFAULT_SCALE,
@@ -129,9 +124,6 @@ export default {
markdownPreviewPath() {
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
},
- isSubmitButtonDisabled() {
- return this.comment.trim().length === 0;
- },
designVariables() {
return {
fullPath: this.projectPath,
@@ -140,11 +132,10 @@ export default {
atVersion: this.designsVersion,
};
},
- mutationPayload() {
+ mutationVariables() {
const { x, y, width, height } = this.annotationCoordinates;
return {
noteableId: this.design.id,
- body: this.comment,
position: {
headSha: this.design.diffRefs.headSha,
baseSha: this.design.diffRefs.baseSha,
@@ -196,13 +187,23 @@ export default {
Mousetrap.unbind(keysFor(ISSUE_CLOSE_DESIGN));
},
methods: {
- addImageDiffNoteToStore(store, { data: { createImageDiffNote } }) {
+ addImageDiffNoteToStore({ data }) {
+ const { createImageDiffNote } = data;
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * The getClient method is not documented. In future,
+ * need to check for any alternative.
+ */
+ const { cache } = this.$apollo.getClient();
+
updateStoreAfterAddImageDiffNote(
- store,
+ cache,
createImageDiffNote,
getDesignQuery,
this.designVariables,
);
+ this.closeCommentForm(data);
},
updateImageDiffNoteInStore(store, { data: { repositionImageDiffNote } }) {
return updateStoreAfterRepositionImageDiffNote(
@@ -249,7 +250,7 @@ export default {
},
onQueryError(message) {
// because we redirect user to /designs (the issue page),
- // we want to create these flashes on the issue page
+ // we want to create these alerts on the issue page
createAlert({ message });
this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
},
@@ -257,14 +258,8 @@ export default {
this.errorMessage = message;
if (e) throw e;
},
- onCreateImageDiffNoteError(e) {
- this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
- },
- onUpdateNoteError(e) {
- this.onError(UPDATE_NOTE_ERROR, e);
- },
- onDesignDiscussionError(e) {
- this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
+ onDeleteNoteError(e) {
+ this.onError(DELETE_NOTE_ERROR, e);
},
onUpdateImageDiffNoteError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
@@ -285,7 +280,6 @@ export default {
}
},
closeCommentForm(data) {
- this.comment = '';
this.annotationCoordinates = null;
if (data?.data && !isNull(this.prevCurrentUserTodos)) {
@@ -324,8 +318,8 @@ export default {
const diffNoteGid = noteId ? toDiffNoteGid(noteId) : undefined;
return this.updateActiveDiscussion(diffNoteGid, ACTIVE_DISCUSSION_SOURCE_TYPES.url);
},
- toggleResolvedComments() {
- this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
+ toggleResolvedComments(newValue) {
+ this.resolvedDiscussionsExpanded = newValue;
},
setMaxScale(event) {
this.maxScale = 1 / event;
@@ -394,35 +388,24 @@ export default {
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
:markdown-preview-path="markdownPreviewPath"
:is-loading="isLoading"
- @onDesignDiscussionError="onDesignDiscussionError"
- @onCreateImageDiffNoteError="onCreateImageDiffNoteError"
- @updateNoteError="onUpdateNoteError"
+ @deleteNoteError="onDeleteNoteError"
@resolveDiscussionError="onResolveDiscussionError"
@toggleResolvedComments="toggleResolvedComments"
@todoError="onTodoError"
>
<template #reply-form>
- <apollo-mutation
+ <design-reply-form
v-if="isAnnotating"
- #default="{ mutate, loading }"
- :mutation="$options.createImageDiffNoteMutation"
- :variables="{
- input: mutationPayload,
- }"
- :update="addImageDiffNoteToStore"
- @done="closeCommentForm"
- @error="onCreateImageDiffNoteError"
- >
- <design-reply-form
- ref="newDiscussionForm"
- v-model="comment"
- :is-saving="loading"
- :markdown-preview-path="markdownPreviewPath"
- :noteable-id="design.id"
- @submit-form="mutate"
- @cancel-form="closeCommentForm"
- /> </apollo-mutation
- ></template>
+ ref="newDiscussionForm"
+ :design-note-mutation="$options.createImageDiffNoteMutation"
+ :mutation-variables="mutationVariables"
+ :markdown-preview-path="markdownPreviewPath"
+ :noteable-id="design.id"
+ :is-discussion="true"
+ @note-submit-complete="addImageDiffNoteToStore"
+ @cancel-form="closeCommentForm"
+ />
+ </template>
</design-sidebar>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index cfec5828c85..9ef0f336d43 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -2,7 +2,7 @@
import produce from 'immer';
import { differenceBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index 42f752efc9e..1ed054abe22 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -13,7 +13,13 @@ export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not update discussion. Please try again.',
);
-export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
+export const UPDATE_NOTE_ERROR = s__(
+ 'DesignManagement|Could not update comment. Please try again.',
+);
+
+export const DELETE_NOTE_ERROR = s__(
+ 'DesignManagement|Could not delete comment. Please try again.',
+);
export const UPLOAD_DESIGN_ERROR = s__(
'DesignManagement|Error uploading a new design. Please try again.',
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 65816495432..e3cd43ac22f 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { merge } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import FilesCommentButton from './files_comment_button';
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 35d1a564178..9ccba88f7e6 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -11,7 +11,7 @@ import {
MR_COMMITS_NEXT_COMMIT,
MR_COMMITS_PREVIOUS_COMMIT,
} from '~/behaviors/shortcuts/keybindings';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { helpPagePath } from '~/helpers/help_page_helper';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -21,6 +21,7 @@ import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
+import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import {
TREE_LIST_WIDTH_STORAGE_KEY,
INITIAL_TREE_WIDTH,
@@ -53,15 +54,14 @@ import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
import TreeList from './tree_list.vue';
import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
+import PreRenderer from './pre_renderer.vue';
export default {
name: 'DiffsApp',
components: {
- DynamicScroller: () =>
- import('vendor/vue-virtual-scroller').then(({ DynamicScroller }) => DynamicScroller),
- DynamicScrollerItem: () =>
- import('vendor/vue-virtual-scroller').then(({ DynamicScrollerItem }) => DynamicScrollerItem),
- PreRenderer: () => import('./pre_renderer.vue').then((PreRenderer) => PreRenderer),
+ DynamicScroller,
+ DynamicScrollerItem,
+ PreRenderer,
VirtualScrollerScrollSync,
CompareVersions,
DiffFile,
@@ -95,6 +95,10 @@ export default {
type: String,
required: true,
},
+ endpointDiffForPath: {
+ type: String,
+ required: true,
+ },
endpointCoverage: {
type: String,
required: false,
@@ -226,6 +230,7 @@ export default {
'isVirtualScrollingEnabled',
'isBatchLoading',
'isBatchLoadingError',
+ 'flatBlobsList',
]),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
@@ -241,7 +246,7 @@ export default {
return this.currentUser.can_fork === true && this.currentUser.can_create_merge_request;
},
renderDiffFiles() {
- return this.diffFiles.length > 0;
+ return this.flatBlobsList.length > 0;
},
renderFileTree() {
return this.renderDiffFiles && this.showTreeList;
@@ -253,7 +258,7 @@ export default {
return this.startVersion === null && this.latestDiff;
},
showFileByFileNavigation() {
- return this.diffFiles.length > 1 && this.viewDiffsFileByFile;
+ return this.flatBlobsList.length > 1 && this.viewDiffsFileByFile;
},
currentFileNumber() {
return this.currentDiffIndex + 1;
@@ -264,9 +269,9 @@ export default {
return currentDiffIndex >= 1 ? currentDiffIndex : null;
},
nextFileNumber() {
- const { currentFileNumber, diffFiles } = this;
+ const { currentFileNumber, flatBlobsList } = this;
- return currentFileNumber < diffFiles.length ? currentFileNumber + 1 : null;
+ return currentFileNumber < flatBlobsList.length ? currentFileNumber + 1 : null;
},
visibleWarning() {
let visible = false;
@@ -321,6 +326,7 @@ export default {
endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
+ endpointDiffForPath: this.endpointDiffForPath,
endpointCoverage: this.endpointCoverage,
endpointUpdateUser: this.endpointUpdateUser,
projectPath: this.projectPath,
@@ -385,7 +391,7 @@ export default {
this.subscribeToEvents();
this.unwatchDiscussions = this.$watch(
- () => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`,
+ () => `${this.flatBlobsList.length}:${this.$store.state.notes.discussions.length}`,
() => {
this.setDiscussions();
@@ -572,8 +578,8 @@ export default {
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
- if (targetIndex >= 0 && targetIndex < this.diffFiles.length) {
- this.scrollToFile({ path: this.diffFiles[targetIndex].file_path });
+ if (targetIndex >= 0 && targetIndex < this.flatBlobsList.length) {
+ this.scrollToFile({ path: this.flatBlobsList[targetIndex].path });
}
},
setTreeDisplay() {
@@ -582,7 +588,7 @@ export default {
if (storedTreeShow !== null) {
showTreeList = parseBoolean(storedTreeShow);
- } else if (!bp.isDesktop() || (!this.isBatchLoading && this.diffFiles.length <= 1)) {
+ } else if (!bp.isDesktop() || (!this.isBatchLoading && this.flatBlobsList.length <= 1)) {
showTreeList = false;
}
@@ -753,7 +759,7 @@ export default {
/>
<gl-sprintf :message="__('File %{current} of %{total}')">
<template #current>{{ currentFileNumber }}</template>
- <template #total>{{ diffFiles.length }}</template>
+ <template #total>{{ flatBlobsList.length }}</template>
</gl-sprintf>
</div>
<gl-loading-icon v-else-if="retrievingBatches" size="lg" />
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
index 11aa856619b..5392c631c14 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -28,7 +28,7 @@ export default {
<template>
<div
data-testid="diff-codequality"
- class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-pl-5 gl-pt-4 gl-pb-4"
+ class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-text-black-normal gl-pl-5 gl-pt-4 gl-pb-4"
>
<h4
data-testid="diff-codequality-findings-heading"
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 8fcbc4b5cce..53a55aac1ec 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, sprintf } from '~/locale';
import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants';
import * as utils from '../store/utils';
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 564f776edd2..c19174dda8a 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -5,7 +5,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { IdState } from 'vendor/vue-virtual-scroller';
import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { hasDiff } from '~/helpers/diffs_helper';
import { diffViewerErrors } from '~/ide/constants';
import { scrollToElement } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index dfca6d61270..1f5c9b4f2f5 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -6,6 +6,7 @@ https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57842
* */
import { memoize } from 'lodash';
import { isLoggedIn } from '~/lib/utils/common_utils';
+import { compatFunctionalMixin } from '~/lib/utils/vue3compat/compat_functional_mixin';
import {
PARALLEL_DIFF_VIEW_TYPE,
CONFLICT_MARKER_THEIR,
@@ -24,6 +25,10 @@ import * as utils from './diff_row_utils';
export default {
DiffGutterAvatars,
CodeQualityGutterIcon: () => import('ee_component/diffs/components/code_quality_gutter_icon.vue'),
+
+ // Temporary mixin for migration from Vue.js 2 to @vue/compat
+ mixins: [compatFunctionalMixin],
+
props: {
fileHash: {
type: String,
diff --git a/app/assets/javascripts/diffs/components/file_row_stats.vue b/app/assets/javascripts/diffs/components/file_row_stats.vue
index 784f74e498f..f99f363a6be 100644
--- a/app/assets/javascripts/diffs/components/file_row_stats.vue
+++ b/app/assets/javascripts/diffs/components/file_row_stats.vue
@@ -10,7 +10,7 @@ export default {
</script>
<template>
- <span v-once class="file-row-stats">
+ <span class="file-row-stats">
<span class="cgreen"> +{{ file.addedLines }} </span>
<span class="cred"> -{{ file.removedLines }} </span>
</span>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 8bb1872567c..ab08c72b08f 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -2,9 +2,10 @@
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import micromatch from 'micromatch';
+import { debounce } from 'lodash';
import { getModifierKey } from '~/constants';
import { s__, sprintf } from '~/locale';
-import FileTree from '~/vue_shared/components/file_tree.vue';
+import { RecycleScroller } from 'vendor/vue-virtual-scroller';
import DiffFileRow from './diff_file_row.vue';
const MODIFIER_KEY = getModifierKey();
@@ -15,7 +16,8 @@ export default {
},
components: {
GlIcon,
- FileTree,
+ DiffFileRow,
+ RecycleScroller,
},
props: {
hideFileStats: {
@@ -26,6 +28,10 @@ export default {
data() {
return {
search: '',
+ scrollerHeight: 0,
+ resizeObserver: null,
+ rowHeight: 0,
+ debouncedHeightCalc: null,
};
},
computed: {
@@ -61,12 +67,51 @@ export default {
return acc;
}, []);
},
+ // Flatten the treeList so there's no nested trees
+ // This gives us fixed row height for virtual scrolling
+ // in: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'c' }]
+ // out: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'b' }, { path: 'c' }]
+ flatFilteredTreeList() {
+ const result = [];
+ const createFlatten = (level) => (item) => {
+ result.push({
+ ...item,
+ level: item.isHeader ? 0 : level,
+ key: item.key || item.path,
+ });
+ if (item.opened || item.isHeader) {
+ item.tree.forEach(createFlatten(level + 1));
+ }
+ };
+
+ this.filteredTreeList.forEach(createFlatten(0));
+
+ return result;
+ },
+ },
+ created() {
+ this.debouncedHeightCalc = debounce(this.calculateScrollerHeight, 50);
+ },
+ mounted() {
+ const heightProp = getComputedStyle(this.$refs.wrapper).getPropertyValue('--file-row-height');
+ this.rowHeight = parseInt(heightProp, 10);
+ this.calculateScrollerHeight();
+ this.resizeObserver = new ResizeObserver(() => {
+ this.debouncedHeightCalc();
+ });
+ this.resizeObserver.observe(this.$refs.scrollRoot);
+ },
+ beforeDestroy() {
+ this.resizeObserver.disconnect();
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
clearSearch() {
this.search = '';
},
+ calculateScrollerHeight() {
+ this.scrollerHeight = this.$refs.scrollRoot.clientHeight;
+ },
},
searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), {
MODIFIER_KEY,
@@ -76,8 +121,12 @@ export default {
</script>
<template>
- <div class="tree-list-holder d-flex flex-column" data-qa-selector="file_tree_container">
- <div class="gl-mb-3 position-relative tree-list-search d-flex">
+ <div
+ ref="wrapper"
+ class="tree-list-holder d-flex flex-column"
+ data-qa-selector="file_tree_container"
+ >
+ <div class="gl-pb-3 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<gl-icon name="search" class="position-absolute tree-list-icon" />
<label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
@@ -89,6 +138,7 @@ export default {
name="diff-tree-search"
class="form-control"
data-testid="diff-tree-search"
+ data-qa-selector="diff_tree_search"
/>
<button
v-show="search"
@@ -101,24 +151,37 @@ export default {
</button>
</div>
</div>
- <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList || search }" class="tree-list-scroll">
- <template v-if="filteredTreeList.length">
- <file-tree
- v-for="file in filteredTreeList"
- :key="file.key"
- :file="file"
- :level="0"
- :viewed-files="viewedDiffFileIds"
- :hide-file-stats="hideFileStats"
- :file-row-component="$options.DiffFileRow"
- :current-diff-file-id="currentDiffFileId"
- :style="{ '--level': 0 }"
- :class="{ 'tree-list-parent': file.tree.length }"
- class="gl-relative"
- @toggleTreeOpen="toggleTreeOpen"
- @clickFile="(path) => scrollToFile({ path })"
- />
- </template>
+ <div
+ ref="scrollRoot"
+ :class="{ 'tree-list-blobs': !renderTreeList || search }"
+ class="gl-flex-grow-1"
+ >
+ <recycle-scroller
+ v-if="flatFilteredTreeList.length"
+ :style="{ height: `${scrollerHeight}px` }"
+ :items="flatFilteredTreeList"
+ :item-size="rowHeight"
+ :buffer="100"
+ key-field="key"
+ >
+ <template #default="{ item }">
+ <diff-file-row
+ :file="item"
+ :level="item.level"
+ :viewed-files="viewedDiffFileIds"
+ :hide-file-stats="hideFileStats"
+ :current-diff-file-id="currentDiffFileId"
+ :style="{ '--level': item.level }"
+ :class="{ 'tree-list-parent': item.tree.length }"
+ class="gl-relative"
+ @toggleTreeOpen="toggleTreeOpen"
+ @clickFile="(path) => scrollToFile({ path })"
+ />
+ </template>
+ <template #after>
+ <div class="tree-list-gutter"></div>
+ </template>
+ </recycle-scroller>
<p v-else class="prepend-top-20 append-bottom-20 text-center">
{{ s__('MergeRequest|No files found') }}
</p>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 6c0c9c4e1d0..873c4819669 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -6,10 +6,8 @@ export const OLD_NO_NEW_LINE_TYPE = 'old-nonewline';
export const NEW_NO_NEW_LINE_TYPE = 'new-nonewline';
export const CONTEXT_LINE_TYPE = 'context';
export const EMPTY_CELL_TYPE = 'empty-cell';
-export const COMMENT_FORM_TYPE = 'commentForm';
export const DIFF_NOTE_TYPE = 'DiffNote';
export const LEGACY_DIFF_NOTE_TYPE = 'LegacyDiffNote';
-export const NOTE_TYPE = 'Note';
export const NEW_LINE_TYPE = 'new';
export const OLD_LINE_TYPE = 'old';
export const TEXT_DIFF_POSITION_TYPE = 'text';
@@ -17,14 +15,10 @@ export const IMAGE_DIFF_POSITION_TYPE = 'image';
export const LINE_POSITION_LEFT = 'left';
export const LINE_POSITION_RIGHT = 'right';
-export const LINE_SIDE_LEFT = 'left-side';
-export const LINE_SIDE_RIGHT = 'right-side';
export const DIFF_VIEW_COOKIE_NAME = 'diff_view';
export const DIFF_WHITESPACE_COOKIE_NAME = 'diff_whitespace';
export const LINE_HOVER_CLASS_NAME = 'is-over';
-export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold';
-export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded';
export const UNFOLD_COUNT = 20;
export const COUNT_OF_AVATARS_IN_GUTTER = 3;
@@ -46,14 +40,12 @@ export const TREE_HIDE_STATS_WIDTH = 260;
export const OLD_LINE_KEY = 'old_line';
export const NEW_LINE_KEY = 'new_line';
export const TYPE_KEY = 'type';
-export const LEFT_LINE_KEY = 'left';
export const MAX_RENDERING_DIFF_LINES = 500;
export const MAX_RENDERING_BULK_ROWS = 30;
export const MIN_RENDERING_MS = 2;
export const START_RENDERING_INDEX = 200;
export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines';
-export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines';
export const DIFF_COMPARE_BASE_VERSION_INDEX = -1;
export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2;
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 7da5ef54b80..00a08434dac 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,5 +1,7 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils';
import notesStore from '~/mr_notes/stores';
@@ -11,20 +13,28 @@ import { getReviewsForMergeRequest } from './utils/file_reviews';
import { getDerivedMergeRequestInformation } from './utils/merge_request';
export default function initDiffsApp(store = notesStore) {
+ const el = document.getElementById('js-diffs-app');
+ const { dataset } = el;
+
+ Vue.use(VueApollo);
+
const vm = new Vue({
- el: '#js-diffs-app',
+ el,
name: 'MergeRequestDiffs',
components: {
DiffsApp,
},
store,
+ apolloProvider,
+ provide: {
+ newSavedRepliesPath: dataset.savedRepliesNewPath,
+ },
data() {
- const { dataset } = document.querySelector(this.$options.el);
-
return {
endpoint: dataset.endpoint,
endpointMetadata: dataset.endpointMetadata || '',
endpointBatch: dataset.endpointBatch || '',
+ endpointDiffForPath: dataset.endpointDiffForPath || '',
endpointCoverage: dataset.endpointCoverage || '',
endpointCodequality: dataset.endpointCodequality || '',
endpointUpdateUser: dataset.updateCurrentUserPath,
@@ -86,6 +96,7 @@ export default function initDiffsApp(store = notesStore) {
endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
+ endpointDiffForPath: this.endpointDiffForPath,
endpointCoverage: this.endpointCoverage,
endpointCodequality: this.endpointCodequality,
endpointUpdateUser: this.endpointUpdateUser,
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 9f90de9abde..9236e14beb1 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -5,7 +5,7 @@ import {
historyPushState,
scrollToElement,
} from '~/lib/utils/common_utils';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
@@ -14,6 +14,8 @@ import Poll from '~/lib/utils/poll';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import notesEventHub from '~/notes/event_hub';
+import { generateTreeList } from '~/diffs/utils/tree_worker_utils';
+import { sortTree } from '~/ide/stores/utils';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
@@ -52,7 +54,6 @@ import { isCollapsed } from '../utils/diff_file';
import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews';
import { getDerivedMergeRequestInformation } from '../utils/merge_request';
import { queueRedisHllEvents } from '../utils/queue_events';
-import TreeWorker from '../workers/tree_worker?worker';
import * as types from './mutation_types';
import {
getDiffPositionByLineCode,
@@ -68,6 +69,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -81,6 +83,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -199,21 +202,12 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
};
export const fetchDiffFilesMeta = ({ commit, state }) => {
- const worker = new TreeWorker();
const urlParams = {
view: 'inline',
w: state.showWhitespace ? '0' : '1',
};
commit(types.SET_LOADING, true);
- eventHub.$emit(EVT_PERF_MARK_FILE_TREE_START);
-
- worker.addEventListener('message', ({ data }) => {
- commit(types.SET_TREE_DATA, data);
- eventHub.$emit(EVT_PERF_MARK_FILE_TREE_END);
-
- worker.terminate();
- });
return axios
.get(mergeUrlParams(urlParams, state.endpointMetadata))
@@ -225,18 +219,24 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []);
commit(types.SET_DIFF_METADATA, strippedData);
- worker.postMessage(data.diff_files);
+ eventHub.$emit(EVT_PERF_MARK_FILE_TREE_START);
+ const { treeEntries, tree } = generateTreeList(data.diff_files);
+ eventHub.$emit(EVT_PERF_MARK_FILE_TREE_END);
+ commit(types.SET_TREE_DATA, {
+ treeEntries,
+ tree: sortTree(tree),
+ });
return data;
})
.catch((error) => {
- worker.terminate();
-
if (error.response.status === HTTP_STATUS_NOT_FOUND) {
createAlert({
message: __('Building your merge request. Wait a few moments, then refresh this page.'),
variant: VARIANT_WARNING,
});
+ } else {
+ throw error;
}
});
};
@@ -821,20 +821,20 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
}
}
-export const setCurrentDiffFileIdFromNote = ({ commit, state, rootGetters }, noteId) => {
+export const setCurrentDiffFileIdFromNote = ({ commit, getters, rootGetters }, noteId) => {
const note = rootGetters.notesById[noteId];
if (!note) return;
const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file?.file_hash;
- if (fileHash && state.diffFiles.some((f) => f.file_hash === fileHash)) {
+ if (fileHash && getters.flatBlobsList.some((f) => f.fileHash === fileHash)) {
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
}
};
-export const navigateToDiffFileIndex = ({ commit, state }, index) => {
- const fileHash = state.diffFiles[index].file_hash;
+export const navigateToDiffFileIndex = ({ commit, getters }, index) => {
+ const { fileHash } = getters.flatBlobsList[index];
document.location.hash = fileHash;
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 3a85c1a9fe1..10a6a872fe4 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -90,6 +90,12 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
export const getDiffFileByHash = (state) => (fileHash) =>
state.diffFiles.find((file) => file.file_hash === fileHash);
+export function isTreePathLoaded(state) {
+ return (path) => {
+ return Boolean(state.treeEntries[path]?.diffLoaded);
+ };
+}
+
export const flatBlobsList = (state) =>
Object.values(state.treeEntries).filter((f) => f.type === 'blob');
@@ -148,7 +154,7 @@ export const fileLineCodequality = () => () => {
export const currentDiffIndex = (state) =>
Math.max(
0,
- state.diffFiles.findIndex((diff) => diff.file_hash === state.currentDiffFileId),
+ flatBlobsList(state).findIndex((diff) => diff.fileHash === state.currentDiffFileId),
);
export const diffLines = (state) => (file) => {
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 329db1fe2cf..593c28f20ec 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -16,6 +16,7 @@ export default () => ({
removedLines: null,
endpoint: '',
endpointUpdateUser: '',
+ endpointDiffForPath: '',
basePath: '',
commit: null,
startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index d2b798245fc..5e7fe8b5cd8 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -15,6 +15,7 @@ import {
prepareDiffData,
isDiscussionApplicableToLine,
updateLineInFile,
+ markTreeEntriesLoaded,
} from './utils';
function updateDiffFilesInState(state, files) {
@@ -33,6 +34,7 @@ export default {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -46,6 +48,7 @@ export default {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -80,9 +83,15 @@ export default {
},
[types.SET_DIFF_DATA_BATCH](state, data) {
- state.diffFiles = prepareDiffData({
- diff: data,
- priorFiles: state.diffFiles,
+ Object.assign(state, {
+ diffFiles: prepareDiffData({
+ diff: data,
+ priorFiles: state.diffFiles,
+ }),
+ treeEntries: markTreeEntriesLoaded({
+ priorEntries: state.treeEntries,
+ loadedFiles: data.diff_files,
+ }),
});
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 0519ca3d715..3739ef0cd55 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -19,6 +19,8 @@ import {
} from '../constants';
import { prepareRawDiffFile } from '../utils/diff_file';
+const SHA1 = /\b([a-f0-9]{40})\b/;
+
export const isAdded = (line) => ['new', 'new-nonewline'].includes(line.type);
export const isRemoved = (line) => ['old', 'old-nonewline'].includes(line.type);
export const isUnchanged = (line) => !line.type;
@@ -556,3 +558,44 @@ export const allDiscussionWrappersExpanded = (diff) => {
return discussionsExpanded;
};
+
+export function isUrlHashNoteLink(urlHash) {
+ const id = urlHash.replace(/^#/, '');
+
+ return id.startsWith('note');
+}
+
+export function isUrlHashFileHeader(urlHash) {
+ const id = urlHash.replace(/^#/, '');
+
+ return id.startsWith('diff-content');
+}
+
+export function parseUrlHashAsFileHash(urlHash, currentDiffFileId = '') {
+ const isNoteLink = isUrlHashNoteLink(urlHash);
+ let id = urlHash.replace(/^#/, '');
+
+ if (isNoteLink && currentDiffFileId) {
+ id = currentDiffFileId;
+ } else if (isUrlHashFileHeader(urlHash)) {
+ id = id.replace('diff-content-', '');
+ } else if (!SHA1.test(id) || isNoteLink) {
+ id = null;
+ }
+
+ return id;
+}
+
+export function markTreeEntriesLoaded({ priorEntries, loadedFiles }) {
+ const newEntries = { ...priorEntries };
+
+ loadedFiles.forEach((newFile) => {
+ const entry = newEntries[newFile.new_path];
+
+ if (entry) {
+ entry.diffLoaded = true;
+ }
+ });
+
+ return newEntries;
+}
diff --git a/app/assets/javascripts/diffs/utils/tree_worker_utils.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
index a90c1a5c64e..8689809cfa9 100644
--- a/app/assets/javascripts/diffs/utils/tree_worker_utils.js
+++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
@@ -85,6 +85,11 @@ export const generateTreeList = (files) => {
if (type === 'blob') {
Object.assign(entry, {
changed: true,
+ diffLoaded: false,
+ filePaths: {
+ old: file.old_path,
+ new: file.new_path,
+ },
tempFile: file.new_file,
deleted: file.deleted_file,
fileHash: file.file_hash,
diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js
deleted file mode 100644
index 04010a99b52..00000000000
--- a/app/assets/javascripts/diffs/workers/tree_worker.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { sortTree } from '~/ide/stores/utils';
-import { generateTreeList } from '../utils/tree_worker_utils';
-
-// eslint-disable-next-line no-restricted-globals
-self.addEventListener('message', (e) => {
- const { data } = e;
-
- if (data === undefined) {
- return;
- }
-
- const { treeEntries, tree } = generateTreeList(data);
-
- // eslint-disable-next-line no-restricted-globals
- self.postMessage({
- treeEntries,
- tree: sortTree(tree),
- });
-});
diff --git a/app/assets/javascripts/drawio/constants.js b/app/assets/javascripts/drawio/constants.js
new file mode 100644
index 00000000000..2e1e074db3b
--- /dev/null
+++ b/app/assets/javascripts/drawio/constants.js
@@ -0,0 +1,15 @@
+/*
+ * TODO: Make this URL configurable
+ */
+export const DRAWIO_EDITOR_URL =
+ 'https://embed.diagrams.net/?ui=sketch&noSaveBtn=1&saveAndExit=1&keepmodified=1&spin=1&embed=1&libraries=1&configure=1&proto=json&toSvg=1'; // TODO Make it configurable
+
+export const DRAWIO_FRAME_ID = 'drawio-frame';
+
+export const DARK_BACKGROUND_COLOR = '#202020';
+
+export const DIAGRAM_BACKGROUND_COLOR = '#ffffff';
+
+export const DRAWIO_IFRAME_TIMEOUT = 4000;
+
+export const DIAGRAM_MAX_SIZE = 10 * 1024 * 1024; // 1MB
diff --git a/app/assets/javascripts/drawio/content_editor_facade.js b/app/assets/javascripts/drawio/content_editor_facade.js
new file mode 100644
index 00000000000..1c41194c1f5
--- /dev/null
+++ b/app/assets/javascripts/drawio/content_editor_facade.js
@@ -0,0 +1,80 @@
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * A set of functions to decouple the content_editor component from
+ * the draw.io editor.
+ * It allows the draw.io editor to obtain a selected drawio_diagram
+ * and replace it or insert a new drawio_diagram node without coupling
+ * the drawio_editor to the Content Editor implementation details
+ * *
+ * @param {Object} params Factory function parameters
+ * @param {Object} params.tiptapEditor See https://tiptap.dev/api/editor
+ * @param {String} params.drawioNodeName Name of the drawio_diagram node in
+ * the ProseMirror document
+ * @param {String} params.uploadsPath API endpoint to upload files
+ * @param {Object} params.assetResolver See
+ * app/assets/javascripts/content_editor/services/asset_resolver.js
+ *
+ * @returns A content_editor_facade object with operations
+ * to get a selected diagram, upload a diagram, insert a new one in the
+ * Content Editor, and update an existing’s diagram URL.
+ */
+export const create = ({ tiptapEditor, drawioNodeName, uploadsPath, assetResolver }) => ({
+ getDiagram: async () => {
+ const { node } = tiptapEditor.state.selection;
+
+ if (!node || node.type.name !== drawioNodeName) {
+ return null;
+ }
+
+ const { src } = node.attrs;
+ const response = await axios.get(src, { responseType: 'text' });
+ const diagramSvg = response.data;
+ const contentType = response.headers['content-type'];
+ const filename = src.split('/').pop();
+
+ return {
+ diagramURL: src,
+ filename,
+ diagramSvg,
+ contentType,
+ };
+ },
+ updateDiagram: async ({ uploadResults: { file_path: canonicalSrc } }) => {
+ const src = await assetResolver.resolveUrl(canonicalSrc);
+
+ tiptapEditor
+ .chain()
+ .focus()
+ .updateAttributes(drawioNodeName, {
+ src,
+ canonicalSrc,
+ })
+ .run();
+ },
+ insertDiagram: async ({ uploadResults: { file_path: canonicalSrc } }) => {
+ const src = await assetResolver.resolveUrl(canonicalSrc);
+
+ tiptapEditor
+ .chain()
+ .focus()
+ .insertContent({
+ type: drawioNodeName,
+ attrs: {
+ src,
+ canonicalSrc,
+ },
+ })
+ .run();
+ },
+ uploadDiagram: async ({ filename, diagramSvg }) => {
+ const blob = new Blob([diagramSvg], { type: 'image/svg+xml' });
+ const formData = new FormData();
+
+ formData.append('file', blob, filename);
+
+ const response = await axios.post(uploadsPath, formData);
+
+ return response.data;
+ },
+});
diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js
new file mode 100644
index 00000000000..38d1cadcc63
--- /dev/null
+++ b/app/assets/javascripts/drawio/drawio_editor.js
@@ -0,0 +1,277 @@
+import _ from 'lodash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { darkModeEnabled } from '~/lib/utils/color_utils';
+import { __ } from '~/locale';
+import { setAttributes } from '~/lib/utils/dom_utils';
+import {
+ DARK_BACKGROUND_COLOR,
+ DRAWIO_EDITOR_URL,
+ DRAWIO_FRAME_ID,
+ DIAGRAM_BACKGROUND_COLOR,
+ DRAWIO_IFRAME_TIMEOUT,
+ DIAGRAM_MAX_SIZE,
+} from './constants';
+
+function updateDrawioEditorState(drawIOEditorState, data) {
+ Object.assign(drawIOEditorState, data);
+}
+
+function postMessageToDrawioEditor(drawIOEditorState, message) {
+ const { origin } = new URL(DRAWIO_EDITOR_URL);
+
+ drawIOEditorState.iframe.contentWindow.postMessage(JSON.stringify(message), origin);
+}
+
+function disposeDrawioEditor(drawIOEditorState) {
+ drawIOEditorState.disposeEventListener();
+ drawIOEditorState.iframe.remove();
+}
+
+function getSvg(data) {
+ const svgPath = atob(data.substring(data.indexOf(',') + 1));
+
+ return `<?xml version="1.0" encoding="UTF-8"?>\n\
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n\
+ ${svgPath}`;
+}
+
+async function saveDiagram(drawIOEditorState, editorFacade) {
+ const { newDiagram, diagramMarkdown, filename, diagramSvg } = drawIOEditorState;
+ const filenameWithExt = filename.endsWith('.drawio.svg') ? filename : `${filename}.drawio.svg`;
+
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'spinner',
+ show: true,
+ messageKey: 'saving',
+ });
+
+ try {
+ const uploadResults = await editorFacade.uploadDiagram({
+ filename: filenameWithExt,
+ diagramSvg,
+ });
+
+ if (newDiagram) {
+ editorFacade.insertDiagram({ uploadResults });
+ } else {
+ editorFacade.updateDiagram({ diagramMarkdown, uploadResults });
+ }
+
+ createAlert({
+ message: __('Diagram saved successfully.'),
+ variant: VARIANT_SUCCESS,
+ fadeTransition: true,
+ });
+ setTimeout(() => disposeDrawioEditor(drawIOEditorState), 10);
+ } catch {
+ postMessageToDrawioEditor(drawIOEditorState, { action: 'spinner', show: false });
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'dialog',
+ titleKey: 'error',
+ modified: true,
+ buttonKey: 'close',
+ messageKey: 'errorSavingFile',
+ });
+ }
+}
+
+function promptName(drawIOEditorState, name, errKey) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'prompt',
+ titleKey: 'filename',
+ okKey: 'save',
+ defaultValue: name || '',
+ });
+
+ if (errKey !== null) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'dialog',
+ titleKey: 'error',
+ messageKey: errKey,
+ buttonKey: 'ok',
+ });
+ }
+}
+
+function sendLoadDiagramMessage(drawIOEditorState) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'load',
+ xml: drawIOEditorState.diagramSvg,
+ border: 8,
+ background: DIAGRAM_BACKGROUND_COLOR,
+ dark: drawIOEditorState.dark,
+ title: drawIOEditorState.filename,
+ });
+}
+
+async function loadExistingDiagram(drawIOEditorState, editorFacade) {
+ let diagram = null;
+
+ try {
+ diagram = await editorFacade.getDiagram();
+ } catch (e) {
+ throw new Error(__('Cannot load the diagram into the diagrams.net editor'));
+ }
+
+ if (diagram) {
+ const { diagramMarkdown, filename, diagramSvg, contentType, diagramURL } = diagram;
+ const resolvedURL = new URL(diagramURL, window.location.origin);
+ const diagramSvgSize = new Blob([diagramSvg]).size;
+
+ if (contentType !== 'image/svg+xml') {
+ throw new Error(__('The selected image is not a valid SVG diagram'));
+ }
+
+ if (resolvedURL.origin !== window.location.origin) {
+ throw new Error(__('The selected image is not an asset uploaded in the application'));
+ }
+
+ if (diagramSvgSize > DIAGRAM_MAX_SIZE) {
+ throw new Error(__('The selected image is too large.'));
+ }
+
+ updateDrawioEditorState(drawIOEditorState, {
+ newDiagram: false,
+ filename,
+ diagramMarkdown,
+ diagramSvg,
+ });
+ } else {
+ updateDrawioEditorState(drawIOEditorState, {
+ newDiagram: true,
+ });
+ }
+
+ sendLoadDiagramMessage(drawIOEditorState);
+}
+
+async function prepareEditor(drawIOEditorState, editorFacade) {
+ const { iframe } = drawIOEditorState;
+
+ iframe.style.cursor = 'wait';
+
+ try {
+ await loadExistingDiagram(drawIOEditorState, editorFacade);
+
+ iframe.style.visibility = 'visible';
+ iframe.style.cursor = '';
+ window.scrollTo(0, 0);
+ } catch (e) {
+ createAlert({
+ message: e.message,
+ error: e,
+ });
+ disposeDrawioEditor(drawIOEditorState);
+ }
+}
+
+function configureDrawIOEditor(drawIOEditorState) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'configure',
+ config: {
+ darkColor: DARK_BACKGROUND_COLOR,
+ settingsName: 'gitlab',
+ },
+ colorSchemeMeta: drawIOEditorState.dark, // For transparent iframe background in dark mode
+ });
+ updateDrawioEditorState(drawIOEditorState, {
+ initialized: true,
+ });
+}
+
+function onDrawIOEditorMessage(drawIOEditorState, editorFacade, evt) {
+ if (_.isNil(evt) || evt.source !== drawIOEditorState.iframe.contentWindow) {
+ return;
+ }
+
+ const msg = JSON.parse(evt.data);
+
+ if (msg.event === 'configure') {
+ configureDrawIOEditor(drawIOEditorState);
+ } else if (msg.event === 'init') {
+ prepareEditor(drawIOEditorState, editorFacade);
+ } else if (msg.event === 'exit') {
+ disposeDrawioEditor(drawIOEditorState);
+ } else if (msg.event === 'prompt') {
+ updateDrawioEditorState(drawIOEditorState, {
+ filename: msg.value,
+ });
+
+ if (!drawIOEditorState.filename) {
+ promptName(drawIOEditorState, 'diagram.drawio.svg', 'filenameShort');
+ } else {
+ saveDiagram(drawIOEditorState, editorFacade);
+ }
+ } else if (msg.event === 'export') {
+ updateDrawioEditorState(drawIOEditorState, {
+ diagramSvg: getSvg(msg.data),
+ });
+ // TODO Add this to draw.io editor configuration
+ sendLoadDiagramMessage(drawIOEditorState); // Save removes diagram from the editor, so we need to reload it.
+ postMessageToDrawioEditor(drawIOEditorState, { action: 'status', modified: true }); // And set editor modified flag to true.
+ if (!drawIOEditorState.filename) {
+ promptName(drawIOEditorState, 'diagram.drawio.svg', null);
+ } else {
+ saveDiagram(drawIOEditorState, editorFacade);
+ }
+ }
+}
+
+function createEditorIFrame(drawIOEditorState) {
+ const iframe = document.createElement('iframe');
+
+ setAttributes(iframe, {
+ id: DRAWIO_FRAME_ID,
+ src: DRAWIO_EDITOR_URL,
+ class: 'drawio-editor',
+ });
+
+ document.body.appendChild(iframe);
+
+ setTimeout(() => {
+ if (drawIOEditorState.initialized === false) {
+ disposeDrawioEditor(drawIOEditorState);
+ createAlert({ message: __('The diagrams.net editor could not be loaded.') });
+ }
+ }, DRAWIO_IFRAME_TIMEOUT);
+
+ updateDrawioEditorState(drawIOEditorState, {
+ iframe,
+ });
+}
+
+function attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade) {
+ const evtHandler = (evt) => {
+ onDrawIOEditorMessage(drawIOEditorState, editorFacade, evt);
+ };
+
+ window.addEventListener('message', evtHandler);
+
+ // Stores a function in the editor state object that allows disposing
+ // the message event listener when the editor exits.
+ updateDrawioEditorState(drawIOEditorState, {
+ disposeEventListener: () => {
+ window.removeEventListener('message', evtHandler);
+ },
+ });
+}
+
+const createDrawioEditorState = ({ filename = null }) => ({
+ newDiagram: true,
+ filename,
+ diagramSvg: null,
+ diagramMarkdown: null,
+ iframe: null,
+ isBusy: false,
+ initialized: false,
+ dark: darkModeEnabled(),
+ disposeEventListener: null,
+});
+
+export function launchDrawioEditor({ editorFacade, filename }) {
+ const drawIOEditorState = createDrawioEditorState({ filename });
+
+ // The execution order of these two functions matter
+ attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade);
+ createEditorIFrame(drawIOEditorState);
+}
diff --git a/app/assets/javascripts/drawio/markdown_field_editor_facade.js b/app/assets/javascripts/drawio/markdown_field_editor_facade.js
new file mode 100644
index 00000000000..4ef203c7aa0
--- /dev/null
+++ b/app/assets/javascripts/drawio/markdown_field_editor_facade.js
@@ -0,0 +1,72 @@
+import { insertMarkdownText, resolveSelectedImage } from '~/lib/utils/text_markdown';
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * A set of functions to decouple the markdown_field component from
+ * the draw.io editor.
+ * It allows the draw.io editor to obtain a selected drawio_diagram
+ * and replace it or insert a new drawio_diagram node without coupling
+ * the drawio_editor to the Markdown Field implementation details
+ *
+ * @param {Object} params Factory function parameters
+ * @param {Object} params.textArea Textarea used to edit and display markdown source
+ * @param {String} params.markdownPreviewPath API endpoint to render Markdown
+ * @param {String} params.uploadsPath API endpoint to upload files
+ *
+ * @returns A markdown_field_facade object with operations
+ * with operations to get a selected diagram, upload a diagram,
+ * insert a new one in the Markdown Field, and update
+ * an existing’s diagram URL.
+ */
+export const create = ({ textArea, markdownPreviewPath, uploadsPath }) => ({
+ getDiagram: async () => {
+ const image = await resolveSelectedImage(textArea, markdownPreviewPath);
+
+ if (!image) {
+ return null;
+ }
+
+ const { imageURL, imageMarkdown, filename } = image;
+ const response = await axios.get(imageURL, { responseType: 'text' });
+ const diagramSvg = response.data;
+ const contentType = response.headers['content-type'];
+
+ return {
+ diagramURL: imageURL,
+ diagramMarkdown: imageMarkdown,
+ filename,
+ diagramSvg,
+ contentType,
+ };
+ },
+ updateDiagram: ({ uploadResults, diagramMarkdown }) => {
+ textArea.focus();
+
+ // eslint-disable-next-line no-param-reassign
+ textArea.value = textArea.value.replace(diagramMarkdown, uploadResults.link.markdown);
+ textArea.dispatchEvent(new Event('input'));
+ },
+ insertDiagram: ({ uploadResults }) => {
+ textArea.focus();
+ const markdown = textArea.value;
+ const selectedMD = markdown.substring(textArea.selectionStart, textArea.selectionEnd);
+
+ // This method dispatches the input event.
+ insertMarkdownText({
+ textArea,
+ text: markdown,
+ tag: uploadResults.link.markdown,
+ selected: selectedMD,
+ });
+ },
+ uploadDiagram: async ({ filename, diagramSvg }) => {
+ const blob = new Blob([diagramSvg], { type: 'image/svg+xml' });
+ const formData = new FormData();
+
+ formData.append('file', blob, filename);
+
+ const response = await axios.post(uploadsPath, formData);
+
+ return response.data;
+ },
+});
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
index c72145f9d2f..67b909d37c3 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue
@@ -2,7 +2,7 @@
import { isEmpty } from 'lodash';
import { GlButtonGroup } from '@gitlab/ui';
import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
-import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
+import { EDITOR_TOOLBAR_BUTTON_GROUPS } from '~/editor/constants';
import SourceEditorToolbarButton from './source_editor_toolbar_button.vue';
export default {
@@ -34,8 +34,7 @@ export default {
return nodes.map((item) => {
return {
...item,
- group:
- (this.$options.groups.includes(item.group) && item.group) || EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS[item.group] || EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
};
});
},
@@ -46,24 +45,38 @@ export default {
return !isEmpty(this.getGroupItems(group));
},
},
- groups: [EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP],
+ groups: EDITOR_TOOLBAR_BUTTON_GROUPS,
};
</script>
<template>
<section
v-if="isVisible"
id="se-toolbar"
- class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-justify-content-space-between gl-align-items-center"
+ class="gl-py-3 gl-px-5 gl-bg-white gl-border-t gl-border-b gl-display-flex gl-align-items-center"
>
- <div v-for="group in $options.groups" :key="group">
- <gl-button-group v-if="hasGroupItems(group)">
- <source-editor-toolbar-button
- v-for="item in getGroupItems(group)"
- :key="item.id"
- :button="item"
- @click="$emit('click', item)"
- />
- </gl-button-group>
- </div>
+ <gl-button-group v-if="hasGroupItems($options.groups.file)">
+ <source-editor-toolbar-button
+ v-for="item in getGroupItems($options.groups.file)"
+ :key="item.id"
+ :button="item"
+ @click="$emit('click', item)"
+ />
+ </gl-button-group>
+ <gl-button-group v-if="hasGroupItems($options.groups.edit)">
+ <source-editor-toolbar-button
+ v-for="item in getGroupItems($options.groups.edit)"
+ :key="item.id"
+ :button="item"
+ @click="$emit('click', item)"
+ />
+ </gl-button-group>
+ <gl-button-group v-if="hasGroupItems($options.groups.settings)" class="gl-ml-auto">
+ <source-editor-toolbar-button
+ v-for="item in getGroupItems($options.groups.settings)"
+ :key="item.id"
+ :button="item"
+ @click="$emit('click', item)"
+ />
+ </gl-button-group>
</section>
</template>
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index d235319dfd7..2be671ec7d8 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -1,3 +1,4 @@
+import { KeyMod, KeyCode } from 'monaco-editor';
import { getModifierKey } from '~/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
@@ -15,15 +16,15 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
-export const EDITOR_TOOLBAR_LEFT_GROUP = 'left';
-export const EDITOR_TOOLBAR_RIGHT_GROUP = 'right';
+export const EDITOR_TOOLBAR_BUTTON_GROUPS = {
+ file: 'file', // external helpers (file-tree, etc.)
+ edit: 'edit', // formatting the text in the editor (bold, italic, add link, etc.)
+ settings: 'settings', // editor-wide settings (soft-wrap, full-screen, etc.)
+};
export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
'SourceEditor|"el" parameter is required for createInstance()',
);
-export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__(
- 'SourceEditor|Source Editor instance is required to set up an extension.',
-);
export const EDITOR_EXTENSION_DEFINITION_ERROR = s__(
'SourceEditor|Extension definition should be either a class or a function',
);
@@ -73,7 +74,8 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
}),
data: {
mdTag: '**',
- mdShortcuts: '["mod+b"]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyCode.KeyB],
},
},
{
@@ -83,7 +85,8 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
}),
data: {
mdTag: '_',
- mdShortcuts: '["mod+i"]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyCode.KeyI],
},
},
{
@@ -93,7 +96,8 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
}),
data: {
mdTag: '~~',
- mdShortcuts: '["mod+shift+x]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyX],
},
},
{
@@ -114,13 +118,14 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
},
{
id: 'link',
- label: sprintf(s__('MarkdownEditor|Add a link (%{modifier_key}K)'), {
+ label: sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), {
modifierKey,
}),
data: {
mdTag: '[{text}](url)',
mdSelect: 'url',
- mdShortcuts: '["mod+k"]',
+ // eslint-disable-next-line no-bitwise
+ mdShortcuts: [KeyMod.CtrlCmd | KeyCode.KeyK],
},
},
{
@@ -166,3 +171,4 @@ export const EXTENSION_MARKDOWN_BUTTONS = [
},
},
];
+export const EXTENSION_SOFTWRAP_ID = 'soft-wrap';
diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
index 0590bb7455a..8ec83e4df1c 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js
@@ -1,8 +1,11 @@
import { Range } from 'monaco-editor';
+import { __ } from '~/locale';
import {
EDITOR_TYPE_CODE,
EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
EXTENSION_BASE_LINE_NUMBERS_CLASS,
+ EDITOR_TOOLBAR_BUTTON_GROUPS,
+ EXTENSION_SOFTWRAP_ID,
} from '../constants';
const hashRegexp = /#?L/g;
@@ -24,6 +27,13 @@ export class SourceEditorExtension {
return 'BaseExtension';
}
+ onSetup(instance) {
+ this.toolbarButtons = [];
+ if (instance.toolbar) {
+ this.setupToolbar(instance);
+ }
+ }
+
// eslint-disable-next-line class-methods-use-this
onUse(instance) {
SourceEditorExtension.highlightLines(instance);
@@ -32,6 +42,31 @@ export class SourceEditorExtension {
}
}
+ onBeforeUnuse(instance) {
+ const ids = this.toolbarButtons.map((item) => item.id);
+ if (instance.toolbar) {
+ instance.toolbar.removeItems(ids);
+ }
+ }
+
+ setupToolbar(instance) {
+ this.toolbarButtons = [
+ {
+ id: EXTENSION_SOFTWRAP_ID,
+ label: __('Soft wrap'),
+ icon: 'soft-wrap',
+ selected: instance.getOption(116) === 'on',
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
+ category: 'primary',
+ selectedLabel: __('No wrap'),
+ selectedIcon: 'soft-unwrap',
+ class: 'soft-wrap-toggle',
+ onClick: () => instance.toggleSoftwrap(),
+ },
+ ];
+ instance.toolbar.addItems(this.toolbarButtons);
+ }
+
static onMouseMoveHandler(e) {
const target = e.target.element;
if (target.classList.contains(EXTENSION_BASE_LINE_NUMBERS_CLASS)) {
@@ -108,6 +143,16 @@ export class SourceEditorExtension {
highlightLines(instance, bounds = null) {
SourceEditorExtension.highlightLines(instance, bounds);
},
+
+ toggleSoftwrap(instance) {
+ const isSoftWrapped = instance.getOption(116) === 'on';
+ instance.updateOptions({ wordWrap: isSoftWrapped ? 'off' : 'on' });
+ if (instance.toolbar) {
+ instance.toolbar.updateItem(EXTENSION_SOFTWRAP_ID, {
+ selected: !isSoftWrapped,
+ });
+ }
+ },
};
}
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
index 6105a577996..0a5843ec631 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
@@ -1,5 +1,5 @@
import { insertMarkdownText } from '~/lib/utils/text_markdown';
-import { EDITOR_TOOLBAR_RIGHT_GROUP, EXTENSION_MARKDOWN_BUTTONS } from '../constants';
+import { EDITOR_TOOLBAR_BUTTON_GROUPS, EXTENSION_MARKDOWN_BUTTONS } from '../constants';
export class EditorMarkdownExtension {
static get extensionName() {
@@ -8,6 +8,7 @@ export class EditorMarkdownExtension {
onSetup(instance) {
this.toolbarButtons = [];
+ this.actions = [];
if (instance.toolbar) {
this.setupToolbar(instance);
}
@@ -17,14 +18,30 @@ export class EditorMarkdownExtension {
if (instance.toolbar) {
instance.toolbar.removeItems(ids);
}
+ this.actions.forEach((action) => {
+ action.dispose();
+ });
+ this.actions = [];
}
setupToolbar(instance) {
this.toolbarButtons = EXTENSION_MARKDOWN_BUTTONS.map((btn) => {
+ if (btn.data.mdShortcuts) {
+ this.actions.push(
+ instance.addAction({
+ id: btn.id,
+ label: btn.label,
+ keybindings: btn.data.mdShortcuts,
+ run(inst) {
+ inst.insertMarkdown(btn.data);
+ },
+ }),
+ );
+ }
return {
...btn,
icon: btn.id,
- group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.edit,
category: 'tertiary',
onClick: (e) => instance.insertMarkdown(e),
};
@@ -66,12 +83,8 @@ export class EditorMarkdownExtension {
instance.setPosition(pos);
},
insertMarkdown: (instance, e) => {
- const {
- mdTag: tag,
- mdBlock: blockTag,
- mdPrepend,
- mdSelect: select,
- } = e.currentTarget.dataset;
+ const { mdTag: tag, mdBlock: blockTag, mdPrepend, mdSelect: select } =
+ e.currentTarget?.dataset || e;
insertMarkdownText({
tag,
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
index 58ddaa94d5e..f8ff533f53f 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
@@ -1,7 +1,7 @@
import { KeyMod, KeyCode, Emitter } from 'monaco-editor';
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import syntaxHighlight from '~/syntax_highlight';
@@ -14,7 +14,7 @@ import {
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
EXTENSION_MARKDOWN_PREVIEW_LABEL,
EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL,
- EDITOR_TOOLBAR_RIGHT_GROUP,
+ EDITOR_TOOLBAR_BUTTON_GROUPS,
} from '../constants';
const fetchPreview = (text, previewMarkdownPath) => {
@@ -116,7 +116,7 @@ export class EditorMarkdownPreviewExtension {
label: EXTENSION_MARKDOWN_PREVIEW_LABEL,
icon: 'live-preview',
selected: false,
- group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ group: EDITOR_TOOLBAR_BUTTON_GROUPS.settings,
category: 'primary',
selectedLabel: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL,
onClick: () => instance.togglePreview(),
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 57477a993c5..a5080332b78 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -318,6 +318,10 @@
"cyclonedx": {
"$ref": "#/definitions/string_file_list",
"markdownDescription": "Path to file or list of files with cyclonedx report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportscyclonedx)."
+ },
+ "load_performance": {
+ "$ref": "#/definitions/string_file_list",
+ "markdownDescription": "Path to file or list of files with load performance testing report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportsload_performance)."
}
}
}
@@ -537,7 +541,7 @@
},
"entrypoint": {
"type": "array",
- "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
+ "markdownDescription": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)",
"minItems": 1,
"items": {
"type": "string"
@@ -572,7 +576,7 @@
},
"command": {
"type": "array",
- "description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.",
+ "markdownDescription": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)",
"minItems": 1,
"items": {
"type": "string"
@@ -580,8 +584,12 @@
},
"alias": {
"type": "string",
- "description": "Additional alias that can be used to access the service from the job's container. Read Accessing the services for more information.",
+ "markdownDescription": "Additional alias that can be used to access the service from the job's container. Read Accessing the services for more information. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)",
"minLength": 1
+ },
+ "variables": {
+ "$ref": "#/definitions/jobVariables",
+ "markdownDescription": "Additional environment variables that are passed exclusively to the service. Service variables cannot reference themselves. [Learn More](https://docs.gitlab.com/ee/ci/services/index.html#available-settings-for-services)"
}
},
"required": [
diff --git a/app/assets/javascripts/editor/utils.js b/app/assets/javascripts/editor/utils.js
index df9d3f2b9fb..cea9a8971b7 100644
--- a/app/assets/javascripts/editor/utils.js
+++ b/app/assets/javascripts/editor/utils.js
@@ -16,17 +16,18 @@ export const setupEditorTheme = () => {
monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME);
};
-export const getBlobLanguage = (blobPath) => {
+export const getBlobLanguage = (path) => {
const defaultLanguage = 'plaintext';
- if (!blobPath) {
+ if (!path) {
return defaultLanguage;
}
- const ext = `.${blobPath.split('.').pop()}`;
+ const blobPath = path.split('/').pop();
+ const ext = blobPath.includes('.') ? `.${blobPath.split('.').pop()}` : blobPath;
const language = monacoLanguages
.getLanguages()
- .find((lang) => lang.extensions.indexOf(ext) !== -1);
+ .find((lang) => lang.extensions.indexOf(ext.toLowerCase()) !== -1);
return language ? language.id : defaultLanguage;
};
diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue
index 4f4c32af113..bbac6866636 100644
--- a/app/assets/javascripts/emoji/components/emoji_group.vue
+++ b/app/assets/javascripts/emoji/components/emoji_group.vue
@@ -1,5 +1,10 @@
<script>
+import { compatFunctionalMixin } from '~/lib/utils/vue3compat/compat_functional_mixin';
+
export default {
+ // Temporary mixin for migration from Vue.js 2 to @vue/compat
+ mixins: [compatFunctionalMixin],
+
props: {
emojis: {
type: Array,
diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js
new file mode 100644
index 00000000000..308077f98b1
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/super_sidebar.js
@@ -0,0 +1,5 @@
+import '~/webpack';
+import '~/commons';
+import { initSuperSidebar } from '~/super_sidebar/super_sidebar_bundle';
+
+initSuperSidebar();
diff --git a/app/assets/javascripts/environments/components/canary_update_modal.vue b/app/assets/javascripts/environments/components/canary_update_modal.vue
index cacd868bed0..aff7d34f191 100644
--- a/app/assets/javascripts/environments/components/canary_update_modal.vue
+++ b/app/assets/javascripts/environments/components/canary_update_modal.vue
@@ -42,7 +42,7 @@ export default {
modalId: CANARY_UPDATE_MODAL,
actionPrimary: {
text: s__('CanaryIngress|Change ratio'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
actionCancel: { text: __('Cancel') },
static: true,
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index 8259574f8e3..53a93bbce30 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -135,7 +135,7 @@ export default {
csrf,
cancelProps: {
text: __('Cancel'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }),
};
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index 78e1b8d5cb2..47f38980acc 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
import deleteEnvironmentMutation from '../graphql/mutations/delete_environment.mutation.graphql';
@@ -29,7 +29,7 @@ export default {
primaryProps() {
return {
text: s__('Environments|Delete environment'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index b00a0777a03..01b8208fd55 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -10,7 +10,7 @@ import {
import { __, s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import deploymentDetails from '../graphql/queries/deployment_details.query.graphql';
import DeploymentStatusBadge from './deployment_status_badge.vue';
import Commit from './commit.vue';
diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue
index 901d0f5b34d..b63a6897a39 100644
--- a/app/assets/javascripts/environments/components/edit_environment.vue
+++ b/app/assets/javascripts/environments/components/edit_environment.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import EnvironmentForm from './environment_form.vue';
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index ee5d95ae6f0..62ceb66d803 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -17,7 +17,7 @@ export default {
GlLink,
GlSprintf,
},
- inject: ['protectedEnvironmentSettingsPath'],
+ inject: { protectedEnvironmentSettingsPath: { default: '' } },
props: {
environment: {
required: true,
diff --git a/app/assets/javascripts/environments/components/kubernetes_agent_info.vue b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
new file mode 100644
index 00000000000..c4f6d225444
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
@@ -0,0 +1,101 @@
+<script>
+import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { getAgentLastContact, getAgentStatus } from '~/clusters_list/clusters_util';
+import { TOKEN_STATUS_ACTIVE } from '~/clusters/agents/constants';
+import { AGENT_STATUSES } from '~/clusters_list/constants';
+import { s__ } from '~/locale';
+import getK8sClusterAgentQuery from '../graphql/queries/k8s_cluster_agent.query.graphql';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlLoadingIcon,
+ TimeAgoTooltip,
+ GlAlert,
+ },
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ agentId: {
+ required: true,
+ type: String,
+ },
+ agentProjectPath: {
+ required: true,
+ type: String,
+ },
+ },
+ apollo: {
+ clusterAgent: {
+ query: getK8sClusterAgentQuery,
+ variables() {
+ return {
+ agentName: this.agentName,
+ projectPath: this.agentProjectPath,
+ tokenStatus: TOKEN_STATUS_ACTIVE,
+ };
+ },
+ update: (data) => data?.project?.clusterAgent,
+ error() {
+ this.clusterAgent = null;
+ },
+ },
+ },
+ data() {
+ return {
+ clusterAgent: null,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.clusterAgent.loading;
+ },
+ agentLastContact() {
+ return getAgentLastContact(this.clusterAgent.tokens.nodes);
+ },
+ agentStatus() {
+ return getAgentStatus(this.agentLastContact);
+ },
+ },
+ methods: {},
+ i18n: {
+ loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
+ agentId: s__('ClusterAgents|Agent ID #%{agentId}'),
+ neverConnectedText: s__('ClusterAgents|Never'),
+ },
+ AGENT_STATUSES,
+};
+</script>
+<template>
+ <gl-loading-icon v-if="isLoading" inline />
+ <div v-else-if="clusterAgent" class="gl-text-gray-900">
+ <gl-icon name="kubernetes-agent" class="gl-text-gray-500" />
+ <gl-link :href="clusterAgent.webPath" class="gl-mr-3">
+ <gl-sprintf :message="$options.i18n.agentId"
+ ><template #agentId>{{ agentId }}</template></gl-sprintf
+ >
+ </gl-link>
+ <span class="gl-mr-3" data-testid="agent-status">
+ <gl-icon
+ :name="$options.AGENT_STATUSES[agentStatus].icon"
+ :class="$options.AGENT_STATUSES[agentStatus].class"
+ />
+ {{ $options.AGENT_STATUSES[agentStatus].name }}
+ </span>
+
+ <span data-testid="agent-last-used-date">
+ <gl-icon name="calendar" />
+ <time-ago-tooltip v-if="agentLastContact" :time="agentLastContact" />
+ <span v-else>{{ $options.i18n.neverConnectedText }}</span>
+ </span>
+ </div>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ $options.i18n.loadingError }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue
new file mode 100644
index 00000000000..cfb18cc4f82
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlCollapse, GlButton } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import KubernetesAgentInfo from './kubernetes_agent_info.vue';
+
+export default {
+ components: {
+ GlCollapse,
+ GlButton,
+ KubernetesAgentInfo,
+ },
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ agentId: {
+ required: true,
+ type: String,
+ },
+ agentProjectPath: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ isVisible: false,
+ };
+ },
+ computed: {
+ chevronIcon() {
+ return this.isVisible ? 'chevron-down' : 'chevron-right';
+ },
+ label() {
+ return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand;
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.isVisible = !this.isVisible;
+ },
+ },
+ i18n: {
+ collapse: __('Collapse'),
+ expand: __('Expand'),
+ sectionTitle: s__('Environment|Kubernetes overview'),
+ },
+};
+</script>
+<template>
+ <div class="gl-px-4">
+ <p class="gl-font-weight-bold gl-text-gray-500 gl-display-flex gl-mb-0">
+ <gl-button
+ :icon="chevronIcon"
+ :aria-label="label"
+ category="tertiary"
+ size="small"
+ class="gl-mr-3"
+ @click="toggleCollapse"
+ />{{ $options.i18n.sectionTitle }}
+ </p>
+ <gl-collapse :visible="isVisible" class="gl-md-pl-7 gl-md-pr-5 gl-mt-4">
+ <template v-if="isVisible">
+ <kubernetes-agent-info
+ :agent-name="agentName"
+ :agent-id="agentId"
+ :agent-project-path="agentProjectPath"
+ class="gl-mb-5"
+ /></template>
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/new_environment.vue b/app/assets/javascripts/environments/components/new_environment.vue
index bb4d6ab3428..4b58d133817 100644
--- a/app/assets/javascripts/environments/components/new_environment.vue
+++ b/app/assets/javascripts/environments/components/new_environment.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import EnvironmentForm from './environment_form.vue';
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 73dfd993c5b..2ec6e12b8b3 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -11,6 +11,7 @@ import {
import { __, s__ } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import isLastDeployment from '../graphql/queries/is_last_deployment.query.graphql';
import ExternalUrl from './environment_external_url.vue';
import Actions from './environment_actions.vue';
@@ -22,6 +23,7 @@ import Terminal from './environment_terminal_button.vue';
import Delete from './environment_delete.vue';
import Deployment from './deployment.vue';
import DeployBoardWrapper from './deploy_board_wrapper.vue';
+import KubernetesOverview from './kubernetes_overview.vue';
export default {
components: {
@@ -42,6 +44,7 @@ export default {
Terminal,
TimeAgoTooltip,
Delete,
+ KubernetesOverview,
EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
EnvironmentApproval: () =>
import('ee_component/environments/components/environment_approval.vue'),
@@ -49,6 +52,7 @@ export default {
directives: {
GlTooltip,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['helpPagePath'],
props: {
environment: {
@@ -162,6 +166,18 @@ export default {
rolloutStatus() {
return this.environment?.rolloutStatus;
},
+ agent() {
+ return this.environment?.agent || {};
+ },
+ isKubernetesOverviewAvailable() {
+ return this.glFeatures?.kasUserAccessProject;
+ },
+ hasRequiredAgentData() {
+ return this.agent.project && this.agent.id && this.agent.name;
+ },
+ showKubernetesOverview() {
+ return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData;
+ },
},
methods: {
toggleCollapse() {
@@ -184,6 +200,13 @@ export default {
'gl-md-pl-7',
'gl-bg-gray-10',
],
+ kubernetesOverviewClasses: [
+ 'gl-border-gray-100',
+ 'gl-border-t-solid',
+ 'gl-border-1',
+ 'gl-py-4',
+ 'gl-bg-gray-10',
+ ],
};
</script>
<template>
@@ -340,6 +363,13 @@ export default {
</template>
</gl-sprintf>
</div>
+ <div v-if="showKubernetesOverview" :class="$options.kubernetesOverviewClasses">
+ <kubernetes-overview
+ :agent-project-path="agent.project"
+ :agent-name="agent.name"
+ :agent-id="agent.id"
+ />
+ </div>
<div v-if="rolloutStatus" :class="$options.deployBoardClasses">
<deploy-board-wrapper
:rollout-status="rolloutStatus"
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 162ad598c8c..dc0c5dc0f46 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -33,7 +33,7 @@ export default {
primaryProps() {
return {
text: s__('Environments|Stop environment'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql
new file mode 100644
index 00000000000..999ae74239f
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql
@@ -0,0 +1,19 @@
+query getK8sClusterAgentQuery(
+ $projectPath: ID!
+ $agentName: String!
+ $tokenStatus: AgentTokenStatus!
+) {
+ project(fullPath: $projectPath) {
+ id
+ clusterAgent(name: $agentName) {
+ id
+ webPath
+ tokens(status: $tokenStatus) {
+ nodes {
+ id
+ lastUsedAt
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 5e936ad8c96..f8e94cf3ea9 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -3,7 +3,7 @@
*/
import { isEqual, isFunction, omitBy } from 'lodash';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { getParameterByName } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index b02c3cd2cba..61c0ddef639 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -13,7 +13,7 @@ import {
GlIcon,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { createAlert, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_WARNING } from '~/alert';
import { __, sprintf, n__ } from '~/locale';
import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
index 603f8611005..adbce7750fa 100644
--- a/app/assets/javascripts/error_tracking/store/actions.js
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import service from '../services';
diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js
index 1409399940a..89b9432c377 100644
--- a/app/assets/javascripts/error_tracking/store/details/actions.js
+++ b/app/assets/javascripts/error_tracking/store/details/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import service from '../../services';
diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index f633711add3..84e4463ca21 100644
--- a/app/assets/javascripts/error_tracking/store/list/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import Service from '../../services';
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
index 4d6fe767f3a..368dd438f89 100644
--- a/app/assets/javascripts/error_tracking_settings/store/actions.js
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index 366ee6bb05b..9fb5d9f0943 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -84,11 +84,9 @@ export default {
cancelActionProps() {
return {
text: this.$options.translations.cancelActionLabel,
- attributes: [
- {
- category: 'secondary',
- },
- ],
+ attributes: {
+ category: 'secondary',
+ },
};
},
canRegenerateInstanceId() {
@@ -98,14 +96,12 @@ export default {
return this.canUserRotateToken
? {
text: this.$options.translations.instanceIdRegenerateActionLabel,
- attributes: [
- {
- category: 'secondary',
- disabled: !this.canRegenerateInstanceId,
- loading: this.isRotating,
- variant: 'danger',
- },
- ],
+ attributes: {
+ category: 'secondary',
+ disabled: !this.canRegenerateInstanceId,
+ loading: this.isRotating,
+ variant: 'danger',
+ },
}
: null;
},
diff --git a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
index ce5f7915dbf..57727cb945e 100644
--- a/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
+++ b/app/assets/javascripts/feature_flags/components/environments_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
index 89400bc4742..420c34a88f1 100644
--- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
+++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
@@ -8,7 +8,7 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js
index f697f203cf5..1993ec7abf2 100644
--- a/app/assets/javascripts/feature_flags/constants.js
+++ b/app/assets/javascripts/feature_flags/constants.js
@@ -1,4 +1,3 @@
-import { property } from 'lodash';
import { s__ } from '~/locale';
export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
@@ -9,15 +8,8 @@ export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList';
export const PERCENT_ROLLOUT_GROUP_ID = 'default';
-export const DEFAULT_PERCENT_ROLLOUT = '100';
-
export const ALL_ENVIRONMENTS_NAME = '*';
-export const INTERNAL_ID_PREFIX = 'internal_';
-
-export const fetchPercentageParams = property(['parameters', 'percentage']);
-export const fetchUserIdParams = property(['parameters', 'userIds']);
-
export const NEW_VERSION_FLAG = 'new_version_flag';
export const LEGACY_FLAG = 'legacy_flag';
diff --git a/app/assets/javascripts/feature_flags/store/edit/actions.js b/app/assets/javascripts/feature_flags/store/edit/actions.js
index 97c22781ac5..585bb1be0c4 100644
--- a/app/assets/javascripts/feature_flags/store/edit/actions.js
+++ b/app/assets/javascripts/feature_flags/store/edit/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
index a9542a9667e..e2218c1ba2e 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 397ba879866..50fca995c81 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -72,6 +72,38 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
}
+ const approvedToken = {
+ token: {
+ formattedKey: __('Approved'),
+ key: 'approved',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'approval',
+ tag: __('Yes or No'),
+ lowercaseValueOnSubmit: true,
+ capitalizeTokenValue: true,
+ hideNotEqual: true,
+ },
+ conditions: [
+ {
+ url: 'approved=yes',
+ tokenKey: 'approved',
+ value: __('Yes'),
+ operator: '=',
+ },
+ {
+ url: 'approved=no',
+ tokenKey: 'approved',
+ value: __('No'),
+ operator: '=',
+ },
+ ],
+ };
+
+ IssuableTokenKeys.tokenKeys.splice(3, 0, approvedToken.token);
+ IssuableTokenKeys.conditions.push(...approvedToken.conditions);
+
const approvedBy = {
token: {
formattedKey: TOKEN_TITLE_APPROVED_BY,
@@ -117,8 +149,8 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
],
};
- const tokenPosition = 3;
- IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
+ const tokenPosition = 4;
+ IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, approvedBy.token);
IssuableTokenKeys.tokenKeysWithAlternative.splice(
tokenPosition,
0,
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 1f8baa470d8..892e9130fe8 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -138,6 +138,11 @@ export default class AvailableDropdownMappings {
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
+ approved: {
+ reference: null,
+ gl: DropdownNonUser,
+ element: this.container.querySelector('#js-dropdown-approved'),
+ },
[TOKEN_TYPE_CONFIDENTIAL]: {
reference: null,
gl: DropdownNonUser,
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index 23591fc0667..e1330433362 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import AjaxFilter from './droplab/plugins/ajax_filter';
import DropdownUtils from './dropdown_utils';
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index 8c50c1860ec..bb33c3ad935 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import Ajax from './droplab/plugins/ajax';
import Filter from './droplab/plugins/filter';
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index ab95986dc62..3046ad42e24 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import Ajax from './droplab/plugins/ajax';
import Filter from './droplab/plugins/filter';
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 16c70fdd069..d865354881a 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,7 +1,8 @@
import { last } from 'lodash';
import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_PROJECT } from '~/issues/constants';
import {
ENTER_KEY_CODE,
BACKSPACE_KEY_CODE,
@@ -82,7 +83,7 @@ export default class FilteredSearchManager {
);
const fullPath = this.searchHistoryDropdownElement
? this.searchHistoryDropdownElement.dataset.fullPath
- : 'project';
+ : WORKSPACE_PROJECT;
const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 33fda7533e4..409f6a4a9dc 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -4,7 +4,7 @@ import * as Emoji from '~/emoji';
import FilteredSearchContainer from '~/filtered_search/container';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index ad339155a59..b45c98b46f6 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js
index db2fd3cc256..76e21f09719 100644
--- a/app/assets/javascripts/grafana_integration/store/actions.js
+++ b/app/assets/javascripts/grafana_integration/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -29,7 +29,7 @@ export const updateGrafanaIntegration = ({ state, dispatch }) =>
export const receiveGrafanaIntegrationUpdateSuccess = () => {
/**
* The operations_controller currently handles successful requests
- * by creating a flash banner messsage to notify the user.
+ * by creating an alert banner message to notify the user.
*/
refreshCurrentPage();
};
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 3c4ca4c197e..77fca45c949 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -26,3 +26,4 @@ export const TYPENAME_USER = 'User';
export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner';
export const TYPENAME_VULNERABILITY = 'Vulnerability';
export const TYPENAME_WORK_ITEM = 'WorkItem';
+export const TYPE_USERS_SAVED_REPLY = 'Users::SavedReply';
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 4a5536986bd..22629dfb7d8 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -152,6 +152,7 @@
"WorkItemWidgetLabels",
"WorkItemWidgetMilestone",
"WorkItemWidgetNotes",
+ "WorkItemWidgetNotifications",
"WorkItemWidgetProgress",
"WorkItemWidgetRequirementLegacy",
"WorkItemWidgetStartAndDueDate",
@@ -159,4 +160,4 @@
"WorkItemWidgetTestReports",
"WorkItemWidgetWeight"
]
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
deleted file mode 100644
index 07398867544..00000000000
--- a/app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-#import "../fragments/user.fragment.graphql"
-
-query getUsersByUsernames($usernames: [String!]) {
- users(usernames: $usernames) {
- nodes {
- ...User
- }
- }
-}
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index cc70d832edc..3b64606b141 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { getGroupPathAvailability } from '~/rest_api';
import axios from '~/lib/utils/axios_utils';
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 148bf0a98ee..82eddf5603f 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
@@ -59,7 +59,7 @@ export default {
primaryProps() {
return {
text: __('Leave group'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
index 5f997ecc7ba..1f9fc68a612 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -18,7 +18,7 @@ import { debounce } from 'lodash';
import { s__, __ } from '~/locale';
import { getGroupPathAvailability } from '~/rest_api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { slugify } from '~/lib/utils/text_utility';
import axios from '~/lib/utils/axios_utils';
import { helpPagePath } from '~/helpers/help_page_helper';
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index 7afea815197..a0a775e2916 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -45,10 +45,7 @@ export default {
});
},
openModal() {
- eventHub.$emit('openModal', {
- source: this.$options.openModalSource,
- });
- this.track(this.$options.buttonClickEvent);
+ eventHub.$emit('openModal', { source: this.$options.openModalSource });
},
},
i18n: {
@@ -59,7 +56,6 @@ export default {
button_text: s__('InviteMembersBanner|Invite your colleagues'),
},
displayEvent: 'invite_members_banner_displayed',
- buttonClickEvent: 'invite_members_banner_button_clicked',
openModalSource: 'invite_members_banner',
dismissEvent: 'invite_members_banner_dismissed',
};
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 6fb12cd6270..6f5b03788a8 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -12,7 +12,6 @@ export const ACTIVE_TAB_SHARED = 'shared';
export const ACTIVE_TAB_ARCHIVED = 'archived';
export const GROUPS_LIST_HOLDER_CLASS = '.js-groups-list-holder';
-export const GROUPS_FILTER_FORM_CLASS = '.js-group-filter-form';
export const CONTENT_LIST_CLASS = '.groups-list';
export const COMMON_STR = {
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
index db8e424e166..8bc5f28ebfb 100644
--- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, n__ } from '~/locale';
import { getSubGroups } from '../api/access_dropdown_api';
import { LEVEL_TYPES } from '../constants';
diff --git a/app/assets/javascripts/groups/store/utils.js b/app/assets/javascripts/groups/store/utils.js
index 371b3aa9d52..d4b583483bd 100644
--- a/app/assets/javascripts/groups/store/utils.js
+++ b/app/assets/javascripts/groups/store/utils.js
@@ -1,3 +1,5 @@
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
+
export const getGroupItemMicrodata = ({ type }) => {
const defaultMicrodata = {
itemscope: true,
@@ -9,14 +11,14 @@ export const getGroupItemMicrodata = ({ type }) => {
};
switch (type) {
- case 'group':
+ case WORKSPACE_GROUP:
return {
...defaultMicrodata,
itemtype: 'https://schema.org/Organization',
itemprop: 'subOrganization',
imageItemprop: 'logo',
};
- case 'project':
+ case WORKSPACE_PROJECT:
return {
...defaultMicrodata,
itemtype: 'https://schema.org/SoftwareSourceCode',
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 6c9354b663f..9cb96283689 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -28,7 +28,7 @@ export default function initTodoToggle() {
});
}
-function initStatusTriggers() {
+export function initStatusTriggers() {
const setStatusModalTriggerEl = document.querySelector('.js-set-status-modal-trigger');
if (setStatusModalTriggerEl) {
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index ace0d77c431..c0a06706fc6 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -12,10 +12,20 @@ import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
import { truncate } from '~/lib/utils/text_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { s__, sprintf } from '~/locale';
+import { sprintf } from '~/locale';
import Tracking from '~/tracking';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import {
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ KBD_HELP,
+} from '~/vue_shared/global_search/constants';
+import {
FIRST_DROPDOWN_INDEX,
SEARCH_BOX_INDEX,
SEARCH_INPUT_DESCRIPTION,
@@ -34,26 +44,14 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue';
export default {
name: 'HeaderSearchApp',
i18n: {
- searchGitlab: s__('GlobalSearch|Search GitLab'),
- searchInputDescribeByNoDropdown: s__(
- 'GlobalSearch|Type and press the enter key to submit search.',
- ),
- searchInputDescribeByWithDropdown: s__(
- 'GlobalSearch|Type for new suggestions to appear below.',
- ),
- searchDescribedByDefault: s__(
- 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
- ),
- searchDescribedByUpdated: s__(
- 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
- ),
- searchResultsLoading: s__('GlobalSearch|Search results are loading'),
- searchResultsScope: s__('GlobalSearch|in %{scope}'),
- kbdHelp: sprintf(
- s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
- { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
- false,
- ),
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ KBD_HELP,
},
directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
@@ -113,9 +111,9 @@ export default {
searchInputDescribeBy() {
if (this.isLoggedIn) {
- return this.$options.i18n.searchInputDescribeByWithDropdown;
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
}
- return this.$options.i18n.searchInputDescribeByNoDropdown;
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN;
},
dropdownResultsDescription() {
if (!this.showSearchDropdown) {
@@ -123,14 +121,14 @@ export default {
}
if (this.showDefaultItems) {
- return sprintf(this.$options.i18n.searchDescribedByDefault, {
+ return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
count: this.searchOptions.length,
});
}
return this.loading
- ? this.$options.i18n.searchResultsLoading
- : sprintf(this.$options.i18n.searchDescribedByUpdated, {
+ ? this.$options.i18n.SEARCH_RESULTS_LOADING
+ : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, {
count: this.searchOptions.length,
});
},
@@ -154,7 +152,7 @@ export default {
return this.searchBarItem?.icon;
},
scopeTokenTitle() {
- return sprintf(this.$options.i18n.searchResultsScope, {
+ return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, {
scope: this.infieldHelpContent,
});
},
@@ -230,7 +228,7 @@ export default {
<form
v-outside="closeDropdown"
role="search"
- :aria-label="$options.i18n.searchGitlab"
+ :aria-label="$options.i18n.SEARCH_GITLAB"
class="header-search gl-relative gl-rounded-base gl-w-full"
:class="searchBarClasses"
data-testid="header-search-form"
@@ -243,7 +241,7 @@ export default {
class="gl-z-index-1"
data-qa-selector="search_term_field"
autocomplete="off"
- :placeholder="$options.i18n.searchGitlab"
+ :placeholder="$options.i18n.SEARCH_GITLAB"
:aria-activedescendant="currentFocusedId"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
@focus="openDropdown"
@@ -267,7 +265,7 @@ export default {
:size="16"
/>{{
getTruncatedScope(
- sprintf($options.i18n.searchResultsScope, {
+ sprintf($options.i18n.SEARCH_RESULTS_SCOPE, {
scope: infieldHelpContent,
}),
)
@@ -277,7 +275,7 @@ export default {
v-show="!isFocused"
v-gl-tooltip.bottom.hover.html
class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
- :title="$options.i18n.kbdHelp"
+ :title="$options.i18n.KBD_HELP"
>/</kbd
>
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
index c85fb4f4158..1838214def6 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -9,27 +9,23 @@ import {
} from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { s__ } from '~/locale';
import highlight from '~/lib/utils/highlight';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { truncateNamespace } from '~/lib/utils/text_utility';
-
import {
GROUPS_CATEGORY,
PROJECTS_CATEGORY,
MERGE_REQUEST_CATEGORY,
ISSUES_CATEGORY,
RECENT_EPICS_CATEGORY,
- LARGE_AVATAR_PX,
- SMALL_AVATAR_PX,
-} from '../constants';
+ AUTOCOMPLETE_ERROR_MESSAGE,
+} from '~/vue_shared/global_search/constants';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
export default {
name: 'HeaderSearchAutocompleteItems',
i18n: {
- autocompleteErrorMessage: s__(
- 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
- ),
+ AUTOCOMPLETE_ERROR_MESSAGE,
},
components: {
GlDropdownItem,
@@ -165,7 +161,7 @@ export default {
:dismissible="false"
variant="danger"
>
- {{ $options.i18n.autocompleteErrorMessage }}
+ {{ $options.i18n.AUTOCOMPLETE_ERROR_MESSAGE }}
</gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue
index 04deaba7b0f..f0d398297e9 100644
--- a/app/assets/javascripts/header_search/components/header_search_default_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -1,12 +1,12 @@
<script>
import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
-import { __ } from '~/locale';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
export default {
name: 'HeaderSearchDefaultItems',
i18n: {
- allGitLab: __('All GitLab'),
+ ALL_GITLAB,
},
components: {
GlDropdownSectionHeader,
@@ -26,7 +26,7 @@ export default {
return (
this.searchContext?.project?.name ||
this.searchContext?.group?.name ||
- this.$options.i18n.allGitLab
+ this.$options.i18n.ALL_GITLAB
);
},
},
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
index f5be1bcb786..1ef88492b23 100644
--- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
@@ -3,10 +3,14 @@ import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
+import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants';
import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
export default {
name: 'HeaderSearchScopedItems',
+ i18n: {
+ SCOPED_SEARCH_ITEM_ARIA_LABEL,
+ },
components: {
GlDropdownItem,
GlIcon,
@@ -28,7 +32,7 @@ export default {
return this.currentFocusedOption?.html_id === option.html_id;
},
ariaLabel(option) {
- return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
+ return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, {
search: this.search,
description: option.description || option.icon,
scope: option.scope || '',
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 65e113e5084..b9bb4e573fd 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -1,45 +1,9 @@
-import { s__ } from '~/locale';
-
-export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
-
-export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created");
-
-export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me');
-
-export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer");
-
-export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
-
-export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
-
-export const MSG_IN_GROUP = s__('GlobalSearch|group');
-
-export const MSG_IN_PROJECT = s__('GlobalSearch|project');
-
export const ICON_PROJECT = 'project';
export const ICON_GROUP = 'group';
export const ICON_SUBGROUP = 'subgroup';
-export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
-
-export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
-
-export const USERS_CATEGORY = s__('GlobalSearch|Users');
-
-export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
-
-export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
-
-export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
-
-export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
-
-export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
-
-export const HELP_CATEGORY = s__('GlobalSearch|Help');
-
export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
@@ -64,18 +28,6 @@ export const IS_SEARCHING = 'is-searching';
export const IS_FOCUSED = 'is-focused';
export const IS_NOT_FOCUSED = 'is-not-focused';
-export const DROPDOWN_ORDER = [
- MERGE_REQUEST_CATEGORY,
- ISSUES_CATEGORY,
- RECENT_EPICS_CATEGORY,
- GROUPS_CATEGORY,
- PROJECTS_CATEGORY,
- USERS_CATEGORY,
- IN_THIS_PROJECT_CATEGORY,
- SETTINGS_CATEGORY,
- HELP_CATEGORY,
-];
-
export const FETCH_TYPES = ['generic', 'search'];
export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index 3da9d2cd961..f86463b94d1 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -7,14 +7,16 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
- ICON_GROUP,
- ICON_SUBGROUP,
- ICON_PROJECT,
MSG_IN_ALL_GITLAB,
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
- SEARCH_SHORTCUTS_MIN_CHARACTERS,
DROPDOWN_ORDER,
+} from '~/vue_shared/global_search/constants';
+import {
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ ICON_PROJECT,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '../constants';
export const searchQuery = (state) => {
@@ -36,6 +38,10 @@ export const searchQuery = (state) => {
};
export const scopedIssuesPath = (state) => {
+ if (state.searchContext?.project?.id && !state.searchContext?.project_metadata?.issues_path) {
+ return false;
+ }
+
return (
state.searchContext?.project_metadata?.issues_path ||
state.searchContext?.group_metadata?.issues_path ||
@@ -54,7 +60,7 @@ export const scopedMRPath = (state) => {
export const defaultSearchOptions = (state, getters) => {
const userName = gon.current_username;
- return [
+ const issues = [
{
html_id: 'default-issues-assigned',
title: MSG_ISSUES_ASSIGNED_TO_ME,
@@ -65,6 +71,9 @@ export const defaultSearchOptions = (state, getters) => {
title: MSG_ISSUES_IVE_CREATED,
url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
},
+ ];
+
+ const mergeRequests = [
{
html_id: 'default-mrs-assigned',
title: MSG_MR_ASSIGNED_TO_ME,
@@ -81,6 +90,7 @@ export const defaultSearchOptions = (state, getters) => {
url: `${getters.scopedMRPath}/?author_username=${userName}`,
},
];
+ return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests];
};
export const projectUrl = (state) => {
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 838debf1ceb..6bbad88715f 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -82,7 +82,7 @@ export default {
eventHub.$on('skip-beforeunload', this.handleSkipBeforeUnload);
if (this.themeName)
- document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
+ document.querySelector('.navbar-gitlab')?.classList.add(`theme-${this.themeName}`);
},
destroyed() {
eventHub.$off('skip-beforeunload', this.handleSkipBeforeUnload);
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index dbfaeba9708..4d728bd35d4 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlModal, GlButton } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import { modalTypes } from '../../constants';
import { trimPathComponents, getPathParent } from '../../utils';
@@ -50,13 +50,13 @@ export default {
actionPrimary() {
return {
text: this.buttonLabel,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
};
},
actionCancel() {
return {
text: i18n.cancelButtonText,
- attributes: [{ variant: 'default' }],
+ attributes: { variant: 'default' },
};
},
isCreatingNewFile() {
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index b95f8bb5acb..9e29cd94a20 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -12,7 +12,7 @@ import {
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
import SourceEditor from '~/editor/source_editor';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import ModelManager from '~/ide/lib/common/model_manager';
import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale';
@@ -27,6 +27,8 @@ import { performanceMarkAndMeasure } from '~/performance/utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
+
import {
leftSidebarViews,
viewerTypes,
@@ -66,7 +68,7 @@ export default {
images: {},
rules: {},
globalEditor: null,
- modelManager: new ModelManager(),
+ modelManager: markRaw(new ModelManager()),
isEditorLoading: true,
unwatchCiYaml: null,
SELivepreviewExtension: null,
@@ -212,7 +214,7 @@ export default {
},
mounted() {
if (!this.globalEditor) {
- this.globalEditor = new SourceEditor();
+ this.globalEditor = markRaw(new SourceEditor());
}
this.initEditor();
@@ -284,14 +286,16 @@ export default {
const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
- this.editor = this.globalEditor[method]({
- el: this.$refs.editor,
- blobPath: this.file.path,
- blobGlobalId: this.file.key,
- blobContent: this.content || this.file.content,
- ...instanceOptions,
- ...this.editorOptions,
- });
+ this.editor = markRaw(
+ this.globalEditor[method]({
+ el: this.$refs.editor,
+ blobPath: this.file.path,
+ blobGlobalId: this.file.key,
+ blobContent: this.content || this.file.content,
+ ...instanceOptions,
+ ...this.editorOptions,
+ }),
+ );
this.editor.use([
{
definition: SourceEditorExtension,
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 29c44d2f596..967c83b320f 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -72,6 +72,7 @@ export const initLegacyWebIDE = (el, options = {}) => {
environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
previewMarkdownPath: el.dataset.previewMarkdownPath,
userPreferencesPath: el.dataset.userPreferencesPath,
+ learnGitlabSource: parseBoolean(el.dataset.learnGitlabSource),
});
},
beforeDestroy() {
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index b7445d3ad0a..0106eeae162 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,6 +1,6 @@
import { escape } from 'lodash';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index cd8088bf667..9f1eae03685 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants';
import service from '../../services';
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 7a6a267e7d0..f4fa52b2d4d 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -1,5 +1,5 @@
import { escape } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import { logError } from '~/lib/logger';
import api from '~/api';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index d490b8c5dad..572465f7587 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,6 +1,7 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { addNumericSuffix } from '~/ide/utils';
import { sprintf, __ } from '~/locale';
+import Tracking from '~/tracking';
import { leftSidebarViews } from '../../../constants';
import eventHub from '../../../eventhub';
import { parseCommitError } from '../../../lib/errors';
@@ -162,6 +163,10 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
);
}
+ if (rootState.learnGitlabSource) {
+ Tracking.event(undefined, 'commit', { label: 'web_ide_learn_gitlab_source' });
+ }
+
dispatch('setLastCommitMessage', data);
dispatch('updateCommitMessage', '');
return dispatch('updateFilesAfterCommit', {
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js b/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js
index a7085c7d04c..b5bb2c7bdf8 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js
@@ -2,9 +2,3 @@ export const scopes = {
assigned: 'assigned-to-me',
created: 'created-by-me',
};
-
-export const states = {
- opened: 'opened',
- closed: 'closed',
- merged: 'merged',
-};
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
index 4748ccfa2e6..0a2f778c715 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js
@@ -1,7 +1,7 @@
-import { states } from './constants';
+import { STATUS_OPEN } from '~/issues/constants';
export default () => ({
isLoading: false,
mergeRequests: [],
- state: states.opened,
+ state: STATUS_OPEN,
});
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
index 874cc5094d3..411ff0beaba 100644
--- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import * as terminalService from '../../../../services/terminals';
diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
index 4aa0768d394..463634c946d 100644
--- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
+++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import * as messages from '../messages';
import * as types from '../mutation_types';
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 356bbf28a48..013a0c3ce8f 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -32,4 +32,5 @@ export default () => ({
environmentsGuidanceAlertDetected: false,
previewMarkdownPath: '',
userPreferencesPath: '',
+ learnGitlabSource: false,
});
diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue
index f351a9a392f..5b9e80f9d68 100644
--- a/app/assets/javascripts/import_entities/components/group_dropdown.vue
+++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue
@@ -3,7 +3,7 @@ import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 6dc0b2cec24..ec2ab9d0c3d 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -7,21 +7,22 @@ import { STATUSES } from '../constants';
const STATISTIC_ITEMS = {
diff_note: __('Diff notes'),
issue: __('Issues'),
- issue_attachment: s__('GithubImporter|Issue attachments'),
+ issue_attachment: s__('GithubImporter|Issue links'),
issue_event: __('Issue events'),
label: __('Labels'),
lfs_object: __('LFS objects'),
- merge_request_attachment: s__('GithubImporter|Merge request attachments'),
+ merge_request_attachment: s__('GithubImporter|Merge request links'),
milestone: __('Milestones'),
note: __('Notes'),
- note_attachment: s__('GithubImporter|Note attachments'),
+ note_attachment: s__('GithubImporter|Note links'),
protected_branch: __('Protected branches'),
+ collaborator: s__('GithubImporter|Collaborators'),
pull_request: s__('GithubImporter|Pull requests'),
pull_request_merged_by: s__('GithubImporter|PR mergers'),
pull_request_review: s__('GithubImporter|PR reviews'),
pull_request_review_request: s__('GithubImporter|PR reviews'),
release: __('Releases'),
- release_attachment: s__('GithubImporter|Release attachments'),
+ release_attachment: s__('GithubImporter|Release links'),
};
// support both camel case and snake case versions
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
index ed7c9e7abe9..d91f314a86c 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
@@ -1,16 +1,9 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
export default {
components: {
GlIcon,
- GlButton,
GlDropdown,
GlDropdownItem,
},
@@ -18,10 +11,6 @@ export default {
GlTooltip,
},
props: {
- isProjectsImportEnabled: {
- type: Boolean,
- required: true,
- },
isFinished: {
type: Boolean,
required: true,
@@ -46,7 +35,7 @@ export default {
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
<gl-dropdown
- v-if="isProjectsImportEnabled && (isAvailableForImport || isFinished)"
+ v-if="isAvailableForImport || isFinished"
:text="isFinished ? __('Re-import with projects') : __('Import with projects')"
:disabled="isInvalid"
variant="confirm"
@@ -59,16 +48,6 @@ export default {
isFinished ? __('Re-import without projects') : __('Import without projects')
}}</gl-dropdown-item>
</gl-dropdown>
- <gl-button
- v-else-if="isAvailableForImport || isFinished"
- :disabled="isInvalid"
- variant="confirm"
- category="secondary"
- data-qa-selector="import_group_button"
- @click="$emit('import-group')"
- >
- {{ isFinished ? __('Re-import') : __('Import') }}
- </gl-button>
<gl-icon
v-if="isFinished"
v-gl-tooltip
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index 7d2ddd2176b..2e6e7cddf8f 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -1,7 +1,6 @@
<script>
import {
GlAlert,
- GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
@@ -15,7 +14,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, __, n__, sprintf } from '~/locale';
import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -50,7 +49,6 @@ const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!';
export default {
components: {
GlAlert,
- GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
@@ -106,7 +104,7 @@ export default {
reimportRequests: [],
importTargets: {},
unavailableFeaturesAlertVisible: true,
- helpUrl: helpPagePath('ee/user/group/import', {
+ helpUrl: helpPagePath('user/group/import/index', {
anchor: 'visibility-rules',
}),
};
@@ -165,10 +163,6 @@ export default {
],
computed: {
- isProjectsImportEnabled() {
- return Boolean(this.glFeatures.bulkImportProjects);
- },
-
groups() {
return this.bulkImportSourceGroups?.nodes ?? [];
},
@@ -707,11 +701,11 @@ export default {
</gl-sprintf>
</span>
<gl-dropdown
- v-if="isProjectsImportEnabled"
:text="s__('BulkImport|Import with projects')"
:disabled="!hasSelectedGroups"
variant="confirm"
category="primary"
+ data-testid="import-selected-groups-dropdown"
class="gl-ml-4"
split
@click="importSelectedGroups({ migrateProjects: true })"
@@ -720,15 +714,6 @@ export default {
{{ s__('BulkImport|Import without projects') }}
</gl-dropdown-item>
</gl-dropdown>
- <gl-button
- v-else
- category="primary"
- variant="confirm"
- class="gl-ml-4"
- :disabled="!hasSelectedGroups"
- @click="importSelectedGroups"
- >{{ s__('BulkImport|Import selected') }}</gl-button
- >
<span class="gl-ml-3">
<gl-icon name="information-o" :size="12" class="gl-text-blue-600" />
<gl-sprintf
@@ -804,7 +789,6 @@ export default {
</template>
<template #cell(actions)="{ item: group, index }">
<import-actions-cell
- :is-projects-import-enabled="isProjectsImportEnabled"
:is-finished="group.flags.isFinished"
:is-available-for-import="group.flags.isAvailableForImport"
:is-invalid="group.flags.isInvalid"
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
index 494a845b1f9..8efc6484794 100644
--- a/app/assets/javascripts/import_entities/import_groups/index.js
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -31,6 +31,7 @@ export function mountImportGroupsApp(mountElement) {
return new Vue({
el: mountElement,
+ name: 'ImportGroupsRoot',
apolloProvider,
render(createElement) {
return createElement(ImportTable, {
diff --git a/app/assets/javascripts/import_entities/import_groups/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
index 6ad5e448a40..10496fce11b 100644
--- a/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
+++ b/app/assets/javascripts/import_entities/import_groups/services/status_poller.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 485511510f7..66ffd378426 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -69,10 +69,13 @@ export default function mountImportProjectsTable({
return new Vue({
el: mountElement,
+ name: 'ImportProjectsRoot',
store,
apolloProvider,
render(createElement) {
- return createElement(Component, { props: { ...props, ...extraProps(mountElement.dataset) } });
+ // We are using attrs instead of props so root-level component with inheritAttrs
+ // will be able to pass them down
+ return createElement(Component, { attrs: { ...props, ...extraProps(mountElement.dataset) } });
},
});
}
diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js
index e0db585eb3e..e3c32028b13 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js
@@ -1,6 +1,6 @@
import Visibility from 'visibilityjs';
import _ from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
@@ -141,7 +141,7 @@ const fetchImportFactory = (importPath = isRequired()) => (
})
.catch((e) => {
const serverErrorMessage = e?.response?.data?.errors;
- const flashMessage = serverErrorMessage
+ const alertMessage = serverErrorMessage
? sprintf(
s__('ImportProjects|Importing the project failed: %{reason}'),
{
@@ -152,7 +152,7 @@ const fetchImportFactory = (importPath = isRequired()) => (
: s__('ImportProjects|Importing the project failed');
createAlert({
- message: flashMessage,
+ message: alertMessage,
});
commit(types.RECEIVE_IMPORT_ERROR, repoId);
@@ -179,7 +179,7 @@ export const cancelImportFactory = (cancelImportPath) => ({ state, commit }, { r
})
.catch((e) => {
const serverErrorMessage = e?.response?.data?.errors;
- const flashMessage = serverErrorMessage
+ const alertMessage = serverErrorMessage
? sprintf(
s__('ImportProjects|Cancelling project import failed: %{reason}'),
{
@@ -190,7 +190,7 @@ export const cancelImportFactory = (cancelImportPath) => ({ state, commit }, { r
: s__('ImportProjects|Cancelling project import failed');
createAlert({
- message: flashMessage,
+ message: alertMessage,
});
});
};
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index ee3f30de880..dde40ec2983 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -44,7 +44,6 @@ export const ESCALATION_STATUSES = {
RESOLVED: s__('AlertManagement|Resolved'),
};
-export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
export const TH_ESCALATION_STATUS_TEST_ID = { 'data-testid': 'incident-management-status-sort' };
diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
index d3850114350..195544c746e 100644
--- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js
+++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { ERROR_MSG } from './constants';
diff --git a/app/assets/javascripts/init_deprecated_notes.js b/app/assets/javascripts/init_deprecated_notes.js
index 5f918b0d2f5..8657a1dcb67 100644
--- a/app/assets/javascripts/init_deprecated_notes.js
+++ b/app/assets/javascripts/init_deprecated_notes.js
@@ -2,9 +2,9 @@ import Notes from './deprecated_notes';
export default () => {
const dataEl = document.querySelector('.js-notes-data');
- const { notesUrl, notesIds, now, diffView, enableGFM } = JSON.parse(dataEl.innerHTML);
+ const { notesUrl, now, diffView, enableGFM } = JSON.parse(dataEl.innerHTML);
// Create a singleton so that we don't need to assign
// into the window object, we can just access the current isntance with Notes.instance
- Notes.initialize(notesUrl, notesIds, now, diffView, enableGFM);
+ Notes.initialize(notesUrl, now, diffView, enableGFM);
};
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 5d08520bb5c..6b5a828c009 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -32,6 +32,8 @@ export const integrationFormSections = {
JIRA_TRIGGER: 'jira_trigger',
JIRA_ISSUES: 'jira_issues',
TRIGGER: 'trigger',
+ APPLE_APP_STORE: 'apple_app_store',
+ GOOGLE_PLAY: 'google_play',
};
export const integrationFormSectionComponents = {
@@ -40,6 +42,8 @@ export const integrationFormSectionComponents = {
[integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger',
[integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues',
[integrationFormSections.TRIGGER]: 'IntegrationSectionTrigger',
+ [integrationFormSections.APPLE_APP_STORE]: 'IntegrationSectionAppleAppStore',
+ [integrationFormSections.GOOGLE_PLAY]: 'IntegrationSectionGooglePlay',
};
export const integrationTriggerEvents = {
@@ -90,7 +94,7 @@ export const billingPlanNames = {
[billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'),
};
-export const INTEGRATION_TYPE_SLACK = 'slack';
+const INTEGRATION_TYPE_SLACK = 'slack';
const INTEGRATION_TYPE_SLACK_APPLICATION = 'gitlab_slack_application';
const INTEGRATION_TYPE_MATTERMOST = 'mattermost';
diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
index bc6aa231a93..024f562b71d 100644
--- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue
@@ -11,7 +11,7 @@ export default {
primaryProps() {
return {
text: __('Save'),
- attributes: [{ variant: 'confirm' }, { category: 'primary' }],
+ attributes: { variant: 'confirm', category: 'primary' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index d671ec33bcb..f119668048d 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -59,9 +59,6 @@ export default {
return this.propsSource.editable;
},
hasSections() {
- if (this.hasSlackNotificationsDisabled) {
- return false;
- }
return this.customState.sections.length !== 0;
},
fieldsWithoutSection() {
@@ -70,17 +67,11 @@ export default {
: this.propsSource.fields;
},
hasFieldsWithoutSection() {
- if (this.hasSlackNotificationsDisabled) {
- return false;
- }
return this.fieldsWithoutSection.length;
},
isSlackIntegration() {
return this.propsSource.type === INTEGRATION_FORM_TYPE_SLACK;
},
- hasSlackNotificationsDisabled() {
- return this.isSlackIntegration && !this.glFeatures.integrationSlackAppNotifications;
- },
showHelpHtml() {
if (this.isSlackIntegration) {
return this.helpHtml;
@@ -90,7 +81,6 @@ export default {
shouldUpgradeSlack() {
return (
this.isSlackIntegration &&
- this.glFeatures.integrationSlackAppNotifications &&
this.customState.shouldUpgradeSlack &&
(this.hasFieldsWithoutSection || this.hasSections)
);
diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
index ce39954735a..5335b7b6ee2 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
@@ -28,6 +28,14 @@ export default {
import(
/* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue'
),
+ IntegrationSectionAppleAppStore: () =>
+ import(
+ /* webpackChunkName: 'IntegrationSectionAppleAppStore' */ '~/integrations/edit/components/sections/apple_app_store.vue'
+ ),
+ IntegrationSectionGooglePlay: () =>
+ import(
+ /* webpackChunkName: 'IntegrationSectionGooglePlay' */ '~/integrations/edit/components/sections/google_play.vue'
+ ),
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
index 41cd650f932..e766064a69b 100644
--- a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
+++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue
@@ -9,7 +9,7 @@ export default {
},
primaryProps: {
text: __('Reset'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelProps: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
new file mode 100644
index 00000000000..775600a9a62
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
@@ -0,0 +1,73 @@
+<script>
+import { mapGetters } from 'vuex';
+import { sprintf, s__ } from '~/locale';
+import UploadDropzoneField from '../upload_dropzone_field.vue';
+import Connection from './connection.vue';
+
+export default {
+ name: 'IntegrationSectionAppleAppStore',
+ components: {
+ Connection,
+ UploadDropzoneField,
+ },
+ data() {
+ return {
+ dropzoneAllowList: ['.p8'],
+ };
+ },
+ i18n: {
+ dropzoneDescription: s__(
+ 'AppleAppStore|Drag your Private Key file here or %{linkStart}click to upload%{linkEnd}.',
+ ),
+ dropzoneErrorMessage: s__(
+ 'AppleAppStore|Error: You are trying to upload something other than a Private Key file.',
+ ),
+ dropzoneConfirmMessage: s__('AppleAppStore|Drop your Private Key file to start the upload.'),
+ dropzoneEmptyInputName: s__('AppleAppStore|The Apple App Store Connect Private Key (.p8)'),
+ dropzoneNonEmptyInputName: s__(
+ 'AppleAppStore|Upload a new Apple App Store Connect Private Key (replace %{currentFileName})',
+ ),
+ dropzoneNonEmptyInputHelp: s__('AppleAppStore|Leave empty to use your current Private Key.'),
+ },
+ computed: {
+ ...mapGetters(['propsSource']),
+ dynamicFields() {
+ return this.propsSource.fields.filter(
+ (field) => field.name !== 'app_store_private_key_file_name',
+ );
+ },
+ fileNameField() {
+ return this.propsSource.fields.find(
+ (field) => field.name === 'app_store_private_key_file_name',
+ );
+ },
+ dropzoneLabel() {
+ return this.fileNameField.value
+ ? sprintf(this.$options.i18n.dropzoneNonEmptyInputName, {
+ currentFileName: this.fileNameField.value,
+ })
+ : this.$options.i18n.dropzoneEmptyInputName;
+ },
+ dropzoneHelpText() {
+ return this.fileNameField.value ? this.$options.i18n.dropzoneNonEmptyInputHelp : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <connection :fields="dynamicFields" />
+
+ <upload-dropzone-field
+ name="service[app_store_private_key]"
+ :label="dropzoneLabel"
+ :help-text="dropzoneHelpText"
+ file-input-name="service[app_store_private_key_file_name]"
+ :allow-list="dropzoneAllowList"
+ :description="$options.i18n.dropzoneDescription"
+ :error-message="$options.i18n.dropzoneErrorMessage"
+ :confirm-message="$options.i18n.dropzoneConfirmMessage"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/google_play.vue b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue
new file mode 100644
index 00000000000..3094e24241a
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/google_play.vue
@@ -0,0 +1,75 @@
+<script>
+import { mapGetters } from 'vuex';
+import { sprintf, s__ } from '~/locale';
+import UploadDropzoneField from '../upload_dropzone_field.vue';
+import Connection from './connection.vue';
+
+export default {
+ name: 'IntegrationSectionGooglePlay',
+ components: {
+ Connection,
+ UploadDropzoneField,
+ },
+ data() {
+ return {
+ dropzoneAllowList: ['.json'],
+ };
+ },
+ i18n: {
+ dropzoneDescription: s__(
+ 'GooglePlay|Drag your key file here or %{linkStart}click to upload%{linkEnd}.',
+ ),
+ dropzoneErrorMessage: s__(
+ "GooglePlay|Error: The file you're trying to upload is not a service account key.",
+ ),
+ dropzoneConfirmMessage: s__('GooglePlay|Drag your key file to start the upload.'),
+ dropzoneEmptyInputName: s__('GooglePlay|Service account key (.json)'),
+ dropzoneNonEmptyInputName: s__(
+ 'GooglePlay|Upload a new service account key (replace %{currentFileName})',
+ ),
+ dropzoneNoneEmpyInputHelp: s__(
+ 'GooglePlay|Leave empty to use your current service account key.',
+ ),
+ },
+ computed: {
+ ...mapGetters(['propsSource']),
+ dynamicFields() {
+ return this.propsSource.fields.filter(
+ (field) => field.name !== 'service_account_key_file_name',
+ );
+ },
+ fileNameField() {
+ return this.propsSource.fields.find(
+ (field) => field.name === 'service_account_key_file_name',
+ );
+ },
+ dropzoneLabel() {
+ return this.fileNameField.value
+ ? sprintf(this.$options.i18n.dropzoneNonEmptyInputName, {
+ currentFileName: this.fileNameField.value,
+ })
+ : this.$options.i18n.dropzoneEmptyInputName;
+ },
+ dropzoneHelpText() {
+ return this.fileNameField.value ? this.$options.i18n.dropzoneNoneEmpyInputHelp : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <connection :fields="dynamicFields" />
+
+ <upload-dropzone-field
+ name="service[service_account_key]"
+ :label="dropzoneLabel"
+ :help-text="dropzoneHelpText"
+ file-input-name="service[service_account_key_file_name]"
+ :allow-list="dropzoneAllowList"
+ :description="$options.i18n.dropzoneDescription"
+ :error-message="$options.i18n.dropzoneErrorMessage"
+ :confirm-message="$options.i18n.dropzoneConfirmMessage"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue b/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue
new file mode 100644
index 00000000000..fbed2547c05
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlLink, GlSprintf, GlAlert, GlFormGroup } from '@gitlab/ui';
+import { validateFileFromAllowList } from '~/lib/utils/file_upload';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import { s__ } from '~/locale';
+
+const i18n = Object.freeze({
+ description: s__('Integrations|Drag your file here or %{linkStart}click to upload%{linkEnd}.'),
+ errorMessage: s__(
+ 'Integrations|Error: You are trying to upload something other than an allowed file.',
+ ),
+ confirmMessage: s__('Integrations|Drop your file to start the upload.'),
+});
+
+export default {
+ name: 'UploadDropzoneField',
+ components: {
+ UploadDropzone,
+ GlLink,
+ GlSprintf,
+ GlAlert,
+ GlFormGroup,
+ },
+ i18n,
+ props: {
+ name: {
+ type: String,
+ required: true,
+ default: null,
+ },
+ label: {
+ type: String,
+ required: true,
+ default: null,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ fileInputName: {
+ type: String,
+ required: true,
+ default: null,
+ },
+ allowList: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: i18n.description,
+ },
+ errorMessage: {
+ type: String,
+ required: false,
+ default: i18n.errorMessage,
+ },
+ confirmMessage: {
+ type: String,
+ required: false,
+ default: i18n.confirmMessage,
+ },
+ },
+ data() {
+ return {
+ fileName: null,
+ fileContents: null,
+ uploadError: false,
+ inputDisabled: true,
+ };
+ },
+ computed: {
+ dropzoneDescription() {
+ return this.fileName ?? this.description;
+ },
+ },
+ methods: {
+ clearError() {
+ this.uploadError = false;
+ },
+ onChange(file) {
+ this.clearError();
+ this.inputDisabled = false;
+ this.fileName = file?.name;
+ this.readFile(file);
+ },
+ isValidFileType(file) {
+ return validateFileFromAllowList(file.name, this.allowList);
+ },
+ onError() {
+ this.uploadError = this.errorMessage;
+ },
+ readFile(file) {
+ const reader = new FileReader();
+ reader.readAsText(file);
+ reader.onload = (evt) => {
+ this.fileContents = evt.target.result;
+ };
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label="label" :label-for="name">
+ <upload-dropzone
+ input-field-name="service[dropzone_file_name]"
+ :is-file-valid="isValidFileType"
+ :valid-file-mimetypes="allowList"
+ :should-update-input-on-file-drop="true"
+ :single-file-selection="true"
+ :enable-drag-behavior="false"
+ :drop-to-start-message="confirmMessage"
+ @change="onChange"
+ @error="onError"
+ >
+ <template #upload-text="{ openFileUpload }">
+ <gl-sprintf :message="dropzoneDescription">
+ <template #link="{ content }">
+ <gl-link @click.stop="openFileUpload">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <template #invalid-drag-data-slot>
+ {{ errorMessage }}
+ </template>
+ </upload-dropzone>
+ <gl-alert v-if="uploadError" variant="danger" :dismissible="true" @dismiss="clearError">
+ {{ uploadError }}
+ </gl-alert>
+ <input :name="name" type="hidden" :disabled="inputDisabled" :value="fileContents || false" />
+ <input
+ :name="fileInputName"
+ type="hidden"
+ :disabled="inputDisabled"
+ :value="fileName || false"
+ />
+ <span>{{ helpText }}</span>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue
index 62f0fe4d6bf..439c243f418 100644
--- a/app/assets/javascripts/integrations/index/components/integrations_table.vue
+++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue
@@ -1,9 +1,7 @@
<script>
import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui';
-import { INTEGRATION_TYPE_SLACK } from '~/integrations/constants';
import { sprintf, s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -15,7 +13,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
integrations: {
type: Array,
@@ -58,15 +55,6 @@ export default {
},
];
},
- filteredIntegrations() {
- if (this.glFeatures.integrationSlackAppNotifications) {
- return this.integrations.filter(
- (integration) =>
- !(integration.name === INTEGRATION_TYPE_SLACK && integration.active === false),
- );
- }
- return this.integrations;
- },
},
methods: {
getStatusTooltipTitle(integration) {
@@ -79,7 +67,7 @@ export default {
</script>
<template>
- <gl-table :items="filteredIntegrations" :fields="fields" :empty-text="emptyText" show-empty fixed>
+ <gl-table :items="integrations" :fields="fields" :empty-text="emptyText" show-empty fixed>
<template #cell(active)="{ item }">
<gl-icon
v-if="item.active"
diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue
index e7f5211dc25..0e9781d77fe 100644
--- a/app/assets/javascripts/invite_members/components/group_select.vue
+++ b/app/assets/javascripts/invite_members/components/group_select.vue
@@ -114,6 +114,7 @@ export default {
defaultFetchOptions: {
exclude_internal: true,
active: true,
+ order_by: 'similarity',
},
};
</script>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 607c888b85a..812e39e6392 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -51,6 +51,8 @@ export default {
MembersTokenSelect,
ModalConfetti,
UserLimitNotification,
+ ActiveTrialNotification: () =>
+ import('ee_component/invite_members/components/active_trial_notification.vue'),
},
mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })],
inject: ['newProjectPath'],
@@ -421,7 +423,6 @@ export default {
:new-users-to-invite="newUsersToInvite"
:root-group-id="rootId"
:users-limit-dataset="usersLimitDataset"
- :active-trial-dataset="activeTrialDataset"
:full-path="fullPath"
@close="onClose"
@cancel="onCancel"
@@ -504,6 +505,10 @@ export default {
</div>
</template>
+ <template #active-trial-alert>
+ <active-trial-notification v-if="!isCelebration" :active-trial-dataset="activeTrialDataset" />
+ </template>
+
<template #select="{ exceptionState, inputId }">
<members-token-select
v-model="newUsersToInvite"
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 42645110e48..6d1a3ceba16 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,15 +1,16 @@
<script>
-import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import {
TRIGGER_ELEMENT_BUTTON,
- TRIGGER_ELEMENT_SIDE_NAV,
TRIGGER_DEFAULT_QA_SELECTOR,
+ TRIGGER_ELEMENT_WITH_EMOJI,
+ TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI,
} from '../constants';
export default {
- components: { GlButton, GlLink, GlIcon },
+ components: { GlButton, GlLink, GlDropdownItem },
props: {
displayText: {
type: String,
@@ -40,16 +41,6 @@ export default {
required: false,
default: 'button',
},
- event: {
- type: String,
- required: false,
- default: '',
- },
- label: {
- type: String,
- required: false,
- default: '',
- },
qaSelector: {
type: String,
required: false,
@@ -58,21 +49,11 @@ export default {
},
computed: {
componentAttributes() {
- const baseAttributes = {
+ return {
class: this.classes,
'data-qa-selector': this.qaSelector,
'data-test-id': 'invite-members-button',
};
-
- if (this.event && this.label) {
- return {
- ...baseAttributes,
- 'data-track-action': this.event,
- 'data-track-label': this.label,
- };
- }
-
- return baseAttributes;
},
},
methods: {
@@ -84,7 +65,8 @@ export default {
},
},
TRIGGER_ELEMENT_BUTTON,
- TRIGGER_ELEMENT_SIDE_NAV,
+ TRIGGER_ELEMENT_WITH_EMOJI,
+ TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI,
};
</script>
@@ -99,16 +81,22 @@ export default {
{{ displayText }}
</gl-button>
<gl-link
- v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)"
+ v-else-if="checkTrigger($options.TRIGGER_ELEMENT_WITH_EMOJI)"
v-bind="componentAttributes"
- data-is-link="true"
@click="openModal"
>
- <span class="nav-icon-container">
- <gl-icon :name="icon" />
- </span>
- <span class="nav-item-name"> {{ displayText }} </span>
+ {{ displayText }}
+ <gl-emoji class="gl-vertical-align-baseline gl-reset-font-size gl-mr-1" :data-name="icon" />
</gl-link>
+ <gl-dropdown-item
+ v-else-if="checkTrigger($options.TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI)"
+ v-bind="componentAttributes"
+ button-class="top-nav-menu-item"
+ @click="openModal"
+ >
+ {{ displayText }}
+ <gl-emoji class="gl-vertical-align-baseline gl-reset-font-size gl-mr-1" :data-name="icon" />
+ </gl-dropdown-item>
<gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
{{ displayText }}
</gl-link>
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 1e3b6093f0b..20dc32b3c9b 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -1,5 +1,14 @@
<script>
-import { GlFormGroup, GlFormSelect, GlModal, GlDatepicker, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlFormGroup,
+ GlFormSelect,
+ GlModal,
+ GlDatepicker,
+ GlLink,
+ GlSprintf,
+ GlButton,
+} from '@gitlab/ui';
+
import Tracking from '~/tracking';
import { sprintf } from '~/locale';
import ContentTransition from '~/vue_shared/components/content_transition.vue';
@@ -33,6 +42,7 @@ export default {
GlLink,
GlModal,
GlSprintf,
+ GlButton,
ContentTransition,
},
mixins: [Tracking.mixin()],
@@ -246,13 +256,10 @@ export default {
data-qa-selector="invite_members_modal_content"
data-testid="invite-modal"
size="sm"
+ dialog-class="gl-mx-5"
:title="modalTitle"
:header-close-label="$options.HEADER_CLOSE_LABEL"
- :action-primary="actionPrimary"
- :action-cancel="actionCancel"
@shown="onShowModal"
- @primary="onSubmit"
- @cancel="onCancel"
@close="onClose"
@hidden="onReset"
>
@@ -330,5 +337,29 @@ export default {
<slot :name="key"></slot>
</template>
</content-transition>
+
+ <template #modal-footer>
+ <div
+ class="gl-m-0 gl-xs-w-full gl-display-flex gl-xs-flex-direction-column! gl-flex-direction-row-reverse"
+ >
+ <gl-button
+ class="gl-xs-w-full gl-xs-mb-3! gl-sm-ml-3!"
+ data-testid="invite-modal-submit"
+ v-bind="actionPrimary.attributes"
+ @click="onSubmit"
+ >
+ {{ actionPrimary.text }}
+ </gl-button>
+
+ <gl-button
+ class="gl-xs-w-full"
+ data-testid="invite-modal-cancel"
+ v-bind="actionCancel.attributes"
+ @click="onCancel"
+ >
+ {{ actionCancel.text }}
+ </gl-button>
+ </div>
+ </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index ac0b708c55e..86badd16d6c 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -19,7 +19,9 @@ export const GROUP_FILTERS = {
export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
export const TRIGGER_ELEMENT_BUTTON = 'button';
-export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav';
+export const TOP_NAV_INVITE_MEMBERS_COMPONENT = 'invite_members';
+export const TRIGGER_ELEMENT_WITH_EMOJI = 'text-emoji';
+export const TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI = 'dropdown-text-emoji';
export const INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal';
export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button';
export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members');
@@ -76,7 +78,6 @@ export const READ_MORE_TEXT = s__(
export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage members');
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
-export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
export const MEMBER_ERROR_LIST_TEXT = s__(
'InviteMembersModal|Review the invite errors and try again:',
diff --git a/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js
index 4d3a7951265..e556582742b 100644
--- a/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js
+++ b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import { TOAST_MESSAGE_LOCALSTORAGE_KEY, TOAST_MESSAGE_SUCCESSFUL } from '../constants';
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 736da92fa9f..c1de507cd80 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, n__ } from '~/locale';
-import { ISSUABLE_TYPE } from '../constants';
export default {
actionCancel: {
@@ -19,7 +19,7 @@ export default {
},
inject: {
issuableType: {
- default: ISSUABLE_TYPE.issues,
+ default: TYPE_ISSUE,
},
email: {
default: '',
@@ -47,14 +47,17 @@ export default {
href: this.exportCsvPath,
variant: 'confirm',
'data-method': 'post',
- 'data-qa-selector': `export_${this.issuableType}_button`,
+ 'data-qa-selector': `export_issues_button`,
'data-track-action': 'click_button',
- 'data-track-label': `export_${this.issuableType}_csv`,
+ 'data-track-label': this.dataTrackLabel,
},
};
},
isIssue() {
- return this.issuableType === ISSUABLE_TYPE.issues;
+ return this.issuableType === TYPE_ISSUE;
+ },
+ dataTrackLabel() {
+ return this.isIssue ? 'export_issues_csv' : 'export_merge-requests_csv';
},
exportText() {
return this.isIssue ? __('Export issues') : __('Export merge requests');
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
index dadb1419649..2cc01c302ec 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -7,8 +7,8 @@ import {
GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
-import { ISSUABLE_TYPE } from '../constants';
import CsvExportModal from './csv_export_modal.vue';
import CsvImportModal from './csv_import_modal.vue';
@@ -34,7 +34,7 @@ export default {
},
inject: {
issuableType: {
- default: ISSUABLE_TYPE.issues,
+ default: TYPE_ISSUE,
},
showExportButton: {
default: false,
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 0e58f3793bc..03f10e9e812 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -2,7 +2,7 @@
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
-import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
@@ -13,7 +13,7 @@ const NoteableTypeText = {
export default {
TYPE_ISSUE,
- WorkspaceType,
+ WORKSPACE_PROJECT,
components: {
GlIcon,
ConfidentialityBadge,
@@ -32,7 +32,7 @@ export default {
return this.getNoteableData.confidential;
},
isMergeRequest() {
- return this.getNoteableData.targetType === 'merge_request';
+ return this.getNoteableData.targetType === TYPE_MERGE_REQUEST;
},
warningIconsMeta() {
return [
@@ -60,7 +60,7 @@ export default {
<confidentiality-badge
v-if="isConfidential"
data-testid="confidential"
- :workspace-type="$options.WorkspaceType.project"
+ :workspace-type="$options.WORKSPACE_PROJECT"
:issuable-type="$options.TYPE_ISSUE"
/>
<template v-for="meta in warningIconsMeta">
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index 608c1deac64..c4b9bdb150b 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -101,26 +101,22 @@ export default {
:class="{
'issuable-info-container': !canReorder,
'card-body': canReorder,
- 'gl-pr-2': canRemove,
}"
- class="item-body d-flex align-items-center gl-py-3 gl-px-5"
+ class="item-body gl-display-flex gl-align-items-center gl-gap-3 gl-mx-n2"
>
<div
- class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7"
+ class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 gl-gap-2 gl-px-3 gl-py-2 py-xl-0 flex-xl-nowrap gl-min-h-7"
>
<!-- Title area: Status icon (XL) and title -->
- <div class="item-title d-flex align-items-xl-center mb-xl-0 gl-min-w-0">
- <div ref="iconElementXL">
- <gl-icon
- v-if="hasState"
- ref="iconElementXL"
- class="gl-mr-3"
- :class="iconClasses"
- :name="iconName"
- :title="stateTitle"
- :aria-label="state"
- />
- </div>
+ <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
+ <gl-icon
+ v-if="hasState"
+ ref="iconElementXL"
+ :class="iconClasses"
+ :name="iconName"
+ :title="stateTitle"
+ :aria-label="state"
+ />
<gl-tooltip :target="() => $refs.iconElementXL">
<span v-safe-html="stateTitle"></span>
</gl-tooltip>
@@ -129,42 +125,46 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
- class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
+ class="confidential-icon"
:aria-label="__('Confidential')"
/>
- <gl-link
- :href="computedPath"
- class="sortable-link gl-font-weight-normal"
- @click="handleTitleClick"
- >
+ <gl-link :href="computedPath" class="sortable-link" @click="handleTitleClick">
{{ title }}
</gl-link>
</div>
<!-- Info area: meta, path, and assignees -->
- <div class="item-info-area d-flex flex-xl-grow-1 flex-shrink-0">
+ <div
+ class="item-info-area gl-display-flex gl-flex-grow-1 gl-flex-shrink-0 gl-gap-3 gl-ml-6 ml-xl-0"
+ >
<!-- Meta area: path and attributes -->
<!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). -->
<!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 -->
<div
- class="item-meta d-flex flex-wrap-reverse justify-content-start justify-content-md-between"
+ class="item-meta gl-display-flex gl-md-justify-content-space-between gl-gap-3 gl-flex-wrap-wrap-reverse"
>
<!-- Path area: status icon (<XL), path, issue # -->
<div
- class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
+ class="item-path-area item-path-id gl-display-flex gl-align-items-center gl-flex-wrap gl-gap-3"
>
<gl-tooltip :target="() => $refs.iconElement">
<span v-safe-html="stateTitle"></span>
</gl-tooltip>
- <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
- itemPath
- }}</span>
+ <span
+ v-if="itemPath"
+ v-gl-tooltip
+ :title="itemPath"
+ class="path-id-text d-inline-block"
+ >{{ itemPath }}</span
+ >
<span>{{ pathIdSeparator }}{{ itemId }}</span>
</div>
<!-- Attributes area: CI, epic count, weight, milestone -->
<!-- They have a different order on large screen sizes -->
- <div class="item-attributes-area d-flex align-items-center mt-2 mt-xl-0">
+ <div
+ class="item-attributes-area gl-display-flex gl-align-items-center gl-flex-wrap gl-gap-3"
+ >
<span v-if="hasPipeline" class="mr-ci-status order-md-last">
<a :href="pipelineStatus.details_path">
<ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
@@ -174,7 +174,7 @@ export default {
<issue-milestone
v-if="hasMilestone"
:milestone="milestone"
- class="d-flex align-items-center item-milestone order-md-first ml-md-0"
+ class="item-milestone gl-font-sm gl-display-flex gl-align-items-center order-md-first"
/>
<!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue -->
@@ -198,24 +198,17 @@ export default {
<issue-assignees
v-if="hasAssignees"
:assignees="assignees"
- class="item-assignees align-items-center align-self-end flex-shrink-0 order-md-2 d-none d-md-flex"
+ class="item-assignees gl-display-flex gl-align-items-center gl-align-self-end gl-flex-shrink-0 order-md-2"
/>
</div>
</div>
-
- <!-- Assignees. On small layouts, these are put here, at the end of the card. -->
- <issue-assignees
- v-if="assignees.length !== 0"
- :assignees="assignees"
- class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none gl-ml-3"
- />
</div>
</div>
<span
v-if="isLocked"
v-gl-tooltip
- class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed"
+ class="gl-display-inline-block gl-cursor-not-allowed"
:title="lockedMessage"
data-testid="lockIcon"
>
@@ -226,8 +219,9 @@ export default {
v-gl-tooltip
icon="close"
category="tertiary"
+ size="small"
:disabled="removeDisabled"
- class="js-issue-item-remove-button gl-ml-3"
+ class="js-issue-item-remove-button"
data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 0c75e44443d..9ffcf14c943 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -4,8 +4,7 @@ import Vue from 'vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
import { __ } from '~/locale';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
-import { IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { STATUS_CLOSED, STATUS_OPEN, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
export const badgeState = Vue.observable({
state: '',
@@ -76,15 +75,15 @@ export default {
return [
CLASSES[this.state],
{
- 'gl-vertical-align-bottom': this.issuableType === IssuableType.MergeRequest,
+ 'gl-vertical-align-bottom': this.issuableType === TYPE_MERGE_REQUEST,
},
];
},
badgeVariant() {
- if (this.state === IssuableStates.Opened) {
+ if (this.state === STATUS_OPEN) {
return 'success';
- } else if (this.state === IssuableStates.Closed) {
- return this.issuableType === IssuableType.MergeRequest ? 'danger' : 'info';
+ } else if (this.state === STATUS_CLOSED) {
+ return this.issuableType === TYPE_MERGE_REQUEST ? 'danger' : 'info';
}
return 'info';
},
diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js
index 5327f251fda..88fc6859acd 100644
--- a/app/assets/javascripts/issuable/constants.js
+++ b/app/assets/javascripts/issuable/constants.js
@@ -1,11 +1 @@
export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change';
-
-export const ISSUABLE_TYPE = {
- issues: 'issues',
- mergeRequests: 'merge-requests',
-};
-
-export const ISSUABLE_INDEX = {
- ISSUE: 'issue_',
- MERGE_REQUEST: 'merge_request_',
-};
diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
index 201782a201a..e5a2388580b 100644
--- a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { difference, intersection, union } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js
index ad8bbf04d6f..76fd4cccf2e 100644
--- a/app/assets/javascripts/issuable/issuable_label_selector.js
+++ b/app/assets/javascripts/issuable/issuable_label_selector.js
@@ -1,11 +1,8 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import {
- DropdownVariant,
- LabelType,
-} from '~/sidebar/components/labels/labels_select_widget/constants';
-import { WorkspaceType } from '~/issues/constants';
+import { DropdownVariant } from '~/sidebar/components/labels/labels_select_widget/constants';
+import { WORKSPACE_PROJECT } from '~/issues/constants';
import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue';
Vue.use(VueApollo);
@@ -43,11 +40,11 @@ export default () => {
fullPath,
initialLabels: JSON.parse(initialLabels),
issuableType,
- labelType: LabelType.project,
+ labelType: WORKSPACE_PROJECT,
labelsFilterBasePath,
labelsManagePath,
variant: DropdownVariant.Embedded,
- workspaceType: WorkspaceType.project,
+ workspaceType: WORKSPACE_PROJECT,
},
render(createElement) {
return createElement(IssuableLabelSelector);
diff --git a/app/assets/javascripts/issuable/popover/components/mr_popover.vue b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
index 92994809362..af93430963e 100644
--- a/app/assets/javascripts/issuable/popover/components/mr_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
@@ -1,14 +1,12 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlBadge, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
+import { __ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { mrStates, humanMRStates } from '../constants';
import query from '../queries/merge_request.query.graphql';
export default {
- // name: 'MRPopover' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
- name: 'MRPopover', // eslint-disable-line @gitlab/require-i18n-strings
components: {
GlBadge,
GlPopover,
@@ -48,9 +46,9 @@ export default {
},
badgeVariant() {
switch (this.mergeRequest.state) {
- case mrStates.merged:
+ case STATUS_MERGED:
return 'info';
- case mrStates.closed:
+ case STATUS_CLOSED:
return 'danger';
default:
return 'success';
@@ -58,12 +56,12 @@ export default {
},
stateHumanName() {
switch (this.mergeRequest.state) {
- case mrStates.merged:
- return humanMRStates.merged;
- case mrStates.closed:
- return humanMRStates.closed;
+ case STATUS_MERGED:
+ return __('Merged');
+ case STATUS_CLOSED:
+ return __('Closed');
default:
- return humanMRStates.open;
+ return __('Open');
}
},
title() {
@@ -101,7 +99,9 @@ export default {
<gl-badge class="gl-mr-3" :variant="badgeVariant">
{{ stateHumanName }}
</gl-badge>
- <span class="gl-text-secondary">Opened <time v-text="formattedTime"></time></span>
+ <span class="gl-text-secondary">
+ {{ __('Opened') }} <time v-text="formattedTime"></time
+ ></span>
</div>
<ci-icon v-if="detailedStatus" :status="detailedStatus" />
</div>
diff --git a/app/assets/javascripts/issuable/popover/constants.js b/app/assets/javascripts/issuable/popover/constants.js
deleted file mode 100644
index 352bc635293..00000000000
--- a/app/assets/javascripts/issuable/popover/constants.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { __ } from '~/locale';
-
-export const mrStates = {
- merged: 'merged',
- closed: 'closed',
- open: 'open',
-};
-
-export const humanMRStates = {
- merged: __('Merged'),
- closed: __('Closed'),
- open: __('Open'),
-};
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index ba05dd731f7..b7d885ed8a7 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -1,36 +1,25 @@
import { __ } from '~/locale';
+export const STATUS_ALL = 'all';
export const STATUS_CLOSED = 'closed';
+export const STATUS_MERGED = 'merged';
export const STATUS_OPEN = 'opened';
export const STATUS_REOPENED = 'reopened';
export const TITLE_LENGTH_MAX = 255;
+export const TYPE_ALERT = 'alert';
export const TYPE_EPIC = 'epic';
+export const TYPE_INCIDENT = 'incident';
export const TYPE_ISSUE = 'issue';
+export const TYPE_MERGE_REQUEST = 'merge_request';
+export const TYPE_TEST_CASE = 'test_case';
+
+export const WORKSPACE_GROUP = 'group';
+export const WORKSPACE_PROJECT = 'project';
export const IssuableStatusText = {
[STATUS_CLOSED]: __('Closed'),
[STATUS_OPEN]: __('Open'),
[STATUS_REOPENED]: __('Open'),
};
-
-// Deprecated - use individual constants instead like `TYPE_ISSUE` above
-export const IssuableType = {
- Issue: 'issue',
- Epic: 'epic',
- MergeRequest: 'merge_request',
- Alert: 'alert',
- TestCase: 'test_case',
-};
-
-export const IssueType = {
- Issue: 'issue',
- Incident: 'incident',
- TestCase: 'test_case',
-};
-
-export const WorkspaceType = {
- project: 'project',
- group: 'group',
-};
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index 977a505437d..c821c18bcb9 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -7,10 +7,14 @@ import {
import confidentialMergeRequestState from '~/confidential_merge_request/state';
import DropLab from '~/filtered_search/droplab/drop_lab_deprecated';
import ISetter from '~/filtered_search/droplab/plugins/input_setter';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import { mergeUrlParams } from '~/lib/utils/url_utility';
+import {
+ findInvalidBranchNameCharacters,
+ humanizeBranchValidationErrors,
+} from '~/lib/utils/text_utility';
import api from '~/api';
// Todo: Remove this when fixing issue in input_setter plugin
@@ -19,6 +23,12 @@ const InputSetter = { ...ISetter };
const CREATE_MERGE_REQUEST = 'create-mr';
const CREATE_BRANCH = 'create-branch';
+const VALIDATION_TYPE_BRANCH_UNAVAILABLE = 'branch_unavailable';
+const VALIDATION_TYPE_INVALID_CHARS = 'invalid_chars';
+
+const INPUT_TARGET_BRANCH = 'branch';
+const INPUT_TARGET_REF = 'ref';
+
function createEndpoint(projectPath, endpoint) {
if (canCreateConfidentialMergeRequest()) {
return endpoint.replace(
@@ -30,6 +40,23 @@ function createEndpoint(projectPath, endpoint) {
return endpoint;
}
+function getValidationError(target, inputValue, validationType) {
+ const invalidChars = findInvalidBranchNameCharacters(inputValue.value);
+ let text;
+
+ if (invalidChars && validationType === VALIDATION_TYPE_INVALID_CHARS) {
+ text = humanizeBranchValidationErrors(invalidChars);
+ }
+
+ if (validationType === VALIDATION_TYPE_BRANCH_UNAVAILABLE) {
+ text =
+ target === INPUT_TARGET_BRANCH
+ ? __('Branch is already taken')
+ : __('Source is not available');
+ }
+
+ return text;
+}
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
@@ -124,18 +151,19 @@ export default class CreateMergeRequestDropdown {
.then(({ data }) => {
this.setUnavailableButtonState(false);
- if (data.can_create_branch) {
- this.available();
- this.enable();
- this.updateBranchName(data.suggested_branch_name);
-
- if (!this.droplabInitialized) {
- this.droplabInitialized = true;
- this.initDroplab();
- this.bindEvents();
- }
- } else {
+ if (!data.can_create_branch) {
this.hide();
+ return;
+ }
+
+ this.available();
+ this.enable();
+ this.updateBranchName(data.suggested_branch_name);
+
+ if (!this.droplabInitialized) {
+ this.droplabInitialized = true;
+ this.initDroplab();
+ this.bindEvents();
}
})
.catch(() => {
@@ -274,7 +302,7 @@ export default class CreateMergeRequestDropdown {
const tags = data[Object.keys(data)[1]];
let result;
- if (target === 'branch') {
+ if (target === INPUT_TARGET_BRANCH) {
result = CreateMergeRequestDropdown.findByValue(branches, ref);
} else {
result =
@@ -354,10 +382,10 @@ export default class CreateMergeRequestDropdown {
}
if (event.target === this.branchInput) {
- target = 'branch';
+ target = INPUT_TARGET_BRANCH;
({ value } = this.branchInput);
} else if (event.target === this.refInput) {
- target = 'ref';
+ target = INPUT_TARGET_REF;
if (event.target === document.activeElement) {
value =
event.target.value.slice(0, event.target.selectionStart) +
@@ -382,7 +410,7 @@ export default class CreateMergeRequestDropdown {
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
- if (target === 'branch') {
+ if (target === INPUT_TARGET_BRANCH) {
this.branchIsValid = true;
} else {
this.refIsValid = true;
@@ -473,7 +501,7 @@ export default class CreateMergeRequestDropdown {
showAvailableMessage(target) {
const { input, message } = this.getTargetData(target);
- const text = target === 'branch' ? __('Branch name') : __('Source');
+ const text = target === INPUT_TARGET_BRANCH ? __('Branch name') : __('Source');
this.removeMessage(target);
input.classList.add('gl-field-success-outline');
@@ -484,7 +512,7 @@ export default class CreateMergeRequestDropdown {
showCheckingMessage(target) {
const { message } = this.getTargetData(target);
- const text = target === 'branch' ? __('branch name') : __('source');
+ const text = target === INPUT_TARGET_BRANCH ? __('branch name') : __('source');
this.removeMessage(target);
message.classList.add('gl-text-gray-600');
@@ -492,10 +520,9 @@ export default class CreateMergeRequestDropdown {
message.style.display = 'inline-block';
}
- showNotAvailableMessage(target) {
+ showNotAvailableMessage(target, validationType = VALIDATION_TYPE_BRANCH_UNAVAILABLE) {
const { input, message } = this.getTargetData(target);
- const text =
- target === 'branch' ? __('Branch is already taken') : __('Source is not available');
+ const text = getValidationError(target, input, validationType);
this.removeMessage(target);
input.classList.add('gl-field-error-outline');
@@ -511,35 +538,35 @@ export default class CreateMergeRequestDropdown {
updateBranchName(suggestedBranchName) {
this.branchInput.value = suggestedBranchName;
- this.updateCreatePaths('branch', suggestedBranchName);
+ this.updateInputState(INPUT_TARGET_BRANCH, suggestedBranchName, '');
+ this.updateCreatePaths(INPUT_TARGET_BRANCH, suggestedBranchName);
}
updateInputState(target, ref, result) {
// target - 'branch' or 'ref' - which the input field we are searching a ref for.
// ref - string - what a user typed.
// result - string - what has been found on backend.
+ if (target === INPUT_TARGET_BRANCH) this.updateTargetBranchInput(ref, result);
+ if (target === INPUT_TARGET_REF) this.updateRefInput(ref, result);
+
+ if (this.inputsAreValid()) {
+ this.enable();
+ } else {
+ this.disableCreateAction();
+ }
+ }
- // If a found branch equals exact the same text a user typed,
- // that means a new branch cannot be created as it already exists.
+ updateRefInput(ref, result) {
+ this.refInput.dataset.value = ref;
if (ref === result) {
- if (target === 'branch') {
- this.branchIsValid = false;
- this.showNotAvailableMessage('branch');
- } else {
- this.refIsValid = true;
- this.refInput.dataset.value = ref;
- this.showAvailableMessage('ref');
- this.updateCreatePaths(target, ref);
- }
- } else if (target === 'branch') {
- this.branchIsValid = true;
- this.showAvailableMessage('branch');
- this.updateCreatePaths(target, ref);
+ this.refIsValid = true;
+ this.showAvailableMessage(INPUT_TARGET_REF);
+ this.updateCreatePaths(INPUT_TARGET_REF, ref);
} else {
this.refIsValid = false;
this.refInput.dataset.value = ref;
this.disableCreateAction();
- this.showNotAvailableMessage('ref');
+ this.showNotAvailableMessage(INPUT_TARGET_REF);
// Show ref hint.
if (result) {
@@ -547,11 +574,24 @@ export default class CreateMergeRequestDropdown {
this.refInput.setSelectionRange(ref.length, result.length);
}
}
+ }
- if (this.inputsAreValid()) {
- this.enable();
+ updateTargetBranchInput(ref, result) {
+ const branchNameErrors = findInvalidBranchNameCharacters(ref);
+ const isInvalidString = branchNameErrors.length;
+ if (ref !== result && !isInvalidString) {
+ this.branchIsValid = true;
+ // If a found branch equals exact the same text a user typed,
+ // Or user typed input contains invalid chars,
+ // that means a new branch cannot be created as it already exists.
+ this.showAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_BRANCH_UNAVAILABLE);
+ this.updateCreatePaths(INPUT_TARGET_BRANCH, ref);
+ } else if (isInvalidString) {
+ this.branchIsValid = false;
+ this.showNotAvailableMessage(INPUT_TARGET_BRANCH, VALIDATION_TYPE_INVALID_CHARS);
} else {
- this.disableCreateAction();
+ this.branchIsValid = false;
+ this.showNotAvailableMessage(INPUT_TARGET_BRANCH);
}
}
@@ -569,6 +609,7 @@ export default class CreateMergeRequestDropdown {
pathReplacement,
);
+ this.wrapperEl.dataset.createBranchPath = this.createBranchPath;
this.wrapperEl.dataset.createMrPath = this.createMrPath;
}
}
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
index a4a2feba716..2546bface58 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -4,12 +4,11 @@ import * as Sentry from '@sentry/browser';
import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql';
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
-import { STATUS_CLOSED } from '~/issues/constants';
+import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import {
CREATED_DESC,
defaultTypeTokenOptions,
i18n,
- PAGE_SIZE,
PARAM_STATE,
UPDATED_DESC,
urlSortParams,
@@ -49,7 +48,7 @@ import {
TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { DEFAULT_PAGE_SIZE, IssuableListTabs } from '~/vue_shared/issuable/list/constants';
import getIssuesCountsQuery from '../queries/get_issues_counts.query.graphql';
import { AutocompleteCache } from '../utils';
@@ -93,7 +92,7 @@ export default {
data() {
const state = getParameterByName(PARAM_STATE);
- const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
+ const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC;
const dashboardSortKey = getSortKey(this.initialSort);
const graphQLSortKey =
isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase();
@@ -110,7 +109,7 @@ export default {
pageInfo: {},
pageParams: getInitialPageParams(),
sortKey,
- state: state || IssuableStates.Opened,
+ state: state || STATUS_OPEN,
};
},
apollo: {
@@ -132,7 +131,6 @@ export default {
skip() {
return !this.hasSearch;
},
- debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
@@ -149,7 +147,6 @@ export default {
skip() {
return !this.hasSearch;
},
- debounce: 200,
context: {
isSingleRequest: true,
},
@@ -314,9 +311,9 @@ export default {
tabCounts() {
const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
return {
- [IssuableStates.Opened]: openedIssues?.count,
- [IssuableStates.Closed]: closedIssues?.count,
- [IssuableStates.All]: allIssues?.count,
+ [STATUS_OPEN]: openedIssues?.count,
+ [STATUS_CLOSED]: closedIssues?.count,
+ [STATUS_ALL]: allIssues?.count,
};
},
urlFilterParams() {
@@ -388,14 +385,14 @@ export default {
handleNextPage() {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
- firstPageSize: PAGE_SIZE,
+ firstPageSize: DEFAULT_PAGE_SIZE,
};
scrollUp();
},
handlePreviousPage() {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
- lastPageSize: PAGE_SIZE,
+ lastPageSize: DEFAULT_PAGE_SIZE,
};
scrollUp();
},
@@ -461,12 +458,20 @@ export default {
@sort="handleSort"
>
<template #nav-actions>
- <gl-button :href="rssPath" icon="rss">
- {{ $options.i18n.rssLabel }}
- </gl-button>
- <gl-button :href="calendarPath" icon="calendar">
- {{ $options.i18n.calendarLabel }}
- </gl-button>
+ <gl-button
+ v-gl-tooltip
+ :href="rssPath"
+ icon="rss"
+ :title="$options.i18n.rssLabel"
+ class="has-tooltip btn-icon"
+ />
+ <gl-button
+ v-gl-tooltip
+ :href="calendarPath"
+ icon="calendar"
+ :title="$options.i18n.calendarLabel"
+ class="has-tooltip btn-icon"
+ />
</template>
<template #timeframe="{ issuable = {} }">
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 5b5f1d273d0..83387d3ac29 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -6,7 +6,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable';
import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
-import { IssueType } from '~/issues/constants';
+import { TYPE_INCIDENT } from '~/issues/constants';
import Issue from '~/issues/issue';
import { initTitleSuggestions, initTypePopover } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
@@ -59,11 +59,11 @@ export function initShow() {
const { issueType, ...issuableData } = parseIssuableData(el);
- if (issueType === IssueType.Incident) {
+ if (issueType === TYPE_INCIDENT) {
initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store);
- initHeaderActions(store, IssueType.Incident);
+ initHeaderActions(store, TYPE_INCIDENT);
initLinkedResources();
- initRelatedIssues(IssueType.Incident);
+ initRelatedIssues(TYPE_INCIDENT);
} else {
initIssueApp(issuableData, store);
initHeaderActions(store);
diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js
index de1c689e590..b7fd99d8042 100644
--- a/app/assets/javascripts/issues/issue.js
+++ b/app/assets/javascripts/issues/issue.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { joinPaths } from '~/lib/utils/url_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import { addDelimiter } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue
index 6c46013e4f9..5c4bf8f19e4 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -2,17 +2,23 @@
import { GlButton, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { isEmpty } from 'lodash';
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
-import { STATUS_CLOSED } from '~/issues/constants';
+import {
+ STATUS_ALL,
+ STATUS_CLOSED,
+ STATUS_OPEN,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { fetchPolicies } from '~/lib/graphql';
import { isPositiveInteger } from '~/lib/utils/number_utils';
@@ -46,7 +52,7 @@ import {
TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import { DEFAULT_PAGE_SIZE, IssuableListTabs } from '~/vue_shared/issuable/list/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import {
@@ -56,7 +62,6 @@ import {
i18n,
ISSUE_REFERENCE,
MAX_LIST_SIZE,
- PAGE_SIZE,
PARAM_FIRST_PAGE_SIZE,
PARAM_LAST_PAGE_SIZE,
PARAM_PAGE_AFTER,
@@ -177,8 +182,8 @@ export default {
pageParams: {},
showBulkEditSidebar: false,
sortKey: CREATED_DESC,
- state: IssuableStates.Opened,
- pageSize: PAGE_SIZE,
+ state: STATUS_OPEN,
+ pageSize: DEFAULT_PAGE_SIZE,
};
},
apollo: {
@@ -206,9 +211,8 @@ export default {
Sentry.captureException(error);
},
skip() {
- return !this.hasAnyIssues;
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
},
- debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
@@ -223,9 +227,8 @@ export default {
Sentry.captureException(error);
},
skip() {
- return !this.hasAnyIssues;
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
},
- debounce: 200,
context: {
isSingleRequest: true,
},
@@ -249,7 +252,7 @@ export default {
};
},
namespace() {
- return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ return this.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP;
},
defaultWorkItemTypes() {
return [...defaultWorkItemTypes, ...this.eeWorkItemTypes];
@@ -275,7 +278,7 @@ export default {
return this.sortKey === RELATIVE_POSITION_ASC;
},
isOpenTab() {
- return this.state === IssuableStates.Opened;
+ return this.state === STATUS_OPEN;
},
showCsvButtons() {
return this.isProject && this.isSignedIn;
@@ -449,7 +452,7 @@ export default {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
showPageSizeControls() {
- return this.currentTabCount > PAGE_SIZE;
+ return this.currentTabCount > DEFAULT_PAGE_SIZE;
},
sortOptions() {
return getSortOptions({
@@ -461,9 +464,9 @@ export default {
tabCounts() {
const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
return {
- [IssuableStates.Opened]: openedIssues?.count,
- [IssuableStates.Closed]: closedIssues?.count,
- [IssuableStates.All]: allIssues?.count,
+ [STATUS_OPEN]: openedIssues?.count,
+ [STATUS_CLOSED]: closedIssues?.count,
+ [STATUS_ALL]: allIssues?.count,
};
},
currentTabCount() {
@@ -726,7 +729,7 @@ export default {
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
const state = getParameterByName(PARAM_STATE);
- const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
+ const defaultSortKey = state === STATUS_CLOSED ? UPDATED_DESC : CREATED_DESC;
const dashboardSortKey = getSortKey(sortValue);
const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase();
@@ -750,7 +753,7 @@ export default {
getParameterByName(PARAM_PAGE_BEFORE),
);
this.sortKey = sortKey;
- this.state = state || IssuableStates.Opened;
+ this.state = state || STATUS_OPEN;
},
},
};
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 31a43c95f5e..99064a50e3f 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -33,7 +33,6 @@ import {
export const ISSUE_REFERENCE = /^#\d+$/;
export const MAX_LIST_SIZE = 10;
-export const PAGE_SIZE = 20;
export const PARAM_ASSIGNEE_ID = 'assignee_id';
export const PARAM_FIRST_PAGE_SIZE = 'first_page_size';
export const PARAM_LAST_PAGE_SIZE = 'last_page_size';
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index bbd081843ca..b086640cd12 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -16,6 +16,7 @@ import {
TOKEN_TYPE_HEALTH,
TOKEN_TYPE_LABEL,
} from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
import {
ALTERNATIVE_FILTER,
API_PARAM,
@@ -35,7 +36,6 @@ import {
MILESTONE_DUE_ASC,
MILESTONE_DUE_DESC,
NORMAL_FILTER,
- PAGE_SIZE,
PARAM_ASSIGNEE_ID,
POPULARITY_ASC,
POPULARITY_DESC,
@@ -56,7 +56,7 @@ import {
export const getInitialPageParams = (
pageSize,
- firstPageSize = pageSize ?? PAGE_SIZE,
+ firstPageSize = pageSize ?? DEFAULT_PAGE_SIZE,
lastPageSize,
afterCursor,
beforeCursor,
@@ -289,9 +289,9 @@ const formatData = (token) => {
};
export const convertToApiParams = (filterTokens) => {
- const params = {};
- const not = {};
- const or = {};
+ const params = new Map();
+ const not = new Map();
+ const or = new Map();
filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
@@ -307,32 +307,34 @@ export const convertToApiParams = (filterTokens) => {
obj = params;
}
const data = formatData(token);
- Object.assign(obj, {
- [apiField]: obj[apiField] ? [obj[apiField], data].flat() : data,
- });
+ obj.set(apiField, obj.has(apiField) ? [obj.get(apiField), data].flat() : data);
});
- if (Object.keys(not).length) {
- Object.assign(params, { not });
+ if (not.size) {
+ params.set('not', Object.fromEntries(not));
}
- if (Object.keys(or).length) {
- Object.assign(params, { or });
+ if (or.size) {
+ params.set('or', Object.fromEntries(or));
}
- return params;
+ return Object.fromEntries(params);
};
-export const convertToUrlParams = (filterTokens) =>
- filterTokens
+export const convertToUrlParams = (filterTokens) => {
+ const urlParamsMap = filterTokens
.filter((token) => token.type !== FILTERED_SEARCH_TERM)
.reduce((acc, token) => {
const filterType = getFilterType(token);
const urlParam = filters[token.type][URL_PARAM][token.value.operator]?.[filterType];
- return Object.assign(acc, {
- [urlParam]: acc[urlParam] ? [acc[urlParam], token.value.data].flat() : token.value.data,
- });
- }, {});
+ return acc.set(
+ urlParam,
+ acc.has(urlParam) ? [acc.get(urlParam), token.value.data].flat() : token.value.data,
+ );
+ }, new Map());
+
+ return Object.fromEntries(urlParamsMap);
+};
export const convertToSearchQuery = (filterTokens) =>
filterTokens
diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js
index 1bb53dfd50d..f22062cf048 100644
--- a/app/assets/javascripts/issues/manual_ordering.js
+++ b/app/assets/javascripts/issues/manual_ordering.js
@@ -1,5 +1,5 @@
import Sortable from 'sortablejs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import { getSortableDefaultOptions, sortableStart } from '~/sortable/utils';
diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index 149049247fb..c3f87699d58 100644
--- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -65,10 +65,10 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
- <div class="card card-slim gl-mt-5 gl-mb-0">
- <div class="card-header gl-bg-gray-10">
+ <div class="card card-slim gl-mt-5 gl-mb-0 gl-bg-gray-10">
+ <div class="card-header gl-px-5 gl-py-4 gl-bg-white">
<div
- class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
+ class="card-title gl-relative gl-display-flex gl-flex-wrap gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
>
<gl-link
class="anchor gl-absolute gl-text-decoration-none"
@@ -79,19 +79,29 @@ export default {
{{ __('Related merge requests') }}
</h3>
<template v-if="totalCount">
- <gl-icon name="merge-request" class="gl-ml-5 gl-mr-2 gl-text-gray-500" />
- <span data-testid="count">{{ totalCount }}</span>
+ <gl-icon name="merge-request" class="gl-ml-3 gl-mr-2 gl-text-gray-500" />
+ <span data-testid="count" class="gl-text-gray-500">{{ totalCount }}</span>
</template>
+ <p
+ v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
+ class="gl-font-sm gl-font-weight-normal gl-flex-basis-full gl-mb-0 gl-text-gray-500"
+ >
+ {{ closingMergeRequestsText }}
+ </p>
</div>
</div>
<gl-loading-icon
v-if="isFetchingMergeRequests"
size="sm"
label="Fetching related merge requests"
- class="gl-py-3"
+ class="gl-py-4"
/>
- <ul v-else class="content-list related-items-list">
- <li v-for="mr in mergeRequests" :key="mr.id" class="list-item gl-m-0! gl-p-0!">
+ <ul v-else class="content-list related-items-list gl-px-4! gl-py-3!">
+ <li
+ v-for="mr in mergeRequests"
+ :key="mr.id"
+ class="list-item gl-m-0! gl-p-0! gl-border-b-0!"
+ >
<related-issuable-item
:id-key="mr.id"
:display-reference="mr.reference"
@@ -110,11 +120,5 @@ export default {
</li>
</ul>
</div>
- <div
- v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
- class="issue-closed-by-widget second-block gl-mt-3"
- >
- {{ closingMergeRequestsText }}
- </div>
</div>
</template>
diff --git a/app/assets/javascripts/issues/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
index 4c81f1d9bc1..ad5b61424dc 100644
--- a/app/assets/javascripts/issues/related_merge_requests/store/actions.js
+++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index decb559ee81..15f97222971 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -1,19 +1,20 @@
<script>
import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
IssuableStatusText,
STATUS_CLOSED,
TYPE_EPIC,
+ TYPE_INCIDENT,
TYPE_ISSUE,
- WorkspaceType,
+ WORKSPACE_PROJECT,
} from '~/issues/constants';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
-import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, INCIDENT_TYPE, POLLING_DELAY } from '../constants';
+import { ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH, POLLING_DELAY } from '../constants';
import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index';
@@ -25,7 +26,7 @@ import PinnedLinks from './pinned_links.vue';
import TitleComponent from './title.vue';
export default {
- WorkspaceType,
+ WORKSPACE_PROJECT,
components: {
GlIcon,
GlBadge,
@@ -52,11 +53,6 @@ export default {
required: true,
type: Boolean,
},
- showInlineEditButton: {
- type: Boolean,
- required: false,
- default: true,
- },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -191,11 +187,6 @@ export default {
required: false,
default: null,
},
- issueIid: {
- type: Number,
- required: false,
- default: null,
- },
},
data() {
const store = new Store({
@@ -281,7 +272,7 @@ export default {
},
},
created() {
- this.flashContainer = null;
+ this.alert = null;
this.service = new Service(this.endpoint);
this.poll = new Poll({
resource: this.service,
@@ -399,7 +390,7 @@ export default {
? { ...formState, issue_type: issueState.issueType }
: formState;
- this.clearFlash();
+ this.alert?.dismiss();
return this.service
.updateIssuable(issuablePayload)
@@ -407,14 +398,14 @@ export default {
.then((data) => {
if (
!window.location.pathname.includes(data.web_url) &&
- issueState.issueType !== INCIDENT_TYPE
+ issueState.issueType !== TYPE_INCIDENT
) {
visitUrl(data.web_url);
}
if (issueState.isDirty) {
const URI =
- issueState.issueType === INCIDENT_TYPE
+ issueState.issueType === TYPE_INCIDENT
? data.web_url.replace(ISSUE_TYPE_PATH, INCIDENT_TYPE_PATH)
: data.web_url;
visitUrl(URI);
@@ -435,7 +426,7 @@ export default {
errMsg += `. ${message}`;
}
- this.flashContainer = createAlert({
+ this.alert = createAlert({
message: errMsg,
});
})
@@ -452,13 +443,6 @@ export default {
this.isStickyHeaderShowing = true;
},
- clearFlash() {
- if (this.flashContainer) {
- this.flashContainer.close();
- this.flashContainer = null;
- }
- },
-
handleSaveDescription(description) {
this.updateFormState();
this.setFormState({ description });
@@ -509,7 +493,6 @@ export default {
:can-update="canUpdate"
:title-html="state.titleHtml"
:title-text="state.titleText"
- :show-inline-edit-button="showInlineEditButton"
/>
<gl-intersection-observer
@@ -538,7 +521,7 @@ export default {
<confidentiality-badge
v-if="isConfidential"
data-testid="confidential"
- :workspace-type="$options.WorkspaceType.project"
+ :workspace-type="$options.WORKSPACE_PROJECT"
:issuable-type="issuableType"
/>
<span
@@ -570,7 +553,6 @@ export default {
<component
:is="descriptionComponent"
:issue-id="issueId"
- :issue-iid="issueIid"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
index f86ee11e64b..26e82f10c3d 100644
--- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
+import { TYPE_EPIC } from '~/issues/constants';
import csrf from '~/lib/utils/csrf';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
@@ -40,7 +41,7 @@ export default {
};
},
bodyText() {
- return this.issueType.toLowerCase() === 'epic'
+ return this.issueType.toLowerCase() === TYPE_EPIC
? __('Delete this epic and all descendants?')
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
issuableType: capitalizeFirstCharacter(this.issueType),
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index bca895bf764..bdee6c5fe9a 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,35 +1,26 @@
<script>
-import { GlModalDirective, GlToast } from '@gitlab/ui';
+import { GlToast } from '@gitlab/ui';
import $ from 'jquery';
-import { uniqueId } from 'lodash';
import Sortable from 'sortablejs';
import Vue from 'vue';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { createAlert } from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
-import { isMetaKey } from '~/lib/utils/common_utils';
-import { isPositiveInteger } from '~/lib/utils/number_utils';
-import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
-import Tracking from '~/tracking';
import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql';
import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import {
sprintfWorkItem,
I18N_WORK_ITEM_ERROR_CREATING,
I18N_WORK_ITEM_ERROR_DELETING,
- TRACKING_CATEGORY_SHOW,
TASK_TYPE_NAME,
} from '~/work_items/constants';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
@@ -51,12 +42,8 @@ const workItemTypes = {
export default {
directives: {
SafeHtml,
- GlModal: GlModalDirective,
},
- components: {
- WorkItemDetailModal,
- },
- mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
+ mixins: [animateMixin],
inject: ['fullPath', 'hasIterationsFeature'],
props: {
canUpdate: {
@@ -97,11 +84,6 @@ export default {
required: false,
default: null,
},
- issueIid: {
- type: Number,
- required: false,
- default: null,
- },
isUpdating: {
type: Boolean,
required: false,
@@ -109,18 +91,12 @@ export default {
},
},
data() {
- const workItemId = getParameterByName('work_item_id');
-
return {
hasTaskListItemActions: false,
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
issueDetails: {},
- activeTask: {},
- workItemId: isPositiveInteger(workItemId)
- ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
- : undefined,
workItemTypes: [],
};
},
@@ -129,21 +105,12 @@ export default {
query: getIssueDetailsQuery,
variables() {
return {
- fullPath: this.fullPath,
- iid: String(this.issueIid),
- };
- },
- update: (data) => data.workspace?.issuable,
- },
- workItem: {
- query: workItemQuery,
- variables() {
- return {
- id: this.workItemId,
+ id: convertToGraphQLId(TYPENAME_ISSUE, this.issueId),
};
},
+ update: (data) => data.issue,
skip() {
- return !this.workItemId;
+ return !this.canUpdate || !this.issueId;
},
},
workItemTypes: {
@@ -156,10 +123,13 @@ export default {
update(data) {
return data.workspace?.workItemTypes?.nodes;
},
+ skip() {
+ return !this.canUpdate;
+ },
},
},
computed: {
- taskWorkItemType() {
+ taskWorkItemTypeId() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
},
issueGid() {
@@ -188,12 +158,6 @@ export default {
this.renderGFM();
this.updateTaskStatusText();
- if (this.workItemId) {
- const taskLink = this.$el.querySelector(
- `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`,
- );
- this.openWorkItemDetailModal(taskLink);
- }
},
beforeDestroy() {
eventHub.$off('convert-task-list-item', this.convertTaskListItem);
@@ -226,10 +190,10 @@ export default {
},
renderSortableLists() {
// We exclude GLFM table of contents which have a `section-nav` class on the root `ul`.
- const lists = document.querySelectorAll(
+ const lists = this.$el.querySelectorAll?.(
'.description .md > ul:not(.section-nav), .description .md > ul:not(.section-nav) ul, .description ol',
);
- lists.forEach((list) => {
+ lists?.forEach((list) => {
if (list.children.length <= 1) {
return;
}
@@ -358,59 +322,17 @@ export default {
this.$emit('saveDescription', newDescription);
},
renderTaskListItemActions() {
- if (!this.$el?.querySelectorAll) {
- return;
- }
-
- const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)');
-
- taskListFields.forEach((item) => {
- const taskLink = item.querySelector('.gfm-issue');
- if (taskLink) {
- const { issue, referenceType, issueType } = taskLink.dataset;
- if (issueType !== workItemTypes.TASK) {
- return;
- }
- const workItemId = convertToGraphQLId(TYPENAME_WORK_ITEM, issue);
- this.addHoverListeners(taskLink, workItemId);
- taskLink.classList.add('gl-link');
- taskLink.addEventListener('click', (e) => {
- if (isMetaKey(e)) {
- return;
- }
- e.preventDefault();
- this.openWorkItemDetailModal(taskLink);
- this.workItemId = workItemId;
- this.updateWorkItemIdUrlQuery(issue);
- this.track('viewed_work_item_from_modal', {
- category: TRACKING_CATEGORY_SHOW,
- label: 'work_item_view',
- property: `type_${referenceType}`,
- });
- });
- return;
- }
+ const taskListItems = this.$el.querySelectorAll?.(
+ '.task-list-item:not(.inapplicable, table .task-list-item)',
+ );
- const toggleClass = uniqueId('task-list-item-actions-');
- const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate, toggleClass });
- this.addPointerEventListeners(item, `.${toggleClass}`);
+ taskListItems?.forEach((item) => {
+ const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate });
this.insertNextToTaskListItemText(dropdown, item);
+ this.addPointerEventListeners(item, '.task-list-item-actions');
this.hasTaskListItemActions = true;
});
},
- addHoverListeners(taskLink, id) {
- let workItemPrefetch;
- taskLink.addEventListener('mouseover', () => {
- workItemPrefetch = setTimeout(() => {
- this.workItemId = id;
- }, 150);
- });
- taskLink.addEventListener('mouseout', () => {
- if (workItemPrefetch) {
- clearTimeout(workItemPrefetch);
- }
- });
- },
insertNextToTaskListItemText(element, listItem) {
const children = Array.from(listItem.children);
const paragraph = children.find((el) => el.tagName === 'P');
@@ -427,27 +349,6 @@ export default {
listItem.append(element);
}
},
- setActiveTask(el) {
- const { parentElement } = el;
- const lineNumbers = parentElement.dataset.sourcepos.match(/\b\d+(?=:)/g);
- this.activeTask = {
- title: parentElement.innerText,
- lineNumberStart: lineNumbers[0],
- lineNumberEnd: lineNumbers[1],
- };
- },
- openWorkItemDetailModal(el) {
- if (!el) {
- return;
- }
-
- this.setActiveTask(el);
- this.$refs.detailsModal.show();
- },
- closeWorkItemDetailModal() {
- this.workItemId = undefined;
- this.updateWorkItemIdUrlQuery(undefined);
- },
async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
@@ -468,7 +369,7 @@ export default {
},
projectPath: this.fullPath,
title,
- workItemTypeId: this.taskWorkItemType,
+ workItemTypeId: this.taskWorkItemTypeId,
};
const { data } = await this.$apollo.mutate({
@@ -532,16 +433,6 @@ export default {
captureError: true,
});
},
- handleDeleteTask(description) {
- this.$emit('updateDescription', description);
- this.$toast.show(s__('WorkItem|Task deleted'));
- },
- updateWorkItemIdUrlQuery(workItemId) {
- updateHistory({
- url: setUrlParams({ work_item_id: workItemId }),
- replace: true,
- });
- },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] },
};
@@ -569,16 +460,5 @@ export default {
data-testid="textarea"
>
</textarea>
- <work-item-detail-modal
- ref="detailsModal"
- :can-update="canUpdate"
- :work-item-id="workItemId"
- :issue-gid="issueGid"
- :lock-version="lockVersion"
- :line-number-start="activeTask.lineNumberStart"
- :line-number-end="activeTask.lineNumberEnd"
- @workItemDeleted="handleDeleteTask"
- @close="closeWorkItemDetailModal"
- />
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 120034b8d67..608e9aec1d7 100644
--- a/app/assets/javascripts/issues/show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -1,17 +1,10 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
import Tracking from '~/tracking';
import eventHub from '../event_hub';
import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
-const issuableTypes = {
- issue: __('Issue'),
- epic: __('Epic'),
- incident: __('Incident'),
-};
-
const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
export default {
@@ -55,11 +48,6 @@ export default {
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
- typeToShow() {
- const { issueState, issuableType } = this;
- const type = issueState.issueType ?? issuableType;
- return issuableTypes[type];
- },
},
methods: {
closeForm() {
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 3bc24e8ce01..43fe1a7b8ea 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -75,7 +75,6 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:enable-autocomplete="enableAutocomplete"
supports-quick-actions
- use-bottom-toolbar
autofocus
@input="$emit('input', $event)"
@keydown.meta.enter="updateIssuable"
diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
index 5ade1a86d30..775f25bdbc0 100644
--- a/app/assets/javascripts/issues/show/components/fields/type.vue
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
@@ -1,8 +1,8 @@
<script>
-import { GlFormGroup, GlIcon, GlListbox } from '@gitlab/ui';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { GlFormGroup, GlIcon, GlCollapsibleListbox } from '@gitlab/ui';
+import { TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
-import { issuableTypes, INCIDENT_TYPE } from '../../constants';
+import { issuableTypes } from '../../constants';
import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql';
@@ -16,7 +16,7 @@ export default {
components: {
GlFormGroup,
GlIcon,
- GlListbox,
+ GlCollapsibleListbox,
},
inject: {
canCreateIncident: {
@@ -46,7 +46,7 @@ export default {
},
computed: {
shouldShowIncident() {
- return this.issueType === INCIDENT_TYPE || this.canCreateIncident;
+ return this.issueType === TYPE_INCIDENT || this.canCreateIncident;
},
},
methods: {
@@ -60,7 +60,7 @@ export default {
});
},
isShown(type) {
- return type.value !== INCIDENT_TYPE || this.shouldShowIncident;
+ return type.value !== TYPE_INCIDENT || this.shouldShowIncident;
},
},
};
@@ -73,7 +73,7 @@ export default {
label-for="issuable-type"
class="mb-2 mb-md-0"
>
- <gl-listbox
+ <gl-collapsible-listbox
v-model="selectedIssueType"
toggle-class="gl-mb-0"
:items="$options.issuableTypes"
@@ -88,6 +88,6 @@ export default {
{{ item.text }}
</span>
</template>
- </gl-listbox>
+ </gl-collapsible-listbox>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 9d92b5cf954..84def374d13 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -2,7 +2,6 @@
import {
GlButton,
GlDropdown,
- GlDropdownDivider,
GlDropdownItem,
GlLink,
GlModal,
@@ -10,9 +9,9 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { IssueType, STATUS_CLOSED } from '~/issues/constants';
+import { STATUS_CLOSED, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants';
import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -20,6 +19,7 @@ import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import issuesEventHub from '../event_hub';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
import DeleteIssueModal from './delete_issue_modal.vue';
@@ -35,6 +35,8 @@ export default {
},
deleteModalId: 'delete-modal-id',
i18n: {
+ edit: __('Edit'),
+ editTitleAndDescription: __('Edit title and description'),
promoteErrorMessage: __(
'Something went wrong while promoting the issue to an epic. Please try again.',
),
@@ -47,7 +49,6 @@ export default {
DeleteIssueModal,
GlButton,
GlDropdown,
- GlDropdownDivider,
GlDropdownItem,
GlLink,
GlModal,
@@ -87,7 +88,7 @@ export default {
default: '',
},
issueType: {
- default: IssueType.Issue,
+ default: TYPE_ISSUE,
},
newIssuePath: {
default: '',
@@ -118,8 +119,8 @@ export default {
},
issueTypeText() {
const issueTypeTexts = {
- [IssueType.Issue]: s__('HeaderAction|issue'),
- [IssueType.Incident]: s__('HeaderAction|incident'),
+ [TYPE_ISSUE]: s__('HeaderAction|issue'),
+ [TYPE_INCIDENT]: s__('HeaderAction|incident'),
};
return issueTypeTexts[this.issueType] ?? this.issueType;
@@ -240,6 +241,9 @@ export default {
this.toggleStateButtonLoading(false);
});
},
+ edit() {
+ issuesEventHub.$emit('open.form');
+ },
},
};
</script>
@@ -255,6 +259,9 @@ export default {
data-testid="mobile-dropdown"
:loading="isToggleStateButtonLoading"
>
+ <gl-dropdown-item v-if="canUpdateIssue" @click="edit">
+ {{ $options.i18n.edit }}
+ </gl-dropdown-item>
<gl-dropdown-item
v-if="showToggleIssueStateButton"
:data-qa-selector="`mobile_${qaSelector}`"
@@ -280,7 +287,6 @@ export default {
{{ __('Submit as spam') }}
</gl-dropdown-item>
<template v-if="canDestroyIssue">
- <gl-dropdown-divider />
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
@@ -292,10 +298,23 @@ export default {
</gl-dropdown>
<gl-button
+ v-if="canUpdateIssue"
+ v-gl-tooltip.bottom
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
+ class="js-issuable-edit gl-display-none gl-sm-display-block"
+ data-testid="edit-button"
+ @click="edit"
+ >
+ {{ $options.i18n.edit }}
+ </gl-button>
+
+ <gl-button
v-if="showToggleIssueStateButton"
- class="gl-display-none gl-sm-display-inline-flex!"
+ class="gl-display-none gl-sm-display-inline-flex! gl-sm-ml-3"
:data-qa-selector="qaSelector"
:loading="isToggleStateButtonLoading"
+ data-testid="toggle-button"
@click="toggleIssueState"
>
{{ buttonText }}
@@ -304,7 +323,7 @@ export default {
<gl-dropdown
v-if="hasDesktopDropdown"
v-gl-tooltip.hover
- class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ class="gl-display-none gl-sm-display-inline-flex! gl-sm-ml-3"
icon="ellipsis_v"
category="tertiary"
data-qa-selector="issue_actions_ellipsis_dropdown"
@@ -338,8 +357,8 @@ export default {
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
+
<template v-if="canDestroyIssue">
- <gl-dropdown-divider />
<gl-dropdown-item
v-gl-modal="$options.deleteModalId"
variant="danger"
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
index 40cb7fbb0ff..ac64c35bf15 100644
--- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -3,7 +3,7 @@ import { produce } from 'immer';
import { sortBy } from 'lodash';
import { GlIcon } from '@gitlab/ui';
import { sprintf } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import { timelineFormI18n } from './constants';
@@ -113,7 +113,7 @@ export default {
>
<div
v-if="hasTimelineEvents"
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-flex-shrink-0 gl-p-3 gl-z-index-1"
>
<gl-icon name="comment" class="note-icon" />
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 997fadec602..4ec64ef838d 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -1,6 +1,6 @@
<script>
import { GlTab, GlTabs } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -35,7 +35,7 @@ export default {
IncidentMetricTab: () =>
import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'),
},
- inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'],
+ inject: ['fullPath', 'iid', 'hasLinkedAlerts', 'uploadMetricsFeatureAvailable'],
i18n: incidentTabsI18n,
apollo: {
alert: {
@@ -59,20 +59,23 @@ export default {
data() {
return {
alert: null,
- activeTabIndex: 0,
};
},
computed: {
loading() {
return this.$apollo.queries.alert.loading;
},
+ activeTabIndex() {
+ const { tabId } = this.$route.params;
+ return tabId ? this.tabMapping.tabNamesToIndex[tabId] : 0;
+ },
tabMapping() {
const availableTabs = [TAB_NAMES.SUMMARY];
if (this.uploadMetricsFeatureAvailable) {
availableTabs.push(TAB_NAMES.METRICS);
}
- if (this.alert) {
+ if (this.hasLinkedAlerts) {
availableTabs.push(TAB_NAMES.ALERTS);
}
@@ -93,20 +96,25 @@ export default {
return this.activeTabIndex;
},
set(index) {
- this.handleTabChange(index);
- this.activeTabIndex = index;
+ const newPath = `/${this.tabMapping.tabIndexToName[index]}`;
+ // Only push if the new path differs from the old path.
+ if (newPath !== this.$route.path) {
+ this.$router.push(newPath);
+ this.updateJsIssueWidgets(index);
+ }
},
},
},
mounted() {
this.trackPageViews();
+ this.updateJsIssueWidgets(this.activeTabIndex);
},
methods: {
trackPageViews() {
const { category, action } = trackIncidentDetailsViewsOptions;
Tracking.event(category, action);
},
- handleTabChange(tabIndex) {
+ updateJsIssueWidgets(tabIndex) {
/**
* TODO: Implement a solution that does not violate Vue principles in using
* DOM manipulation directly (#361618)
@@ -153,7 +161,7 @@ export default {
<incident-metric-tab />
</gl-tab>
<gl-tab
- v-if="alert"
+ v-if="hasLinkedAlerts"
class="alert-management-details"
:title="$options.i18n.alertsTitle"
data-testid="alert-details-tab"
diff --git a/app/assets/javascripts/issues/show/components/incidents/router.js b/app/assets/javascripts/issues/show/components/incidents/router.js
new file mode 100644
index 00000000000..01326f3b5de
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/router.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+
+Vue.use(VueRouter);
+
+export default (currentPath, currentTab = null) => {
+ // If navigating directly to a tab, determine the base
+ // path to initialize router, then set the current route.
+ const base = currentPath.replace(new RegExp(`/${currentTab}$`), '');
+
+ const router = new VueRouter({
+ mode: 'history',
+ base,
+ routes: [{ path: '/:tabId', name: 'tab' }],
+ });
+
+ if (currentTab) router.push(`/${currentTab}`);
+
+ return router;
+};
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
index 7944362a40f..243666b2323 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
@@ -255,11 +255,10 @@ export default {
</gl-form-group>
</div>
<gl-form-group class="gl-mb-0">
- <div class="gl-display-flex">
+ <div class="gl-display-flex gl-flex-wrap gl-gap-3">
<gl-button
variant="confirm"
category="primary"
- class="gl-mr-3"
data-testid="save-button"
:disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@@ -271,7 +270,6 @@ export default {
v-if="showSaveAndAdd"
variant="confirm"
category="secondary"
- class="gl-mr-3 gl-ml-n2"
data-testid="save-and-add-button"
:disabled="!isTimelineTextValid"
:loading="isEventProcessed"
@@ -279,7 +277,7 @@ export default {
>
{{ $options.i18n.saveAndAdd }}
</gl-button>
- <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
+ <gl-button :disabled="isEventProcessed" @click="$emit('cancel')">
{{ $options.i18n.cancel }}
</gl-button>
<gl-button
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index 10b80529a66..5aef4b1b809 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -1,6 +1,6 @@
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sprintf } from '~/locale';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index ce33e91c3b8..2072961ce29 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
export const displayAndLogError = (error) =>
diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue
index 4414e693ed0..482aad32daa 100644
--- a/app/assets/javascripts/issues/show/components/locked_warning.vue
+++ b/app/assets/javascripts/issues/show/components/locked_warning.vue
@@ -1,7 +1,13 @@
<script>
import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-import { IssuableType } from '~/issues/constants';
+import {
+ TYPE_ALERT,
+ TYPE_EPIC,
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ TYPE_TEST_CASE,
+} from '~/issues/constants';
export const i18n = Object.freeze({
alertMessage: __(
@@ -20,7 +26,9 @@ export default {
type: String,
required: true,
validator(value) {
- return Object.values(IssuableType).includes(value);
+ return [TYPE_ALERT, TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE].includes(
+ value,
+ );
},
},
},
diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
index d0beb0f39b3..03d298e0ddf 100644
--- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
+++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
@@ -13,7 +13,7 @@ export default {
GlDropdown,
GlDropdownItem,
},
- inject: ['canUpdate', 'toggleClass'],
+ inject: ['canUpdate'],
methods: {
convertToTask() {
eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
@@ -35,7 +35,7 @@ export default {
right
:text="$options.i18n.taskActions"
text-sr-only
- :toggle-class="`task-list-item-actions gl-opacity-0 gl-p-2! ${toggleClass}`"
+ toggle-class="task-list-item-actions gl-opacity-0 gl-p-2!"
>
<gl-dropdown-item v-if="canUpdate" @click="convertToTask">
{{ $options.i18n.convertToTask }}
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 6978f730e1d..2d2ef327018 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -1,17 +1,9 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlTooltipDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { __ } from '~/locale';
-import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
export default {
- i18n: {
- editTitleAndDescription: __('Edit title and description'),
- },
- components: {
- GlButton,
- },
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
@@ -35,11 +27,6 @@ export default {
type: String,
required: true,
},
- showInlineEditButton: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -60,9 +47,6 @@ export default {
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·');
},
- edit() {
- eventHub.$emit('open.form');
- },
},
};
</script>
@@ -77,16 +61,8 @@ export default {
}"
class="title gl-font-size-h-display"
data-qa-selector="title_content"
+ data-testid="issue-title"
dir="auto"
></h1>
- <gl-button
- v-if="showInlineEditButton && canUpdate"
- v-gl-tooltip.bottom
- icon="pencil"
- class="btn-edit js-issuable-edit"
- :title="$options.i18n.editTitleAndDescription"
- :aria-label="$options.i18n.editTitleAndDescription"
- @click="edit"
- />
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js
index a100aaf88ad..4d8c11f9669 100644
--- a/app/assets/javascripts/issues/show/constants.js
+++ b/app/assets/javascripts/issues/show/constants.js
@@ -1,6 +1,5 @@
import { __ } from '~/locale';
-export const INCIDENT_TYPE = 'incident';
export const INCIDENT_TYPE_PATH = 'issues/incident';
export const ISSUE_STATE_EVENT_CLOSE = 'CLOSE';
export const ISSUE_STATE_EVENT_REOPEN = 'REOPEN';
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 1793ce66ad4..e677328cd2e 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -2,14 +2,16 @@ import Vue from 'vue';
import { mapGetters } from 'vuex';
import errorTrackingStore from '~/error_tracking/store';
import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { TYPE_INCIDENT } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import IssueApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue';
import IncidentTabs from './components/incidents/incident_tabs.vue';
import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue';
-import { INCIDENT_TYPE, issueState } from './constants';
+import { issueState } from './constants';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
+import createRouter from './components/incidents/router';
const bootstrapApollo = (state = {}) => {
return apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -35,23 +37,28 @@ export function initIncidentApp(issueData = {}, store) {
canUpdateTimelineEvent,
iid,
issuableId,
+ currentPath,
+ currentTab,
projectNamespace,
projectPath,
projectId,
+ hasLinkedAlerts,
slaFeatureAvailable,
uploadMetricsFeatureAvailable,
state,
} = issueData;
const fullPath = `${projectNamespace}/${projectPath}`;
+ const router = createRouter(currentPath, currentTab);
return new Vue({
el,
name: 'DescriptionRoot',
apolloProvider,
store,
+ router,
provide: {
- issueType: INCIDENT_TYPE,
+ issueType: TYPE_INCIDENT,
canCreateIncident,
canUpdateTimelineEvent,
canUpdate,
@@ -59,6 +66,7 @@ export function initIncidentApp(issueData = {}, store) {
iid,
issuableId,
projectId,
+ hasLinkedAlerts: parseBoolean(hasLinkedAlerts),
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
contentEditorOnIssues: gon.features.contentEditorOnIssues,
@@ -125,7 +133,6 @@ export function initIssueApp(issueData, store) {
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
issueId: this.getNoteableData?.id,
- issueIid: this.getNoteableData?.iid,
},
});
},
@@ -142,7 +149,7 @@ export function initHeaderActions(store, type = '') {
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
const canCreate =
- type === INCIDENT_TYPE ? el.dataset.canCreateIncident : el.dataset.canCreateIssue;
+ type === TYPE_INCIDENT ? el.dataset.canCreateIncident : el.dataset.canCreateIssue;
return new Vue({
el,
diff --git a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql
new file mode 100644
index 00000000000..f4a0b10672e
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_job.fragment.graphql
@@ -0,0 +1,11 @@
+#import "~/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql"
+
+fragment BaseCiJob on CiJob {
+ id
+ manualVariables {
+ nodes {
+ ...ManualCiVariable
+ }
+ }
+ __typename
+}
diff --git a/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql
new file mode 100644
index 00000000000..0479df7bc4c
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/graphql/fragments/ci_variable.fragment.graphql
@@ -0,0 +1,6 @@
+fragment ManualCiVariable on CiVariable {
+ __typename
+ id
+ key
+ value
+}
diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql
new file mode 100644
index 00000000000..520deef5136
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql
@@ -0,0 +1,11 @@
+#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql"
+
+mutation playJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) {
+ jobPlay(input: { id: $id, variables: $variables }) {
+ job {
+ ...BaseCiJob
+ webPath
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql
index 2b79892a072..e35d603ea71 100644
--- a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql
+++ b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql
@@ -1,14 +1,9 @@
+#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql"
+
mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) {
jobRetry(input: { id: $id, variables: $variables }) {
job {
- id
- manualVariables {
- nodes {
- id
- key
- value
- }
- }
+ ...BaseCiJob
webPath
}
errors
diff --git a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql
index aaf1dec8e0f..95e3521091d 100644
--- a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql
+++ b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql
@@ -1,16 +1,11 @@
+#import "~/jobs/components/job/graphql/fragments/ci_job.fragment.graphql"
+
query getJob($fullPath: ID!, $id: JobID!) {
project(fullPath: $fullPath) {
id
job(id: $id) {
- id
+ ...BaseCiJob
manualJob
- manualVariables {
- nodes {
- id
- key
- value
- }
- }
name
}
}
diff --git a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
index e9809ac661b..ea7e13418f2 100644
--- a/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
@@ -103,6 +103,8 @@ export default {
} else {
next();
}
+ }).catch(() => {
+ this.failureCount = null;
});
}
},
diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
index 763eb6705aa..19a75ffaa85 100644
--- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
@@ -10,16 +10,17 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { cloneDeep, uniqueId } from 'lodash';
-import { mapActions } from 'vuex';
import { fetchPolicies } from '~/lib/graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
+import { reportMessageToSentry } from '~/jobs/utils';
import GetJob from './graphql/queries/get_job.query.graphql';
+import playJobWithVariablesMutation from './graphql/mutations/job_play_with_variables.mutation.graphql';
import retryJobWithVariablesMutation from './graphql/mutations/job_retry_with_variables.mutation.graphql';
// This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue
@@ -54,8 +55,9 @@ export default {
const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes);
return [...jobVariables.reverse(), ...this.variables];
},
- error() {
+ error(error) {
createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText });
+ reportMessageToSentry(this.$options.name, error, {});
},
},
},
@@ -69,13 +71,14 @@ export default {
required: true,
},
},
- clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0'],
+ clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0 gl-m-0! gl-ml-3!'],
inputTypes: {
key: 'key',
value: 'value',
},
i18n: {
- clearInputs: s__('CiVariables|Clear inputs'),
+ cancel: s__('CiVariables|Cancel'),
+ removeInputs: s__('CiVariables|Remove inputs'),
formHelpText: s__(
'CiVariables|Specify variable values to be used in this run. The variables specified in the configuration file and %{linkStart}CI/CD settings%{linkEnd} are used by default.',
),
@@ -86,14 +89,10 @@ export default {
keyLabel: s__('CiVariables|Key'),
keyPlaceholder: s__('CiVariables|Input variable key'),
runAgainButtonText: s__('CiVariables|Run job again'),
- triggerButtonText: s__('CiVariables|Run job'),
+ runButtonText: s__('CiVariables|Run job'),
valueLabel: s__('CiVariables|Value'),
valuePlaceholder: s__('CiVariables|Input variable value'),
},
- variableValueKeys: {
- rest: 'secret_value',
- gql: 'value',
- },
data() {
return {
job: {},
@@ -104,30 +103,63 @@ export default {
value: '',
},
],
- runAgainBtnDisabled: false,
- triggerBtnDisabled: false,
+ runBtnDisabled: false,
};
},
computed: {
+ mutationVariables() {
+ return {
+ id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId),
+ variables: this.preparedVariables,
+ };
+ },
preparedVariables() {
- // filtering out 'id' along with empty variables to send only key, value in the mutation.
- // This will be removed in: https://gitlab.com/gitlab-org/gitlab/-/issues/377268
-
return this.variables
.filter((variable) => variable.key !== '')
- .map(({ key, value }) => ({ key, [this.valueKey]: value }));
+ .map(({ key, value }) => ({ key, value }));
},
- valueKey() {
+ runBtnText() {
return this.isRetryable
- ? this.$options.variableValueKeys.gql
- : this.$options.variableValueKeys.rest;
+ ? this.$options.i18n.runAgainButtonText
+ : this.$options.i18n.runButtonText;
},
variableSettings() {
return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' });
},
},
methods: {
- ...mapActions(['triggerManualJob']),
+ async playJob() {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: playJobWithVariablesMutation,
+ variables: this.mutationVariables,
+ });
+ if (data.jobPlay?.errors?.length) {
+ createAlert({ message: data.jobPlay.errors[0] });
+ } else {
+ this.navigateToJob(data.jobPlay?.job?.webPath);
+ }
+ } catch (error) {
+ createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText });
+ reportMessageToSentry(this.$options.name, error, {});
+ }
+ },
+ async retryJob() {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: retryJobWithVariablesMutation,
+ variables: this.mutationVariables,
+ });
+ if (data.jobRetry?.errors?.length) {
+ createAlert({ message: data.jobRetry.errors[0] });
+ } else {
+ this.navigateToJob(data.jobRetry?.job?.webPath);
+ }
+ } catch (error) {
+ createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText });
+ reportMessageToSentry(this.$options.name, error, {});
+ }
+ },
addEmptyVariable() {
const lastVar = this.variables[this.variables.length - 1];
@@ -153,37 +185,17 @@ export default {
inputRef(type, id) {
return `${this.$options.inputTypes[type]}-${id}`;
},
- navigateToRetriedJob(retryPath) {
- redirectTo(retryPath);
+ navigateToJob(path) {
+ redirectTo(path);
},
- async retryJob() {
- try {
- const { data } = await this.$apollo.mutate({
- mutation: retryJobWithVariablesMutation,
- variables: {
- id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId),
- // we need to ensure no empty variables are passed to the API
- variables: this.preparedVariables,
- },
- });
- if (data.jobRetry?.errors?.length) {
- createAlert({ message: data.jobRetry.errors[0] });
- } else {
- this.navigateToRetriedJob(data.jobRetry?.job?.webPath);
- }
- } catch (error) {
- createAlert({ message: JOB_GRAPHQL_ERRORS.retryMutationErrorText });
- }
- },
- runAgain() {
- this.runAgainBtnDisabled = true;
-
- this.retryJob();
- },
- triggerJob() {
- this.triggerBtnDisabled = true;
+ runJob() {
+ this.runBtnDisabled = true;
- this.triggerManualJob(this.preparedVariables);
+ if (this.isRetryable) {
+ this.retryJob();
+ } else {
+ this.playJob();
+ }
},
},
};
@@ -197,7 +209,7 @@ export default {
<div
v-for="(variable, index) in variables"
:key="variable.id"
- class="gl-display-flex gl-align-items-center gl-mb-4"
+ class="gl-display-flex gl-align-items-center gl-mb-5"
data-testid="ci-variable-row"
>
<gl-form-input-group class="gl-mr-4 gl-flex-grow-1">
@@ -232,12 +244,11 @@ export default {
<gl-button
v-if="canRemove(index)"
v-gl-tooltip
- :aria-label="$options.i18n.clearInputs"
- :title="$options.i18n.clearInputs"
+ :aria-label="$options.i18n.removeInputs"
+ :title="$options.i18n.removeInputs"
:class="$options.clearBtnSharedClasses"
category="tertiary"
- variant="danger"
- icon="clear"
+ icon="remove"
data-testid="delete-variable-btn"
@click="deleteVariable(variable.id)"
/>
@@ -248,8 +259,7 @@ export default {
:class="$options.clearBtnSharedClasses"
data-testid="delete-variable-btn-placeholder"
category="tertiary"
- variant="danger"
- icon="clear"
+ icon="remove"
/>
</div>
@@ -271,37 +281,23 @@ export default {
</template>
</gl-sprintf>
</div>
- <div v-if="isRetryable" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-button
+ v-if="isRetryable"
class="gl-mt-5"
- :aria-label="__('Cancel')"
data-testid="cancel-btn"
@click="$emit('hideManualVariablesForm')"
- >{{ __('Cancel') }}</gl-button
+ >{{ $options.i18n.cancel }}</gl-button
>
<gl-button
class="gl-mt-5"
variant="confirm"
category="primary"
- :aria-label="__('Run manual job again')"
- :disabled="runAgainBtnDisabled"
+ :disabled="runBtnDisabled"
data-testid="run-manual-job-btn"
- @click="runAgain"
- >
- {{ $options.i18n.runAgainButtonText }}
- </gl-button>
- </div>
- <div v-else class="gl-display-flex gl-justify-content-center gl-mt-5">
- <gl-button
- class="gl-mt-5"
- variant="confirm"
- category="primary"
- :aria-label="__('Trigger manual job')"
- :disabled="triggerBtnDisabled"
- data-testid="trigger-manual-job-btn"
- @click="triggerJob"
+ @click="runJob"
>
- {{ $options.i18n.triggerButtonText }}
+ {{ runBtnText }}
</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
index 913924cc7b1..a3f1a2c4be8 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
@@ -30,18 +30,16 @@ export default {
return {
primaryProps: {
text: this.$options.i18n.primaryText,
- attributes: [
- {
- 'data-method': 'post',
- 'data-testid': 'retry-button-modal',
- href: this.href,
- variant: 'danger',
- },
- ],
+ attributes: {
+ 'data-method': 'post',
+ 'data-testid': 'retry-button-modal',
+ href: this.href,
+ variant: 'danger',
+ },
},
cancelProps: {
text: this.$options.i18n.cancel,
- attributes: [{ category: 'secondary', variant: 'default' }],
+ attributes: { category: 'secondary', variant: 'default' },
},
};
},
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
index 8100bc2d87a..d791705d80d 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index 41ce6e4d64d..1b572e60c58 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
/* Error constants */
-export const POST_FAILURE = 'post_failure';
export const DEFAULT = 'default';
export const RAW_TEXT_WARNING = s__(
'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.',
diff --git a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
index 8bcd7ffd10f..5390c023da4 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
+++ b/app/assets/javascripts/jobs/components/table/graphql/cache_config.js
@@ -11,42 +11,48 @@ export default {
},
CiJobConnection: {
merge(existing = {}, incoming, { args = {} }) {
- let nodes;
+ if (incoming.nodes) {
+ let nodes;
- const areNodesEqual = isEqual(existing.nodes, incoming.nodes);
- const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses;
- const { pageInfo } = incoming;
+ const areNodesEqual = isEqual(existing.nodes, incoming.nodes);
+ const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses;
+ const { pageInfo } = incoming;
- if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
- if (areNodesEqual) {
- if (incoming.pageInfo.hasNextPage) {
- nodes = [...existing.nodes, ...incoming.nodes];
+ if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) {
+ if (areNodesEqual) {
+ if (incoming.pageInfo.hasNextPage) {
+ nodes = [...existing.nodes, ...incoming.nodes];
+ } else {
+ nodes = [...incoming.nodes];
+ }
} else {
- nodes = [...incoming.nodes];
- }
- } else {
- if (!existing.pageInfo?.hasNextPage) {
- nodes = [...incoming.nodes];
+ if (!existing.pageInfo?.hasNextPage) {
+ nodes = [...incoming.nodes];
- return {
- nodes,
- statuses,
- pageInfo,
- count: incoming.count,
- };
- }
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ };
+ }
- nodes = [...existing.nodes, ...incoming.nodes];
+ nodes = [...existing.nodes, ...incoming.nodes];
+ }
+ } else {
+ nodes = [...incoming.nodes];
}
- } else {
- nodes = [...incoming.nodes];
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ };
}
return {
- nodes,
- statuses,
- pageInfo,
- count: incoming.count,
+ nodes: existing.nodes,
+ pageInfo: existing.pageInfo,
+ statuses: args.statuses,
};
},
},
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index 851be211b25..69719011079 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -2,7 +2,6 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo
project(fullPath: $fullPath) {
id
jobs(after: $after, first: $first, statuses: $statuses) {
- count
pageInfo {
endCursor
hasNextPage
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql
new file mode 100644
index 00000000000..a4e02ae721a
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs_count.query.graphql
@@ -0,0 +1,8 @@
+query getJobsCount($fullPath: ID!, $statuses: [CiJobStatus!]) {
+ project(fullPath: $fullPath) {
+ id
+ jobs(statuses: $statuses) {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index 3209fc4b90d..3d87cea6445 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -1,11 +1,12 @@
<script>
import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
import { validateQueryString } from '../filtered_search/utils';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
+import GetJobsCount from './graphql/queries/get_jobs_count.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
import JobsTableTabs from './jobs_table_tabs.vue';
@@ -13,7 +14,8 @@ import { RAW_TEXT_WARNING } from './constants';
export default {
i18n: {
- errorMsg: __('There was an error fetching the jobs for your project.'),
+ jobsFetchErrorMsg: __('There was an error fetching the jobs for your project.'),
+ jobsCountErrorMsg: __('There was an error fetching the number of jobs for your project.'),
loadingAriaLabel: __('Loading'),
},
filterSearchBoxStyles:
@@ -43,15 +45,32 @@ export default {
};
},
update(data) {
- const { jobs: { nodes: list = [], pageInfo = {}, count } = {} } = data.project || {};
+ const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {};
return {
list,
pageInfo,
- count,
};
},
error() {
- this.hasError = true;
+ this.error = this.$options.i18n.jobsFetchErrorMsg;
+ },
+ },
+ jobsCount: {
+ query: GetJobsCount,
+ context: {
+ isSingleRequest: true,
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ ...this.validatedQueryString,
+ };
+ },
+ update({ project }) {
+ return project?.jobs?.count || 0;
+ },
+ error() {
+ this.error = this.$options.i18n.jobsCountErrorMsg;
},
},
},
@@ -60,11 +79,11 @@ export default {
jobs: {
list: [],
},
- hasError: false,
- isAlertDismissed: false,
+ error: '',
scope: null,
infiniteScrollingTriggered: false,
filterSearchTriggered: false,
+ jobsCount: null,
count: 0,
};
},
@@ -72,9 +91,6 @@ export default {
loading() {
return this.$apollo.queries.jobs.loading;
},
- shouldShowAlert() {
- return this.hasError && !this.isAlertDismissed;
- },
// Show when on All tab with no jobs
// Show only when not loading and filtered search has not been triggered
// So we don't show empty state when results are empty on a filtered search
@@ -95,9 +111,6 @@ export default {
showFilteredSearch() {
return !this.scope;
},
- jobsCount() {
- return this.jobs.count;
- },
validatedQueryString() {
const queryStringObject = queryToObject(window.location.search);
@@ -146,6 +159,7 @@ export default {
});
this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
+ this.$apollo.queries.jobsCount.refetch({ statuses: filter.value.data });
}
});
},
@@ -168,14 +182,14 @@ export default {
<template>
<div>
<gl-alert
- v-if="shouldShowAlert"
+ v-if="error"
class="gl-mt-2"
variant="danger"
data-testid="jobs-table-error-alert"
dismissible
- @dismiss="isAlertDismissed = true"
+ @dismiss="error = ''"
>
- {{ $options.i18n.errorMsg }}
+ {{ error }}
</gl-alert>
<jobs-table-tabs
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 027d896ba0e..40b3de7edd9 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -19,7 +19,7 @@ export const JOB_SIDEBAR_COPY = {
};
export const JOB_GRAPHQL_ERRORS = {
- retryMutationErrorText: __('There was an error running the job. Please try again.'),
+ jobMutationErrorText: __('There was an error running the job. Please try again.'),
jobQueryErrorText: __('There was an error fetching the job.'),
};
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index af2d720643f..b348478ccda 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon';
import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
@@ -22,7 +22,7 @@ export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
pagePath,
});
- return Promise.all([dispatch('fetchJob')]);
+ return dispatch('fetchJob');
};
export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint);
diff --git a/app/assets/javascripts/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue
index 1b99a094c48..298cc20ab35 100644
--- a/app/assets/javascripts/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/labels/components/promote_label_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
@@ -9,7 +9,7 @@ import eventHub from '../event_hub';
export default {
primaryProps: {
text: s__('Labels|Promote Label'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
cancelProps: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/labels/create_label_dropdown.js b/app/assets/javascripts/labels/create_label_dropdown.js
index 033ca9dd3ea..60ab0c92256 100644
--- a/app/assets/javascripts/labels/create_label_dropdown.js
+++ b/app/assets/javascripts/labels/create_label_dropdown.js
@@ -37,6 +37,8 @@ export default class CreateLabelDropdown {
// eslint-disable-next-line @gitlab/no-global-event-off
this.$newColorField.off('keyup change');
// eslint-disable-next-line @gitlab/no-global-event-off
+ this.$colorPreview.off('keyup change');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$dropdownBack.off('click');
// eslint-disable-next-line @gitlab/no-global-event-off
this.$cancelButton.off('click');
@@ -54,6 +56,10 @@ export default class CreateLabelDropdown {
this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
+ this.$colorPreview.on('keyup change', this.enableLabelCreateButton.bind(this));
+
+ this.$newColorField.on('input', this.updateColorPreview.bind(this));
+ this.$colorPreview.on('input', this.updateColorPickerPreview.bind(this));
this.$dropdownBack.on('click', this.resetForm.bind(this));
@@ -73,7 +79,19 @@ export default class CreateLabelDropdown {
e.stopPropagation();
this.$newColorField.val($this.data('color')).trigger('change');
- this.$colorPreview.css('background-color', $this.data('color')).parent().addClass('is-active');
+ this.$colorPreview.val($this.data('color')).trigger('change');
+ }
+
+ updateColorPreview() {
+ const previewColor = this.$newColorField.val();
+ return this.$colorPreview.val(previewColor);
+ // Updates the preview color with the hex-color input
+ }
+
+ updateColorPickerPreview() {
+ const previewColor = this.$colorPreview.val();
+ return this.$newColorField.val(previewColor);
+ // Updates the input color with the hex-color from the picker
}
enableLabelCreateButton() {
@@ -92,7 +110,7 @@ export default class CreateLabelDropdown {
this.$addList.prop('checked', this.addListDefault);
- this.$colorPreview.css('background-color', '').parent().removeClass('is-active');
+ this.$colorPreview.val('');
}
saveLabel(e) {
diff --git a/app/assets/javascripts/labels/group_label_subscription.js b/app/assets/javascripts/labels/group_label_subscription.js
index c4f80d32a83..3683bb5d5e5 100644
--- a/app/assets/javascripts/labels/group_label_subscription.js
+++ b/app/assets/javascripts/labels/group_label_subscription.js
@@ -1,7 +1,8 @@
import $ from 'jquery';
import { __ } from '~/locale';
import { fixTitle, hide } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
const tooltipTitles = {
@@ -65,7 +66,7 @@ export default class GroupLabelSubscription {
static setNewTooltip($button) {
if (!$button.hasClass('js-subscribe-button')) return;
- const type = $button.hasClass('js-group-level') ? 'group' : 'project';
+ const type = $button.hasClass('js-group-level') ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
const newTitle = tooltipTitles[type];
const $el = $('.js-unsubscribe-button', $button.closest('.label-actions-list'));
diff --git a/app/assets/javascripts/labels/label_manager.js b/app/assets/javascripts/labels/label_manager.js
index be515869bff..f4d7c610cae 100644
--- a/app/assets/javascripts/labels/label_manager.js
+++ b/app/assets/javascripts/labels/label_manager.js
@@ -3,7 +3,7 @@
import $ from 'jquery';
import Sortable from 'sortablejs';
import { dispose } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/labels/labels.js b/app/assets/javascripts/labels/labels.js
index cd8cf0d354c..acb29e1a1f0 100644
--- a/app/assets/javascripts/labels/labels.js
+++ b/app/assets/javascripts/labels/labels.js
@@ -7,29 +7,39 @@ export default class Labels {
this.cleanBinding();
this.addBinding();
this.updateColorPreview();
+ this.updateColorPickerPreview();
}
addBinding() {
$(document).on('click', '.suggest-colors a', this.setSuggestedColor);
+ $(document).on('input', '.label-color-preview', this.updateColorPickerPreview);
return $(document).on('input', 'input#label_color', this.updateColorPreview);
}
// eslint-disable-next-line class-methods-use-this
cleanBinding() {
$(document).off('click', '.suggest-colors a');
+ $(document).off('input', '.label-color-preview');
return $(document).off('input', 'input#label_color');
}
// eslint-disable-next-line class-methods-use-this
updateColorPreview() {
const previewColor = $('input#label_color').val();
- return $('div.label-color-preview').css('background-color', previewColor);
+ return $('.label-color-preview').val(previewColor);
// Updates the preview color with the hex-color input
}
+ // eslint-disable-next-line class-methods-use-this
+ updateColorPickerPreview() {
+ const previewColor = $('.label-color-preview').val();
+ return $('input#label_color').val(previewColor);
+ // Updates the input color with the hex-color from the picker
+ }
// Updates the preview color with a click on a suggested color
setSuggestedColor(e) {
const color = $(e.currentTarget).data('color');
$('input#label_color').val(color);
this.updateColorPreview();
+ this.updateColorPickerPreview();
// Notify the form, that color has changed
$('.label-form').trigger('keyup');
return e.preventDefault();
diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js
index 515b0a79a03..587cc82f0fa 100644
--- a/app/assets/javascripts/labels/labels_select.js
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -6,7 +6,7 @@ import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import IssuableBulkUpdateActions from '~/issuable/issuable_bulk_update_actions';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { sprintf, __ } from '~/locale';
import CreateLabelDropdown from './create_label_dropdown';
diff --git a/app/assets/javascripts/labels/project_label_subscription.js b/app/assets/javascripts/labels/project_label_subscription.js
index 9ca6ee5609c..1629d3b4d88 100644
--- a/app/assets/javascripts/labels/project_label_subscription.js
+++ b/app/assets/javascripts/labels/project_label_subscription.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { fixTitle } from '~/tooltips';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -68,7 +69,7 @@ export default class ProjectLabelSubscription {
}
static setNewTitle($button, originalTitle, newStatus) {
- const type = /group/.test(originalTitle) ? 'group' : 'project';
+ const type = /group/.test(originalTitle) ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
const newTitle = tooltipTitles[type][newStatus];
$button.attr('title', newTitle);
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index b8138f34d45..f5078962b8f 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -70,10 +70,11 @@ function initDeferred() {
}
export default function initLayoutNav() {
- const contextualSidebar = new ContextualSidebar();
- contextualSidebar.bindEvents();
-
- initFlyOutNav();
+ if (!gon.use_new_navigation) {
+ const contextualSidebar = new ContextualSidebar();
+ contextualSidebar.bindEvents();
+ initFlyOutNav();
+ }
requestIdleCallback(initDeferred);
}
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 2c8953237cf..fb69a61880a 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -26,3 +26,8 @@ export const DEFAULT_TH_CLASSES =
export const DRAWER_Z_INDEX = 252;
export const MIN_USERNAME_LENGTH = 2;
+
+export const BYTES_FORMAT_BYTES = 'Bytes';
+export const BYTES_FORMAT_KIB = 'KiB';
+export const BYTES_FORMAT_MIB = 'MiB';
+export const BYTES_FORMAT_GIB = 'GiB';
diff --git a/app/assets/javascripts/lib/utils/error_message.js b/app/assets/javascripts/lib/utils/error_message.js
new file mode 100644
index 00000000000..4cea4257e7b
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/error_message.js
@@ -0,0 +1,20 @@
+export const USER_FACING_ERROR_MESSAGE_PREFIX = 'UF:';
+
+const getMessageFromError = (error = '') => {
+ return error.message || error;
+};
+
+export const parseErrorMessage = (error = '') => {
+ const messageString = getMessageFromError(error);
+
+ if (messageString.startsWith(USER_FACING_ERROR_MESSAGE_PREFIX)) {
+ return {
+ message: messageString.replace(USER_FACING_ERROR_MESSAGE_PREFIX, '').trim(),
+ userFacing: true,
+ };
+ }
+ return {
+ message: messageString,
+ userFacing: false,
+ };
+};
diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js
index f99a4927338..c80d3f24d07 100644
--- a/app/assets/javascripts/lib/utils/file_upload.js
+++ b/app/assets/javascripts/lib/utils/file_upload.js
@@ -29,3 +29,10 @@ export const validateImageName = (file) => {
const legalImageRegex = /^[\w.\-+]+\.(png|jpg|jpeg|gif|bmp|tiff|ico|webp)$/;
return legalImageRegex.test(fileName) ? fileName : 'image.png';
};
+
+export const validateFileFromAllowList = (fileName, allowList) => {
+ const parts = fileName.split('.');
+ const ext = `.${parts[parts.length - 1]}`;
+
+ return allowList.includes(ext);
+};
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index b0e31fe729b..d64f84d2040 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,5 +1,12 @@
import { sprintf, __ } from '~/locale';
-import { BYTES_IN_KIB, THOUSAND } from './constants';
+import {
+ BYTES_IN_KIB,
+ THOUSAND,
+ BYTES_FORMAT_BYTES,
+ BYTES_FORMAT_KIB,
+ BYTES_FORMAT_MIB,
+ BYTES_FORMAT_GIB,
+} from './constants';
/**
* Function that allows a number with an X amount of decimals
@@ -64,25 +71,51 @@ export function bytesToGiB(number) {
}
/**
- * Port of rails number_to_human_size
* Formats the bytes in number into a more understandable
- * representation (e.g., giving it 1500 yields 1.5 KB).
+ * representation. Returns an array with the first value being the human size
+ * and the second value being the format (e.g., [1.5, 'KiB']).
*
* @param {Number} size
* @param {Number} digits - The number of digits to appear after the decimal point
* @returns {String}
*/
-export function numberToHumanSize(size, digits = 2) {
+export function numberToHumanSizeSplit(size, digits = 2) {
const abs = Math.abs(size);
if (abs < BYTES_IN_KIB) {
- return sprintf(__('%{size} bytes'), { size });
+ return [size.toString(), BYTES_FORMAT_BYTES];
} else if (abs < BYTES_IN_KIB ** 2) {
- return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(digits) });
+ return [bytesToKiB(size).toFixed(digits), BYTES_FORMAT_KIB];
} else if (abs < BYTES_IN_KIB ** 3) {
- return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(digits) });
+ return [bytesToMiB(size).toFixed(digits), BYTES_FORMAT_MIB];
+ }
+ return [bytesToGiB(size).toFixed(digits), BYTES_FORMAT_GIB];
+}
+
+/**
+ * Port of rails number_to_human_size
+ * Formats the bytes in number into a more understandable
+ * representation (e.g., giving it 1500 yields 1.5 KB).
+ *
+ * @param {Number} size
+ * @param {Number} digits - The number of digits to appear after the decimal point
+ * @returns {String}
+ */
+export function numberToHumanSize(size, digits = 2) {
+ const [humanSize, format] = numberToHumanSizeSplit(size, digits);
+
+ switch (format) {
+ case BYTES_FORMAT_BYTES:
+ return sprintf(__('%{size} bytes'), { size: humanSize });
+ case BYTES_FORMAT_KIB:
+ return sprintf(__('%{size} KiB'), { size: humanSize });
+ case BYTES_FORMAT_MIB:
+ return sprintf(__('%{size} MiB'), { size: humanSize });
+ case BYTES_FORMAT_GIB:
+ return sprintf(__('%{size} GiB'), { size: humanSize });
+ default:
+ return '';
}
- return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(digits) });
}
/**
diff --git a/app/assets/javascripts/lib/utils/ref_validator.js b/app/assets/javascripts/lib/utils/ref_validator.js
new file mode 100644
index 00000000000..d679a3b4198
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/ref_validator.js
@@ -0,0 +1,145 @@
+import { __, sprintf } from '~/locale';
+
+// this service validates tagName agains git ref format.
+// the git spec can be found here: https://git-scm.com/docs/git-check-ref-format#_description
+
+// the ruby counterpart of the validator is here:
+// lib/gitlab/git_ref_validator.rb
+
+const EXPANDED_PREFIXES = ['refs/heads/', 'refs/remotes/', 'refs/tags'];
+const DISALLOWED_PREFIXES = ['-', '/'];
+const DISALLOWED_POSTFIXES = ['/'];
+const DISALLOWED_NAMES = ['HEAD', '@'];
+const DISALLOWED_SUBSTRINGS = [' ', '\\', '~', ':', '..', '^', '?', '*', '[', '@{'];
+const DISALLOWED_SEQUENCE_POSTFIXES = ['.lock', '.'];
+const DISALLOWED_SEQUENCE_PREFIXES = ['.'];
+
+// eslint-disable-next-line no-control-regex
+const CONTROL_CHARACTERS_REGEX = /[\x00-\x19\x7f]/;
+
+const toReadableString = (array) => array.map((item) => `"${item}"`).join(', ');
+
+const DisallowedPrefixesValidationMessage = sprintf(
+ __('Tag name should not start with %{prefixes}'),
+ {
+ prefixes: toReadableString([...EXPANDED_PREFIXES, ...DISALLOWED_PREFIXES]),
+ },
+ false,
+);
+
+const DisallowedPostfixesValidationMessage = sprintf(
+ __('Tag name should not end with %{postfixes}'),
+ { postfixes: toReadableString(DISALLOWED_POSTFIXES) },
+ false,
+);
+
+const DisallowedNameValidationMessage = sprintf(
+ __('Tag name cannot be one of the following: %{names}'),
+ { names: toReadableString(DISALLOWED_NAMES) },
+ false,
+);
+
+const EmptyNameValidationMessage = __('Tag name should not be empty');
+
+const DisallowedSubstringsValidationMessage = sprintf(
+ __('Tag name should not contain any of the following: %{substrings}'),
+ { substrings: toReadableString(DISALLOWED_SUBSTRINGS) },
+ false,
+);
+
+const DisallowedSequenceEmptyValidationMessage = __(
+ `No slash-separated tag name component can be empty`,
+);
+
+const DisallowedSequencePrefixesValidationMessage = sprintf(
+ __('No slash-separated component can begin with %{sequencePrefixes}'),
+ { sequencePrefixes: toReadableString(DISALLOWED_SEQUENCE_PREFIXES) },
+ false,
+);
+
+const DisallowedSequencePostfixesValidationMessage = sprintf(
+ __('No slash-separated component can end with %{sequencePostfixes}'),
+ { sequencePostfixes: toReadableString(DISALLOWED_SEQUENCE_POSTFIXES) },
+ false,
+);
+
+const ControlCharactersValidationMessage = __('Tag name should not contain any control characters');
+
+export const validationMessages = {
+ EmptyNameValidationMessage,
+ DisallowedPrefixesValidationMessage,
+ DisallowedPostfixesValidationMessage,
+ DisallowedNameValidationMessage,
+ DisallowedSubstringsValidationMessage,
+ DisallowedSequenceEmptyValidationMessage,
+ DisallowedSequencePrefixesValidationMessage,
+ DisallowedSequencePostfixesValidationMessage,
+ ControlCharactersValidationMessage,
+};
+
+export class ValidationResult {
+ isValid = true;
+ validationErrors = [];
+
+ addValidationError = (errorMessage) => {
+ this.isValid = false;
+ this.validationErrors.push(errorMessage);
+ };
+}
+
+export const validateTag = (refName) => {
+ if (typeof refName !== 'string') {
+ throw new Error('refName argument must be a string');
+ }
+
+ const validationResult = new ValidationResult();
+
+ if (!refName || refName.trim() === '') {
+ validationResult.addValidationError(EmptyNameValidationMessage);
+ return validationResult;
+ }
+
+ if (CONTROL_CHARACTERS_REGEX.test(refName)) {
+ validationResult.addValidationError(ControlCharactersValidationMessage);
+ }
+
+ if (DISALLOWED_NAMES.some((name) => name === refName)) {
+ validationResult.addValidationError(DisallowedNameValidationMessage);
+ }
+
+ if ([...EXPANDED_PREFIXES, ...DISALLOWED_PREFIXES].some((prefix) => refName.startsWith(prefix))) {
+ validationResult.addValidationError(DisallowedPrefixesValidationMessage);
+ }
+
+ if (DISALLOWED_POSTFIXES.some((postfix) => refName.endsWith(postfix))) {
+ validationResult.addValidationError(DisallowedPostfixesValidationMessage);
+ }
+
+ if (DISALLOWED_SUBSTRINGS.some((substring) => refName.includes(substring))) {
+ validationResult.addValidationError(DisallowedSubstringsValidationMessage);
+ }
+
+ const refNameParts = refName.split('/');
+
+ if (refNameParts.some((part) => part === '')) {
+ validationResult.addValidationError(DisallowedSequenceEmptyValidationMessage);
+ }
+
+ if (
+ refNameParts.some((part) =>
+ DISALLOWED_SEQUENCE_PREFIXES.some((prefix) => part.startsWith(prefix)),
+ )
+ ) {
+ validationResult.addValidationError(DisallowedSequencePrefixesValidationMessage);
+ }
+
+ if (
+ refNameParts.some((part) =>
+ DISALLOWED_SEQUENCE_POSTFIXES.some((postfix) => part.endsWith(postfix)),
+ )
+ ) {
+ validationResult.addValidationError(DisallowedSequencePostfixesValidationMessage);
+ }
+
+ return validationResult;
+};
diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js
index 5d194340b9e..1db863294f8 100644
--- a/app/assets/javascripts/lib/utils/resize_observer.js
+++ b/app/assets/javascripts/lib/utils/resize_observer.js
@@ -19,27 +19,31 @@ export function createResizeObserver() {
* @param {Object} options
* @param {string} options.targetId - id of element to scroll to
* @param {string} options.container - Selector of element containing target
+ * @param {Element} options.component - Element containing target
*
* @return {ResizeObserver|null} - ResizeObserver instance if target looks like a note DOM ID
*/
export function scrollToTargetOnResize({
targetId = window.location.hash.slice(1),
container = '#content-body',
+ containerId,
} = {}) {
if (!targetId) return null;
const ro = createResizeObserver();
- const containerEl = document.querySelector(container);
+ const containerEl =
+ document.querySelector(`#${containerId}`) || document.querySelector(container);
let interactionListenersAdded = false;
- function keepTargetAtTop() {
+ function keepTargetAtTop(evt) {
const anchorEl = document.getElementById(targetId);
+ const scrollContainer = containerId ? evt.target : document.documentElement;
if (!anchorEl) return;
const anchorTop = anchorEl.getBoundingClientRect().top + window.scrollY;
const top = anchorTop - contentTop();
- document.documentElement.scrollTo({
+ scrollContainer.scrollTo({
top,
});
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 05ed08931bb..2d5e9bc91f2 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import { insertText } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
const INDENT_CHAR = ' ';
@@ -625,11 +626,11 @@ export function addMarkdownListeners(form) {
Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn);
});
- // eslint-disable-next-line @gitlab/no-global-event-off
- const $allToolbarBtns = $('.js-md', form)
- .off('click')
- .on('click', function () {
- const $toolbarBtn = $(this);
+ const $allToolbarBtns = $(form)
+ .off('click', '.js-md, .saved-replies-dropdown li')
+ .on('click', '.js-md, .saved-replies-dropdown li', function () {
+ const $savedReplyContent = $('.js-saved-reply-content', this);
+ const $toolbarBtn = $savedReplyContent.length ? $savedReplyContent : $(this);
return updateTextForToolbarBtn($toolbarBtn);
});
@@ -669,3 +670,50 @@ export function removeMarkdownListeners(form) {
// eslint-disable-next-line @gitlab/no-global-event-off
return $('.js-md', form).off('click');
}
+
+/**
+ * If the textarea cursor is positioned in a Markdown image declaration,
+ * it uses the Markdown API to resolve the image’s absolute URL.
+ * @param {Object} textarea Textarea DOM element
+ * @param {String} markdownPreviewPath Markdown API path
+ * @returns {Object} an object containing the image’s absolute URL, filename,
+ * and the markdown declaration. If the textarea cursor is not positioned
+ * in an image, it returns null.
+ */
+export const resolveSelectedImage = async (textArea, markdownPreviewPath = '') => {
+ const { lines, startPos } = linesFromSelection(textArea);
+
+ // image declarations can’t span more than one line in Markdown
+ if (lines > 0) {
+ return null;
+ }
+
+ const selectedLine = lines[0];
+
+ if (!/!\[.+?\]\(.+?\)/.test(selectedLine)) return null;
+
+ const lineSelectionStart = textArea.selectionStart - startPos;
+ const preExlm = selectedLine.substring(0, lineSelectionStart).lastIndexOf('!');
+ const postClose = selectedLine.substring(lineSelectionStart).indexOf(')');
+
+ if (preExlm >= 0 && postClose >= 0) {
+ const imageMarkdown = selectedLine.substring(preExlm, lineSelectionStart + postClose + 1);
+ const { data } = await axios.post(markdownPreviewPath, { text: imageMarkdown });
+ const parser = new DOMParser();
+
+ const dom = parser.parseFromString(data.body, 'text/html');
+ const imageURL = dom.body.querySelector('a').getAttribute('href');
+
+ if (imageURL) {
+ const filename = imageURL.substring(imageURL.lastIndexOf('/') + 1);
+
+ return {
+ imageMarkdown,
+ imageURL,
+ filename,
+ };
+ }
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 367180714df..1bed38b7dbe 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,4 +1,5 @@
import { isString, memoize } from 'lodash';
+import { sprintf, __ } from '~/locale';
import { base64ToBuffer, bufferToBase64 } from '~/authentication/webauthn/util';
import {
TRUNCATE_WIDTH_DEFAULT_WIDTH,
@@ -525,3 +526,45 @@ export function base64DecodeUnicode(str) {
const decoder = new TextDecoder('utf8');
return decoder.decode(base64ToBuffer(str));
}
+
+// returns an array of errors (if there are any)
+const INVALID_BRANCH_NAME_CHARS = [' ', '~', '^', ':', '?', '*', '[', '..', '@{', '\\', '//'];
+
+/**
+ * Returns an array of invalid characters found in a branch name
+ *
+ * @param {String} name branch name to check
+ * @return {Array} Array of invalid characters found
+ */
+export const findInvalidBranchNameCharacters = (name) => {
+ const invalidChars = [];
+
+ INVALID_BRANCH_NAME_CHARS.forEach((pattern) => {
+ if (name.indexOf(pattern) > -1) {
+ invalidChars.push(pattern);
+ }
+ });
+
+ return invalidChars;
+};
+
+/**
+ * Returns a string describing validation errors for a branch name
+ *
+ * @param {Array} invalidChars Array of invalid characters that were found
+ * @return {String} Error message describing on the invalid characters found
+ */
+export const humanizeBranchValidationErrors = (invalidChars = []) => {
+ const chars = invalidChars.filter((c) => INVALID_BRANCH_NAME_CHARS.includes(c));
+
+ if (chars.length && !chars.includes(' ')) {
+ return sprintf(__("Can't contain %{chars}"), { chars: chars.join(', ') });
+ } else if (chars.includes(' ') && chars.length <= 1) {
+ return __("Can't contain spaces");
+ } else if (chars.includes(' ') && chars.length > 1) {
+ return sprintf(__("Can't contain spaces, %{chars}"), {
+ chars: chars.filter((c) => c !== ' ').join(', '),
+ });
+ }
+ return '';
+};
diff --git a/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js b/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js
new file mode 100644
index 00000000000..ec8feb7d2e6
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+
+export const compatFunctionalMixin = Vue.version.startsWith('3')
+ ? {
+ created() {
+ this.props = this.$props;
+ this.listeners = this.$listeners;
+ },
+ }
+ : {
+ created() {
+ throw new Error('This mixin should not be executed in Vue.js 2');
+ },
+ };
diff --git a/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js b/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js
new file mode 100644
index 00000000000..daafbad8ba1
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js
@@ -0,0 +1,9 @@
+// this will be replaced by markRaw from vue.js v3
+export function markRaw(obj) {
+ Object.defineProperty(obj, '__v_skip', {
+ value: true,
+ configurable: true,
+ });
+
+ return obj;
+}
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index c8c6b51f374..12df67670f9 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -22,7 +22,9 @@ export default (input, parameters, escapeParameters = true) => {
mappedParameters.forEach((key, parameterName) => {
const parameterValue = mappedParameters.get(parameterName);
const escapedParameterValue = escapeParameters ? escape(parameterValue) : parameterValue;
- output = output.replace(new RegExp(`%{${parameterName}}`, 'g'), escapedParameterValue);
+ // Pass the param value as a function to ignore special replacement patterns like $` and $'.
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#syntax
+ output = output.replace(new RegExp(`%{${parameterName}}`, 'g'), () => escapedParameterValue);
});
}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 4c715c4993f..a1539aba786 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -89,7 +89,7 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
- initTopNav();
+ if (!gon.use_new_navigation) initTopNav();
initBreadcrumbs();
initTodoToggle();
initPrefetchLinks('.js-prefetch-document');
@@ -104,14 +104,6 @@ function deferredInitialisation() {
initCopyCodeButton();
initGitlabVersionCheck();
- // Init super sidebar
- if (gon.use_new_navigation) {
- // eslint-disable-next-line promise/catch-or-return
- import('./super_sidebar/super_sidebar_bundle').then(({ initSuperSidebar }) => {
- initSuperSidebar();
- });
- }
-
addSelectOnFocusBehaviour('.js-select-on-focus');
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 68c5831db62..8e5b88d362e 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -192,8 +192,6 @@ export const MEMBER_STATE_ACTIVE = 2;
export const BADGE_LABELS_AWAITING_SIGNUP = __('Awaiting user signup');
export const BADGE_LABELS_PENDING = __('Pending owner action');
-export const DAYS_TO_EXPIRE_SOON = 7;
-
export const LEAVE_MODAL_ID = 'member-leave-modal';
export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
index 707e8a0645f..c6feb684795 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -2,7 +2,7 @@
import { GlButton } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE } from '../constants';
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
index f84eaabf9e7..07a32a77c6a 100644
--- a/app/assets/javascripts/merge_conflicts/store/actions.js
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -1,5 +1,5 @@
import { setCookie } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants';
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 61abdca0a5b..4277e535d20 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,7 +1,8 @@
/* eslint-disable func-names, no-underscore-dangle, consistent-return */
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
import eventHub from '~/vue_merge_request_widget/event_hub';
@@ -27,7 +28,7 @@ function MergeRequest(opts) {
if ($('.description.js-task-list-container').length) {
this.taskList = new TaskList({
- dataType: 'merge_request',
+ dataType: TYPE_MERGE_REQUEST,
fieldName: 'description',
selector: '.detail-page-description',
lockVersion: this.$el.data('lockVersion'),
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 46ee8fecfc5..d55e942dafa 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
import $ from 'jquery';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils';
import { parseUrlPathname } from '~/lib/utils/url_utility';
import createEventHub from '~/helpers/event_hub_factory';
diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
index 1590e693c07..a5a4e683214 100644
--- a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
+++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
@@ -1,13 +1,13 @@
<script>
-import { GlListbox } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
- GlListbox,
+ GlCollapsibleListbox,
},
props: {
staticData: {
@@ -124,7 +124,7 @@ export default {
:name="inputName"
data-testid="target-project-input"
/>
- <gl-listbox
+ <gl-collapsible-listbox
v-model="selected"
:items="filteredData"
:toggle-text="current.text || dropdownHeader"
diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 4b3c1bd7d10..8e7428089e2 100644
--- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility';
@@ -103,7 +103,7 @@ Once deleted, it cannot be undone or recovered.`),
},
primaryProps: {
text: s__('Milestones|Delete milestone'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelProps: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
index 9e537fa2c82..63791dcd011 100644
--- a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
@@ -80,11 +80,11 @@ export default {
},
primaryAction: {
text: s__('Milestones|Promote Milestone'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
cancelAction: {
text: __('Cancel'),
- attributes: [],
+ attributes: {},
},
};
</script>
diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js
index d9e72340d62..20cc0352c33 100644
--- a/app/assets/javascripts/milestones/milestone.js
+++ b/app/assets/javascripts/milestones/milestone.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index 2995f19c470..299c9731ad7 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { hide } from '~/tooltips';
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 037120a0d81..68b18a34ded 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { escape } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
diff --git a/app/assets/javascripts/ml/experiment_tracking/constants.js b/app/assets/javascripts/ml/experiment_tracking/constants.js
index 15462b519e1..11cf321ad51 100644
--- a/app/assets/javascripts/ml/experiment_tracking/constants.js
+++ b/app/assets/javascripts/ml/experiment_tracking/constants.js
@@ -1,19 +1,4 @@
-import { __, s__ } from '~/locale';
-
-export const METRIC_KEY_PREFIX = 'metric.';
-
-export const LIST_KEY_CREATED_AT = 'created_at';
-
-export const BASE_SORT_FIELDS = Object.freeze([
- {
- orderBy: 'name',
- label: __('Name'),
- },
- {
- orderBy: LIST_KEY_CREATED_AT,
- label: __('Created at'),
- },
-]);
+import { s__ } from '~/locale';
export const EMPTY_STATE_SVG = '/assets/illustrations/empty-state/empty-dag-md.svg';
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js
new file mode 100644
index 00000000000..529bd6fe9f2
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/index.js
@@ -0,0 +1,3 @@
+import MlCandidatesShow from './ml_candidates_show.vue';
+
+export default MlCandidatesShow;
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
index d0c42905ee2..3c765de92a2 100644
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
@@ -1,11 +1,21 @@
<script>
import { GlLink } from '@gitlab/ui';
-import { __ } from '~/locale';
import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+import {
+ TITLE_LABEL,
+ INFO_LABEL,
+ ID_LABEL,
+ STATUS_LABEL,
+ EXPERIMENT_LABEL,
+ ARTIFACTS_LABEL,
+ PARAMETERS_LABEL,
+ METRICS_LABEL,
+ METADATA_LABEL,
+} from './translations';
export default {
- name: 'MlCandidate',
+ name: 'MlCandidatesShow',
components: {
IncubationAlert,
GlLink,
@@ -17,29 +27,29 @@ export default {
},
},
i18n: {
- titleLabel: __('Model candidate details'),
- infoLabel: __('Info'),
- idLabel: __('ID'),
- statusLabel: __('Status'),
- experimentLabel: __('Experiment'),
- artifactsLabel: __('Artifacts'),
- parametersLabel: __('Parameters'),
- metricsLabel: __('Metrics'),
- metadataLabel: __('Metadata'),
+ TITLE_LABEL,
+ INFO_LABEL,
+ ID_LABEL,
+ STATUS_LABEL,
+ EXPERIMENT_LABEL,
+ ARTIFACTS_LABEL,
+ PARAMETERS_LABEL,
+ METRICS_LABEL,
+ METADATA_LABEL,
},
computed: {
sections() {
return [
{
- sectionName: this.$options.i18n.parametersLabel,
+ sectionName: this.$options.i18n.PARAMETERS_LABEL,
sectionValues: this.candidate.params,
},
{
- sectionName: this.$options.i18n.metricsLabel,
+ sectionName: this.$options.i18n.METRICS_LABEL,
sectionValues: this.candidate.metrics,
},
{
- sectionName: this.$options.i18n.metadataLabel,
+ sectionName: this.$options.i18n.METADATA_LABEL,
sectionValues: this.candidate.metadata,
},
];
@@ -58,7 +68,7 @@ export default {
/>
<h3>
- {{ $options.i18n.titleLabel }}
+ {{ $options.i18n.TITLE_LABEL }}
</h3>
<table class="candidate-details">
@@ -66,20 +76,20 @@ export default {
<tr class="divider"></tr>
<tr>
- <td class="gl-text-secondary gl-font-weight-bold">{{ $options.i18n.infoLabel }}</td>
- <td class="gl-font-weight-bold">{{ $options.i18n.idLabel }}</td>
+ <td class="gl-text-secondary gl-font-weight-bold">{{ $options.i18n.INFO_LABEL }}</td>
+ <td class="gl-font-weight-bold">{{ $options.i18n.ID_LABEL }}</td>
<td>{{ candidate.info.iid }}</td>
</tr>
<tr>
<td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.statusLabel }}</td>
+ <td class="gl-font-weight-bold">{{ $options.i18n.STATUS_LABEL }}</td>
<td>{{ candidate.info.status }}</td>
</tr>
<tr>
<td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.experimentLabel }}</td>
+ <td class="gl-font-weight-bold">{{ $options.i18n.EXPERIMENT_LABEL }}</td>
<td>
<gl-link :href="candidate.info.path_to_experiment">{{
candidate.info.experiment_name
@@ -89,10 +99,10 @@ export default {
<tr v-if="candidate.info.path_to_artifact">
<td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.artifactsLabel }}</td>
+ <td class="gl-font-weight-bold">{{ $options.i18n.ARTIFACTS_LABEL }}</td>
<td>
<gl-link :href="candidate.info.path_to_artifact">{{
- $options.i18n.artifactsLabel
+ $options.i18n.ARTIFACTS_LABEL
}}</gl-link>
</td>
</tr>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
new file mode 100644
index 00000000000..caad145873e
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
@@ -0,0 +1,11 @@
+import { s__ } from '~/locale';
+
+export const TITLE_LABEL = s__('MlExperimentTracking|Model candidate details');
+export const INFO_LABEL = s__('MlExperimentTracking|Info');
+export const ID_LABEL = s__('MlExperimentTracking|ID');
+export const STATUS_LABEL = s__('MlExperimentTracking|Status');
+export const EXPERIMENT_LABEL = s__('MlExperimentTracking|Experiment');
+export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts');
+export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters');
+export const METRICS_LABEL = s__('MlExperimentTracking|Metrics');
+export const METADATA_LABEL = s__('MlExperimentTracking|Metadata');
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js
new file mode 100644
index 00000000000..4d34555ac2f
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js
@@ -0,0 +1,21 @@
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const METRIC_KEY_PREFIX = 'metric.';
+export const LIST_KEY_CREATED_AT = 'created_at';
+export const BASE_SORT_FIELDS = Object.freeze([
+ {
+ orderBy: 'name',
+ label: s__('MlExperimentTracking|Name'),
+ },
+ {
+ orderBy: LIST_KEY_CREATED_AT,
+ label: s__('MlExperimentTracking|Created at'),
+ },
+]);
+export const CREATE_CANDIDATE_HELP_PATH = helpPagePath(
+ 'user/project/ml/experiment_tracking/index.md',
+ {
+ anchor: 'tracking-new-experiments-and-trials',
+ },
+);
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js
new file mode 100644
index 00000000000..5903866b6dd
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/index.js
@@ -0,0 +1,3 @@
+import MlExperimentsShow from './ml_experiments_show.vue';
+
+export default MlExperimentsShow;
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
index c09aabb0d40..ca0a42fda10 100644
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
@@ -1,35 +1,54 @@
<script>
-import { GlTable, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { GlTableLite, GlLink, GlEmptyState } from '@gitlab/ui';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
- LIST_KEY_CREATED_AT,
- BASE_SORT_FIELDS,
- METRIC_KEY_PREFIX,
FEATURE_NAME,
FEATURE_FEEDBACK_ISSUE,
+ EMPTY_STATE_SVG,
} from '~/ml/experiment_tracking/constants';
-import { s__ } from '~/locale';
import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue';
import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+import {
+ LIST_KEY_CREATED_AT,
+ BASE_SORT_FIELDS,
+ METRIC_KEY_PREFIX,
+ CREATE_CANDIDATE_HELP_PATH,
+} from './constants';
+import * as translations from './translations';
export default {
- name: 'MlExperiment',
+ name: 'MlExperimentsShow',
components: {
- GlTable,
+ GlTableLite,
GlLink,
+ GlEmptyState,
TimeAgo,
IncubationAlert,
RegistrySearch,
KeysetPagination,
},
- directives: {
- GlTooltip: GlTooltipDirective,
+ props: {
+ candidates: {
+ type: Array,
+ required: true,
+ },
+ metricNames: {
+ type: Array,
+ required: true,
+ },
+ paramNames: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
},
- inject: ['candidates', 'metricNames', 'paramNames', 'pageInfo'],
data() {
const query = queryToObject(window.location.search);
@@ -54,13 +73,12 @@ export default {
if (this.candidates.length === 0) return [];
return [
- { key: 'name', label: this.$options.i18n.nameLabel },
- { key: 'created_at', label: this.$options.i18n.createdAtLabel },
- { key: 'user', label: this.$options.i18n.userLabel },
+ { key: 'nameColumn', label: this.$options.i18n.NAME_LABEL },
+ { key: 'created_at', label: this.$options.i18n.CREATED_AT_LABEL },
+ { key: 'user', label: this.$options.i18n.USER_LABEL },
...this.paramNames,
...this.metricNames,
- { key: 'details', label: '' },
- { key: 'artifact', label: '' },
+ { key: 'artifact', label: this.$options.i18n.ARTIFACTS_LABEL },
];
},
displayPagination() {
@@ -94,6 +112,18 @@ export default {
return { ...filterByQuery, orderBy, orderByType, sort };
},
+ tableItems() {
+ return this.candidates.map((candidate) => ({
+ ...candidate,
+ nameColumn: {
+ name: candidate.name,
+ details_path: candidate.details,
+ },
+ }));
+ },
+ hasItems() {
+ return this.candidates.length > 0;
+ },
},
methods: {
submitFilters() {
@@ -110,33 +140,23 @@ export default {
this.submitFilters();
},
},
- i18n: {
- titleLabel: s__('MlExperimentTracking|Experiment candidates'),
- emptyStateLabel: s__('MlExperimentTracking|No candidates to display'),
- artifactsLabel: s__('MlExperimentTracking|Artifacts'),
- detailsLabel: s__('MlExperimentTracking|Details'),
- userLabel: s__('MlExperimentTracking|User'),
- createdAtLabel: s__('MlExperimentTracking|Created at'),
- nameLabel: s__('MlExperimentTracking|Name'),
- noDataContent: s__('MlExperimentTracking|-'),
- filterCandidatesLabel: s__('MlExperimentTracking|Filter candidates'),
+ i18n: translations,
+ constants: {
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
+ CREATE_CANDIDATE_HELP_PATH,
+ EMPTY_STATE_SVG,
},
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
};
</script>
<template>
<div>
<incubation-alert
- :feature-name="$options.FEATURE_NAME"
- :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
+ :feature-name="$options.constants.FEATURE_NAME"
+ :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE"
/>
- <h3>
- {{ $options.i18n.titleLabel }}
- </h3>
-
<registry-search
:filters="filters"
:sorting="sorting"
@@ -147,53 +167,54 @@ export default {
@filter:clear="filters = []"
/>
- <gl-table
- :fields="fields"
- :items="candidates"
- :empty-text="$options.i18n.emptyStateLabel"
- show-empty
- small
- class="gl-mt-0! ml-candidate-table"
- >
- <template #cell()="data">
- <div v-gl-tooltip.hover :title="data.value">{{ data.value }}</div>
- </template>
-
- <template #cell(artifact)="data">
- <gl-link
- v-if="data.value"
- v-gl-tooltip.hover
- :href="data.value"
- target="_blank"
- :title="$options.i18n.artifactsLabel"
- >{{ $options.i18n.artifactsLabel }}</gl-link
- >
- <div v-else v-gl-tooltip.hover :title="$options.i18n.artifactsLabel">
- {{ $options.i18n.noDataContent }}
- </div>
- </template>
-
- <template #cell(details)="data">
- <gl-link v-gl-tooltip.hover :href="data.value" :title="$options.i18n.detailsLabel">{{
- $options.i18n.detailsLabel
- }}</gl-link>
- </template>
-
- <template #cell(created_at)="data">
- <time-ago v-gl-tooltip.hover :time="data.value" :title="data.value" />
- </template>
-
- <template #cell(user)="data">
- <gl-link
- v-if="data.value"
- v-gl-tooltip.hover
- :href="data.value.path"
- :title="data.value.username"
- >@{{ data.value.username }}</gl-link
- >
- <div v-else>{{ $options.i18n.noDataContent }}</div>
- </template>
- </gl-table>
+ <div v-if="hasItems" class="gl-overflow-x-auto">
+ <gl-table-lite
+ :fields="fields"
+ :items="tableItems"
+ show-empty
+ small
+ class="gl-mt-0! ml-candidate-table"
+ >
+ <template #cell()="data">
+ <div>{{ data.value }}</div>
+ </template>
+
+ <template #cell(nameColumn)="data">
+ <gl-link :href="data.value.details_path">
+ <span v-if="data.value.name"> {{ data.value.name }}</span>
+ <span v-else class="gl-font-style-italic">{{ $options.i18n.NO_CANDIDATE_NAME }}</span>
+ </gl-link>
+ </template>
+
+ <template #cell(artifact)="data">
+ <gl-link v-if="data.value" :href="data.value" target="_blank">{{
+ $options.i18n.ARTIFACTS_LABEL
+ }}</gl-link>
+ <div v-else class="gl-font-style-italic gl-text-gray-500">
+ {{ $options.i18n.NO_ARTIFACT }}
+ </div>
+ </template>
+
+ <template #cell(created_at)="data">
+ <time-ago :time="data.value" />
+ </template>
+
+ <template #cell(user)="data">
+ <gl-link v-if="data.value" :href="data.value.path">@{{ data.value.username }}</gl-link>
+ <div v-else>{{ $options.i18n.NO_DATA_CONTENT }}</div>
+ </template>
+ </gl-table-lite>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.EMPTY_STATE_TITLE_LABEL"
+ :primary-button-text="$options.i18n.CREATE_NEW_LABEL"
+ :primary-button-link="$options.constants.CREATE_CANDIDATE_HELP_PATH"
+ :svg-path="$options.constants.EMPTY_STATE_SVG"
+ :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
+ class="gl-py-8"
+ />
<keyset-pagination v-if="displayPagination" v-bind="pageInfo" />
</div>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
new file mode 100644
index 00000000000..63b0d902b72
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
@@ -0,0 +1,16 @@
+import { s__ } from '~/locale';
+
+export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts');
+export const DETAILS_LABEL = s__('MlExperimentTracking|Details');
+export const USER_LABEL = s__('MlExperimentTracking|Author');
+export const CREATED_AT_LABEL = s__('MlExperimentTracking|Created at');
+export const NAME_LABEL = s__('MlExperimentTracking|Name');
+export const NO_DATA_CONTENT = s__('MlExperimentTracking|-');
+export const FILTER_CANDIDATES_LABEL = s__('MlExperimentTracking|Filter candidates');
+export const NO_CANDIDATE_NAME = s__('MlExperimentTracking|No name');
+export const NO_ARTIFACT = s__('MlExperimentTracking|No artifacts');
+export const CREATE_NEW_LABEL = s__('MlExperimentTracking|Create new candidates');
+export const EMPTY_STATE_DESCRIPTION_LABEL = s__(
+ 'MlExperimentTracking|No candidates logged for the query. Create new candidates using the MLflow client.',
+);
+export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No candidates');
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 2c185794d17..ab2b713ac9f 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -11,7 +11,7 @@ import {
import Mousetrap from 'mousetrap';
import VueDraggable from 'vuedraggable';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+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';
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 1b506c6564b..faef4b01c27 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -226,12 +226,6 @@ export const OVERVIEW_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
*/
export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/';
-export const OPERATORS = {
- greaterThan: '>',
- equalTo: '==',
- lessThan: '<',
-};
-
/**
* Dashboard yml files support custom user-defined variables that
* are rendered as input elements in the monitoring dashboard.
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 0ef365c6368..4fdc08487f2 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index d968c125068..2488c8aee9c 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -1,5 +1,8 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
+
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { parseBoolean } from '~/lib/utils/common_utils';
import store from '~/mr_notes/stores';
@@ -22,6 +25,8 @@ export default () => {
return;
}
+ Vue.use(VueApollo);
+
const notesFilterProps = getNotesFilterData(el);
const notesDataset = el.dataset;
@@ -33,8 +38,10 @@ export default () => {
NotesApp,
},
store,
+ apolloProvider,
provide: {
reportAbusePath: notesDataset.reportAbusePath,
+ newSavedRepliesPath: notesDataset.savedRepliesNewPath,
},
data() {
const noteableData = JSON.parse(notesDataset.noteableData);
diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js
index 09757ce17fa..b7dd509f7f2 100644
--- a/app/assets/javascripts/namespaces/leave_by_url.js
+++ b/app/assets/javascripts/namespaces/leave_by_url.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { initRails } from '~/lib/utils/rails_ujs';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index da22a8d2fb7..ca6232fa4c4 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -1,7 +1,7 @@
<script>
-import { GlBadge, GlToggle } from '@gitlab/ui';
+import { GlBadge, GlToggle, GlDisclosureDropdownItem } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -18,6 +18,7 @@ export default {
components: {
GlBadge,
GlToggle,
+ GlDisclosureDropdownItem,
},
props: {
enabled: {
@@ -28,6 +29,11 @@ export default {
type: String,
required: true,
},
+ newNavigation: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -61,7 +67,18 @@ export default {
</script>
<template>
- <li>
+ <gl-disclosure-dropdown-item v-if="newNavigation" @action="toggleNav">
+ <div class="gl-new-dropdown-item-content">
+ <div
+ class="gl-new-dropdown-item-text-wrapper gl-display-flex! gl-justify-content-space-between gl-align-items-center gl-py-2!"
+ >
+ {{ $options.i18n.toggleMenuItemLabel }}
+ <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" />
+ </div>
+ </div>
+ </gl-disclosure-dropdown-item>
+
+ <li v-else>
<div
class="gl-px-4 gl-py-2 gl-display-flex gl-justify-content-space-between gl-align-items-center"
>
@@ -74,7 +91,12 @@ export default {
@click.prevent.stop="toggleNav"
>
{{ $options.i18n.toggleMenuItemLabel }}
- <gl-toggle :value="isEnabled" :label="$options.i18n.toggleLabel" label-position="hidden" />
+ <gl-toggle
+ :value="isEnabled"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ data-qa-selector="new_navigation_toggle"
+ />
</div>
</li>
</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
index bfcdcfc7292..2dfd77bc02e 100644
--- a/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
+++ b/app/assets/javascripts/nav/components/top_nav_new_dropdown.vue
@@ -1,5 +1,7 @@
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { TOP_NAV_INVITE_MEMBERS_COMPONENT } from '~/invite_members/constants';
export default {
components: {
@@ -7,6 +9,7 @@ export default {
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
+ InviteMembersTrigger,
},
props: {
viewModel: {
@@ -22,6 +25,11 @@ export default {
return this.sections.length > 1;
},
},
+ methods: {
+ isInvitedMembers(menuItem) {
+ return menuItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT;
+ },
+ },
};
</script>
@@ -41,7 +49,16 @@ export default {
{{ title }}
</gl-dropdown-section-header>
<template v-for="menuItem in menu_items">
+ <invite-members-trigger
+ v-if="isInvitedMembers(menuItem)"
+ :key="`${index}_item_${menuItem.id}`"
+ :trigger-element="`dropdown-${menuItem.data.trigger_element}`"
+ :display-text="menuItem.title"
+ :icon="menuItem.icon"
+ :trigger-source="menuItem.data.trigger_source"
+ />
<gl-dropdown-item
+ v-else
:key="`${index}_item_${menuItem.id}`"
link-class="top-nav-menu-item"
:href="menuItem.href"
diff --git a/app/assets/javascripts/notebook/cells/output/error.vue b/app/assets/javascripts/notebook/cells/output/error.vue
new file mode 100644
index 00000000000..9afc89cde4f
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/error.vue
@@ -0,0 +1,40 @@
+<script>
+import Prompt from '../prompt.vue';
+import Markdown from '../markdown.vue';
+
+export default {
+ name: 'ErrorOutput',
+ components: {
+ Prompt,
+ Markdown,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ rawCode: {
+ type: Array,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ parsedError() {
+ let parsed = this.rawCode.map((l) => l.replace(/\u001B\[[0-9][0-9;]*m/g, '')); // eslint-disable-line no-control-regex
+ parsed = ['```error', ...parsed, '```'].join('\n'); // eslint-disable-line @gitlab/require-i18n-strings
+ return { source: [parsed] };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="output">
+ <prompt type="Out" :count="count" />
+ <markdown :cell="parsedError" :hide-prompt="true" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index bd01534089e..22bcb5dd66a 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -4,8 +4,10 @@ import HtmlOutput from './html.vue';
import ImageOutput from './image.vue';
import LatexOutput from './latex.vue';
import MarkdownOutput from './markdown.vue';
+import ErrorOutput from './error.vue';
const TEXT_MARKDOWN = 'text/markdown';
+const ERROR_OUTPUT_TYPE = 'error';
export default {
props: {
@@ -28,6 +30,8 @@ export default {
outputType(output) {
if (output.text) {
return 'text/plain';
+ } else if (output.output_type === ERROR_OUTPUT_TYPE) {
+ return 'error';
} else if (output.data['image/png']) {
return 'image/png';
} else if (output.data['image/jpeg']) {
@@ -56,6 +60,8 @@ export default {
getComponent(output) {
if (output.text) {
return CodeOutput;
+ } else if (output.output_type === ERROR_OUTPUT_TYPE) {
+ return ErrorOutput;
} else if (output.data['image/png']) {
return ImageOutput;
} else if (output.data['image/jpeg']) {
@@ -80,6 +86,10 @@ export default {
return output.text.join('');
}
+ if (output.output_type === ERROR_OUTPUT_TYPE) {
+ return output.traceback;
+ }
+
return this.dataForType(output, this.outputType(output));
},
},
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index df9694b7cd8..5f254cae73d 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -60,6 +60,10 @@ export default {
margin-bottom: 10px;
}
+.output .text-cell {
+ overflow-x: auto;
+}
+
.cell pre {
margin: 0;
width: 100%;
diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue
index cc372520c70..02d128eb119 100644
--- a/app/assets/javascripts/notes/components/comment_field_layout.vue
+++ b/app/assets/javascripts/notes/components/comment_field_layout.vue
@@ -76,7 +76,7 @@ export default {
></div>
<noteable-warning
v-if="hasWarning"
- class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
+ class="gl-py-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
:is-locked="isLocked"
:is-confidential="isConfidential"
:noteable-type="noteableType"
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 4f7256d0b0e..d78b48e0a6d 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,12 +1,11 @@
<script>
import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
-import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
-import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { badgeState } from '~/issuable/components/status_box.vue';
+import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import {
capitalizeFirstCharacter,
@@ -14,7 +13,7 @@ import {
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
import { sprintf } from '~/locale';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -35,7 +34,7 @@ export default {
components: {
NoteSignedOutWidget,
DiscussionLockedWidget,
- MarkdownField,
+ MarkdownEditor,
GlAlert,
GlButton,
TimelineEntryItem,
@@ -61,6 +60,14 @@ export default {
errors: [],
noteIsInternal: false,
isSubmitting: false,
+ formFieldProps: {
+ 'aria-label': this.$options.i18n.comment,
+ placeholder: this.$options.i18n.bodyPlaceholder,
+ id: 'note-body',
+ name: 'note[note]',
+ class: 'js-note-text note-textarea js-gfm-input markdown-area',
+ 'data-qa-selector': 'comment_field',
+ },
};
},
computed: {
@@ -95,16 +102,11 @@ export default {
}
return this.noteType === constants.COMMENT ? comment : startThread;
},
- textareaPlaceholder() {
- return this.noteIsInternal
- ? this.$options.i18n.bodyPlaceholderInternal
- : this.$options.i18n.bodyPlaceholder;
- },
discussionsRequireResolution() {
return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE;
},
isOpen() {
- return this.openState === constants.OPENED || this.openState === constants.REOPENED;
+ return this.openState === STATUS_OPEN || this.openState === STATUS_REOPENED;
},
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
@@ -152,7 +154,7 @@ export default {
canToggleIssueState() {
return (
this.getNoteableData.current_user.can_update &&
- this.openState !== constants.MERGED &&
+ this.openState !== STATUS_MERGED &&
!this.closedAndLocked
);
},
@@ -180,14 +182,27 @@ export default {
containsLink() {
return ATTACHMENT_REGEXP.test(this.note);
},
+ autosaveKey() {
+ if (this.isLoggedIn) {
+ const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
+ return `${this.$options.i18n.note}/${noteableType}/${this.getNoteableData.id}`;
+ }
+
+ return null;
+ },
+ },
+ watch: {
+ noteIsInternal(val) {
+ this.formFieldProps.placeholder = val
+ ? this.$options.i18n.bodyPlaceholderInternal
+ : this.$options.i18n.bodyPlaceholder;
+ },
},
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
- this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED);
+ this.toggleIssueLocalState(isClosed ? STATUS_CLOSED : STATUS_REOPENED);
});
-
- this.initAutoSave();
},
methods: {
...mapActions([
@@ -232,7 +247,6 @@ export default {
}
this.note = ''; // Empty textarea while being requested. Repopulate in catch
- this.resizeTextarea();
this.stopPolling();
this.isSubmitting = true;
@@ -249,7 +263,6 @@ export default {
.catch(({ response }) => {
this.handleSaveError(response);
- this.discard(false);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
@@ -286,20 +299,10 @@ export default {
}),
);
},
- discard(shouldClear = true) {
- // `blur` is needed to clear slash commands autocomplete cache if event fired.
- // `focus` is needed to remain cursor in the textarea.
- this.$refs.textarea.blur();
- this.$refs.textarea.focus();
-
- if (shouldClear) {
- this.note = '';
- this.noteIsInternal = false;
- this.resizeTextarea();
- this.$refs.markdownField.previewMarkdown = false;
- }
-
- this.autosave.reset();
+ discard() {
+ this.note = '';
+ this.noteIsInternal = false;
+ this.$refs.markdownEditor.togglePreview(false);
},
editCurrentUserLastNote() {
if (this.note === '') {
@@ -312,28 +315,15 @@ export default {
}
}
},
- initAutoSave() {
- if (this.isLoggedIn) {
- const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
-
- this.autosave = new Autosave(this.$refs.textarea, [
- this.$options.i18n.note,
- noteableType,
- this.getNoteableData.id,
- ]);
- }
- },
- resizeTextarea() {
- this.$nextTick(() => {
- Autosize.update(this.$refs.textarea);
- });
- },
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
dismissError(index) {
this.errors.splice(index, 1);
},
+ onInput(value) {
+ this.note = value;
+ },
},
};
</script>
@@ -362,35 +352,24 @@ export default {
:noteable-type="noteableType"
:contains-link="containsLink"
>
- <markdown-field
- ref="markdownField"
- :is-submitting="isSubmitting"
- :markdown-preview-path="markdownPreviewPath"
+ <markdown-editor
+ ref="markdownEditor"
+ :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
+ :value="note"
+ :render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
- :textarea-value="note"
- >
- <template #textarea>
- <textarea
- id="note-body"
- ref="textarea"
- v-model="note"
- dir="auto"
- :disabled="isSubmitting"
- name="note[note]"
- class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
- data-qa-selector="comment_field"
- data-testid="comment-field"
- data-supports-quick-actions="true"
- :aria-label="$options.i18n.comment"
- :placeholder="textareaPlaceholder"
- @keydown.up="editCurrentUserLastNote()"
- @keydown.meta.enter="handleEnter()"
- @keydown.ctrl.enter="handleEnter()"
- ></textarea>
- </template>
- </markdown-field>
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :form-field-props="formFieldProps"
+ :autosave-key="autosaveKey"
+ :disabled="isSubmitting"
+ supports-quick-actions
+ autofocus
+ @keydown.up="editCurrentUserLastNote()"
+ @keydown.meta.enter="handleEnter()"
+ @keydown.ctrl.enter="handleEnter()"
+ @input="onInput"
+ />
</comment-field-layout>
<div class="note-form-actions">
<template v-if="hasDrafts">
diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
index 8ac3f6bea68..bcf9b4cf893 100644
--- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -33,8 +33,10 @@ export default {
</script>
<template>
- <div class="disabled-comment text-center">
- <span class="issuable-note-warning inline">
+ <div class="disabled-comments gl-mt-3">
+ <span
+ class="issuable-note-warning gl-display-inline-block gl-w-full gl-px-5 gl-py-4 gl-rounded-base"
+ >
<gl-icon :size="16" name="lock" class="icon" />
<span v-if="isProjectArchived">
{{ projectArchivedWarning }}
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index abed95a9706..89cd252b94b 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -3,7 +3,7 @@ import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui
import { mapActions, mapGetters, mapState } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 9d59994788e..21841680cab 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import AwardsList from '~/vue_shared/components/awards_list.vue';
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 20cf21cd1b6..eef011db7d2 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -208,7 +208,7 @@ export default {
v-if="note.last_edited_at"
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
- action-text="Edited"
+ :action-text="__('Edited')"
class="note_edited_ago"
/>
<note-awards-list
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index e0c3ed0c67a..25c82c29a29 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -1,10 +1,13 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
+import { GlSprintf, GlLink } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { EDITED_TEXT } from '../i18n';
export default {
name: 'EditedNoteText',
components: {
+ GlSprintf,
+ GlLink,
TimeAgoTooltip,
},
props: {
@@ -33,19 +36,42 @@ export default {
default: 'edited-text',
},
},
+ i18n: EDITED_TEXT,
};
</script>
<template>
<div :class="className">
- {{ actionText }}
- <template v-if="editedBy">
- by
- <a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link">
- {{ editedBy.name }}
- </a>
- </template>
- {{ actionDetailText }}
- <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
+ <gl-sprintf v-if="editedBy" :message="$options.i18n.actionWithAuthor">
+ <template #actionText>
+ {{ actionText }}
+ </template>
+ <template #actionDetail>
+ {{ actionDetailText }}
+ </template>
+ <template #timeago>
+ <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
+ </template>
+ <template #author>
+ <gl-link
+ :href="editedBy.path"
+ :data-user-id="editedBy.id"
+ class="js-user-link author-link gl-hover-text-decoration-underline"
+ >
+ {{ editedBy.name }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.i18n.actionWithoutAuthor">
+ <template #actionText>
+ {{ actionText }}
+ </template>
+ <template #actionDetail>
+ {{ actionDetailText }}
+ </template>
+ <template #timeago>
+ <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
+ </template>
+ </gl-sprintf>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index c83b3d870d7..7dc6b045b4d 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -68,6 +68,11 @@ export default {
required: false,
default: false,
},
+ noteUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -85,7 +90,9 @@ export default {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
noteTimestampLink() {
- return this.noteId ? `#note_${this.noteId}` : undefined;
+ if (this.noteUrl) return this.noteUrl;
+
+ return this.noteId ? `#note_${getIdFromGraphQLId(this.noteId)}` : undefined;
},
hasAuthor() {
return this.author && Object.keys(this.author).length;
@@ -159,15 +166,13 @@ export default {
:data-user-id="authorId"
:data-username="author.username"
>
- <span class="note-header-author-name gl-font-weight-bold">
- {{ authorName }}
- </span>
+ <span class="note-header-author-name gl-font-weight-bold" v-text="authorName"></span>
</a>
<span v-if="!isSystemNote" class="text-nowrap author-username">
<a
ref="authorUsernameLink"
class="author-username-link"
- :href="author.path"
+ :href="authorHref"
@mouseenter="handleUsernameMouseEnter"
@mouseleave="handleUsernameMouseLeave"
><span class="note-headline-light">@{{ author.username }}</span>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index ff801cdccea..60ae573bae7 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import DraftNote from '~/batch_comments/components/draft_note.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 93575ad57ff..80025d6f98a 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -6,7 +6,7 @@ import { mapGetters, mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { HTTP_STATUS_GONE } from '~/lib/utils/http_status';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { truncateSha } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 4437d461308..b0f7a4a4732 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -39,7 +39,7 @@ export default {
},
liClasses() {
return this.collapsed
- ? 'gl-text-gray-500 gl-rounded-bottom-left-base gl-rounded-bottom-right-base'
+ ? 'gl-text-gray-500 gl-rounded-bottom-left-base! gl-rounded-bottom-right-base! replies-widget-collapsed'
: 'gl-border-b';
},
buttonIcon() {
@@ -76,7 +76,7 @@ export default {
v-for="author in uniqueAuthors"
:key="author.username"
class="gl-mr-3 reply-author-avatar"
- :link-href="author.path"
+ :link-href="author.path || author.webUrl"
:img-alt="author.name"
img-css-classes="gl-mr-0!"
:img-src="author.avatar_url || author.avatarUrl"
@@ -95,7 +95,7 @@ export default {
<gl-sprintf :message="$options.i18n.lastReplyBy">
<template #name>
<gl-link
- :href="lastReply.author.path"
+ :href="lastReply.author.path || lastReply.author.webUrl"
class="gl-text-body! gl-text-decoration-none! gl-mx-2"
>
{{ lastReply.author.name }}
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 88f438975f6..15eb4f95910 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -1,3 +1,4 @@
+import { STATUS_CLOSED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { __ } from '~/locale';
export const DISCUSSION_NOTE = 'DiscussionNote';
@@ -6,10 +7,6 @@ export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
export const COMMENT = 'comment';
-export const OPENED = 'opened';
-export const REOPENED = 'reopened';
-export const CLOSED = 'closed';
-export const MERGED = 'merged';
export const ISSUE_NOTEABLE_TYPE = 'Issue';
export const EPIC_NOTEABLE_TYPE = 'Epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
@@ -43,13 +40,19 @@ export const DISCUSSION_FILTER_TYPES = {
export const toggleStateErrorMessage = {
Epic: {
- [CLOSED]: __('Something went wrong while reopening the epic. Please try again later.'),
- [OPENED]: __('Something went wrong while closing the epic. Please try again later.'),
- [REOPENED]: __('Something went wrong while closing the epic. Please try again later.'),
+ [STATUS_CLOSED]: __('Something went wrong while reopening the epic. Please try again later.'),
+ [STATUS_OPEN]: __('Something went wrong while closing the epic. Please try again later.'),
+ [STATUS_REOPENED]: __('Something went wrong while closing the epic. Please try again later.'),
},
MergeRequest: {
- [CLOSED]: __('Something went wrong while reopening the merge request. Please try again later.'),
- [OPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
- [REOPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
+ [STATUS_CLOSED]: __(
+ 'Something went wrong while reopening the merge request. Please try again later.',
+ ),
+ [STATUS_OPEN]: __(
+ 'Something went wrong while closing the merge request. Please try again later.',
+ ),
+ [STATUS_REOPENED]: __(
+ 'Something went wrong while closing the merge request. Please try again later.',
+ ),
},
};
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
index a758a55014a..4bf2a8d70a7 100644
--- a/app/assets/javascripts/notes/i18n.js
+++ b/app/assets/javascripts/notes/i18n.js
@@ -49,3 +49,8 @@ export const COMMENT_FORM = {
'Notes|Attachments are sent by email. Attachments over 10 MB are sent as links to your GitLab instance, and only accessible to project members.',
),
};
+
+export const EDITED_TEXT = {
+ actionWithAuthor: __('%{actionText} %{actionDetail} %{timeago} by %{author}'),
+ actionWithoutAuthor: __('%{actionText} %{actionDetail}'),
+};
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 2e09c9f2288..b884c6b6d19 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getLocationHash } from '~/lib/utils/url_utility';
import NotesApp from './components/notes_app.vue';
@@ -11,6 +13,8 @@ export default () => {
return;
}
+ Vue.use(VueApollo);
+
const notesFilterProps = getNotesFilterData(el);
const showTimelineViewToggle = parseBoolean(el.dataset.showTimelineViewToggle);
@@ -50,9 +54,11 @@ export default () => {
NotesApp,
},
store,
+ apolloProvider,
provide: {
showTimelineViewToggle,
reportAbusePath: notesDataset.reportAbusePath,
+ newSavedRepliesPath: notesDataset.savedRepliesNewPath,
},
data() {
return {
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
index 9a140029c07..0509ff24959 100644
--- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -1,7 +1,7 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils';
import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { clearDraft } from '~/lib/utils/autosave';
import { s__ } from '~/locale';
import { formatLineRange } from '~/notes/components/multiline_comment_utils';
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 44751020173..63822a31cd1 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
export default {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index f6b9be6ee9b..cdfa0d11f56 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -2,9 +2,9 @@ import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
import Api from '~/api';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { STATUS_CLOSED, STATUS_REOPENED, TYPE_ISSUE } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
@@ -19,7 +19,6 @@ import { mergeUrlParams } from '~/lib/utils/url_utility';
import sidebarTimeTrackingEventHub from '~/sidebar/event_hub';
import TaskList from '~/task_list';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
-import SidebarStore from '~/sidebar/stores/sidebar_store';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_NOTE } from '~/graphql_shared/constants';
import notesEventHub from '../event_hub';
@@ -407,7 +406,7 @@ export const emitStateChangedEvent = ({ getters }, data) => {
const event = new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, {
detail: {
data,
- isClosed: getters.openState === constants.CLOSED,
+ isClosed: getters.openState === STATUS_CLOSED,
},
});
@@ -415,9 +414,9 @@ export const emitStateChangedEvent = ({ getters }, data) => {
};
export const toggleIssueLocalState = ({ commit }, newState) => {
- if (newState === constants.CLOSED) {
+ if (newState === STATUS_CLOSED) {
commit(types.CLOSE_ISSUE);
- } else if (newState === constants.REOPENED) {
+ } else if (newState === STATUS_REOPENED) {
commit(types.REOPEN_ISSUE);
}
};
@@ -467,12 +466,17 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const processQuickActions = (res) => {
const {
- errors: { commands_only: commandsOnly, command_names: commandNames } = {
+ errors: { commands_only: commandsOnly } = {
commands_only: null,
command_names: [],
},
+ command_names: commandNames,
} = res;
- let message = commandsOnly;
+ const message = commandsOnly;
+
+ if (commandNames?.indexOf('submit_review') >= 0) {
+ dispatch('batchComments/clearDrafts');
+ }
/*
The following reply means that quick actions have been successfully applied:
@@ -491,13 +495,6 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
confidentialWidget.setConfidentiality();
}
- const commands = ['approve', 'merge', 'assign_reviewer', 'assign'];
- const commandUpdatesAttentionRequest = commandNames[0].some((c) => commands.includes(c));
-
- if (commandUpdatesAttentionRequest && SidebarStore.singleton.currentUserHasAttention) {
- message = sprintf(__('%{message}. Your attention request was removed.'), { message });
- }
-
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
createAlert({
@@ -759,10 +756,10 @@ export const submitSuggestion = (
const errorMessage = err.response.data?.message;
- const flashMessage = errorMessage || defaultMessage;
+ const alertMessage = errorMessage || defaultMessage;
createAlert({
- message: flashMessage,
+ message: alertMessage,
parent: flashContainer,
});
})
@@ -795,10 +792,10 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl
const errorMessage = err.response.data?.message;
- const flashMessage = errorMessage || defaultMessage;
+ const alertMessage = errorMessage || defaultMessage;
createAlert({
- message: flashMessage,
+ message: alertMessage,
parent: flashContainer,
});
})
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 5d532b68f1b..7a7aa0deb1d 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -1,4 +1,5 @@
import { isEqual } from 'lodash';
+import { STATUS_CLOSED, STATUS_REOPENED } from '~/issues/constants';
import { isInMRPage } from '~/lib/utils/common_utils';
import * as constants from '../constants';
import * as types from './mutation_types';
@@ -319,11 +320,11 @@ export default {
},
[types.CLOSE_ISSUE](state) {
- Object.assign(state.noteableData, { state: constants.CLOSED });
+ Object.assign(state.noteableData, { state: STATUS_CLOSED });
},
[types.REOPEN_ISSUE](state) {
- Object.assign(state.noteableData, { state: constants.REOPENED });
+ Object.assign(state.noteableData, { state: STATUS_REOPENED });
},
[types.TOGGLE_STATE_BUTTON_LOADING](state, value) {
diff --git a/app/assets/javascripts/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue
index ff9cf6ff6c5..36cbe715149 100644
--- a/app/assets/javascripts/observability/components/observability_app.vue
+++ b/app/assets/javascripts/observability/components/observability_app.vue
@@ -2,7 +2,7 @@
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
-import { MESSAGE_EVENT_TYPE, SKELETON_VARIANTS_BY_ROUTE } from '../constants';
+import { MESSAGE_EVENT_TYPE, FULL_APP_DIMENSIONS } from '../constants';
import ObservabilitySkeleton from './skeleton/index.vue';
export default {
@@ -14,25 +14,33 @@ export default {
type: String,
required: true,
},
+ inlineEmbed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ skeletonVariant: {
+ type: String,
+ required: false,
+ default: 'dashboards',
+ },
+ height: {
+ type: String,
+ required: false,
+ default: FULL_APP_DIMENSIONS.HEIGHT,
+ },
+ width: {
+ type: String,
+ required: false,
+ default: FULL_APP_DIMENSIONS.WIDTH,
+ },
},
computed: {
iframeSrcWithParams() {
- return setUrlParams(
+ return `${setUrlParams(
{ theme: darkModeEnabled() ? 'dark' : 'light', username: gon?.current_username },
this.observabilityIframeSrc,
- );
- },
- getSkeletonVariant() {
- const [, variant] =
- Object.entries(SKELETON_VARIANTS_BY_ROUTE).find(([path]) =>
- this.$route.path.endsWith(path),
- ) || [];
-
- const DEFAULT_SKELETON = 'dashboards';
-
- if (!variant) return DEFAULT_SKELETON;
-
- return variant;
+ )}${this.inlineEmbed ? '&kiosk=inline-embed' : ''}`;
},
},
mounted() {
@@ -54,38 +62,24 @@ export default {
this.$refs.observabilitySkeleton.onContentLoaded();
break;
case MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE:
- this.routeUpdateHandler(payload);
+ this.$emit('route-update', payload);
break;
default:
break;
}
},
- routeUpdateHandler(payload) {
- const isNewObservabilityPath = this.$route?.query?.observability_path !== payload?.url;
-
- const shouldNotHandleMessage = !payload.url || !isNewObservabilityPath;
-
- if (shouldNotHandleMessage) {
- return;
- }
-
- // this will update the `observability_path` query param on each route change inside Observability UI
- this.$router.replace({
- name: this.$route.pathname,
- query: { ...this.$route.query, observability_path: payload.url },
- });
- },
},
};
</script>
<template>
- <observability-skeleton ref="observabilitySkeleton" :variant="getSkeletonVariant">
+ <observability-skeleton ref="observabilitySkeleton" :variant="skeletonVariant">
<iframe
id="observability-ui-iframe"
data-testid="observability-ui-iframe"
frameborder="0"
- height="100%"
+ :width="width"
+ :height="height"
:src="iframeSrcWithParams"
sandbox="allow-same-origin allow-forms allow-scripts"
></iframe>
diff --git a/app/assets/javascripts/observability/components/skeleton/embed.vue b/app/assets/javascripts/observability/components/skeleton/embed.vue
new file mode 100644
index 00000000000..7abaf2b1bc7
--- /dev/null
+++ b/app/assets/javascripts/observability/components/skeleton/embed.vue
@@ -0,0 +1,15 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+<template>
+ <gl-skeleton-loader>
+ <rect y="5" width="400" height="30" rx="2" ry="2" />
+ <rect y="50" width="400" height="80" rx="2" ry="2" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue
index c8f196a43f4..d91f2874943 100644
--- a/app/assets/javascripts/observability/components/skeleton/index.vue
+++ b/app/assets/javascripts/observability/components/skeleton/index.vue
@@ -8,10 +8,12 @@ import {
OBSERVABILITY_ROUTES,
TIMEOUT_ERROR_LABEL,
TIMEOUT_ERROR_MESSAGE,
+ SKELETON_VARIANT_EMBED,
} from '../../constants';
import DashboardsSkeleton from './dashboards.vue';
import ExploreSkeleton from './explore.vue';
import ManageSkeleton from './manage.vue';
+import EmbedSkeleton from './embed.vue';
export default {
components: {
@@ -19,11 +21,13 @@ export default {
DashboardsSkeleton,
ExploreSkeleton,
ManageSkeleton,
+ EmbedSkeleton,
GlAlert,
},
SKELETON_VARIANTS_BY_ROUTE,
SKELETON_STATE,
OBSERVABILITY_ROUTES,
+ SKELETON_VARIANT_EMBED,
i18n: {
TIMEOUT_ERROR_LABEL,
TIMEOUT_ERROR_MESSAGE,
@@ -102,6 +106,7 @@ export default {
<dashboards-skeleton v-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.DASHBOARDS)" />
<explore-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.EXPLORE)" />
<manage-skeleton v-else-if="isSkeletonShown($options.OBSERVABILITY_ROUTES.MANAGE)" />
+ <embed-skeleton v-else-if="variant === $options.SKELETON_VARIANT_EMBED" />
<gl-skeleton-loader v-else>
<rect y="2" width="10" height="8" />
@@ -122,12 +127,14 @@ export default {
{{ $options.i18n.TIMEOUT_ERROR_MESSAGE }}
</gl-alert>
- <div
- v-show="state === $options.SKELETON_STATE.HIDDEN"
- data-testid="observability-wrapper"
- class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"
- >
- <slot></slot>
- </div>
+ <transition>
+ <div
+ v-show="state === $options.SKELETON_STATE.HIDDEN"
+ data-testid="observability-wrapper"
+ class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"
+ >
+ <slot></slot>
+ </div>
+ </transition>
</div>
</template>
diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js
index e4827dd169f..6b97c51e997 100644
--- a/app/assets/javascripts/observability/constants.js
+++ b/app/assets/javascripts/observability/constants.js
@@ -17,6 +17,8 @@ export const SKELETON_VARIANTS_BY_ROUTE = Object.freeze({
[OBSERVABILITY_ROUTES.MANAGE]: 'manage',
});
+export const SKELETON_VARIANT_EMBED = 'embed';
+
export const SKELETON_STATE = Object.freeze({
ERROR: 'error',
VISIBLE: 'visible',
@@ -30,3 +32,13 @@ export const DEFAULT_TIMERS = Object.freeze({
export const TIMEOUT_ERROR_LABEL = __('Unable to load the page');
export const TIMEOUT_ERROR_MESSAGE = __('Reload the page to try again.');
+
+export const INLINE_EMBED_DIMENSIONS = Object.freeze({
+ HEIGHT: '366px',
+ WIDTH: '768px',
+});
+
+export const FULL_APP_DIMENSIONS = Object.freeze({
+ HEIGHT: '100%',
+ WIDTH: '100%',
+});
diff --git a/app/assets/javascripts/observability/index.js b/app/assets/javascripts/observability/index.js
index cd342ebee3e..72ff1357551 100644
--- a/app/assets/javascripts/observability/index.js
+++ b/app/assets/javascripts/observability/index.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import ObservabilityApp from './components/observability_app.vue';
+import { SKELETON_VARIANTS_BY_ROUTE } from './constants';
Vue.use(VueRouter);
@@ -17,10 +18,41 @@ export default () => {
return new Vue({
el,
router,
+ computed: {
+ skeletonVariant() {
+ const [, variant] =
+ Object.entries(SKELETON_VARIANTS_BY_ROUTE).find(([path]) =>
+ this.$route.path.endsWith(path),
+ ) || [];
+
+ return variant;
+ },
+ },
+ methods: {
+ routeUpdateHandler(payload) {
+ const isNewObservabilityPath = this.$route?.query?.observability_path !== payload?.url;
+
+ const shouldNotHandleMessage = !payload.url || !isNewObservabilityPath;
+
+ if (shouldNotHandleMessage) {
+ return;
+ }
+
+ // this will update the `observability_path` query param on each route change inside Observability UI
+ this.$router.replace({
+ name: this.$route?.pathname,
+ query: { ...this.$route.query, observability_path: payload.url },
+ });
+ },
+ },
render(h) {
return h(ObservabilityApp, {
props: {
observabilityIframeSrc: el.dataset.observabilityIframeSrc,
+ skeletonVariant: this.skeletonVariant,
+ },
+ on: {
+ 'route-update': (payload) => this.routeUpdateHandler(payload),
},
});
},
diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js
index 5f60cab8bdd..7fa79da59c4 100644
--- a/app/assets/javascripts/operation_settings/store/actions.js
+++ b/app/assets/javascripts/operation_settings/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -26,7 +26,7 @@ export const saveChanges = ({ state, dispatch }) =>
export const receiveSaveChangesSuccess = () => {
/**
* The operations_controller currently handles successful requests
- * by creating a flash banner messsage to notify the user.
+ * by creating an alert banner message to notify the user.
*/
refreshCurrentPage();
};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
index 2da8ca2d8a8..0757ac5522a 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
@@ -85,7 +85,7 @@ export default {
size="sm"
:action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: __('Delete'),
- attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }],
+ attributes: { variant: 'danger', disabled: disablePrimaryButton },
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
:action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: __('Cancel'),
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
index c10d8be69a0..863d1c2629b 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -1,6 +1,6 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { n__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
@@ -87,6 +87,9 @@ export default {
tags() {
return this.containerRepository?.tags?.nodes || [];
},
+ hideBulkDelete() {
+ return !(this.containerRepository?.canDelete || false);
+ },
tagsPageInfo() {
return this.containerRepository?.tags?.pageInfo;
},
@@ -98,9 +101,6 @@ export default {
sort: this.sort,
};
},
- showMultiDeleteButton() {
- return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
- },
hasNoTags() {
return this.tags.length === 0;
},
@@ -186,6 +186,7 @@ export default {
/>
<template v-else>
<registry-list
+ :hidden-delete="hideBulkDelete"
:title="listTitle"
:pagination="tagsPageInfo"
:items="tags"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
index 38b601ac3ec..8e89128a382 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
@@ -109,9 +109,6 @@ export default {
isInvalidTag() {
return !this.tag.digest;
},
- isDeleteDisabled() {
- return this.disabled || !this.tag.canDelete;
- },
},
};
</script>
@@ -179,16 +176,16 @@ export default {
</gl-sprintf>
</span>
</template>
- <template #right-action>
+ <template v-if="tag.canDelete" #right-action>
<gl-dropdown
- :disabled="isDeleteDisabled"
+ :disabled="disabled"
icon="ellipsis_v"
:text="$options.i18n.MORE_ACTIONS_TEXT"
:text-sr-only="true"
category="tertiary"
no-caret
right
- :class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }"
+ :class="{ 'gl-opacity-0 gl-pointer-events-none': disabled }"
data-testid="additional-actions"
data-qa-selector="more_actions_menu"
>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index 4f89d217623..f6f816f435c 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { n__ } from '~/locale';
import Tracking from '~/tracking';
@@ -28,7 +28,6 @@ export default {
DeleteButton,
GlSprintf,
GlButton,
- GlIcon,
ListItem,
GlSkeletonLoader,
CleanupStatus,
@@ -80,8 +79,8 @@ export default {
},
tagsCountText() {
return n__(
- 'ContainerRegistry|%{count} Tag',
- 'ContainerRegistry|%{count} Tags',
+ 'ContainerRegistry|%{count} tag',
+ 'ContainerRegistry|%{count} tags',
this.item.tagsCount,
);
},
@@ -152,7 +151,6 @@ export default {
<span v-if="deleting">{{ $options.i18n.ROW_SCHEDULED_FOR_DELETION }}</span>
<template v-else>
<span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
- <gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
{{ item.tagsCount }}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
index e57ac2a9efe..a0a80600603 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
@@ -12,6 +12,7 @@ query getContainerRepositoryTags(
containerRepository(id: $id) {
id
tagsCount
+ canDelete
tags(after: $after, before: $before, first: $first, last: $last, name: $name, sort: $sort) {
nodes {
digest
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
index 83c0d2cdfca..2b5fb1a70ed 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
@@ -1,7 +1,7 @@
<script>
import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
index 8a038d7c974..6d9273d543f 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -10,7 +10,8 @@ import {
} from '@gitlab/ui';
import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import Tracking from '~/tracking';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
@@ -145,7 +146,7 @@ export default {
return [];
},
graphqlResource() {
- return this.config.isGroupPage ? 'group' : 'project';
+ return this.config.isGroupPage ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
queryVariables() {
return {
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index 45dc217b9e3..b24ec65464f 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -55,7 +55,7 @@ export default {
modalButtons: {
primary: {
text: s__('DependencyProxy|Clear cache'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
secondary: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
index bafcd78ad5d..bff32a124bc 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
@@ -9,7 +9,7 @@ import {
TAG_LABEL,
} from '~/packages_and_registries/harbor_registry/constants/index';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue';
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
index 1323d347d10..8bc1ecba5fe 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
@@ -4,7 +4,7 @@ import TagsList from '~/packages_and_registries/harbor_registry/components/tags/
import { getHarborTags } from '~/rest_api';
import { FETCH_TAGS_ERROR_MESSAGE } from '~/packages_and_registries/harbor_registry/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { formatPagination } from '~/packages_and_registries/harbor_registry/utils';
export default {
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
index 931a99649cb..1d8cb0f1360 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
@@ -12,7 +12,7 @@ import {
dockerPushCommand,
dockerLoginCommand,
} from '~/packages_and_registries/harbor_registry/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
SORT_FIELDS,
CONNECTION_ERROR_TITLE,
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
index fd099ee4e69..fdc58e4bd05 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
@@ -122,15 +122,15 @@ export default {
modal: {
packageDeletePrimaryAction: {
text: __('Delete'),
- attributes: [
- { variant: 'danger' },
- { category: 'primary' },
- { 'data-qa-selector': 'delete_modal_button' },
- ],
+ attributes: {
+ variant: 'danger',
+ category: 'primary',
+ 'data-qa-selector': 'delete_modal_button',
+ },
},
fileDeletePrimaryAction: {
text: __('Delete'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelAction: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
index 223f427ce0e..62c4f96eff7 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import {
DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
index 0aeeb2c3d15..6ea1fff9ef0 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
index 7af3fc1c2db..05673215a66 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js
@@ -6,7 +6,6 @@ export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __(
export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully');
export const DEFAULT_PAGE = 1;
-export const DEFAULT_PAGE_SIZE = 20;
export const GROUP_PAGE_TYPE = 'groups';
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
index 7a452abdc26..122123f49cd 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
@@ -1,13 +1,13 @@
import Api from '~/api';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
import {
FETCH_PACKAGES_LIST_ERROR_MESSAGE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
DEFAULT_PAGE,
- DEFAULT_PAGE_SIZE,
MISSING_DELETE_PATH_ERROR,
TERRAFORM_SEARCH_TYPE,
} from '../constants';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
index 011a2668a8b..b167fff26b0 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/delete_modal.vue
@@ -32,7 +32,7 @@ export default {
modal: {
packagesDeletePrimaryAction: {
text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelAction: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue
index 4510c7a7322..95b83d87792 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/maven_installation.vue
@@ -13,6 +13,7 @@ import {
TRACKING_LABEL_CODE_INSTRUCTION,
TRACKING_LABEL_MAVEN_INSTALLATION,
MAVEN_HELP_PATH,
+ MAVEN_INSTALLATION_COMMAND,
} from '~/packages_and_registries/package_registry/constants';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -55,11 +56,6 @@ export default {
<version>${this.appVersion}</version>
</dependency>`;
},
-
- mavenInstallationCommand() {
- return `mvn dependency:get -Dartifact=${this.appGroup}:${this.appName}:${this.appVersion}`;
- },
-
mavenSetupXml() {
return `<repositories>
<repository>
@@ -135,6 +131,7 @@ export default {
{ value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') },
{ value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') },
],
+ MAVEN_INSTALLATION_COMMAND,
};
</script>
@@ -164,8 +161,9 @@ export default {
/>
<code-instruction
+ class="gl-w-20 gl-mt-5"
:label="s__('PackageRegistry|Maven Command')"
- :instruction="mavenInstallationCommand"
+ :instruction="$options.MAVEN_INSTALLATION_COMMAND"
:copy-text="s__('PackageRegistry|Copy Maven command')"
:tracking-action="$options.tracking.TRACKING_ACTION_COPY_MAVEN_COMMAND"
:tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
index d982df4f984..3d5ac528920 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -4,16 +4,22 @@ import VersionRow from '~/packages_and_registries/package_registry/components/de
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
+import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import {
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
export default {
components: {
DeleteModal,
+ DeletePackageModal,
VersionRow,
PackagesListLoader,
RegistryList,
@@ -42,6 +48,7 @@ export default {
},
data() {
return {
+ itemToBeDeleted: null,
itemsToBeDeleted: [],
};
},
@@ -52,8 +59,25 @@ export default {
isListEmpty() {
return this.versions.length === 0;
},
+ tracking() {
+ const category = this.itemToBeDeleted
+ ? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
+ : undefined;
+ return {
+ category,
+ };
+ },
},
methods: {
+ deleteItemConfirmation() {
+ this.$emit('delete', [this.itemToBeDeleted]);
+ this.track(DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ this.itemToBeDeleted = null;
+ },
+ deleteItemCanceled() {
+ this.track(CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ this.itemToBeDeleted = null;
+ },
deleteItemsCanceled() {
this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.itemsToBeDeleted = [];
@@ -63,7 +87,16 @@ export default {
this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.itemsToBeDeleted = [];
},
+ setItemToBeDeleted(item) {
+ this.itemToBeDeleted = { ...item };
+ this.track(REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ },
setItemsToBeDeleted(items) {
+ if (items.length === 1) {
+ const [item] = items;
+ this.setItemToBeDeleted(item);
+ return;
+ }
this.itemsToBeDeleted = items;
this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
this.$refs.deletePackagesModal.show();
@@ -89,18 +122,22 @@ export default {
@next-page="$emit('next-page')"
>
<template #default="{ first, item, isSelected, selectItem }">
- <!-- `first` prop is used to decide whether to show the top border
- for the first element. We want to show the top border only when
- user has permission to bulk delete versions. -->
<version-row
- :first="canDestroy && first"
+ :first="first"
:package-entity="item"
:selected="isSelected(item)"
+ @delete="setItemToBeDeleted(item)"
@select="selectItem(item)"
/>
</template>
</registry-list>
+ <delete-package-modal
+ :item-to-be-deleted="itemToBeDeleted"
+ @ok="deleteItemConfirmation"
+ @cancel="deleteItemCanceled"
+ />
+
<delete-modal
ref="deletePackagesModal"
:items-to-be-deleted="itemsToBeDeleted"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
index fdc6e75c932..ea6ebb614f4 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
@@ -28,6 +28,9 @@ export default {
},
},
computed: {
+ isPrivatePackage() {
+ return !this.packageEntity.publicPackage;
+ },
pypiPipCommand() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `pip install ${this.packageEntity.name} --index-url ${this.packageEntity.pypiUrl}`;
@@ -75,7 +78,7 @@ password = <your personal access token>`;
:tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND"
:tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
/>
- <template #description>
+ <template v-if="isPrivatePackage" #description>
<gl-sprintf :message="$options.i18n.tokenText">
<template #link="{ content }">
<gl-link
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 9f8f6328970..193a222853f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -1,5 +1,7 @@
<script>
import {
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -13,6 +15,7 @@ import PublishMethod from '~/packages_and_registries/shared/components/publish_m
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
+ DELETE_PACKAGE_TEXT,
ERRORED_PACKAGE_TEXT,
ERROR_PUBLISHING,
PACKAGE_ERROR_STATUS,
@@ -22,6 +25,8 @@ import {
export default {
name: 'PackageVersionRow',
components: {
+ GlDropdown,
+ GlDropdownItem,
GlFormCheckbox,
GlIcon,
GlLink,
@@ -58,6 +63,7 @@ export default {
},
},
i18n: {
+ deletePackage: DELETE_PACKAGE_TEXT,
erroredPackageText: ERRORED_PACKAGE_TEXT,
errorPublishing: ERROR_PUBLISHING,
warningText: WARNING_TEXT,
@@ -121,5 +127,19 @@ export default {
</gl-sprintf>
</span>
</template>
+
+ <template v-if="packageEntity.canDestroy" #right-action>
+ <gl-dropdown
+ icon="ellipsis_v"
+ :text="$options.i18n.moreActions"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item variant="danger" @click="$emit('delete')">{{
+ $options.i18n.deletePackage
+ }}</gl-dropdown-item>
+ </gl-dropdown>
+ </template>
</list-item>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
index 0914c013108..b7e66d20e78 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
@@ -1,6 +1,6 @@
<script>
import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import {
DELETE_PACKAGE_ERROR_MESSAGE,
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 16f21bfe61d..c5354b7e7df 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -8,9 +8,10 @@ import {
GlTooltipDirective,
GlTruncate,
} from '@gitlab/ui';
-import { s__, __ } from '~/locale';
+import { __ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
+ DELETE_PACKAGE_TEXT,
ERRORED_PACKAGE_TEXT,
ERROR_PUBLISHING,
PACKAGE_ERROR_STATUS,
@@ -91,7 +92,7 @@ export default {
i18n: {
erroredPackageText: ERRORED_PACKAGE_TEXT,
createdAt: __('Created %{timestamp}'),
- deletePackage: s__('PackageRegistry|Delete package'),
+ deletePackage: DELETE_PACKAGE_TEXT,
errorPublishing: ERROR_PUBLISHING,
warning: WARNING_TEXT,
moreActions: __('More actions'),
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index d979ae5c08c..eda8d9e0066 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -27,15 +27,8 @@ export const PACKAGE_TYPE_DEBIAN = 'DEBIAN';
export const PACKAGE_TYPE_HELM = 'HELM';
export const TRACKING_LABEL_CODE_INSTRUCTION = 'code_instruction';
-export const TRACKING_LABEL_CONAN_INSTALLATION = 'conan_installation';
export const TRACKING_LABEL_MAVEN_INSTALLATION = 'maven_installation';
-export const TRACKING_LABEL_NPM_INSTALLATION = 'npm_installation';
-export const TRACKING_LABEL_NUGET_INSTALLATION = 'nuget_installation';
-export const TRACKING_LABEL_PYPI_INSTALLATION = 'pypi_installation';
-export const TRACKING_LABEL_COMPOSER_INSTALLATION = 'composer_installation';
-
-export const TRACKING_ACTION_INSTALLATION = 'installation';
-export const TRACKING_ACTION_REGISTRY_SETUP = 'registry_setup';
+export const MAVEN_INSTALLATION_COMMAND = 'mvn install';
export const TRACKING_ACTION_COPY_CONAN_COMMAND = 'copy_conan_command';
export const TRACKING_ACTION_COPY_CONAN_SETUP_COMMAND = 'copy_conan_setup_command';
@@ -68,7 +61,6 @@ export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
export const TRACKING_LABEL_PACKAGE_ASSET = 'package_assets';
-export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset';
export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset';
export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha';
@@ -119,6 +111,10 @@ export const DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'delete_package_versions'
export const REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'request_delete_package_versions';
export const CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'cancel_delete_package_versions';
+export const DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'delete_package_version';
+export const REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'request_delete_package_version';
+export const CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION = 'cancel_delete_package_version';
+
export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting packages.',
);
@@ -127,6 +123,7 @@ export const DELETE_PACKAGES_SUCCESS_MESSAGE = s__('PackageRegistry|Packages del
export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages');
export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete');
+export const DELETE_PACKAGE_TEXT = s__('PackageRegistry|Delete package');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
@@ -142,8 +139,6 @@ export const PACKAGE_REGISTRY_TITLE = __('Package Registry');
export const PACKAGE_ERROR_STATUS = 'ERROR';
export const PACKAGE_DEFAULT_STATUS = 'DEFAULT';
-export const PACKAGE_HIDDEN_STATUS = 'HIDDEN';
-export const PACKAGE_PROCESSING_STATUS = 'PROCESSING';
export const NPM_PACKAGE_MANAGER = 'npm';
export const YARN_PACKAGE_MANAGER = 'yarn';
@@ -151,8 +146,6 @@ export const YARN_PACKAGE_MANAGER = 'yarn';
export const PROJECT_PACKAGE_ENDPOINT_TYPE = 'project';
export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance';
-export const PROJECT_RESOURCE_TYPE = 'project';
-export const GROUP_RESOURCE_TYPE = 'group';
export const GRAPHQL_PAGE_SIZE = 20;
export const LIST_KEY_NAME = 'name';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index 109d535469b..b5313f929f8 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -15,6 +15,7 @@ query getPackageDetails(
updatedAt
status
canDestroy
+ publicPackage
npmUrl
mavenUrl
conanUrl
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index 4591c2eca87..1ce2140894e 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -10,7 +10,7 @@ import {
GlTabs,
GlSprintf,
} from '@gitlab/ui';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -314,19 +314,19 @@ export default {
modal: {
packageDeletePrimaryAction: {
text: s__('PackageRegistry|Permanently delete'),
- attributes: [
- { variant: 'danger' },
- { category: 'primary' },
- { 'data-qa-selector': 'delete_modal_button' },
- ],
+ attributes: {
+ variant: 'danger',
+ category: 'primary',
+ 'data-qa-selector': 'delete_modal_button',
+ },
},
fileDeletePrimaryAction: {
text: __('Delete'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
filesDeletePrimaryAction: {
text: s__('PackageRegistry|Permanently delete assets'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ attributes: { variant: 'danger', category: 'primary' },
},
cancelAction: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
index 31c76c95e45..6e92a6420ac 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
@@ -1,12 +1,11 @@
<script>
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
import {
- PROJECT_RESOURCE_TYPE,
- GROUP_RESOURCE_TYPE,
GRAPHQL_PAGE_SIZE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
EMPTY_LIST_HELP_URL,
@@ -44,7 +43,7 @@ export default {
return this.queryVariables;
},
update(data) {
- return data[this.graphqlResource].packages;
+ return data[this.graphqlResource]?.packages ?? {};
},
skip() {
return !this.sort;
@@ -64,7 +63,7 @@ export default {
};
},
graphqlResource() {
- return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
+ return this.isGroupPage ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
pageInfo() {
return this.packages?.pageInfo ?? {};
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 36eb65c623b..4c25c0f97de 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -72,7 +72,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="packages-and-registries-group-settings">
<gl-alert v-if="alertMessage" variant="warning" class="gl-mt-4" @dismiss="dismissAlert">
{{ alertMessage }}
</gl-alert>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index c93cd7f7d78..b47759df35f 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -78,8 +78,4 @@ export const MAVEN_FORWARDING_FIELDS = {
// Parameters
-export const PACKAGES_DOCS_PATH = helpPagePath('user/packages/index');
-export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed';
-export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex';
-
export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
index 11d8732426d..dd22d29d9a7 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
@@ -312,7 +312,7 @@ export default {
>
{{ __('Cancel') }}
</gl-button>
- <span class="gl-font-style-italic gl-text-gray-400">{{
+ <span class="gl-font-style-italic gl-text-gray-500">{{
$options.i18n.EXPIRATION_POLICY_FOOTER_NOTE
}}</span>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
index f06e3a41bd0..0bbb501011a 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
@@ -64,7 +64,7 @@ export default {
</gl-form-select>
</div>
<template v-if="description" #description>
- <span data-testid="description" class="gl-text-gray-400">
+ <span data-testid="description" class="gl-text-gray-500">
{{ description }}
</span>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
index 3fbbfd75ffb..749650e1060 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
@@ -101,7 +101,7 @@ export default {
trim
/>
<template #description>
- <span data-testid="description" class="gl-text-gray-400">
+ <span data-testid="description" class="gl-text-gray-500">
<gl-sprintf :message="description">
<template #link="{ content }">
<gl-link :href="tagsRegexHelpPagePath">{{ content }}</gl-link>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 2c1368262f2..4cc9cc190e8 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -42,7 +42,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="packages-and-registries-project-settings">
<gl-alert
v-if="showAlert"
variant="success"
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
index 7485f8282ee..1c8f80972df 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
@@ -125,7 +125,7 @@ export default {
:select-item="selectItem"
:is-selected="isSelected"
:item="item"
- :first="index === 0"
+ :first="!hiddenDelete && index === 0"
></slot>
</div>
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index ab29f9149f7..7634f131e4d 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -1,3 +1,4 @@
+import { initAbuseReportsApp } from '~/admin/abuse_reports';
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import UsersSelect from '~/users_select';
import AbuseReports from './abuse_reports';
@@ -6,3 +7,4 @@ new AbuseReports(); /* eslint-disable-line no-new */
new UsersSelect(); /* eslint-disable-line no-new */
initDeprecatedRemoveRowBehavior();
+initAbuseReportsApp();
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
index 96477b9f476..7e6654140a9 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
@@ -207,6 +207,10 @@ export default {
emailConfirmationSettingsOffHelpText: s__(
'ApplicationSettings|New users can sign up without confirming their email address.',
),
+ emailConfirmationSettingsSoftLabel: s__('ApplicationSettings|Soft'),
+ emailConfirmationSettingsSoftHelpText: s__(
+ 'ApplicationSettings|Send a confirmation email during sign up. New users can log in immediately, but must confirm their email within three days.',
+ ),
emailConfirmationSettingsHardLabel: s__('ApplicationSettings|Hard'),
emailConfirmationSettingsHardHelpText: s__(
'ApplicationSettings|Send a confirmation email during sign up. New users must confirm their email address before they can log in.',
@@ -286,16 +290,23 @@ export default {
v-model="form.emailConfirmationSetting"
name="application_setting[email_confirmation_setting]"
>
- <gl-form-radio value="hard">
- {{ $options.i18n.emailConfirmationSettingsHardLabel }}
-
- <template #help> {{ $options.i18n.emailConfirmationSettingsHardHelpText }} </template>
- </gl-form-radio>
<gl-form-radio value="off">
{{ $options.i18n.emailConfirmationSettingsOffLabel }}
<template #help> {{ $options.i18n.emailConfirmationSettingsOffHelpText }} </template>
</gl-form-radio>
+
+ <gl-form-radio value="soft">
+ {{ $options.i18n.emailConfirmationSettingsSoftLabel }}
+
+ <template #help> {{ $options.i18n.emailConfirmationSettingsSoftHelpText }} </template>
+ </gl-form-radio>
+
+ <gl-form-radio value="hard">
+ {{ $options.i18n.emailConfirmationSettingsHardLabel }}
+
+ <template #help> {{ $options.i18n.emailConfirmationSettingsHardHelpText }} </template>
+ </gl-form-radio>
</gl-form-radio-group>
</gl-form-group>
diff --git a/app/assets/javascripts/pages/admin/application_settings/network/index.js b/app/assets/javascripts/pages/admin/application_settings/network/index.js
new file mode 100644
index 00000000000..841c68c5cd0
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/network/index.js
@@ -0,0 +1,3 @@
+import initNetworkOutbound from '~/admin/application_settings/network_outbound';
+
+initNetworkOutbound();
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
index 97fb64f9971..54c1e37d899 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index 1cd19fc09a8..41862789185 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
index d5857294617..3bc785ee1b6 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import {
@@ -43,7 +43,7 @@ export default {
},
primaryAction: {
text: PRIMARY_ACTION_TEXT,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
cancelAction: {
text: CANCEL_TEXT,
diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
index 48241a213ef..3a91f8e2c55 100644
--- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -72,7 +72,7 @@ export default {
primaryProps() {
return {
text: __('Delete project'),
- attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.canSubmit }],
+ attributes: { variant: 'danger', category: 'primary', disabled: !this.canSubmit },
};
},
},
diff --git a/app/assets/javascripts/pages/admin/runners/register/index.js b/app/assets/javascripts/pages/admin/runners/register/index.js
new file mode 100644
index 00000000000..d7ee2ee369a
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/runners/register/index.js
@@ -0,0 +1,3 @@
+import { initAdminRegisterRunner } from '~/ci/runner/admin_register_runner';
+
+initAdminRegisterRunner();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 2fdf3c42935..f57b6144b69 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import { getGroups } from '~/api/groups_api';
import { getProjects } from '~/api/projects_api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { addDelimiter } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index f01e5e595a3..8b68cb5f3bf 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -2,7 +2,7 @@
import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg';
import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg';
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
import createGroupDescriptionDetails from './create_group_description_details.vue';
@@ -11,6 +11,15 @@ export default {
NewNamespacePage,
},
props: {
+ groupsUrl: {
+ type: String,
+ required: true,
+ },
+ parentGroupUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
parentGroupName: {
type: String,
required: false,
@@ -28,8 +37,16 @@ export default {
},
},
computed: {
- initialBreadcrumb() {
- return this.parentGroupName || __('New group');
+ initialBreadcrumbs() {
+ return this.parentGroupUrl
+ ? [
+ { text: this.parentGroupName, href: this.parentGroupUrl },
+ { text: s__('GroupsNew|New subgroup'), href: '#' },
+ ]
+ : [
+ { text: s__('GroupsNew|Groups'), href: this.groupsUrl },
+ { text: s__('GroupsNew|New group'), href: '#' },
+ ];
},
panels() {
return [
@@ -68,7 +85,7 @@ export default {
<template>
<new-namespace-page
:jump-to-last-persisted-panel="hasErrors"
- :initial-breadcrumb="initialBreadcrumb"
+ :initial-breadcrumbs="initialBreadcrumbs"
:panels="panels"
:title="s__('GroupsNew|Create new group')"
persistence-key="new_group_last_active_tab"
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index fa111032b2e..16f4f7b7f7e 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
import { getGroupPathAvailability } from '~/rest_api';
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index a555038ed5c..b16c5f3da9f 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -22,6 +22,8 @@ initFilePickers();
function initNewGroupCreation(el) {
const {
hasErrors,
+ groupsUrl,
+ parentGroupUrl,
parentGroupName,
importExistingGroupPath,
verificationRequired,
@@ -30,6 +32,8 @@ function initNewGroupCreation(el) {
} = el.dataset;
const props = {
+ groupsUrl,
+ parentGroupUrl,
parentGroupName,
importExistingGroupPath,
hasErrors: parseBoolean(hasErrors),
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
index 3dcababb4fd..582aee3c9a3 100644
--- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -10,11 +10,12 @@ import {
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { getBulkImportsHistory } from '~/rest_api';
import ImportStatus from '~/import_entities/components/import_status.vue';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
@@ -131,15 +132,15 @@ export default {
},
getPresentationUrl(item) {
- const suffix = item.entity_type === 'group' ? '/' : '';
+ const suffix = item.entity_type === WORKSPACE_GROUP ? '/' : '';
return `${item.destination_full_path}${suffix}`;
},
getEntityTooltip(item) {
switch (item.entity_type) {
- case 'project':
+ case WORKSPACE_PROJECT:
return __('Project');
- case 'group':
+ case WORKSPACE_GROUP:
return __('Group');
default:
return '';
diff --git a/app/assets/javascripts/pages/import/history/components/import_error_details.vue b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
index 6af137cd722..9c26804f73d 100644
--- a/app/assets/javascripts/pages/import/history/components/import_error_details.vue
+++ b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import API from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DEFAULT_ERROR } from '../utils/error_messages';
export default {
diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
index 09b1b3a9c0f..938c2be89c5 100644
--- a/app/assets/javascripts/pages/import/history/components/import_history_app.vue
+++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { s__, __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { getProjects } from '~/rest_api';
import ImportStatus from '~/import_entities/components/import_status.vue';
diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js
index 91b20a05196..b576aab9291 100644
--- a/app/assets/javascripts/pages/profiles/index.js
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
+import LengthValidator from '~/validators/length_validator';
import initPasswordPrompt from './password_prompt';
import { initTimezoneDropdown } from './init_timezone_dropdown';
@@ -19,6 +20,7 @@ $(document).on('input.ssh_key', '#key_key', function () {
});
new Profile(); // eslint-disable-line no-new
+new LengthValidator(); // eslint-disable-line no-new
initSearchSettings();
initPasswordPrompt();
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
index 44728ea9cdf..7db94ea435e 100644
--- a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
+++ b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
@@ -33,7 +33,7 @@ export default {
primaryProps() {
return {
text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
- attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }],
+ attributes: { variant: 'danger', category: 'primary', disabled: !this.isValid },
};
},
},
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 96c4d0e0670..ea6bca644ed 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -1,4 +1,5 @@
import { mount2faRegistration } from '~/authentication/mount_2fa';
+import { initWebAuthnRegistration } from '~/authentication/webauthn/registration';
import { initRecoveryCodes, initManageTwoFactorForm } from '~/authentication/two_factor_auth';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -15,6 +16,7 @@ if (skippable) {
}
mount2faRegistration();
+initWebAuthnRegistration();
initRecoveryCodes();
diff --git a/app/assets/javascripts/pages/projects/airflow/dags/index/index.js b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js
deleted file mode 100644
index 1d7cf4a5b8e..00000000000
--- a/app/assets/javascripts/pages/projects/airflow/dags/index/index.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import Vue from 'vue';
-import AirflowDags from '~/airflow/dags/components/dags.vue';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-
-const initShowDags = () => {
- const element = document.querySelector('#js-show-airflow-dags');
- if (!element) {
- return null;
- }
-
- const dags = JSON.parse(element.dataset.dags);
- const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination));
-
- return new Vue({
- el: element,
- render(h) {
- return h(AirflowDags, {
- props: {
- dags,
- pagination,
- },
- });
- },
- });
-};
-
-initShowDags();
diff --git a/app/assets/javascripts/pages/projects/blame/show/index.js b/app/assets/javascripts/pages/projects/blame/show/index.js
index 1e4b9de90f2..f0fdd18c828 100644
--- a/app/assets/javascripts/pages/projects/blame/show/index.js
+++ b/app/assets/javascripts/pages/projects/blame/show/index.js
@@ -1,5 +1,10 @@
import initBlob from '~/pages/projects/init_blob';
import redirectToCorrectPage from '~/blame/blame_redirect';
+import { renderBlamePageStreams } from '~/blame/streaming';
-redirectToCorrectPage();
+if (new URLSearchParams(window.location.search).get('streaming')) {
+ renderBlamePageStreams(window.blamePageStream);
+} else {
+ redirectToCorrectPage();
+}
initBlob();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index e45f9a10294..a0f391c912b 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -13,6 +13,9 @@ import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_sta
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load';
import createStore from '~/code_navigation/store';
+import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
Vue.use(Vuex);
Vue.use(VueApollo);
@@ -26,6 +29,33 @@ const router = new VueRouter({ mode: 'history' });
const viewBlobEl = document.querySelector('#js-view-blob-app');
+const initRefSwitcher = () => {
+ const refSwitcherEl = document.getElementById('js-tree-ref-switcher');
+
+ if (!refSwitcherEl) return false;
+
+ const { projectId, projectRootPath, ref } = refSwitcherEl.dataset;
+
+ return new Vue({
+ el: refSwitcherEl,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: ref,
+ },
+ on: {
+ input(selectedRef) {
+ visitUrl(generateRefDestinationPath(projectRootPath, ref, selectedRef));
+ },
+ },
+ });
+ },
+ });
+};
+
+initRefSwitcher();
+
if (viewBlobEl) {
const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset;
diff --git a/app/assets/javascripts/pages/projects/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js
index bde0007ec6a..23f5b083589 100644
--- a/app/assets/javascripts/pages/projects/boards/index.js
+++ b/app/assets/javascripts/pages/projects/boards/index.js
@@ -1,7 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initBoards from '~/boards';
-import UsersSelect from '~/users_select';
-new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initBoards();
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 667fd89af55..f871cd804e7 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -4,7 +4,7 @@ import Vue from 'vue';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import initDeprecatedNotes from '~/init_deprecated_notes';
import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
import axios from '~/lib/utils/axios_utils';
@@ -20,7 +20,9 @@ import { initReportAbuse } from '~/projects/report_abuse';
const hasPerfBar = document.querySelector('.with-performance-bar');
const performanceHeight = hasPerfBar ? 35 : 0;
-initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
+initDiffStatsDropdown(
+ (document.querySelector('.navbar-gitlab')?.offsetHeight ?? 0) + performanceHeight,
+);
new ZenMode();
new ShortcutsNavigation();
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 85fe3477d7c..2cfedd78bd8 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -12,7 +12,7 @@ import {
} from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
index 5e0c5735bc0..12ddf538775 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlCollapsibleListbox } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
index 10bfcdc2294..b2e96471769 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlButton, GlListbox, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { get } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
@@ -12,7 +12,7 @@ export default {
GlAlert,
GlAreaChart,
GlButton,
- GlListbox,
+ GlCollapsibleListbox,
GlSprintf,
},
props: {
@@ -98,7 +98,7 @@ export default {
mappedCoverages() {
return this.dailyCoverageData?.map((item, index) => ({
// A numerical index makes an item into a group header, so
- // convert these to strings to get non-header GlListbox items
+ // convert these to strings to get non-header GlCollapsibleListbox items
value: index.toString(),
text: item.group_name,
}));
@@ -182,7 +182,7 @@ export default {
{{ __('It seems that there is currently no available data for code coverage') }}
</span>
</gl-alert>
- <gl-listbox
+ <gl-collapsible-listbox
v-if="canShowData"
:items="mappedCoverages"
:selected="selectedCoverageIndex.toString()"
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index 406959c80ea..f8cb8b30250 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index af75c05b300..3ae8018714a 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -3,10 +3,9 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
-import { ISSUABLE_INDEX } from '~/issuable/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST);
+initBulkUpdateSidebar('merge_request_');
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration');
diff --git a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
index fee6258eddc..9dc85cded0e 100644
--- a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
@@ -1,4 +1,4 @@
import { initSimpleApp } from '~/helpers/init_simple_app_helper';
-import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue';
+import MlCandidateShow from '~/ml/experiment_tracking/routes/candidates/show';
-initSimpleApp('#js-show-ml-candidate', MlCandidate);
+initSimpleApp('#js-show-ml-candidate', MlCandidateShow);
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
index 0e64d8c17db..a90cabb3c68 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
@@ -1,32 +1,24 @@
import Vue from 'vue';
-import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue';
+import MlExperimentsShow from '~/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
const initShowExperiment = () => {
const element = document.querySelector('#js-show-ml-experiment');
if (!element) {
- return;
+ return undefined;
}
- const container = document.createElement('div');
- element.appendChild(container);
+ const props = {
+ candidates: JSON.parse(element.dataset.candidates),
+ metricNames: JSON.parse(element.dataset.metrics),
+ paramNames: JSON.parse(element.dataset.params),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)),
+ };
- const candidates = JSON.parse(element.dataset.candidates);
- const metricNames = JSON.parse(element.dataset.metrics);
- const paramNames = JSON.parse(element.dataset.params);
- const pageInfo = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo));
-
- // eslint-disable-next-line no-new
- new Vue({
- el: container,
- provide: {
- candidates,
- metricNames,
- paramNames,
- pageInfo,
- },
+ return new Vue({
+ el: element,
render(h) {
- return h(MlExperiment);
+ return h(MlExperimentsShow, { props });
},
});
};
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 5773737c41b..5f15a11e708 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
import initClonePanel from '~/clone_panel';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { serializeForm } from '~/lib/utils/forms';
import { mergeUrlParams } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 964c6ca9792..9ec56015405 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -6,6 +6,7 @@ import initDeployFreeze from '~/deploy_freeze';
import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
+import initRefSwitcherBadges from '~/projects/settings/mount_ref_switcher_badges';
import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
import { initCiSecureFiles } from '~/ci_secure_files';
@@ -42,6 +43,7 @@ initArtifactsSettings();
initProjectRunners();
initSharedRunnersToggle();
+initRefSwitcherBadges();
initInstallRunner();
initTokenAccess();
initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js
index 380091a3501..f64de693188 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/form.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/form.js
@@ -10,8 +10,8 @@ import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list';
import initSettingsPanels from '~/settings_panels';
export default () => {
- new ProtectedTagCreate();
- new ProtectedTagEditList();
+ new ProtectedTagCreate({ hasLicense: false });
+ new ProtectedTagEditList({ hasLicense: false });
initDeployKeys();
initSettingsPanels();
new ProtectedBranchCreate({ hasLicense: false });
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index f2bc4796324..2f29d96d85e 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -65,7 +65,7 @@ export default {
releasesHelpText: s__(
'ProjectSettings|Combine git tags with release notes, release evidence, and assets to create a release.',
),
- securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'),
+ securityAndComplianceLabel: s__('ProjectSettings|Security and Compliance'),
snippetsLabel: s__('ProjectSettings|Snippets'),
wikiLabel: s__('ProjectSettings|Wiki'),
pucWarningLabel: s__('ProjectSettings|Warn about Potentially Unwanted Characters'),
@@ -825,7 +825,7 @@ export default {
</project-setting-row>
<project-setting-row
:label="$options.i18n.securityAndComplianceLabel"
- :help-text="s__('ProjectSettings|Security & Compliance for this project')"
+ :help-text="s__('ProjectSettings|Security and compliance for this project.')"
>
<project-feature-setting
v-model="securityAndComplianceAccessLevel"
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index eaafc0235a8..b8de2757284 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -1,7 +1,7 @@
import { trackNewRegistrations } from '~/google_tag_manager';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
-import LengthValidator from '~/pages/sessions/new/length_validator';
+import LengthValidator from '~/validators/length_validator';
import UsernameValidator from '~/pages/sessions/new/username_validator';
import EmailFormatValidator from '~/pages/sessions/new/email_format_validator';
import { initLanguageSwitcher } from '~/language_switcher';
@@ -10,10 +10,7 @@ import Tracking from '~/tracking';
new UsernameValidator(); // eslint-disable-line no-new
new LengthValidator(); // eslint-disable-line no-new
new NoEmojiValidator(); // eslint-disable-line no-new
-
-if (gon.features.trialEmailValidation) {
- new EmailFormatValidator(); // eslint-disable-line no-new
-}
+new EmailFormatValidator(); // eslint-disable-line no-new
trackNewRegistrations();
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index a84ed5f01ad..a8b4dca0845 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import initVueAlerts from '~/vue_alerts';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
import { initLanguageSwitcher } from '~/language_switcher';
-import LengthValidator from './length_validator';
+import LengthValidator from '~/validators/length_validator';
import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
import SigninTabsMemoizer from './signin_tabs_memoizer';
diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
index 1848aa70cf0..664909a9012 100644
--- a/app/assets/javascripts/pages/sessions/new/username_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -1,6 +1,6 @@
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import InputValidator from '~/validators/input_validator';
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
index b19809aff53..8491d667213 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
@@ -1,7 +1,7 @@
<script>
import { GlSkeletonLoader, GlAlert } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { handleLocationHash } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 0d2bbfbbc43..549c964cce4 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -351,6 +351,7 @@ export default {
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
:autofocus="pageInfo.persisted"
+ :drawio-enabled="true"
@contentEditor="notifyContentEditorActive"
@markdownField="notifyContentEditorInactive"
@keydown.ctrl.enter="submitFormShortcut"
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index 8d0105bc681..ec085eae199 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -16,6 +16,17 @@ export default class Wikis {
sidebarToggles[i].addEventListener('click', (e) => this.handleToggleSidebar(e));
}
+ const listToggles = document.querySelectorAll('.js-wiki-list-toggle');
+
+ listToggles.forEach((listToggle) => {
+ listToggle.querySelector('.js-wiki-list-expand-button')?.addEventListener('click', () => {
+ listToggle.classList.remove('collapsed');
+ });
+ listToggle.querySelector('.js-wiki-list-collapse-button')?.addEventListener('click', () => {
+ listToggle.classList.add('collapsed');
+ });
+ });
+
window.addEventListener('resize', () => this.renderSidebar());
this.renderSidebar();
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index fb761725c43..13bba06d425 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -1,7 +1,7 @@
import { select } from 'd3-selection';
import $ from 'jquery';
import { last } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import dateFormat from '~/lib/dateformat';
import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
@@ -58,7 +58,7 @@ export const getLevelFromContributions = (count) => {
};
export default class ActivityCalendar {
- constructor(
+ constructor({
container,
activitiesContainer,
timestamps,
@@ -66,7 +66,8 @@ export default class ActivityCalendar {
utcOffset = 0,
firstDayOfWeek = firstDayOfWeekChoices.sunday,
monthsAgo = 12,
- ) {
+ onClickDay,
+ }) {
this.calendarActivitiesPath = calendarActivitiesPath;
this.clickDay = this.clickDay.bind(this);
this.currentSelectedDate = '';
@@ -91,6 +92,7 @@ export default class ActivityCalendar {
this.firstDayOfWeek = firstDayOfWeek;
this.activitiesContainer = activitiesContainer;
this.container = container;
+ this.onClickDay = onClickDay;
// Loop through the timestamps to create a group of objects
// The group of objects will be grouped based on the day of the week they are
@@ -152,7 +154,8 @@ export default class ActivityCalendar {
.append('svg')
.attr('width', width)
.attr('height', 169)
- .attr('class', 'contrib-calendar');
+ .attr('class', 'contrib-calendar')
+ .attr('data-testid', 'contrib-calendar');
}
dayYPos(day) {
@@ -181,6 +184,7 @@ export default class ActivityCalendar {
});
return `translate(${this.daySizeWithSpace * i + 1 + this.daySizeWithSpace}, 18)`;
})
+ .attr('data-testid', 'user-contrib-cell-group')
.selectAll('rect')
.data((stamp) => stamp)
.enter()
@@ -192,6 +196,7 @@ export default class ActivityCalendar {
.attr('data-level', (stamp) => getLevelFromContributions(stamp.count))
.attr('title', (stamp) => formatTooltipText(stamp))
.attr('class', 'user-contrib-cell has-tooltip')
+ .attr('data-testid', 'user-contrib-cell')
.attr('data-html', true)
.attr('data-container', 'body')
.on('click', this.clickDay);
@@ -281,6 +286,12 @@ export default class ActivityCalendar {
this.currentSelectedDate.getDate(),
].join('-');
+ if (this.onClickDay) {
+ this.onClickDay(date);
+
+ return;
+ }
+
$(this.activitiesContainer)
.empty()
.append(loadingIconForLegacyJS({ size: 'lg' }));
diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js
index f1b4e00c810..c213753257d 100644
--- a/app/assets/javascripts/pages/users/show/index.js
+++ b/app/assets/javascripts/pages/users/show/index.js
@@ -1,16 +1,7 @@
-import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
+import { initProfileTabs, initUserAchievements } from '~/profile';
-if (window.gon.features?.profileTabsVue) {
- import('~/profile')
- .then(({ initProfileTabs }) => {
- initProfileTabs();
- })
- .catch(() => {
- createAlert({
- message: s__(
- 'UserProfile|An error occurred loading the profile. Please refresh the page to try again.',
- ),
- });
- });
+if (gon.features?.profileTabsVue) {
+ initProfileTabs();
}
+
+initUserAchievements();
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 90eafa85886..430022f9a9b 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -247,15 +247,15 @@ export default class UserTabs {
$calendarWrap.find('.calendar-hint').text(calendarHint);
// eslint-disable-next-line no-new
- new ActivityCalendar(
- '.tab-pane.active .js-contrib-calendar',
- '.tab-pane.active .user-calendar-activities',
- data,
+ new ActivityCalendar({
+ container: '.tab-pane.active .js-contrib-calendar',
+ activitiesContainer: '.tab-pane.active .user-calendar-activities',
+ timestamps: data,
calendarActivitiesPath,
utcOffset,
- gon.first_day_of_week,
+ firstDayOfWeek: gon.first_day_of_week,
monthsAgo,
- );
+ });
}
toggleLoading(status) {
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 6ee33902a01..71dc8c3d020 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index e37f63d4053..3130fe42c3c 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -22,6 +22,8 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-ultimate-feature-removal-banner',
'.js-geo-enable-hashed-storage-callout',
'.js-geo-migrate-hashed-storage-callout',
+ '.js-unlimited-members-during-trial-alert',
+ '.js-branch-rules-info-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue
index 0c063241173..c08d825e6af 100644
--- a/app/assets/javascripts/pipeline_wizard/components/editor.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue
@@ -5,6 +5,7 @@ import { CONTENT_UPDATE_DEBOUNCE } from '~/editor/constants';
import SourceEditor from '~/editor/source_editor';
import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
export default {
name: 'YamlEditor',
@@ -43,11 +44,13 @@ export default {
},
},
mounted() {
- this.editor = new SourceEditor().createInstance({
- el: this.$el,
- blobPath: this.filename,
- language: 'yaml',
- });
+ this.editor = markRaw(
+ new SourceEditor().createInstance({
+ el: this.$el,
+ blobPath: this.filename,
+ language: 'yaml',
+ }),
+ );
[, this.yamlEditorExtension] = this.editor.use([
{ definition: SourceEditorExtension },
{
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 992e3d2f552..22895a31082 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -39,6 +39,9 @@ export default {
confirmationModalDocLink: helpPagePath('/ci/pipelines/downstream_pipelines'),
i18n: {
bridgeBadgeText: __('Trigger job'),
+ bridgeRetryText: s__(
+ 'PipelineGraph|Downstream pipeline might not display in the graph while the new downstream pipeline is being created.',
+ ),
unauthorizedTooltip: __('You are not authorized to run this manual job'),
confirmationModal: {
title: s__('PipelineGraph|Are you sure you want to retry %{jobName}?'),
@@ -288,6 +291,10 @@ export default {
},
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
+
+ if (this.isBridge) {
+ this.$toast.show(this.$options.i18n.bridgeRetryText);
+ }
},
executePendingAction() {
this.shouldTriggerActionClick = true;
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
index 605d40eddee..16f6aa5aaa4 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import GetFailedJobsQuery from '../../graphql/queries/get_failed_jobs.query.graphql';
import { prepareFailedJobs } from './utils';
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
index 041b62e02ec..778f014bcd3 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -2,7 +2,7 @@
import { GlButton, GlLink, GlTableLite } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, s__ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql';
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index f1ad312dcaa..661de43fe3c 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
import produce from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import eventHub from '~/jobs/components/table/event_hub';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index 7020bfc1e65..ffb6ab71b22 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { dasherize } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
index ec42b738e03..936cd6f0be5 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
@@ -14,7 +14,7 @@
import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import eventHub from '../../event_hub';
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index eb70b5fbb7a..9f38be668f2 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -42,7 +42,7 @@ export default {
primaryProps() {
return {
text: s__('Pipeline|Stop pipeline'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 4111823e0bb..640129b9c4c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,7 +1,7 @@
<script>
import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { isEqual } from 'lodash';
-import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/flash';
+import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index f34b3f56c5b..50d34070e61 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -1,6 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { s__, __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
index b57d0ac1fd7..81f46d5f2f9 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue
@@ -2,7 +2,7 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants';
export default {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
index 5846a1f6ed9..b32f5de2d7e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue
@@ -2,7 +2,7 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants';
export default {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
index 73f7d3f52c3..a89354c671a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
@@ -8,7 +8,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
ANY_TRIGGER_AUTHOR,
FETCH_AUTHOR_ERROR_MESSAGE,
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
index 2d1f1945e5a..10db3e1c56b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
@@ -66,7 +66,7 @@ export default {
},
modalCloseButton: {
text: __('Close'),
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
};
</script>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 1cd28e027f3..2974bd2dd37 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -100,7 +100,7 @@ export default {
{{ __('Duration') }}
</div>
<div role="rowheader" class="table-section section-10">
- {{ __('Details'), }}
+ {{ __('Details') }}
</div>
</div>
@@ -162,7 +162,7 @@ export default {
</div>
<div class="table-section section-10 section-wrap">
- <div role="rowheader" class="table-mobile-header">{{ __('Details'), }}</div>
+ <div role="rowheader" class="table-mobile-header">{{ __('Details') }}</div>
<div class="table-mobile-content">
<gl-button v-gl-modal-directive="`test-case-details-${index}`">{{
__('View details')
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 7ab48da1a9d..2b7b2d78424 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -50,13 +50,13 @@ export default {
{{ __('Failed') }}
</div>
<div role="rowheader" class="table-section section-10 gl-text-center">
- {{ __('Errors'), }}
+ {{ __('Errors') }}
</div>
<div role="rowheader" class="table-section section-10 gl-text-center">
- {{ __('Skipped'), }}
+ {{ __('Skipped') }}
</div>
<div role="rowheader" class="table-section section-10 gl-text-center">
- {{ __('Passed'), }}
+ {{ __('Passed') }}
</div>
<div role="rowheader" class="table-section section-10 gl-pr-5 gl-text-right">
{{ __('Total') }}
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 820501089ed..ca146ac1e87 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
-export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source'];
@@ -35,8 +34,6 @@ export const RAW_TEXT_WARNING = s__(
export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure';
export const DRAW_FAILURE = 'draw_failure';
-export const EMPTY_PIPELINE_DATA = 'empty_data';
-export const INVALID_CI_CONFIG = 'invalid_ci_config';
export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index e6770b71113..481953608e9 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { helpPagePath } from '~/helpers/help_page_helper';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index ba51347ad69..61847affa1f 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,5 +1,5 @@
import VueRouter from 'vue-router';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { pipelineTabName } from './constants';
import { createPipelineHeaderApp } from './pipeline_details_header';
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index 6360ccc41bc..d94602c23b4 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -2,11 +2,13 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import createTestReportsStore from './stores/test_reports';
import { getPipelineDefaultTab, reportToSentry } from './utils';
+Vue.use(GlToast);
Vue.use(VueApollo);
Vue.use(VueRouter);
Vue.use(Vuex);
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
index c77b4813e33..1b51bb804d0 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
index bff30acfe36..466574157f5 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
index 3cb2dce87d3..c64fbc91d12 100644
--- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -38,11 +38,12 @@ export default {
primaryProps() {
return {
text: __('Delete account'),
- attributes: [
- { variant: 'danger', 'data-qa-selector': 'confirm_delete_account_button' },
- { category: 'primary' },
- { disabled: !this.canSubmit },
- ],
+ attributes: {
+ variant: 'danger',
+ 'data-qa-selector': 'confirm_delete_account_button',
+ category: 'primary',
+ disabled: !this.canSubmit,
+ },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index 51e62984715..d96b5748abc 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -2,7 +2,7 @@
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { escape } from 'lodash';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_INFO } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __, s__, sprintf } from '~/locale';
@@ -60,11 +60,7 @@ Please update your Git repository remotes as soon as possible.`),
primaryProps() {
return {
text: __('Update username'),
- attributes: [
- { variant: 'confirm' },
- { category: 'primary' },
- { disabled: this.isRequestPending },
- ],
+ attributes: { variant: 'confirm', category: 'primary', disabled: this.isRequestPending },
};
},
cancelProps() {
@@ -117,6 +113,7 @@ Please update your Git repository remotes as soon as possible.`),
<input
:id="$options.inputId"
v-model="newUsername"
+ data-testid="new-username-input"
:disabled="isRequestPending"
class="form-control"
required="required"
diff --git a/app/assets/javascripts/profile/components/activity_calendar.vue b/app/assets/javascripts/profile/components/activity_calendar.vue
new file mode 100644
index 00000000000..d359b478d35
--- /dev/null
+++ b/app/assets/javascripts/profile/components/activity_calendar.vue
@@ -0,0 +1,100 @@
+<script>
+import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { debounce } from 'lodash';
+
+import { __ } from '~/locale';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import ActivityCalendar from '~/pages/users/activity_calendar';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { getVisibleCalendarPeriod } from '../utils';
+
+export default {
+ i18n: {
+ errorAlertTitle: __('There was an error loading users activity calendar.'),
+ retry: __('Retry'),
+ calendarHint: __('Issues, merge requests, pushes, and comments.'),
+ },
+ components: { GlLoadingIcon, GlAlert },
+ inject: ['userCalendarPath', 'utcOffset'],
+ data() {
+ return {
+ isLoading: true,
+ showCalendar: true,
+ hasError: false,
+ };
+ },
+ mounted() {
+ this.renderActivityCalendar();
+ window.addEventListener('resize', this.handleResize);
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.handleResize);
+ },
+ methods: {
+ async renderActivityCalendar() {
+ if (bp.getBreakpointSize() === 'xs') {
+ this.showCalendar = false;
+
+ return;
+ }
+
+ this.showCalendar = true;
+ this.isLoading = true;
+ this.hasError = false;
+
+ try {
+ const data = await AjaxCache.retrieve(this.userCalendarPath);
+
+ this.isLoading = false;
+
+ // Wait for `calendarContainer` to render
+ await this.$nextTick();
+ const monthsAgo = getVisibleCalendarPeriod(this.$refs.calendarContainer);
+
+ // eslint-disable-next-line no-new
+ new ActivityCalendar({
+ container: this.$refs.calendarSvgContainer,
+ timestamps: data,
+ utcOffset: this.utcOffset,
+ firstDayOfWeek: gon.first_day_of_week,
+ monthsAgo,
+ onClickDay: this.handleClickDay,
+ });
+ } catch {
+ this.isLoading = false;
+ this.hasError = true;
+ }
+ },
+ handleResize: debounce(function debouncedHandleResize() {
+ this.renderActivityCalendar();
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ handleClickDay() {
+ // Render activities for specific day.
+ // Blocked by https://gitlab.com/gitlab-org/gitlab/-/issues/378695
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="showCalendar" ref="calendarContainer">
+ <gl-loading-icon v-if="isLoading" size="md" />
+ <gl-alert
+ v-else-if="hasError"
+ :title="$options.i18n.errorAlertTitle"
+ :dismissible="false"
+ variant="danger"
+ :primary-button-text="$options.i18n.retry"
+ @primaryAction="renderActivityCalendar"
+ />
+ <div v-else class="gl-text-center">
+ <div class="gl-display-inline-block gl-relative">
+ <div ref="calendarSvgContainer"></div>
+ <p class="gl-absolute gl-right-0 gl-bottom-0 gl-mb-0 gl-font-sm">
+ {{ $options.i18n.calendarHint }}
+ </p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue
index 47651c33eb8..5b69f835294 100644
--- a/app/assets/javascripts/profile/components/followers_tab.vue
+++ b/app/assets/javascripts/profile/components/followers_tab.vue
@@ -1,17 +1,24 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
i18n: {
title: s__('UserProfile|Followers'),
},
- components: { GlTab },
+ components: {
+ GlBadge,
+ GlTab,
+ },
+ inject: ['followers'],
};
</script>
<template>
- <gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
+ <gl-tab>
+ <template #title>
+ <span>{{ $options.i18n.title }}</span>
+ <gl-badge size="sm" class="gl-ml-2">{{ followers }}</gl-badge>
+ </template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue
index 6d9631c5e89..d39d15a08f3 100644
--- a/app/assets/javascripts/profile/components/following_tab.vue
+++ b/app/assets/javascripts/profile/components/following_tab.vue
@@ -1,17 +1,24 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlBadge, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
i18n: {
title: s__('UserProfile|Following'),
},
- components: { GlTab },
+ components: {
+ GlBadge,
+ GlTab,
+ },
+ inject: ['followees'],
};
</script>
<template>
- <gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
+ <gl-tab>
+ <template #title>
+ <span>{{ $options.i18n.title }}</span>
+ <gl-badge size="sm" class="gl-ml-2">{{ followees }}</gl-badge>
+ </template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql b/app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql
new file mode 100644
index 00000000000..e60f383ad1c
--- /dev/null
+++ b/app/assets/javascripts/profile/components/graphql/get_user_achievements.query.graphql
@@ -0,0 +1,21 @@
+query getUserAchievements($id: UserID!) {
+ user(id: $id) {
+ id
+ userAchievements {
+ nodes {
+ id
+ createdAt
+ achievement {
+ id
+ name
+ description
+ avatarUrl
+ namespace {
+ id
+ fullPath
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
index e884c2d7083..76fb13919df 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -1,17 +1,18 @@
<script>
import { GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
+import ActivityCalendar from './activity_calendar.vue';
export default {
i18n: {
title: s__('UserProfile|Overview'),
},
- components: { GlTab },
+ components: { GlTab, ActivityCalendar },
};
</script>
<template>
<gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
+ <activity-calendar />
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
index 2425d56c52a..b39bfabb832 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -66,7 +66,12 @@ export default {
</script>
<template>
- <gl-tabs>
- <component :is="component" v-for="{ key, component } in $options.tabs" :key="key" />
+ <gl-tabs nav-class="gl-bg-gray-10" align="center">
+ <component
+ :is="component"
+ v-for="{ key, component } in $options.tabs"
+ :key="key"
+ class="container-fluid container-limited"
+ />
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/profile/components/user_achievements.vue b/app/assets/javascripts/profile/components/user_achievements.vue
new file mode 100644
index 00000000000..790b0e9f303
--- /dev/null
+++ b/app/assets/javascripts/profile/components/user_achievements.vue
@@ -0,0 +1,100 @@
+<script>
+import { GlPopover, GlSprintf } from '@gitlab/ui';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { s__ } from '~/locale';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import getUserAchievements from './graphql/get_user_achievements.query.graphql';
+
+export default {
+ name: 'UserAchievements',
+ components: { GlPopover, GlSprintf },
+ mixins: [timeagoMixin],
+ inject: ['rootUrl', 'userId'],
+ apollo: {
+ userAchievements: {
+ query: getUserAchievements,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPENAME_USER, this.userId),
+ };
+ },
+ update(data) {
+ return this.processNodes(data.user.userAchievements.nodes);
+ },
+ error() {
+ return [];
+ },
+ },
+ },
+ methods: {
+ processNodes(nodes) {
+ return nodes.slice(0, 3).map(
+ ({
+ achievement,
+ createdAt,
+ achievement: {
+ namespace: { fullPath },
+ },
+ }) => {
+ return {
+ id: `user-achievement-${getIdFromGraphQLId(achievement.id)}`,
+ name: achievement.name,
+ timeAgo: this.timeFormatted(createdAt),
+ avatarUrl: achievement.avatarUrl || gon.gitlab_logo,
+ description: achievement.description,
+ namespace: {
+ fullPath,
+ webUrl: this.rootUrl + fullPath,
+ },
+ };
+ },
+ );
+ },
+ },
+ i18n: {
+ awardedBy: s__('Achievements|Awarded %{timeAgo} by %{namespace}'),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mb-3">
+ <div
+ v-for="userAchievement in userAchievements"
+ :key="userAchievement.id"
+ class="gl-display-inline-block"
+ data-testid="user-achievement"
+ >
+ <img
+ :id="userAchievement.id"
+ :src="userAchievement.avatarUrl"
+ :alt="''"
+ tabindex="0"
+ class="gl-avatar gl-avatar-s32 gl-mx-2"
+ />
+ <gl-popover triggers="hover focus" placement="top" :target="userAchievement.id">
+ <div class="gl-font-weight-bold">{{ userAchievement.name }}</div>
+ <div>
+ <gl-sprintf :message="$options.i18n.awardedBy">
+ <template #timeAgo>
+ <span>{{ userAchievement.timeAgo }}</span>
+ </template>
+ <template #namespace>
+ <a :href="userAchievement.namespace.webUrl">{{
+ userAchievement.namespace.fullPath
+ }}</a>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div
+ v-if="userAchievement.description"
+ class="gl-mt-5"
+ data-testid="achievement-description"
+ >
+ {{ userAchievement.description }}
+ </div>
+ </gl-popover>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/profile/constants.js b/app/assets/javascripts/profile/constants.js
new file mode 100644
index 00000000000..e19994c6784
--- /dev/null
+++ b/app/assets/javascripts/profile/constants.js
@@ -0,0 +1,7 @@
+export const CALENDAR_PERIOD_6_MONTHS = 6;
+export const CALENDAR_PERIOD_12_MONTHS = 12;
+/* computation based on
+ * width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
+ * (see activity_calendar.js)
+ */
+export const OVERVIEW_CALENDAR_BREAKPOINT = 918;
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
index 5378ed3d743..fbe0e3534d8 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -1,16 +1,52 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+
+import createDefaultClient from '~/lib/graphql';
import ProfileTabs from './components/profile_tabs.vue';
+import UserAchievements from './components/user_achievements.vue';
+
+Vue.use(VueApollo);
export const initProfileTabs = () => {
const el = document.getElementById('js-profile-tabs');
if (!el) return false;
+ const { followees, followers, userCalendarPath, utcOffset } = el.dataset;
+
return new Vue({
el,
+ provide: {
+ followees: parseInt(followers, 10),
+ followers: parseInt(followees, 10),
+ userCalendarPath,
+ utcOffset,
+ },
render(createElement) {
return createElement(ProfileTabs);
},
});
};
+
+export const initUserAchievements = () => {
+ const el = document.getElementById('js-user-achievements');
+
+ if (!el) return false;
+
+ const { rootUrl, userId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ name: 'UserAchievements',
+ provide: { rootUrl, userId: parseInt(userId, 10) },
+ render(createElement) {
+ return createElement(UserAchievements);
+ },
+ });
+};
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index a33a20b49f6..d0d947ddd6e 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/flash';
+import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/alert';
import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants';
import IntegrationView from './integration_view.vue';
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index c031c5e5e8e..e21f8557d68 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
-import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash';
+import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { parseRailsFormFields } from '~/lib/utils/forms';
diff --git a/app/assets/javascripts/profile/utils.js b/app/assets/javascripts/profile/utils.js
new file mode 100644
index 00000000000..5b757120544
--- /dev/null
+++ b/app/assets/javascripts/profile/utils.js
@@ -0,0 +1,13 @@
+import {
+ OVERVIEW_CALENDAR_BREAKPOINT,
+ CALENDAR_PERIOD_6_MONTHS,
+ CALENDAR_PERIOD_12_MONTHS,
+} from './constants';
+
+export const getVisibleCalendarPeriod = (calendarContainer) => {
+ const { width } = calendarContainer.getBoundingClientRect();
+
+ return width < OVERVIEW_CALENDAR_BREAKPOINT
+ ? CALENDAR_PERIOD_6_MONTHS
+ : CALENDAR_PERIOD_12_MONTHS;
+};
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index 0ed154c47dd..77e809e88ce 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { debounce } from 'lodash';
+import { debounce, uniqBy } from 'lodash';
import {
I18N_NO_RESULTS_MESSAGE,
I18N_BRANCH_HEADER,
@@ -19,11 +19,6 @@ export default {
required: false,
default: '',
},
- blanked: {
- type: Boolean,
- required: false,
- default: false,
- },
},
i18n: {
noResultsMessage: I18N_NO_RESULTS_MESSAGE,
@@ -32,26 +27,26 @@ export default {
},
data() {
return {
- searchTerm: this.blanked ? '' : this.value,
+ searchTerm: '',
};
},
computed: {
...mapGetters(['joinedBranches']),
- ...mapState(['isFetching']),
+ ...mapState(['isFetching', 'branch']),
listboxItems() {
- return this.joinedBranches.map((value) => ({ value, text: value }));
- },
- },
- watch: {
- // Parent component can set the branch value (e.g. when the user selects a different project)
- // and we need to keep the search term in sync with the selected value
- value(val) {
- this.searchTerm = val;
- this.fetchBranches(this.searchTerm);
+ const selectedItem = { value: this.branch, text: this.branch };
+ const transformedList = this.joinedBranches.map((value) => ({ value, text: value }));
+
+ if (this.searchTerm) {
+ return transformedList;
+ }
+
+ // Add selected item to top of list if not searching
+ return uniqBy([selectedItem].concat(transformedList), 'value');
},
},
mounted() {
- this.fetchBranches(this.searchTerm);
+ this.fetchBranches();
},
methods: {
...mapActions(['fetchBranches']),
@@ -70,8 +65,10 @@ export default {
</script>
<template>
<gl-collapsible-listbox
+ class="gl-max-w-full"
:header-text="$options.i18n.branchHeaderTitle"
:toggle-text="value"
+ toggle-class="gl-w-full"
:items="listboxItems"
searchable
:search-placeholder="$options.i18n.branchSearchPlaceholder"
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index f78afef1c17..28bbf67c090 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -41,11 +41,6 @@ export default {
required: false,
default: false,
},
- isRevert: {
- type: Boolean,
- required: false,
- default: false,
- },
primaryActionEventName: {
type: String,
required: false,
@@ -57,16 +52,16 @@ export default {
checked: true,
actionPrimary: {
text: this.i18n.actionPrimaryText,
- attributes: [
- { variant: 'confirm' },
- { category: 'primary' },
- { 'data-testid': 'submit-commit' },
- { 'data-qa-selector': 'submit_commit_button' },
- ],
+ attributes: {
+ variant: 'confirm',
+ category: 'primary',
+ 'data-testid': 'submit-commit',
+ 'data-qa-selector': 'submit_commit_button',
+ },
},
actionCancel: {
text: this.i18n.actionCancelText,
- attributes: [{ 'data-testid': 'cancel-commit' }],
+ attributes: { 'data-testid': 'cancel-commit' },
},
};
},
@@ -85,7 +80,6 @@ export default {
]),
},
mounted() {
- this.setSelectedProject(this.targetProjectId);
eventHub.$on(this.openModal, this.show);
},
methods: {
@@ -141,7 +135,7 @@ export default {
:value="targetProjectId"
/>
- <projects-dropdown :value="targetProjectName" @selectProject="setSelectedProject" />
+ <projects-dropdown :value="targetProjectName" @input="setSelectedProject" />
</gl-form-group>
<gl-form-group
@@ -151,7 +145,7 @@ export default {
>
<input id="start_branch" type="hidden" name="start_branch" :value="branch" />
- <branches-dropdown :value="branch" :blanked="isRevert" @input="setBranch" />
+ <branches-dropdown :value="branch" @input="setBranch" />
</gl-form-group>
<gl-form-checkbox
diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
index d43f5b99e2c..fe54b62e2c8 100644
--- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
@@ -1,6 +1,7 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
+import { debounce, uniqBy } from 'lodash';
import {
I18N_NO_RESULTS_MESSAGE,
I18N_PROJECT_HEADER,
@@ -26,7 +27,7 @@ export default {
},
data() {
return {
- filterTerm: this.value,
+ filterTerm: '',
};
},
computed: {
@@ -39,7 +40,18 @@ export default {
);
},
listboxItems() {
- return this.filteredResults.map(({ id, name }) => ({ value: id, text: name }));
+ const selectedItem = { value: this.selectedProject.id, text: this.selectedProject.name };
+ const transformedList = this.filteredResults.map(({ id, name }) => ({
+ value: id,
+ text: name,
+ }));
+
+ if (this.filterTerm) {
+ return transformedList;
+ }
+
+ // Add selected item to top of list if not searching
+ return uniqBy([selectedItem].concat(transformedList), 'value');
},
selectedProject() {
return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {};
@@ -47,28 +59,26 @@ export default {
},
methods: {
selectProject(value) {
- this.$emit('selectProject', value);
-
- // when we select a project, we want the dropdown to filter to the selected project
- const project = this.listboxItems.find((x) => x.value === value);
- this.filterTerm = project?.text || '';
- },
- filterTermChanged(value) {
- this.filterTerm = value;
+ this.$emit('input', value);
},
+ debouncedSearch: debounce(function debouncedSearch(value) {
+ this.filterTerm = value.trim();
+ }, 250),
},
};
</script>
<template>
<gl-collapsible-listbox
+ class="gl-max-w-full"
:header-text="$options.i18n.projectHeaderTitle"
:items="listboxItems"
searchable
:search-placeholder="$options.i18n.projectSearchPlaceholder"
:selected="selectedProject.id"
:toggle-text="selectedProject.name"
+ toggle-class="gl-w-full"
:no-results-text="$options.i18n.noResultsMessage"
- @search="filterTermChanged"
+ @search="debouncedSearch"
@select="selectProject"
/>
</template>
diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
index 41be71932e5..849b2f4858c 100644
--- a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js
@@ -49,7 +49,6 @@ export default function initInviteMembersModal(primaryActionEventName) {
i18n: { ...I18N_REVERT_MODAL, ...I18N_MODAL },
openModal: OPEN_REVERT_MODAL,
modalId: REVERT_MODAL_ID,
- isRevert: true,
primaryActionEventName,
},
}),
diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js
index cfff93eac5a..501006a8be5 100644
--- a/app/assets/javascripts/projects/commit/store/actions.js
+++ b/app/assets/javascripts/projects/commit/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { PROJECT_BRANCHES_ERROR } from '../constants';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index dafc4bc5abf..54d13ecc9c8 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import {
getQueryHeaders,
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
index 62b1209131c..71f53613a3b 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
getQueryHeaders,
toggleQueryPollingByVisibility,
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index 9365066418b..5175f7f9151 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/browser';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
index 10531e950f9..8af1667e26b 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
@@ -149,6 +149,7 @@ export default {
:key="branch"
is-check-item
:is-checked="selectedRevision === branch"
+ data-testid="branches-dropdown-item"
@click="onClick(branch)"
>
{{ branch }}
@@ -161,6 +162,7 @@ export default {
:key="tag"
is-check-item
:is-checked="selectedRevision === tag"
+ data-testid="tags-dropdown-item"
@click="onClick(tag)"
>
{{ tag }}
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
index 1e1677e947c..034bae3066d 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
@@ -1,6 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
@@ -132,6 +132,7 @@ export default {
:key="`branch${index}`"
is-check-item
:is-checked="selectedRevision === branch"
+ data-testid="branches-dropdown-item"
@click="onClick(branch)"
>
{{ branch }}
@@ -144,6 +145,7 @@ export default {
:key="`tag${index}`"
is-check-item
:is-checked="selectedRevision === tag"
+ data-testid="tags-dropdown-item"
@click="onClick(tag)"
>
{{ tag }}
diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue
index 64a16b462f5..06c0230c8e0 100644
--- a/app/assets/javascripts/projects/components/shared/delete_button.vue
+++ b/app/assets/javascripts/projects/components/shared/delete_button.vue
@@ -62,11 +62,11 @@ export default {
return {
primary: {
text: __('Yes, delete project'),
- attributes: [
- { variant: 'danger' },
- { disabled: this.confirmDisabled },
- { 'data-qa-selector': 'confirm_delete_button' },
- ],
+ attributes: {
+ variant: 'danger',
+ disabled: this.confirmDisabled,
+ 'data-qa-selector': 'confirm_delete_button',
+ },
},
cancel: {
text: __('Cancel, keep project'),
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 3100029eb31..1599661505f 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -59,6 +59,20 @@ export default {
SafeHtml,
},
props: {
+ projectsUrl: {
+ type: String,
+ required: true,
+ },
+ parentGroupUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ parentGroupName: {
+ type: String,
+ required: false,
+ default: '',
+ },
hasErrors: {
type: Boolean,
required: false,
@@ -77,6 +91,14 @@ export default {
},
computed: {
+ initialBreadcrumbs() {
+ return [
+ this.parentGroupUrl
+ ? { text: this.parentGroupName, href: this.parentGroupUrl }
+ : { text: s__('ProjectsNew|Projects'), href: this.projectsUrl },
+ { text: s__('ProjectsNew|New project'), href: '#' },
+ ];
+ },
availablePanels() {
return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL);
},
@@ -95,7 +117,7 @@ export default {
<template>
<new-namespace-page
- :initial-breadcrumb="__('New project')"
+ :initial-breadcrumbs="initialBreadcrumbs"
:panels="availablePanels"
:jump-to-last-persisted-panel="hasErrors"
:title="s__('ProjectsNew|Create new project')"
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
index 910244c657b..7330874eefe 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -15,12 +15,18 @@ export function initNewProjectCreation() {
newProjectGuidelines,
hasErrors,
isCiCdAvailable,
+ parentGroupUrl,
+ parentGroupName,
+ projectsUrl,
} = el.dataset;
const props = {
hasErrors: parseBoolean(hasErrors),
isCiCdAvailable: parseBoolean(isCiCdAvailable),
newProjectGuidelines,
+ parentGroupUrl,
+ parentGroupName,
+ projectsUrl,
};
const provide = {
diff --git a/app/assets/javascripts/projects/project_find_file.js b/app/assets/javascripts/projects/project_find_file.js
index 71329c4f461..a8b884a68a0 100644
--- a/app/assets/javascripts/projects/project_find_file.js
+++ b/app/assets/javascripts/projects/project_find_file.js
@@ -2,7 +2,7 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { sanitize } from '~/lib/dompurify';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index dcf7415a444..71c9e580420 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -1,7 +1,7 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this */
import { escape, find, countBy } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { n__, s__, __, sprintf } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
@@ -469,6 +469,14 @@ export default class AccessDropdown {
}
}
+ if (this.accessLevel === ACCESS_LEVELS.CREATE && deployKeys.length) {
+ consolidatedData = consolidatedData.concat(
+ [{ type: 'divider' }],
+ [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }],
+ deployKeys,
+ );
+ }
+
return consolidatedData;
}
@@ -506,7 +514,10 @@ export default class AccessDropdown {
break;
case LEVEL_TYPES.DEPLOY_KEY:
groupRowEl =
- this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : '';
+ this.accessLevel === ACCESS_LEVELS.PUSH || this.accessLevel === ACCESS_LEVELS.CREATE
+ ? this.deployKeyRowHtml(item, isActive)
+ : '';
+
break;
case LEVEL_TYPES.GROUP:
groupRowEl = this.groupRowHtml(item, isActive);
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
index f2b1c749abc..3dcacf9eb34 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/branch_dropdown.vue
@@ -7,7 +7,7 @@ import {
GlSprintf,
GlLink,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import branchesQuery from '../../queries/branches.query.graphql';
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
index a98c2439cde..b71c33d2b91 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
@@ -14,11 +14,6 @@ export const I18N = {
wildcardsHelpText: s__(
'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/ are supported',
),
- forcePushTitle: s__('BranchRules|Force push'),
- allowForcePushDescription: s__(
- 'BranchRules|All users with push access are allowed to force push.',
- ),
- disallowForcePushDescription: s__('BranchRules|Force push is not allowed.'),
approvalsTitle: s__('BranchRules|Approvals'),
manageApprovalsLinkTitle: s__('BranchRules|Manage in merge request approvals'),
approvalsDescription: s__(
@@ -33,6 +28,19 @@ export const I18N = {
allowedToPushHeader: s__('BranchRules|Allowed to push and merge (%{total})'),
allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'),
approvalsHeader: s__('BranchRules|Required approvals (%{total})'),
+ allowForcePushTitle: s__('BranchRules|Allows force push'),
+ doesNotAllowForcePushTitle: s__('BranchRules|Does not allow force push'),
+ forcePushDescription: s__('BranchRules|From users with push access.'),
+ requiresCodeOwnerApprovalTitle: s__('BranchRules|Requires approval from code owners'),
+ doesNotRequireCodeOwnerApprovalTitle: s__(
+ 'BranchRules|Does not require approval from code owners',
+ ),
+ requiresCodeOwnerApprovalDescription: s__(
+ 'BranchRules|Also rejects code pushes that change files listed in CODEOWNERS file.',
+ ),
+ doesNotRequireCodeOwnerApprovalDescription: s__(
+ 'BranchRules|Also accepts code pushes that change files listed in CODEOWNERS file.',
+ ),
noData: s__('BranchRules|No data to display'),
};
@@ -48,3 +56,9 @@ export const PROTECTED_BRANCHES_HELP_PATH = 'user/project/protected_branches';
export const APPROVALS_HELP_PATH = 'user/project/merge_requests/approvals/index.md';
export const STATUS_CHECKS_HELP_PATH = 'user/project/merge_requests/status_checks.md';
+
+export const REQUIRED_ICON = 'check-circle-filled';
+export const NOT_REQUIRED_ICON = 'status-failed';
+
+export const REQUIRED_ICON_CLASS = 'gl-fill-green-500';
+export const NOT_REQUIRED_ICON_CLASS = 'gl-text-red-500';
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
index 740868e1d75..b0abe7ac463 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlSprintf, GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale';
import { getParameterByName, mergeUrlParams } from '~/lib/utils/url_utility';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -12,6 +12,10 @@ import {
BRANCH_PARAM_NAME,
WILDCARDS_HELP_PATH,
PROTECTED_BRANCHES_HELP_PATH,
+ REQUIRED_ICON,
+ NOT_REQUIRED_ICON,
+ REQUIRED_ICON_CLASS,
+ NOT_REQUIRED_ICON_CLASS,
} from './constants';
const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH);
@@ -22,7 +26,7 @@ export default {
i18n: I18N,
wildcardsHelpDocLink,
protectedBranchesHelpDocLink,
- components: { Protection, GlSprintf, GlLink, GlLoadingIcon },
+ components: { Protection, GlSprintf, GlLink, GlLoadingIcon, GlIcon },
inject: {
projectPath: {
default: '',
@@ -33,6 +37,9 @@ export default {
branchesPath: {
default: '',
},
+ showStatusChecks: { default: false },
+ showApprovers: { default: false },
+ showCodeOwners: { default: false },
},
apollo: {
project: {
@@ -63,10 +70,28 @@ export default {
};
},
computed: {
- forcePushDescription() {
- return this.branchProtection?.allowForcePush
- ? this.$options.i18n.allowForcePushDescription
- : this.$options.i18n.disallowForcePushDescription;
+ forcePushAttributes() {
+ const { allowForcePush } = this.branchProtection || {};
+ const icon = allowForcePush ? REQUIRED_ICON : NOT_REQUIRED_ICON;
+ const iconClass = allowForcePush ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS;
+ const title = allowForcePush
+ ? this.$options.i18n.allowForcePushTitle
+ : this.$options.i18n.doesNotAllowForcePushTitle;
+
+ return { icon, iconClass, title };
+ },
+ codeOwnersApprovalAttributes() {
+ const { codeOwnerApprovalRequired } = this.branchProtection || {};
+ const icon = codeOwnerApprovalRequired ? REQUIRED_ICON : NOT_REQUIRED_ICON;
+ const iconClass = codeOwnerApprovalRequired ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS;
+ const title = codeOwnerApprovalRequired
+ ? this.$options.i18n.requiresCodeOwnerApprovalTitle
+ : this.$options.i18n.doesNotRequireCodeOwnerApprovalTitle;
+ const description = codeOwnerApprovalRequired
+ ? this.$options.i18n.requiresCodeOwnerApprovalDescription
+ : this.$options.i18n.doesNotRequireCodeOwnerApprovalDescription;
+
+ return { icon, iconClass, title, description };
},
mergeAccessLevels() {
const { mergeAccessLevels } = this.branchProtection || {};
@@ -98,7 +123,7 @@ export default {
: this.$options.i18n.branchNameOrPattern;
},
matchingBranchesLinkHref() {
- return mergeUrlParams({ state: 'all', search: this.branch }, this.branchesPath);
+ return mergeUrlParams({ state: 'all', search: `^${this.branch}$` }, this.branchesPath);
},
matchingBranchesLinkTitle() {
const total = this.matchingBranchesCount;
@@ -164,10 +189,6 @@ export default {
:groups="pushAccessLevels.groups"
/>
- <!-- Force push -->
- <strong>{{ $options.i18n.forcePushTitle }}</strong>
- <p>{{ forcePushDescription }}</p>
-
<!-- Allowed to merge -->
<protection
:header="allowedToMergeHeader"
@@ -178,9 +199,37 @@ export default {
:groups="mergeAccessLevels.groups"
/>
+ <!-- Force push -->
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon
+ :size="14"
+ data-testid="force-push-icon"
+ :name="forcePushAttributes.icon"
+ :class="forcePushAttributes.iconClass"
+ />
+ <strong class="gl-ml-2">{{ forcePushAttributes.title }}</strong>
+ </div>
+
+ <div class="gl-text-gray-400 gl-mb-2">{{ $options.i18n.forcePushDescription }}</div>
+
<!-- EE start -->
+ <!-- Code Owners -->
+ <div v-if="showCodeOwners">
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon
+ data-testid="code-owners-icon"
+ :size="14"
+ :name="codeOwnersApprovalAttributes.icon"
+ :class="codeOwnersApprovalAttributes.iconClass"
+ />
+ <strong class="gl-ml-2">{{ codeOwnersApprovalAttributes.title }}</strong>
+ </div>
+
+ <div class="gl-text-gray-400">{{ codeOwnersApprovalAttributes.description }}</div>
+ </div>
+
<!-- Approvals -->
- <template v-if="approvalsHeader">
+ <template v-if="showApprovers">
<h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4>
<gl-sprintf :message="$options.i18n.approvalsDescription">
<template #link="{ content }">
@@ -200,7 +249,7 @@ export default {
</template>
<!-- Status checks -->
- <template v-if="statusChecksHeader">
+ <template v-if="showStatusChecks">
<h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4>
<gl-sprintf :message="$options.i18n.statusChecksDescription">
<template #link="{ content }">
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
index 9bff2f5506c..721248e53e3 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue
@@ -101,12 +101,7 @@ export default {
<div v-if="statusCheckUrl" class="gl-ml-7 gl-flex-grow-1">{{ statusCheckUrl }}</div>
- <div
- v-for="(item, index) in accessLevels"
- :key="index"
- data-testid="access-level"
- class="gl-w-quarter"
- >
+ <div v-for="(item, index) in accessLevels" :key="index" data-testid="access-level">
<span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span>
{{ item.accessLevelDescription }}
</div>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
index 081d6cec958..c429c352bfa 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import View from 'ee_else_ce/projects/settings/branch_rules/components/view/index.vue';
export default function mountBranchRules(el) {
@@ -20,6 +21,9 @@ export default function mountBranchRules(el) {
approvalRulesPath,
statusChecksPath,
branchesPath,
+ showStatusChecks,
+ showApprovers,
+ showCodeOwners,
} = el.dataset;
return new Vue({
@@ -31,6 +35,9 @@ export default function mountBranchRules(el) {
approvalRulesPath,
statusChecksPath,
branchesPath,
+ showStatusChecks: parseBoolean(showStatusChecks),
+ showApprovers: parseBoolean(showApprovers),
+ showCodeOwners: parseBoolean(showCodeOwners),
},
render(h) {
return h(View);
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index cc47496971d..08a1c586f69 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__, n__ } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api';
import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants';
@@ -86,7 +86,10 @@ export default {
return groupBy(this.preselectedItems, 'type');
},
showDeployKeys() {
- return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length;
+ return (
+ (this.accessLevel === ACCESS_LEVELS.PUSH || this.accessLevel === ACCESS_LEVELS.CREATE) &&
+ this.deployKeys.length
+ );
},
toggleLabel() {
const counts = Object.entries(this.selected).reduce((acc, [key, value]) => {
diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js
index 9cf1afd334f..595cbc9c991 100644
--- a/app/assets/javascripts/projects/settings/constants.js
+++ b/app/assets/javascripts/projects/settings/constants.js
@@ -17,6 +17,7 @@ export const LEVEL_ID_PROP = {
export const ACCESS_LEVELS = {
MERGE: 'merge_access_levels',
PUSH: 'push_access_levels',
+ CREATE: 'create_access_levels',
};
export const ACCESS_LEVEL_NONE = 0;
diff --git a/app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js b/app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js
new file mode 100644
index 00000000000..527678250fb
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/mount_ref_switcher_badges.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { generateRefDestinationPath } from './utils';
+
+export default function initRefSwitcherBadges() {
+ const refSwitcherElements = document.getElementsByClassName('js-ref-switcher-badge');
+
+ if (refSwitcherElements.length === 0) return false;
+
+ return Array.from(refSwitcherElements).forEach((element) => {
+ const { projectId, ref } = element.dataset;
+
+ return new Vue({
+ el: element,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: ref,
+ },
+ on: {
+ input(selectedRef) {
+ visitUrl(generateRefDestinationPath(selectedRef));
+ },
+ },
+ });
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
index f3d392a0ec4..7709419b6f8 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql';
import { expandSection } from '~/settings_panels';
import { scrollToElement } from '~/lib/utils/common_utils';
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
index fa96eee5f92..b565bda247d 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
@@ -27,6 +27,9 @@ export default {
branchRulesPath: {
default: '',
},
+ showCodeOwners: { default: false },
+ showStatusChecks: { default: false },
+ showApprovers: { default: false },
},
props: {
name: {
@@ -70,7 +73,7 @@ export default {
return this.approvalDetails.length;
},
detailsPath() {
- return `${this.branchRulesPath}?branch=${this.name}`;
+ return `${this.branchRulesPath}?branch=${encodeURIComponent(this.name)}`;
},
statusChecksText() {
return sprintf(this.$options.i18n.statusChecks, {
@@ -112,13 +115,13 @@ export default {
if (this.branchProtection?.allowForcePush) {
approvalDetails.push(this.$options.i18n.allowForcePush);
}
- if (this.branchProtection?.codeOwnerApprovalRequired) {
+ if (this.showCodeOwners && this.branchProtection?.codeOwnerApprovalRequired) {
approvalDetails.push(this.$options.i18n.codeOwnerApprovalRequired);
}
- if (this.statusChecksTotal) {
+ if (this.showStatusChecks && this.statusChecksTotal) {
approvalDetails.push(this.statusChecksText);
}
- if (this.approvalRulesTotal) {
+ if (this.showApprovers && this.approvalRulesTotal) {
approvalDetails.push(this.approvalRulesText);
}
if (this.mergeAccessLevels.total > 0) {
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
index 042be089e09..a8736c87e22 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import BranchRulesApp from '~/projects/settings/repository/branch_rules/app.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(VueApollo);
@@ -12,7 +13,13 @@ const apolloProvider = new VueApollo({
export default function mountBranchRules(el) {
if (!el) return null;
- const { projectPath, branchRulesPath } = el.dataset;
+ const {
+ projectPath,
+ branchRulesPath,
+ showCodeOwners,
+ showStatusChecks,
+ showApprovers,
+ } = el.dataset;
return new Vue({
el,
@@ -20,6 +27,9 @@ export default function mountBranchRules(el) {
provide: {
projectPath,
branchRulesPath,
+ showCodeOwners: parseBoolean(showCodeOwners),
+ showStatusChecks: parseBoolean(showStatusChecks),
+ showApprovers: parseBoolean(showApprovers),
},
render(createElement) {
return createElement(BranchRulesApp);
diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
index 3d553e71f71..47477d39b8a 100644
--- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
+++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
@@ -1,5 +1,6 @@
<script>
-import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
+import { GlTokenSelector, GlAvatarLabeled, GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
@@ -8,8 +9,15 @@ export default {
components: {
GlTokenSelector,
GlAvatarLabeled,
+ GlFormGroup,
+ GlLink,
+ GlSprintf,
},
i18n: {
+ topicsTitle: s__('ProjectSettings|Topics'),
+ topicsHelpText: s__(
+ 'ProjectSettings|Topics are publicly visible even on private projects. Do not include sensitive information in topic names. %{linkStart}Learn more%{linkEnd}.',
+ ),
placeholder: s__('ProjectSettings|Search for topic'),
},
props: {
@@ -51,6 +59,11 @@ export default {
placeholderText() {
return this.selectedTokens.length ? '' : this.$options.i18n.placeholder;
},
+ topicsHelpUrl() {
+ return helpPagePath('user/admin_area/index.html', {
+ anchor: 'administering-topics',
+ });
+ },
},
methods: {
handleEnter(event) {
@@ -70,25 +83,34 @@ export default {
};
</script>
<template>
- <gl-token-selector
- ref="tokenSelector"
- v-model="selectedTokens"
- :dropdown-items="topics"
- :loading="loading"
- allow-user-defined-tokens
- :placeholder="placeholderText"
- @keydown.enter="handleEnter"
- @text-input="filterTopics"
- @input="onTokensUpdate"
- >
- <template #dropdown-item-content="{ dropdownItem }">
- <gl-avatar-labeled
- :src="dropdownItem.avatarUrl"
- :entity-name="dropdownItem.name"
- :label="dropdownItem.title"
- :size="32"
- :shape="$options.AVATAR_SHAPE_OPTION_RECT"
- />
+ <gl-form-group id="project_topics" :label="$options.i18n.topicsTitle">
+ <gl-token-selector
+ ref="tokenSelector"
+ v-model="selectedTokens"
+ :dropdown-items="topics"
+ :loading="loading"
+ allow-user-defined-tokens
+ :placeholder="placeholderText"
+ @keydown.enter="handleEnter"
+ @text-input="filterTopics"
+ @input="onTokensUpdate"
+ >
+ <template #dropdown-item-content="{ dropdownItem }">
+ <gl-avatar-labeled
+ :src="dropdownItem.avatarUrl"
+ :entity-name="dropdownItem.name"
+ :label="dropdownItem.title"
+ :size="32"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ />
+ </template>
+ </gl-token-selector>
+ <template #description>
+ <gl-sprintf :message="$options.i18n.topicsHelpText">
+ <template #link="{ content }">
+ <gl-link :href="topicsHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
- </gl-token-selector>
+ </gl-form-group>
</template>
diff --git a/app/assets/javascripts/projects/settings/utils.js b/app/assets/javascripts/projects/settings/utils.js
index ea4574119c0..9c19657bb39 100644
--- a/app/assets/javascripts/projects/settings/utils.js
+++ b/app/assets/javascripts/projects/settings/utils.js
@@ -1,3 +1,24 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
+export const generateRefDestinationPath = (selectedRef) => {
+ const namespace = '-/settings/ci_cd';
+ const { pathname } = window.location;
+
+ if (!selectedRef || !pathname.includes(namespace)) {
+ return window.location.href;
+ }
+
+ const [projectRootPath] = pathname.split(namespace);
+
+ const destinationPath = joinPaths(projectRootPath, namespace);
+
+ const newURL = new URL(window.location);
+ newURL.pathname = destinationPath;
+ newURL.searchParams.set('ref', selectedRef);
+
+ return newURL.href;
+};
+
export const getAccessLevels = (accessLevels = {}) => {
const total = accessLevels.edges?.length;
const accessLevelTypes = { total, users: [], groups: [], roles: [] };
diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js
index 55c3d68cd11..f294811dfff 100644
--- a/app/assets/javascripts/projects/star.js
+++ b/app/assets/javascripts/projects/star.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index 9f9b6424125..5b620aa2300 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js
index ae5eaa8e622..b5d00cb7e82 100644
--- a/app/assets/javascripts/protected_branches/constants.js
+++ b/app/assets/javascripts/protected_branches/constants.js
@@ -9,12 +9,3 @@ export const LEVEL_TYPES = {
GROUP: 'group',
DEPLOY_KEY: 'deploy_key',
};
-
-export const LEVEL_ID_PROP = {
- ROLE: 'access_level',
- USER: 'user_id',
- GROUP: 'group_id',
- DEPLOY_KEY: 'deploy_key_id',
-};
-
-export const ACCESS_LEVEL_NONE = 0;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 120f75d4f0c..cd37c0de6a5 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import CreateItemDropdown from '~/create_item_dropdown';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 1693d869b54..b6c86750723 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,5 +1,5 @@
import { find } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
diff --git a/app/assets/javascripts/protected_tags/constants.js b/app/assets/javascripts/protected_tags/constants.js
index 3e71ba62877..758b820c4c4 100644
--- a/app/assets/javascripts/protected_tags/constants.js
+++ b/app/assets/javascripts/protected_tags/constants.js
@@ -1,3 +1,14 @@
import { s__ } from '~/locale';
export const FAILED_TO_UPDATE_TAG_MESSAGE = s__('ProjectSettings|Failed to update tag!');
+
+export const ACCESS_LEVELS = {
+ CREATE: 'create_access_levels',
+};
+
+export const LEVEL_TYPES = {
+ ROLE: 'role',
+ USER: 'user',
+ GROUP: 'group',
+ DEPLOY_KEY: 'deploy_key',
+};
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
index 75fd11cd074..365b9a3b142 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -1,12 +1,21 @@
import $ from 'jquery';
-import { __ } from '~/locale';
-import CreateItemDropdown from '../create_item_dropdown';
-import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import CreateItemDropdown from '~/create_item_dropdown';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { s__, __ } from '~/locale';
+import AccessDropdown from '~/projects/settings/access_dropdown';
+import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export default class ProtectedTagCreate {
- constructor() {
+ constructor({ hasLicense }) {
+ this.hasLicense = hasLicense;
this.$form = $('.js-new-protected-tag');
this.buildDropdowns();
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.$form.on('submit', this.onFormSubmit.bind(this));
}
buildDropdowns() {
@@ -16,15 +25,14 @@ export default class ProtectedTagCreate {
this.onSelectCallback = this.onSelect.bind(this);
// Allowed to Create dropdown
- this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ this.protectedTagAccessDropdown = new AccessDropdown({
$dropdown: $allowedToCreateDropdown,
- data: gon.create_access_levels,
+ accessLevelsData: gon.create_access_levels,
onSelect: this.onSelectCallback,
+ accessLevel: ACCESS_LEVELS.CREATE,
+ hasLicense: this.hasLicense,
});
- // Select default
- $allowedToCreateDropdown.data('deprecatedJQueryDropdown').selectRowAtIndex(0);
-
// Protected tag dropdown
this.createItemDropdown = new CreateItemDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'),
@@ -39,7 +47,7 @@ export default class ProtectedTagCreate {
onSelect() {
// Enable submit button
const $tagInput = this.$form.find('input[name="protected_tag[name]"]');
- const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
+ const $allowedToCreateInput = this.protectedTagAccessDropdown.getSelectedItems();
this.$form
.find('button[type="submit"]')
@@ -49,4 +57,57 @@ export default class ProtectedTagCreate {
static getProtectedTags(term, callback) {
callback(gon.open_tags);
}
+
+ getFormData() {
+ const formData = {
+ authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
+ protected_tag: {
+ name: this.$form.find('input[name="protected_tag[name]"]').val(),
+ },
+ };
+
+ Object.keys(ACCESS_LEVELS).forEach((level) => {
+ const accessLevel = ACCESS_LEVELS[level];
+ const selectedItems = this.protectedTagAccessDropdown.getSelectedItems();
+ const levelAttributes = [];
+
+ selectedItems.forEach((item) => {
+ if (item.type === LEVEL_TYPES.USER) {
+ levelAttributes.push({
+ user_id: item.user_id,
+ });
+ } else if (item.type === LEVEL_TYPES.ROLE) {
+ levelAttributes.push({
+ access_level: item.access_level,
+ });
+ } else if (item.type === LEVEL_TYPES.GROUP) {
+ levelAttributes.push({
+ group_id: item.group_id,
+ });
+ } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
+ levelAttributes.push({
+ deploy_key_id: item.deploy_key_id,
+ });
+ }
+ });
+
+ formData.protected_tag[`${accessLevel}_attributes`] = levelAttributes;
+ });
+
+ return formData;
+ }
+
+ onFormSubmit(e) {
+ e.preventDefault();
+
+ axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData())
+ .then(() => {
+ window.location.reload();
+ })
+ .catch(() =>
+ createAlert({
+ message: s__('ProjectSettings|Failed to protect the tag'),
+ }),
+ );
+ }
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
index 40c52eba99e..4fa3ac3be4b 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -1,57 +1,115 @@
-import { createAlert } from '~/flash';
-import axios from '../lib/utils/axios_utils';
-import { FAILED_TO_UPDATE_TAG_MESSAGE } from './constants';
-import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import { find } from 'lodash';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import AccessDropdown from '~/projects/settings/access_dropdown';
+import { ACCESS_LEVELS, LEVEL_TYPES, FAILED_TO_UPDATE_TAG_MESSAGE } from './constants';
export default class ProtectedTagEdit {
constructor(options) {
+ this.hasLicense = options.hasLicense;
+ this.hasChanges = false;
this.$wrap = options.$wrap;
this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create');
- this.onSelectCallback = this.onSelect.bind(this);
+
+ this.$allowedToCreateDropdownContainer = this.$allowedToCreateDropdownButton.closest(
+ '.create_access_levels-container',
+ );
this.buildDropdowns();
}
buildDropdowns() {
// Allowed to create dropdown
- this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ this.protectedTagAccessDropdown = new AccessDropdown({
+ accessLevel: ACCESS_LEVELS.CREATE,
+ accessLevelsData: gon.create_access_levels,
$dropdown: this.$allowedToCreateDropdownButton,
- data: gon.create_access_levels,
- onSelect: this.onSelectCallback,
+ onSelect: this.onSelectOption.bind(this),
+ onHide: this.onDropdownHide.bind(this),
+ hasLicense: this.hasLicense,
});
}
- onSelect() {
- const $allowedToCreateInput = this.$wrap.find(
- `input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`,
- );
+ onSelectOption() {
+ this.hasChanges = true;
+ }
- // Do not update if one dropdown has not selected any option
- if (!$allowedToCreateInput.length) return;
+ onDropdownHide() {
+ if (!this.hasChanges) {
+ return;
+ }
- this.$allowedToCreateDropdownButton.disable();
+ this.hasChanges = true;
+ this.updatePermissions();
+ }
+
+ updatePermissions() {
+ const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => {
+ const accessLevelName = ACCESS_LEVELS[level];
+ const inputData = this.protectedTagAccessDropdown.getInputData(accessLevelName);
+ acc[`${accessLevelName}_attributes`] = inputData;
+
+ return acc;
+ }, {});
axios
.patch(this.$wrap.data('url'), {
- protected_tag: {
- create_access_levels_attributes: [
- {
- id: this.$allowedToCreateDropdownButton.data('accessLevelId'),
- access_level: $allowedToCreateInput.val(),
- },
- ],
- },
+ protected_tag: formData,
})
- .then(() => {
- this.$allowedToCreateDropdownButton.enable();
+ .then(({ data }) => {
+ this.hasChanges = false;
+
+ Object.keys(ACCESS_LEVELS).forEach((level) => {
+ const accessLevelName = ACCESS_LEVELS[level];
+
+ // The data coming from server will be the new persisted *state* for each dropdown
+ this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`);
+ });
})
.catch(() => {
- this.$allowedToCreateDropdownButton.enable();
-
window.scrollTo({ top: 0, behavior: 'smooth' });
createAlert({
message: FAILED_TO_UPDATE_TAG_MESSAGE,
});
});
}
+
+ setSelectedItemsToDropdown(items = []) {
+ const itemsToAdd = items.map((currentItem) => {
+ if (currentItem.user_id) {
+ // Do this only for users for now
+ // get the current data for selected items
+ const selectedItems = this.protectedTagAccessDropdown.getSelectedItems();
+ const currentSelectedItem = find(selectedItems, {
+ user_id: currentItem.user_id,
+ });
+
+ return {
+ id: currentItem.id,
+ user_id: currentItem.user_id,
+ type: LEVEL_TYPES.USER,
+ persisted: true,
+ name: currentSelectedItem.name,
+ username: currentSelectedItem.username,
+ avatar_url: currentSelectedItem.avatar_url,
+ };
+ } else if (currentItem.group_id) {
+ return {
+ id: currentItem.id,
+ group_id: currentItem.group_id,
+ type: LEVEL_TYPES.GROUP,
+ persisted: true,
+ };
+ }
+
+ return {
+ id: currentItem.id,
+ access_level: currentItem.access_level,
+ type: LEVEL_TYPES.ROLE,
+ persisted: true,
+ };
+ });
+
+ this.protectedTagAccessDropdown.setSelectedItems(itemsToAdd);
+ }
}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
index b35bf4d4606..8ceb970bf03 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
@@ -4,7 +4,8 @@ import $ from 'jquery';
import ProtectedTagEdit from './protected_tag_edit';
export default class ProtectedTagEditList {
- constructor() {
+ constructor(options) {
+ this.hasLicense = options.hasLicense;
this.$wrap = $('.protected-tags-list');
this.initEditForm();
}
@@ -13,6 +14,7 @@ export default class ProtectedTagEditList {
this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => {
new ProtectedTagEdit({
$wrap: $(el),
+ hasLicense: this.hasLicense,
});
});
}
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index adae92a92e9..7ecc39a56e7 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -182,7 +182,7 @@ export default {
:checked="linkedIssueType"
/>
</gl-form-group>
- <p class="bold">
+ <p class="bold gl-mb-2">
{{ issuableInputText }}
</p>
</template>
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 8d6a3110f35..1846b9cf8f4 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -182,7 +182,7 @@ export default {
<div
ref="issuableFormWrapper"
:class="{ focus: isInputFocused }"
- class="add-issuable-form-input-wrapper form-control gl-field-error-outline gl-h-auto gl-p-3 gl-pb-2"
+ class="add-issuable-form-input-wrapper form-control gl-field-error-outline gl-h-auto gl-px-3 gl-pt-2 gl-pb-0"
role="button"
@click="onIssuableFormWrapperClick"
>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index 4a130ade631..043d925198c 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
+import { GlLink, GlIcon, GlLoadingIcon, GlButton, GlCard } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import {
issuableIconMap,
@@ -16,8 +16,10 @@ export default {
name: 'RelatedIssuesBlock',
components: {
GlLink,
- GlButton,
GlIcon,
+ GlLoadingIcon,
+ GlButton,
+ GlCard,
AddIssuableForm,
RelatedIssuesList,
},
@@ -181,64 +183,69 @@ export default {
<template>
<div id="related-issues" class="related-issues-block">
- <div class="card card-slim gl-overflow-hidden gl-mt-5 gl-mb-0">
- <div
- :class="{
- 'gl-border-b-1': isOpen,
- 'gl-border-b-0': !isOpen,
- }"
- class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-solid gl-border-b-gray-100"
- >
- <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
- <gl-link
- id="user-content-related-issues"
- class="anchor position-absolute gl-text-decoration-none"
- href="#related-issues"
- aria-hidden="true"
- />
- <slot name="header-text">{{ headerText }}</slot>
-
- <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3">
- <span class="gl-display-inline-flex gl-align-items-center">
- <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
- {{ badgeLabel }}
- </span>
- </div>
- </h3>
- <slot name="header-actions"></slot>
- <gl-button
- v-if="canAdmin"
- size="small"
- data-qa-selector="related_issues_plus_button"
- data-testid="related-issues-plus-button"
- :aria-label="addIssuableButtonText"
- class="gl-ml-3"
- @click="addButtonClick"
+ <gl-card
+ class="gl-overflow-hidden gl-mt-5 gl-mb-0"
+ header-class="gl-p-0 gl-border-0"
+ body-class="gl-p-0 gl-bg-gray-10"
+ >
+ <template #header>
+ <div
+ :class="{
+ 'gl-border-b-1': isOpen,
+ 'gl-border-b-0': !isOpen,
+ }"
+ class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-pl-5 gl-pr-4 gl-py-4 gl-bg-white gl-border-b-solid gl-border-b-gray-100"
>
- <slot name="add-button-text">{{ __('Add') }}</slot>
- </gl-button>
- <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100">
+ <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
+ <gl-link
+ id="user-content-related-issues"
+ class="anchor position-absolute gl-text-decoration-none"
+ href="#related-issues"
+ aria-hidden="true"
+ />
+ <slot name="header-text">{{ headerText }}</slot>
+
+ <div
+ class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3 gl-text-gray-500"
+ >
+ <span class="gl-display-inline-flex gl-align-items-center">
+ <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
+ {{ badgeLabel }}
+ </span>
+ </div>
+ </h3>
+ <slot name="header-actions"></slot>
<gl-button
- category="tertiary"
+ v-if="canAdmin"
size="small"
- :icon="toggleIcon"
- :aria-label="toggleLabel"
- data-testid="toggle-links"
- @click="handleToggle"
- />
+ data-qa-selector="related_issues_plus_button"
+ data-testid="related-issues-plus-button"
+ :aria-label="addIssuableButtonText"
+ class="gl-ml-3"
+ @click="addButtonClick"
+ >
+ <slot name="add-button-text">{{ __('Add') }}</slot>
+ </gl-button>
+ <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100">
+ <gl-button
+ category="tertiary"
+ size="small"
+ :icon="toggleIcon"
+ :aria-label="toggleLabel"
+ data-testid="toggle-links"
+ @click="handleToggle"
+ />
+ </div>
</div>
- </div>
+ </template>
<div
v-if="isOpen"
- class="linked-issues-card-body gl-bg-gray-10"
- :class="{
- 'gl-p-5': isFormVisible || shouldShowTokenBody,
- }"
+ class="linked-issues-card-body gl-py-3 gl-px-4 gl-bg-gray-10"
data-testid="related-issues-body"
>
<div
v-if="isFormVisible"
- class="js-add-related-issues-form-area card-body bordered-box bg-white"
+ class="js-add-related-issues-form-area card-body bg-white gl-mt-2 gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base"
:class="{ 'gl-mb-5': shouldShowTokenBody, 'gl-show-field-errors': hasError }"
>
<add-issuable-form
@@ -261,6 +268,7 @@ export default {
/>
</div>
<template v-if="shouldShowTokenBody">
+ <gl-loading-icon v-if="isFetching" size="sm" class="gl-py-2" />
<related-issues-list
v-for="(category, index) in categorisedIssues"
:key="category.linkType"
@@ -272,13 +280,16 @@ export default {
:issuable-type="issuableType"
:path-id-separator="pathIdSeparator"
:related-issues="category.issues"
- :class="{ 'gl-mt-5': index > 0 }"
+ :class="{
+ 'gl-pb-3 gl-mb-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100':
+ index !== categorisedIssues.length - 1,
+ }"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
@saveReorder="$emit('saveReorder', $event)"
/>
</template>
<div v-if="!shouldShowTokenBody && !isFormVisible" data-testid="related-items-empty">
- <p class="gl-my-5 gl-px-5">
+ <p class="gl-p-2 gl-mb-0 gl-text-gray-500">
{{ emptyStateMessage }}
<gl-link
v-if="hasHelpPath"
@@ -292,6 +303,6 @@ export default {
</p>
</div>
</div>
- </div>
+ </gl-card>
</div>
</template>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 7387b9ab87c..4429c1beb00 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -97,11 +97,13 @@ export default {
<template>
<div :data-link-type="listLinkType">
- <h4 v-if="heading" class="gl-font-base mt-0">{{ heading }}</h4>
- <div
- class="related-issues-token-body bordered-box bg-white"
- :class="{ 'sortable-container': canReorder }"
+ <h4
+ v-if="heading"
+ class="gl-font-sm gl-font-weight-semibold gl-text-gray-700 gl-mx-2 gl-mt-3 gl-mb-2"
>
+ {{ heading }}
+ </h4>
+ <div class="related-issues-token-body" :class="{ 'sortable-container': canReorder }">
<div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder">
<gl-loading-icon
ref="loadingIcon"
@@ -121,7 +123,7 @@ export default {
}"
:data-key="issue.id"
:data-ordering-id="issuableOrderingId(issue)"
- class="js-related-issues-token-list-item list-item pt-0 pb-0"
+ class="js-related-issues-token-list-item list-item pt-0 pb-0 gl-border-b-0!"
>
<related-issuable-item
:id-key="issue.id"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index ed70e1ce8a8..51f4e4f7d7b 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -23,7 +23,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
and hide the `AddIssuableForm` area.
*/
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/issues/constants';
import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 2a4ce70511b..25fc875db65 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -1,12 +1,5 @@
import { __, sprintf } from '~/locale';
-import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
-
-export const issuableTypesMap = {
- ISSUE: 'issue',
- INCIDENT: 'incident',
- EPIC: 'epic',
- MERGE_REQUEST: 'merge_request',
-};
+import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
export const linkedIssueTypesMap = {
BLOCKS: 'blocks',
@@ -27,7 +20,7 @@ export const autoCompleteTextMap = {
{ emphasisStart: '<', emphasisEnd: '>' },
false,
),
- [issuableTypesMap.INCIDENT]: sprintf(
+ [TYPE_INCIDENT]: sprintf(
__(' or %{emphasisStart}#id%{emphasisEnd}'),
{ emphasisStart: '<', emphasisEnd: '>' },
false,
@@ -37,7 +30,7 @@ export const autoCompleteTextMap = {
{ emphasisStart: '<', emphasisEnd: '>' },
false,
),
- [issuableTypesMap.MERGE_REQUEST]: sprintf(
+ [TYPE_MERGE_REQUEST]: sprintf(
__(' or %{emphasisStart}!merge request id%{emphasisEnd}'),
{ emphasisStart: '<', emphasisEnd: '>' },
false,
@@ -46,21 +39,21 @@ export const autoCompleteTextMap = {
false: {
[TYPE_ISSUE]: '',
[TYPE_EPIC]: '',
- [issuableTypesMap.MERGE_REQUEST]: __(' or references'),
+ [TYPE_MERGE_REQUEST]: __(' or references'),
},
};
export const inputPlaceholderTextMap = {
[TYPE_ISSUE]: __('Paste issue link'),
- [issuableTypesMap.INCIDENT]: __('Paste link'),
+ [TYPE_INCIDENT]: __('Paste link'),
[TYPE_EPIC]: __('Paste epic link'),
- [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'),
+ [TYPE_MERGE_REQUEST]: __('Enter merge request URLs'),
};
export const inputPlaceholderConfidentialTextMap = {
[TYPE_ISSUE]: __('Paste confidential issue link'),
[TYPE_EPIC]: __('Paste confidential epic link'),
- [issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'),
+ [TYPE_MERGE_REQUEST]: __('Enter merge request URLs'),
};
export const relatedIssuesRemoveErrorMap = {
@@ -96,7 +89,7 @@ export const addRelatedItemErrorMap = {
*/
export const issuableIconMap = {
[TYPE_ISSUE]: 'issues',
- [issuableTypesMap.INCIDENT]: 'issues',
+ [TYPE_INCIDENT]: 'issues',
[TYPE_EPIC]: 'epic',
};
@@ -107,13 +100,13 @@ export const PathIdSeparator = {
export const issuablesBlockHeaderTextMap = {
[TYPE_ISSUE]: __('Linked items'),
- [issuableTypesMap.INCIDENT]: __('Linked incidents or issues'),
+ [TYPE_INCIDENT]: __('Linked incidents or issues'),
[TYPE_EPIC]: __('Linked epics'),
};
export const issuablesBlockHelpTextMap = {
[TYPE_ISSUE]: __('Learn more about linking issues'),
- [issuableTypesMap.INCIDENT]: __('Learn more about linking issues and incidents'),
+ [TYPE_INCIDENT]: __('Learn more about linking issues and incidents'),
[TYPE_EPIC]: __('Learn more about linking epics'),
};
@@ -124,12 +117,12 @@ export const issuablesBlockAddButtonTextMap = {
export const issuablesFormCategoryHeaderTextMap = {
[TYPE_ISSUE]: __('The current issue'),
- [issuableTypesMap.INCIDENT]: __('The current incident'),
+ [TYPE_INCIDENT]: __('The current incident'),
[TYPE_EPIC]: __('The current epic'),
};
export const issuablesFormInputTextMap = {
[TYPE_ISSUE]: __('the following issues'),
- [issuableTypesMap.INCIDENT]: __('the following incidents or issues'),
+ [TYPE_INCIDENT]: __('the following incidents or issues'),
[TYPE_EPIC]: __('the following epics'),
};
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 9f200856db3..515d9efaefd 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { historyPushState } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index 544f2de5132..111d9e232c5 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { popCreateReleaseNotification } from '~/releases/release_notification_service';
import oneReleaseQuery from '../graphql/queries/one_release.query.graphql';
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 2ddab5dddea..2ac61988393 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -68,10 +68,7 @@ export default {
},
},
showTagNameValidationError() {
- return (
- this.isInputDirty &&
- (this.validationErrors.isTagNameEmpty || this.validationErrors.existingRelease)
- );
+ return this.isInputDirty && !this.validationErrors.tagNameValidation.isValid;
},
tagNameInputId() {
return uniqueId('tag-name-input-');
@@ -80,9 +77,7 @@ export default {
return uniqueId('create-from-selector-');
},
tagFeedback() {
- return this.validationErrors.existingRelease
- ? __('Selected tag is already in use. Choose another option.')
- : __('Tag name is required.');
+ return this.validationErrors.tagNameValidation.validationErrors[0];
},
},
methods: {
diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js
index 4f862741e11..5e9e65a01b3 100644
--- a/app/assets/javascripts/releases/constants.js
+++ b/app/assets/javascripts/releases/constants.js
@@ -49,3 +49,8 @@ export const SORT_MAP = {
};
export const DEFAULT_SORT = RELEASED_AT_DESC;
+
+export const i18n = {
+ tagNameIsRequiredMessage: __('Tag name is required.'),
+ tagIsAlredyInUseMessage: __('Selected tag is already in use. Choose another option.'),
+};
diff --git a/app/assets/javascripts/releases/release_notification_service.js b/app/assets/javascripts/releases/release_notification_service.js
index a4f926d7561..775c62802d4 100644
--- a/app/assets/javascripts/releases/release_notification_service.js
+++ b/app/assets/javascripts/releases/release_notification_service.js
@@ -1,5 +1,5 @@
import { s__, sprintf } from '~/locale';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
const createReleaseSessionKey = (projectPath) => `createRelease:${projectPath}`;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 42ceed81c00..a7d8825ed33 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -1,5 +1,5 @@
import { getTag } from '~/rest_api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql';
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 0d77095d099..8ff479058f2 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -2,6 +2,8 @@ import { isEmpty } from 'lodash';
import { s__ } from '~/locale';
import { hasContent } from '~/lib/utils/text_utility';
import { getDuplicateItemsFromArray } from '~/lib/utils/array_utility';
+import { validateTag, ValidationResult } from '~/lib/utils/ref_validator';
+import { i18n } from '~/releases/constants';
/**
* @param {Object} link The link to test
@@ -35,18 +37,21 @@ export const validationErrors = (state) => {
assets: {
links: {},
},
+ tagNameValidation: new ValidationResult(),
};
if (!state.release) {
return errors;
}
- if (!state.release.tagName?.trim?.().length) {
- errors.isTagNameEmpty = true;
+ if (!state.release.tagName || typeof state.release.tagName !== 'string') {
+ errors.tagNameValidation.addValidationError(i18n.tagNameIsRequiredMessage);
+ } else {
+ errors.tagNameValidation = validateTag(state.release.tagName);
}
if (state.existingRelease) {
- errors.existingRelease = true;
+ errors.tagNameValidation.addValidationError(i18n.tagIsAlredyInUseMessage);
}
// Each key of this object is a URL, and the value is an
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
index d029f8cf89f..e26036b5620 100644
--- a/app/assets/javascripts/repository/commits_service.js
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -1,7 +1,7 @@
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { normalizeData } from 'ee_else_ce/repository/utils/commit';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { COMMIT_BATCH_SIZE, I18N_COMMIT_DATA_FETCH_ERROR } from './constants';
let requestedOffsets = [];
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 101625a4b72..236351005e7 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -4,7 +4,7 @@ import { uniqueId } from 'lodash';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue
index 29c2c3762fc..d3e306619bf 100644
--- a/app/assets/javascripts/repository/components/blob_controls.vue
+++ b/app/assets/javascripts/repository/components/blob_controls.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import getRefMixin from '~/repository/mixins/get_ref';
import initSourcegraph from '~/sourcegraph';
import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
diff --git a/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
index 1114a0942ec..53dad19028d 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_viewers/notebook_viewer.vue
@@ -1,11 +1,15 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import notebookLoader from '~/blob/notebook';
import { stripPathTail } from '~/lib/utils/url_utility';
+import NotebookViewer from '~/blob/notebook/notebook_viewer.vue';
export default {
- components: {
- GlLoadingIcon,
+ components: { NotebookViewer },
+ provide() {
+ // `relativeRawPath` is injected in app/assets/javascripts/notebook/cells/markdown.vue
+ // It is needed for images in Markdown cells that reference local files to work.
+ // See the following MR for more context:
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69075
+ return { relativeRawPath: stripPathTail(this.url) };
},
props: {
blob: {
@@ -18,14 +22,9 @@ export default {
url: this.blob.rawPath,
};
},
- mounted() {
- notebookLoader({ el: this.$refs.viewer, relativeRawPath: stripPathTail(this.url) });
- },
};
</script>
<template>
- <div ref="viewer" :data-endpoint="url" data-testid="notebook">
- <gl-loading-icon class="gl-my-4" size="lg" />
- </div>
+ <notebook-viewer :endpoint="url" />
</template>
diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue
index baf8449b188..cbdf6ef9ccd 100644
--- a/app/assets/javascripts/repository/components/delete_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue
@@ -101,23 +101,19 @@ export default {
primaryOptions() {
return {
text: this.$options.i18n.PRIMARY_OPTIONS_TEXT,
- attributes: [
- {
- variant: 'danger',
- loading: this.loading,
- disabled: this.loading || !this.form.state,
- },
- ],
+ attributes: {
+ variant: 'danger',
+ loading: this.loading,
+ disabled: this.loading || !this.form.state,
+ },
};
},
cancelOptions() {
return {
text: this.$options.i18n.SECONDARY_OPTIONS_TEXT,
- attributes: [
- {
- disabled: this.loading,
- },
- ],
+ attributes: {
+ disabled: this.loading,
+ },
};
},
showCreateNewMrToggle() {
diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue
index 9804837b200..1a834ba1d82 100644
--- a/app/assets/javascripts/repository/components/fork_info.vue
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -1,8 +1,16 @@
<script>
-import { GlIcon, GlLink, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
+import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ POLLING_INTERVAL_DEFAULT,
+ POLLING_INTERVAL_BACKOFF,
+ FIVE_MINUTES_IN_MS,
+} from '../constants';
import forkDetailsQuery from '../queries/fork_details.query.graphql';
+import ConflictsModal from './fork_sync_conflicts_modal.vue';
export const i18n = {
forkedFrom: s__('ForkedFromProjectPath|Forked from'),
@@ -12,7 +20,9 @@ export const i18n = {
behind: s__('ForksDivergence|%{behindLinkStart}%{behind} %{commit_word} behind%{behindLinkEnd}'),
ahead: s__('ForksDivergence|%{aheadLinkStart}%{ahead} %{commit_word} ahead%{aheadLinkEnd} of'),
behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'),
+ limitedVisibility: s__('ForksDivergence|Source project has a limited visibility.'),
error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'),
+ sync: s__('ForksDivergence|Update fork'),
};
export default {
@@ -20,17 +30,19 @@ export default {
components: {
GlIcon,
GlLink,
+ GlButton,
GlSprintf,
GlSkeletonLoader,
+ ConflictsModal,
+ GlLoadingIcon,
},
+ mixins: [glFeatureFlagMixin()],
apollo: {
project: {
query: forkDetailsQuery,
+ notifyOnNetworkStatusChange: true,
variables() {
- return {
- projectPath: this.projectPath,
- ref: this.selectedBranch,
- };
+ return this.forkDetailsQueryVariables;
},
skip() {
return !this.sourceName;
@@ -42,6 +54,12 @@ export default {
error,
});
},
+ result({ loading }) {
+ this.handlePolingInterval(loading);
+ },
+ pollInterval() {
+ return this.pollInterval;
+ },
},
},
props: {
@@ -53,6 +71,11 @@ export default {
type: String,
required: true,
},
+ sourceDefaultBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
sourceName: {
type: String,
required: false,
@@ -76,18 +99,33 @@ export default {
},
data() {
return {
- project: {
- forkDetails: {
- ahead: null,
- behind: null,
- },
- },
+ project: {},
+ currentPollInterval: null,
+ isSyncTriggered: false,
};
},
computed: {
+ forkDetailsQueryVariables() {
+ return {
+ projectPath: this.projectPath,
+ ref: this.selectedBranch,
+ };
+ },
+ pollInterval() {
+ return this.isSyncing ? this.currentPollInterval : 0;
+ },
isLoading() {
return this.$apollo.queries.project.loading;
},
+ forkDetails() {
+ return this.project?.forkDetails;
+ },
+ hasConflicts() {
+ return this.forkDetails?.hasConflicts;
+ },
+ isSyncing() {
+ return this.forkDetails?.isSyncing;
+ },
ahead() {
return this.project?.forkDetails?.ahead;
},
@@ -107,7 +145,10 @@ export default {
});
},
isUnknownDivergence() {
- return (!this.ahead && this.ahead !== 0) || (!this.behind && this.behind !== 0);
+ return this.sourceName && this.ahead === null && this.behind === null;
+ },
+ isUpToDate() {
+ return this.ahead === 0 && this.behind === 0;
},
behindAheadMessage() {
const messages = [];
@@ -122,7 +163,16 @@ export default {
hasBehindAheadMessage() {
return this.behindAheadMessage.length > 0;
},
+ isSyncButtonAvailable() {
+ return (
+ this.glFeatures.synchronizeFork &&
+ ((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence)
+ );
+ },
forkDivergenceMessage() {
+ if (!this.forkDetails) {
+ return this.$options.i18n.limitedVisibility;
+ }
if (this.isUnknownDivergence) {
return this.$options.i18n.unknown;
}
@@ -134,6 +184,73 @@ export default {
return this.$options.i18n.upToDate;
},
},
+ watch: {
+ hasConflicts(newVal) {
+ if (newVal && this.isSyncTriggered) {
+ this.showConflictsModal();
+ this.isSyncTriggered = false;
+ }
+ },
+ },
+ methods: {
+ async syncForkWithPolling() {
+ await this.$apollo.mutate({
+ mutation: syncForkMutation,
+ variables: {
+ projectPath: this.projectPath,
+ targetBranch: this.selectedBranch,
+ },
+ error(error) {
+ createAlert({
+ message: error.message,
+ captureError: true,
+ error,
+ });
+ },
+ update: (store, { data: { projectSyncFork } }) => {
+ const { details } = projectSyncFork;
+
+ store.writeQuery({
+ query: forkDetailsQuery,
+ variables: this.forkDetailsQueryVariables,
+ data: {
+ project: {
+ id: this.project.id,
+ forkDetails: details,
+ },
+ },
+ });
+ },
+ });
+ },
+ showConflictsModal() {
+ this.$refs.modal.show();
+ },
+ startSyncing() {
+ this.isSyncTriggered = true;
+ this.syncForkWithPolling();
+ },
+ checkIfSyncIsPossible() {
+ if (this.hasConflicts) {
+ this.showConflictsModal();
+ } else {
+ this.startSyncing();
+ }
+ },
+ handlePolingInterval(loading) {
+ if (!loading && this.isSyncing) {
+ const backoff = POLLING_INTERVAL_BACKOFF;
+ const interval = this.currentPollInterval;
+ const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS);
+ this.currentPollInterval = this.currentPollInterval
+ ? newInterval
+ : POLLING_INTERVAL_DEFAULT;
+ }
+ if (this.currentPollInterval === FIVE_MINUTES_IN_MS) {
+ this.$apollo.queries.forkDetailsQuery.stopPolling();
+ }
+ },
+ },
};
</script>
@@ -141,23 +258,45 @@ export default {
<div class="info-well gl-sm-display-flex gl-flex-direction-column">
<div class="well-segment gl-p-5 gl-w-full gl-display-flex">
<gl-icon name="fork" :size="16" class="gl-display-block gl-m-4 gl-text-center" />
- <div v-if="sourceName">
- {{ $options.i18n.forkedFrom }}
- <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link>
- <gl-skeleton-loader v-if="isLoading" :lines="1" />
- <div v-else class="gl-text-secondary" data-testid="divergence-message">
- <gl-sprintf :message="forkDivergenceMessage">
- <template #aheadLink="{ content }">
- <gl-link :href="aheadComparePath">{{ content }}</gl-link>
- </template>
- <template #behindLink="{ content }">
- <gl-link :href="behindComparePath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1"
+ >
+ <div v-if="sourceName">
+ {{ $options.i18n.forkedFrom }}
+ <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link>
+ <gl-skeleton-loader v-if="isLoading" :lines="1" />
+ <div v-else class="gl-text-secondary" data-testid="divergence-message">
+ <gl-sprintf :message="forkDivergenceMessage">
+ <template #aheadLink="{ content }">
+ <gl-link :href="aheadComparePath">{{ content }}</gl-link>
+ </template>
+ <template #behindLink="{ content }">
+ <gl-link :href="behindComparePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</div>
- </div>
- <div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex">
- {{ $options.i18n.inaccessibleProject }}
+ <div
+ v-else
+ data-testid="inaccessible-project"
+ class="gl-align-items-center gl-display-flex"
+ >
+ {{ $options.i18n.inaccessibleProject }}
+ </div>
+ <gl-button
+ v-if="isSyncButtonAvailable"
+ :disabled="forkDetails.isSyncing"
+ @click="checkIfSyncIsPossible"
+ >
+ <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" />
+ <span>{{ $options.i18n.sync }}</span>
+ </gl-button>
+ <conflicts-modal
+ ref="modal"
+ :source-name="sourceName"
+ :source-path="sourcePath"
+ :source-default-branch="sourceDefaultBranch"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
new file mode 100644
index 00000000000..0bfb90bb3ec
--- /dev/null
+++ b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue
@@ -0,0 +1,137 @@
+<script>
+/* eslint-disable @gitlab/require-i18n-strings */
+import { GlModal, GlButton } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { getBaseURL } from '~/lib/utils/url_utility';
+
+export const i18n = {
+ modalTitle: s__('ForksDivergence|Resolve merge conflicts manually'),
+ modalMessage: s__(
+ 'ForksDivergence|The upstream changes could not be synchronized to this project due to file conflicts in the default branch. You must resolve the conflicts manually:',
+ ),
+ step1: __('Step 1.'),
+ step2: __('Step 2.'),
+ step3: __('Step 3.'),
+ step4: __('Step 4.'),
+ step1Text: s__(
+ "ForksDivergence|Fetch the latest changes from the upstream repository's default branch:",
+ ),
+ step2Text: s__(
+ "ForksDivergence|Check out to a new branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.",
+ ),
+ step3Text: s__('ForksDivergence|Push the updates to remote:'),
+ step4Text: s__("ForksDivergence|Create a merge request to your project's default branch."),
+ copyToClipboard: __('Copy to clipboard'),
+ close: __('Close'),
+};
+
+export default {
+ name: 'ForkSyncConflictsModal',
+ components: {
+ GlModal,
+ GlButton,
+ ModalCopyButton,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ sourceDefaultBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sourceName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sourcePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ instructionsStep1() {
+ const baseUrl = getBaseURL();
+ return `git fetch ${baseUrl}${this.sourcePath} ${this.sourceDefaultBranch}`;
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.modal.show();
+ },
+ hide() {
+ this.$refs.modal.hide();
+ },
+ },
+ i18n,
+ instructionsStep2: 'git checkout -b &lt;new-branch-name&gt;\ngit merge FETCH_HEAD',
+ instructionsStep2Clipboard: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD',
+ instructionsStep3: 'git commit\ngit push',
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="fork-sync-conflicts-modal"
+ :title="$options.i18n.modalTitle"
+ size="md"
+ >
+ <p>{{ $options.i18n.modalMessage }}</p>
+ <p>
+ <b> {{ $options.i18n.step1 }}</b> {{ $options.i18n.modalMessage }}
+ </p>
+ <div class="gl-display-flex gl-mb-4">
+ <pre class="gl-w-full gl-mb-0 gl-mr-3" data-testid="resolve-conflict-instructions">{{
+ instructionsStep1
+ }}</pre>
+ <modal-copy-button
+ modal-id="fork-sync-conflicts-modal"
+ :text="instructionsStep1"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
+ />
+ </div>
+ <p>
+ <b> {{ $options.i18n.step2 }}</b> {{ $options.i18n.step2Text }}
+ </p>
+ <div class="gl-display-flex gl-mb-4">
+ <pre
+ class="gl-w-full gl-mb-0 gl-mr-3"
+ data-testid="resolve-conflict-instructions"
+ v-html="$options.instructionsStep2 /* eslint-disable-line vue/no-v-html */"
+ ></pre>
+ <modal-copy-button
+ modal-id="fork-sync-conflicts-modal"
+ :text="$options.instructionsStep2Clipboard"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
+ />
+ </div>
+ <p>
+ <b> {{ $options.i18n.step3 }}</b> {{ $options.i18n.step3Text }}
+ </p>
+ <div class="gl-display-flex gl-mb-4">
+ <pre class="gl-w-full gl-mb-0" data-testid="resolve-conflict-instructions"
+ >{{ $options.instructionsStep3 }}
+</pre
+ >
+ <modal-copy-button
+ modal-id="fork-sync-conflicts-modal"
+ :text="$options.instructionsStep3"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0 gl-ml-3"
+ />
+ </div>
+ <p>
+ <b> {{ $options.i18n.step4 }}</b> {{ $options.i18n.step4Text }}
+ </p>
+ <template #modal-footer>
+ <gl-button @click="hide" @keydown.esc="hide">{{ $options.i18n.close }}</gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 4d3c1521559..2d2e21dfd92 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -9,6 +9,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import SignatureBadge from '~/commit/components/signature_badge.vue';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
@@ -23,6 +24,7 @@ export default {
GlLink,
GlLoadingIcon,
UserAvatarImage,
+ SignatureBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -170,10 +172,7 @@ export default {
<div
class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row"
>
- <div
- v-if="commit.signatureHtml"
- v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <signature-badge v-if="commit.signature" :signature="commit.signature" />
<div v-if="commit.pipeline" class="ci-status-link">
<gl-link
v-gl-tooltip.left
diff --git a/app/assets/javascripts/repository/components/new_directory_modal.vue b/app/assets/javascripts/repository/components/new_directory_modal.vue
index b28ebe7bb1e..f36a700c902 100644
--- a/app/assets/javascripts/repository/components/new_directory_modal.vue
+++ b/app/assets/javascripts/repository/components/new_directory_modal.vue
@@ -8,7 +8,7 @@ import {
GlFormTextarea,
GlToggle,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -93,23 +93,19 @@ export default {
primaryOptions() {
return {
text: this.primaryBtnText,
- attributes: [
- {
- variant: 'confirm',
- loading: this.loading,
- disabled: !this.formCompleted || this.loading,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
};
},
cancelOptions() {
return {
text: SECONDARY_OPTIONS_TEXT,
- attributes: [
- {
- disabled: this.loading,
- },
- ],
+ attributes: {
+ disabled: this.loading,
+ },
};
},
showCreateNewMrToggle() {
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index f6d6004ba96..0c9b46344c5 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -1,10 +1,8 @@
<script>
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
-import { createAlert } from '~/flash';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { createAlert } from '~/alert';
import {
TREE_PAGE_SIZE,
- TREE_INITIAL_FETCH_COUNT,
TREE_PAGE_LIMIT,
COMMIT_BATCH_SIZE,
GITALY_UNAVAILABLE_CODE,
@@ -23,7 +21,7 @@ export default {
FileTable,
FilePreview,
},
- mixins: [getRefMixin, glFeatureFlagMixin()],
+ mixins: [getRefMixin],
apollo: {
projectPath: {
query: projectPathQuery,
@@ -59,13 +57,6 @@ export default {
};
},
computed: {
- pageSize() {
- // we want to exponentially increase the page size to reduce the load on the frontend
- const exponentialSize = (TREE_PAGE_SIZE / TREE_INITIAL_FETCH_COUNT) * (this.fetchCounter + 1);
- return exponentialSize < TREE_PAGE_SIZE && this.glFeatures.increasePageSizeExponentially
- ? exponentialSize
- : TREE_PAGE_SIZE;
- },
totalEntries() {
return Object.values(this.entries).flat().length;
},
@@ -110,7 +101,7 @@ export default {
ref: this.ref,
path: originalPath,
nextPageCursor: this.nextPageCursor,
- pageSize: this.pageSize,
+ pageSize: TREE_PAGE_SIZE,
},
})
.then(({ data }) => {
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index 4603ea2710d..4ca625bc0de 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -9,7 +9,7 @@ import {
GlButton,
GlAlert,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
import { numberToHumanSize } from '~/lib/utils/number_utils';
@@ -106,23 +106,19 @@ export default {
primaryOptions() {
return {
text: this.primaryBtnText,
- attributes: [
- {
- variant: 'confirm',
- loading: this.loading,
- disabled: !this.formCompleted || this.loading,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
};
},
cancelOptions() {
return {
text: SECONDARY_OPTIONS_TEXT,
- attributes: [
- {
- disabled: this.loading,
- },
- ],
+ attributes: {
+ disabled: this.loading,
+ },
};
},
formattedFileSize() {
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 5098053c4f7..a6191203b2f 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -3,7 +3,6 @@ import { __ } from '~/locale';
export const GITALY_UNAVAILABLE_CODE = 'unavailable';
export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
-export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
export const COMMIT_BATCH_SIZE = 25; // we request commit data in batches of 25
@@ -106,3 +105,10 @@ export const i18n = {
generalError: __('An error occurred while fetching folder content.'),
gitalyError: __('Error: Gitaly is unavailable. Contact your administrator.'),
};
+
+export const FIVE_MINUTES_IN_MS = 1000 * 60 * 5;
+
+export const POLLING_INTERVAL_DEFAULT = 2500;
+export const POLLING_INTERVAL_BACKOFF = 2;
+
+export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal';
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 494e270a66c..6cedc606a37 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -69,7 +69,13 @@ export default function setupVueRepositoryList() {
if (!forkEl) {
return null;
}
- const { sourceName, sourcePath, aheadComparePath, behindComparePath } = forkEl.dataset;
+ const {
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ aheadComparePath,
+ behindComparePath,
+ } = forkEl.dataset;
return new Vue({
el: forkEl,
apolloProvider,
@@ -80,6 +86,7 @@ export default function setupVueRepositoryList() {
selectedBranch: ref,
sourceName,
sourcePath,
+ sourceDefaultBranch,
aheadComparePath,
behindComparePath,
},
diff --git a/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql b/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql
new file mode 100644
index 00000000000..b3426038694
--- /dev/null
+++ b/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql
@@ -0,0 +1,11 @@
+mutation syncFork($projectPath: ID!, $targetBranch: String!) {
+ projectSyncFork(input: { projectPath: $projectPath, targetBranch: $targetBranch }) {
+ details {
+ ahead
+ behind
+ isSyncing
+ hasConflicts
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/fork_details.query.graphql b/app/assets/javascripts/repository/queries/fork_details.query.graphql
index d1a37d00d55..3d37f69b48d 100644
--- a/app/assets/javascripts/repository/queries/fork_details.query.graphql
+++ b/app/assets/javascripts/repository/queries/fork_details.query.graphql
@@ -4,6 +4,8 @@ query getForkDetails($projectPath: ID!, $ref: String) {
forkDetails(ref: $ref) {
ahead
behind
+ isSyncing
+ hasConflicts
}
}
}
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 3256e13f4da..297b8ae1fc2 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -70,7 +70,7 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
const $expandIcon = $('.js-sidebar-expand');
const $toggleContainer = $('.js-sidebar-toggle-container');
const isExpanded = $toggleContainer.data('is-expanded');
- const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
+ const tooltipLabel = isExpanded ? __('Collapse sidebar') : __('Expand sidebar');
e.preventDefault();
if (isExpanded) {
diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/saved_replies/components/app.vue
index db8476c44f3..e4b481f0908 100644
--- a/app/assets/javascripts/saved_replies/components/app.vue
+++ b/app/assets/javascripts/saved_replies/components/app.vue
@@ -17,7 +17,7 @@ export default {};
</p>
</div>
<div class="col-lg-8">
- <router-view />
+ <keep-alive><router-view /></keep-alive>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/saved_replies/components/form.vue b/app/assets/javascripts/saved_replies/components/form.vue
new file mode 100644
index 00000000000..efec9b96764
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/components/form.vue
@@ -0,0 +1,182 @@
+<script>
+import { GlButton, GlForm, GlFormGroup, GlFormInput, GlAlert } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { logError } from '~/lib/logger';
+import { __ } from '~/locale';
+import createSavedReplyMutation from '../queries/create_saved_reply.mutation.graphql';
+import updateSavedReplyMutation from '../queries/update_saved_reply.mutation.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlAlert,
+ MarkdownField,
+ },
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ content: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ errors: [],
+ saving: false,
+ showValidation: false,
+ updateSavedReply: {
+ name: this.name,
+ content: this.content,
+ },
+ };
+ },
+ computed: {
+ isNameValid() {
+ if (this.showValidation) return Boolean(this.updateSavedReply.name);
+
+ return true;
+ },
+ isContentValid() {
+ if (this.showValidation) return Boolean(this.updateSavedReply.content);
+
+ return true;
+ },
+ isValid() {
+ return this.isNameValid && this.isContentValid;
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.showValidation = true;
+
+ if (!this.isValid) return;
+
+ this.errors = [];
+ this.saving = true;
+
+ this.$apollo
+ .mutate({
+ mutation: this.id ? updateSavedReplyMutation : createSavedReplyMutation,
+ variables: {
+ id: this.id,
+ name: this.updateSavedReply.name,
+ content: this.updateSavedReply.content,
+ },
+ update: (store, { data: { savedReplyMutation } }) => {
+ if (savedReplyMutation.errors.length) {
+ this.errors = savedReplyMutation.errors.map((e) => e);
+ } else {
+ this.$emit('saved');
+ this.updateSavedReply = { name: '', content: '' };
+ this.showValidation = false;
+ }
+ },
+ })
+ .catch((error) => {
+ const errors = error.graphQLErrors;
+
+ if (errors?.length) {
+ this.errors = errors.map((e) => e.message);
+ } else {
+ // Let's be sure to log the original error so it isn't just swallowed.
+ // Also, we don't want to translate console messages.
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ logError('Unexpected error while saving reply', error);
+
+ this.errors = [__('An unexpected error occurred. Please try again.')];
+ }
+ })
+ .finally(() => {
+ this.saving = false;
+ });
+ },
+ },
+ restrictedToolbarItems: ['full-screen'],
+ markdownDocsPath: helpPagePath('user/markdown'),
+};
+</script>
+
+<template>
+ <gl-form
+ class="new-note common-note-form"
+ data-testid="saved-reply-form"
+ @submit.prevent="onSubmit"
+ >
+ <gl-alert
+ v-for="error in errors"
+ :key="error"
+ variant="danger"
+ class="gl-mb-3"
+ :dismissible="false"
+ >
+ {{ error }}
+ </gl-alert>
+ <gl-form-group
+ :label="__('Name')"
+ :state="isNameValid"
+ :invalid-feedback="__('Please enter a name for the saved reply.')"
+ data-testid="saved-reply-name-form-group"
+ >
+ <gl-form-input
+ v-model="updateSavedReply.name"
+ :placeholder="__('Enter a name for your saved reply')"
+ data-testid="saved-reply-name-input"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="__('Content')"
+ :state="isContentValid"
+ :invalid-feedback="__('Please enter the saved reply content.')"
+ data-testid="saved-reply-content-form-group"
+ >
+ <markdown-field
+ :enable-preview="false"
+ :is-submitting="saving"
+ :add-spacing-classes="false"
+ :textarea-value="updateSavedReply.content"
+ :markdown-docs-path="$options.markdownDocsPath"
+ :restricted-tool-bar-items="$options.restrictedToolbarItems"
+ :force-autosize="false"
+ class="js-no-autosize gl-border-gray-400!"
+ >
+ <template #textarea>
+ <textarea
+ v-model="updateSavedReply.content"
+ dir="auto"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-supports-quick-actions="false"
+ :aria-label="__('Content')"
+ :placeholder="__('Write saved reply content here…')"
+ data-testid="saved-reply-content-input"
+ @keydown.meta.enter="onSubmit"
+ @keydown.ctrl.enter="onSubmit"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ <gl-button
+ variant="confirm"
+ class="gl-mr-3 js-no-auto-disable"
+ type="submit"
+ :loading="saving"
+ data-testid="saved-reply-form-submit-btn"
+ >
+ {{ __('Save') }}
+ </gl-button>
+ <gl-button v-if="id" :to="{ path: '/' }">{{ __('Cancel') }}</gl-button>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/saved_replies/components/list.vue
index 30089cfa53f..dbe326d429a 100644
--- a/app/assets/javascripts/saved_replies/components/list.vue
+++ b/app/assets/javascripts/saved_replies/components/list.vue
@@ -1,43 +1,51 @@
<script>
import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
-import savedRepliesQuery from '../queries/saved_replies.query.graphql';
import ListItem from './list_item.vue';
export default {
- apollo: {
- savedReplies: {
- query: savedRepliesQuery,
- update: (r) => r.currentUser?.savedReplies?.nodes,
- result({ data }) {
- const pageInfo = data.currentUser?.savedReplies?.pageInfo;
-
- this.count = data.currentUser?.savedReplies?.count;
-
- if (pageInfo) {
- this.pageInfo = pageInfo;
- }
- },
- },
- },
components: {
GlLoadingIcon,
GlKeysetPagination,
GlSprintf,
ListItem,
},
- data() {
- return {
- savedReplies: [],
- count: 0,
- pageInfo: {},
- };
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ savedReplies: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ },
+ methods: {
+ prevPage() {
+ this.$emit('input', {
+ before: this.pageInfo.beforeCursor,
+ });
+ },
+ nextPage() {
+ this.$emit('input', {
+ after: this.pageInfo.endCursor,
+ });
+ },
},
};
</script>
<template>
<div>
- <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="lg" />
+ <gl-loading-icon v-if="loading" size="lg" />
<template v-else>
<h5 class="gl-font-lg" data-testid="title">
<gl-sprintf :message="__('My saved replies (%{count})')">
@@ -51,6 +59,8 @@ export default {
v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
v-bind="pageInfo"
class="gl-mt-4"
+ @prev="prevPage"
+ @next="nextPage"
/>
</template>
</div>
diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue
index dfa9a405dee..3ad5642afc7 100644
--- a/app/assets/javascripts/saved_replies/components/list_item.vue
+++ b/app/assets/javascripts/saved_replies/components/list_item.vue
@@ -1,19 +1,101 @@
<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlModal, GlModalDirective, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql';
+
export default {
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ },
props: {
reply: {
type: Object,
required: true,
},
},
+ data() {
+ return {
+ isDeleting: false,
+ modalId: uniqueId('delete-saved-reply-'),
+ };
+ },
+ computed: {
+ id() {
+ return getIdFromGraphQLId(this.reply.id);
+ },
+ },
+ methods: {
+ onDelete() {
+ this.isDeleting = true;
+
+ this.$apollo.mutate({
+ mutation: deleteSavedReplyMutation,
+ variables: {
+ id: this.reply.id,
+ },
+ update: (cache) => {
+ const cacheId = cache.identify(this.reply);
+ cache.evict({ id: cacheId });
+ },
+ });
+ },
+ },
+ actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
+ actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
};
</script>
<template>
<li class="gl-mb-5">
<div class="gl-display-flex gl-align-items-center">
- <strong>{{ reply.name }}</strong>
+ <strong data-testid="saved-reply-name">{{ reply.name }}</strong>
+ <div class="gl-ml-auto">
+ <gl-button
+ v-gl-tooltip
+ :to="{ name: 'edit', params: { id: id } }"
+ icon="pencil"
+ :title="__('Edit')"
+ :aria-label="__('Edit')"
+ class="gl-mr-3"
+ data-testid="saved-reply-edit-btn"
+ />
+ <gl-button
+ v-gl-modal="modalId"
+ v-gl-tooltip
+ icon="remove"
+ :aria-label="__('Delete')"
+ :title="__('Delete')"
+ variant="danger"
+ category="secondary"
+ data-testid="saved-reply-delete-btn"
+ :loading="isDeleting"
+ />
+ </div>
</div>
<div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div>
+ <gl-modal
+ :title="__('Delete saved reply')"
+ :action-primary="$options.actionPrimary"
+ :action-secondary="$options.actionSecondary"
+ :modal-id="modalId"
+ size="sm"
+ @primary="onDelete"
+ >
+ <gl-sprintf
+ :message="__('Are you sure you want to delete %{name}? This action cannot be undone.')"
+ >
+ <template #name
+ ><strong>{{ reply.name }}</strong></template
+ >
+ </gl-sprintf>
+ </gl-modal>
</li>
</template>
diff --git a/app/assets/javascripts/saved_replies/pages/edit.vue b/app/assets/javascripts/saved_replies/pages/edit.vue
new file mode 100644
index 00000000000..94215389844
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/pages/edit.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { fetchPolicies } from '~/lib/graphql';
+import { createAlert } from '~/alert';
+import { __ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_USERS_SAVED_REPLY } from '~/graphql_shared/constants';
+import CreateForm from '../components/form.vue';
+import getSavedReply from '../queries/get_saved_reply.query.graphql';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ CreateForm,
+ },
+ apollo: {
+ savedReply: {
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ query: getSavedReply,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPE_USERS_SAVED_REPLY, this.$route.params.id),
+ };
+ },
+ update: (r) => r.currentUser.savedReply,
+ skip() {
+ return !this.$route.params.id;
+ },
+ result({
+ data: {
+ currentUser: { savedReply },
+ },
+ }) {
+ if (!savedReply) {
+ createAlert({ message: __('Unable to find saved reply') });
+ this.redirectToRoot();
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ savedReply: null,
+ };
+ },
+ methods: {
+ redirectToRoot() {
+ this.$router.push({ path: '/' });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Edit saved reply') }}
+ </h5>
+ <gl-loading-icon v-if="$apollo.queries.savedReply.loading" size="lg" />
+ <create-form
+ v-else-if="savedReply"
+ :id="savedReply.id"
+ :name="savedReply.name"
+ :content="savedReply.content"
+ @saved="redirectToRoot"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue
index 38f51dbc365..3e96fc0714e 100644
--- a/app/assets/javascripts/saved_replies/pages/index.vue
+++ b/app/assets/javascripts/saved_replies/pages/index.vue
@@ -1,15 +1,67 @@
<script>
+import { fetchPolicies } from '~/lib/graphql';
+import CreateForm from '../components/form.vue';
+import savedRepliesQuery from '../queries/saved_replies.query.graphql';
import List from '../components/list.vue';
export default {
+ apollo: {
+ savedReplies: {
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ query: savedRepliesQuery,
+ update: (r) => r.currentUser?.savedReplies?.nodes,
+ variables() {
+ return {
+ ...this.pagination,
+ };
+ },
+ result({ data }) {
+ const pageInfo = data.currentUser?.savedReplies?.pageInfo;
+
+ this.count = data.currentUser?.savedReplies?.count;
+
+ if (pageInfo) {
+ this.pageInfo = pageInfo;
+ }
+ },
+ },
+ },
components: {
+ CreateForm,
List,
},
+ data() {
+ return {
+ savedReplies: [],
+ count: 0,
+ pageInfo: {},
+ pagination: {},
+ };
+ },
+ methods: {
+ refetchSavedReplies() {
+ this.pagination = {};
+ this.$apollo.queries.savedReplies.refetch();
+ },
+ changePage(pageInfo) {
+ this.pagination = pageInfo;
+ },
+ },
};
</script>
<template>
<div>
- <list />
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Add new saved reply') }}
+ </h5>
+ <create-form @saved="refetchSavedReplies" />
+ <list
+ :loading="$apollo.queries.savedReplies.loading"
+ :saved-replies="savedReplies"
+ :page-info="pageInfo"
+ :count="count"
+ @input="changePage"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..c4e632d0f16
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/queries/create_saved_reply.mutation.graphql
@@ -0,0 +1,10 @@
+mutation savedReplyCreate($name: String!, $content: String!) {
+ savedReplyMutation: savedReplyCreate(input: { name: $name, content: $content }) {
+ errors
+ savedReply {
+ id
+ name
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..76571ba628c
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/queries/delete_saved_reply.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteSavedReply($id: UsersSavedReplyID!) {
+ savedReplyDestroy(input: { id: $id }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql b/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql
new file mode 100644
index 00000000000..66f5f43af49
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/queries/get_saved_reply.query.graphql
@@ -0,0 +1,10 @@
+query getSavedReply($id: UsersSavedReplyID!) {
+ currentUser {
+ id
+ savedReply(id: $id) {
+ id
+ name
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
index af1f12f3ceb..d8e76b5e2a8 100644
--- a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
+++ b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
@@ -1,7 +1,7 @@
-query savedReplies {
+query savedReplies($after: String = "", $before: String = "") {
currentUser {
id
- savedReplies {
+ savedReplies(after: $after, before: $before) {
nodes {
id
name
diff --git a/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..14a47d7bc9c
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/queries/update_saved_reply.mutation.graphql
@@ -0,0 +1,10 @@
+mutation savedReplyUpdate($id: UsersSavedReplyID!, $name: String!, $content: String!) {
+ savedReplyMutation: savedReplyUpdate(input: { id: $id, name: $name, content: $content }) {
+ errors
+ savedReply {
+ id
+ name
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/saved_replies/routes.js
index bd582a5ed86..7687c6f335a 100644
--- a/app/assets/javascripts/saved_replies/routes.js
+++ b/app/assets/javascripts/saved_replies/routes.js
@@ -1,8 +1,15 @@
import IndexComponent from './pages/index.vue';
+import EditComponent from './pages/edit.vue';
+
export default [
{
path: '/',
component: IndexComponent,
},
+ {
+ name: 'edit',
+ path: '/:id',
+ component: EditComponent,
+ },
];
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 2efc80fef75..60de63c7d7a 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState } from 'vuex';
+import { mapState, mapGetters } from 'vuex';
import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants';
@@ -16,18 +16,21 @@ export default {
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState(['urlQuery']),
+ ...mapGetters(['currentScope']),
showIssueAndMergeFilters() {
- return this.urlQuery.scope === SCOPE_ISSUES || this.urlQuery.scope === SCOPE_MERGE_REQUESTS;
+ return this.currentScope === SCOPE_ISSUES || this.currentScope === SCOPE_MERGE_REQUESTS;
},
showBlobFilter() {
- return this.urlQuery.scope === SCOPE_BLOB && this.glFeatures.searchBlobsLanguageAggregation;
+ return this.currentScope === SCOPE_BLOB;
},
},
};
</script>
<template>
- <section class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5">
+ <section
+ class="search-sidebar gl-display-flex gl-flex-direction-column gl-md-mr-5 gl-mb-6 gl-mt-5"
+ >
<scope-navigation />
<results-filters v-if="showIssueAndMergeFilters" />
<language-filter v-if="showBlobFilter" />
diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
index b580d58b21b..f7873a994aa 100644
--- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import { intersection } from 'lodash';
import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants';
import { formatSearchResultCount } from '../../store/utils';
@@ -12,31 +12,26 @@ export default {
GlFormCheckbox,
},
props: {
- filterData: {
+ filtersData: {
type: Object,
required: true,
},
},
computed: {
...mapState(['query']),
- scope() {
- return this.query.scope;
- },
- queryFilters() {
- return this.query[this.filterData?.filterParam] || [];
- },
+ ...mapGetters(['queryLanguageFilters']),
dataFilters() {
- return Object.values(this.filterData?.filters || []);
+ return Object.values(this.filtersData?.filters || []);
},
flatDataFilterValues() {
return this.dataFilters.map(({ value }) => value);
},
selectedFilter: {
get() {
- return intersection(this.flatDataFilterValues, this.queryFilters);
+ return intersection(this.flatDataFilterValues, this.queryLanguageFilters);
},
set(value) {
- this.setQuery({ key: this.filterData?.filterParam, value });
+ this.setQuery({ key: this.filtersData?.filterParam, value });
},
},
labelCountClasses() {
@@ -56,7 +51,7 @@ export default {
<template>
<div class="gl-mx-5">
- <h5 class="gl-mt-0">{{ filterData.header }}</h5>
+ <h5 class="gl-mt-0">{{ filtersData.header }}</h5>
<gl-form-checkbox-group v-model="selectedFilter">
<gl-form-checkbox
v-for="f in dataFilters"
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter.vue
index 26ce204cb5c..b2f8d3e1f5f 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/language_filter.vue
@@ -27,19 +27,24 @@ export default {
apply: __('Apply'),
showingMax: sprintf(s__('GlobalSearch|Showing top %{maxItems}'), { maxItems: MAX_ITEM_LENGTH }),
loadError: s__('GlobalSearch|Aggregations load error.'),
+ reset: s__('GlobalSearch|Reset filters'),
},
computed: {
...mapState(['aggregations', 'sidebarDirty']),
- ...mapGetters(['langugageAggregationBuckets']),
+ ...mapGetters([
+ 'languageAggregationBuckets',
+ 'currentUrlQueryHasLanguageFilters',
+ 'queryLanguageFilters',
+ ]),
hasBuckets() {
- return this.langugageAggregationBuckets.length > 0;
+ return this.languageAggregationBuckets.length > 0;
},
filtersData() {
return convertFiltersData(this.shortenedLanguageFilters);
},
shortenedLanguageFilters() {
if (!this.hasShowMore) {
- return this.langugageAggregationBuckets;
+ return this.languageAggregationBuckets;
}
if (this.showAll) {
return this.trimBuckets(MAX_ITEM_LENGTH);
@@ -47,25 +52,40 @@ export default {
return this.trimBuckets(DEFAULT_ITEM_LENGTH);
},
hasShowMore() {
- return this.langugageAggregationBuckets.length > DEFAULT_ITEM_LENGTH;
+ return this.languageAggregationBuckets.length > DEFAULT_ITEM_LENGTH;
},
hasOverMax() {
- return this.langugageAggregationBuckets.length > MAX_ITEM_LENGTH;
+ return this.languageAggregationBuckets.length > MAX_ITEM_LENGTH;
},
dividerClasses() {
return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD];
},
+ hasQueryFilters() {
+ return this.queryLanguageFilters.length > 0;
+ },
},
async created() {
await this.fetchLanguageAggregation();
},
methods: {
- ...mapActions(['applyQuery', 'fetchLanguageAggregation']),
+ ...mapActions([
+ 'applyQuery',
+ 'resetLanguageQuery',
+ 'resetLanguageQueryWithRedirect',
+ 'fetchLanguageAggregation',
+ ]),
onShowMore() {
this.showAll = true;
},
trimBuckets(length) {
- return this.langugageAggregationBuckets.slice(0, length);
+ return this.languageAggregationBuckets.slice(0, length);
+ },
+ cleanResetFilters() {
+ if (this.currentUrlQueryHasLanguageFilters) {
+ return this.resetLanguageQueryWithRedirect();
+ }
+ this.showAll = false;
+ return this.resetLanguageQuery();
},
},
HR_DEFAULT_CLASSES,
@@ -84,7 +104,7 @@ export default {
class="gl-overflow-x-hidden gl-overflow-y-auto"
:class="{ 'language-filter-max-height': showAll }"
>
- <checkbox-filter class="gl-px-5" :filter-data="filtersData" />
+ <checkbox-filter :filters-data="filtersData" />
<span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
$options.i18n.showingMax
}}</span>
@@ -106,7 +126,9 @@ export default {
</div>
<div v-if="!aggregations.error">
<hr :class="$options.HR_DEFAULT_CLASSES" />
- <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-mx-5 gl-px-5">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-mt-4 gl-mx-5"
+ >
<gl-button
category="primary"
variant="confirm"
@@ -116,6 +138,16 @@ export default {
>
{{ $options.i18n.apply }}
</gl-button>
+ <gl-button
+ category="tertiary"
+ variant="link"
+ size="small"
+ :disabled="!hasQueryFilters && !sidebarDirty"
+ data-testid="reset-button"
+ @click="cleanResetFilters"
+ >
+ {{ $options.i18n.reset }}
+ </gl-button>
</div>
</div>
</gl-form>
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
index aa7c26b8044..0733dc72d2e 100644
--- a/app/assets/javascripts/search/sidebar/components/radio_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
export default {
@@ -17,12 +17,10 @@ export default {
},
computed: {
...mapState(['query']),
+ ...mapGetters(['currentScope']),
ANY() {
return this.filterData.filters.ANY;
},
- scope() {
- return this.query.scope;
- },
initialFilter() {
return this.query[this.filterData.filterParam];
},
@@ -30,7 +28,7 @@ export default {
return this.initialFilter || this.ANY.value;
},
filtersArray() {
- return this.filterData.filterByScope[this.scope];
+ return this.filterData.filterByScope[this.currentScope];
},
selectedFilter: {
get() {
diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
index 4d9cc9d6450..7d995f26684 100644
--- a/app/assets/javascripts/search/sidebar/components/results_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import { stateFilterData } from '../constants/state_filter_data';
import ConfidentialityFilter from './confidentiality_filter.vue';
@@ -16,14 +16,15 @@ export default {
},
computed: {
...mapState(['urlQuery', 'sidebarDirty']),
+ ...mapGetters(['currentScope']),
showReset() {
return this.urlQuery.state || this.urlQuery.confidential;
},
showConfidentialityFilter() {
- return Object.values(confidentialFilterData.scopes).includes(this.urlQuery.scope);
+ return Object.values(confidentialFilterData.scopes).includes(this.currentScope);
},
showStatusFilter() {
- return Object.values(stateFilterData.scopes).includes(this.urlQuery.scope);
+ return Object.values(stateFilterData.scopes).includes(this.currentScope);
},
},
methods: {
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
index 5863381e2ef..02a3870f499 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
@@ -44,9 +44,6 @@ export default {
isHighlighted ? 'gl-text-gray-900' : 'gl-text-gray-500',
];
},
- isActive(scope, index) {
- return this.urlQuery.scope ? this.urlQuery.scope === scope : index === 0;
- },
qaSelectorValue(item) {
return `${slugifyWithUnderscore(item.label)}_tab`;
},
@@ -60,16 +57,17 @@ export default {
<nav data-testid="search-filter">
<gl-nav vertical pills>
<gl-nav-item
- v-for="(item, scope, index) in navigation"
+ v-for="(item, scope) in navigation"
:key="scope"
- :link-classes="linkClasses(isActive(scope, index))"
+ :link-classes="linkClasses(item.active)"
class="gl-mb-1"
:href="item.link"
- :active="isActive(scope, index)"
+ :active="item.active"
:data-qa-selector="qaSelectorValue(item)"
+ :data-testid="qaSelectorValue(item)"
@click="handleClick(scope)"
- ><span>{{ item.label }}</span
- ><span v-if="item.count" :class="countClasses(isActive(scope, index))">
+ ><span data-testid="label">{{ item.label }}</span
+ ><span v-if="item.count" data-testid="count" :class="countClasses(item.active)">
{{ showFormatedCount(item.count)
}}<gl-icon
v-if="isCountOverLimit(item.count)"
diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js
index 5c08ad2f959..4357d6202df 100644
--- a/app/assets/javascripts/search/sidebar/utils.js
+++ b/app/assets/javascripts/search/sidebar/utils.js
@@ -1,20 +1,17 @@
import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
-export const convertFiltersData = (rawBuckets) => {
- return rawBuckets.reduce(
- (acc, bucket) => {
- return {
- ...acc,
- filters: {
- ...acc.filters,
- [bucket.key.toUpperCase()]: {
- label: bucket.key,
- value: bucket.key,
- count: bucket.count,
- },
+export const convertFiltersData = (rawBuckets) =>
+ rawBuckets.reduce(
+ (acc, bucket) => ({
+ ...acc,
+ filters: {
+ ...acc.filters,
+ [bucket.key.toUpperCase()]: {
+ label: bucket.key,
+ value: bucket.key,
+ count: bucket.count,
},
- };
- },
+ },
+ }),
{ ...languageFilterData, filters: {} },
);
-};
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index fc0817be882..da2bf4b602e 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -1,9 +1,10 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { logError } from '~/lib/logger';
import { __ } from '~/locale';
+import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
import {
@@ -12,6 +13,7 @@ import {
mergeById,
isSidebarDirty,
getAggregationsUrl,
+ prepareSearchAggregations,
} from './utils';
export const fetchGroups = ({ commit }, search) => {
@@ -105,17 +107,27 @@ export const applyQuery = ({ state }) => {
};
export const resetQuery = ({ state }) => {
- visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
+ visitUrl(
+ setUrlParams({ ...state.query, page: null, state: null, confidential: null }, undefined, true),
+ );
+};
+
+export const resetLanguageQueryWithRedirect = ({ state }) => {
+ visitUrl(setUrlParams({ ...state.query, language: null }, undefined, true));
+};
+
+export const resetLanguageQuery = ({ commit }) => {
+ commit(types.SET_QUERY, { key: languageFilterData?.filterParam, value: [] });
};
export const fetchSidebarCount = ({ commit, state }) => {
- const promises = Object.keys(state.navigation).map((scope) => {
+ const promises = Object.values(state.navigation).map((navItem) => {
// active nav item has count already so we skip it
- if (scope !== state.urlQuery.scope) {
+ if (!navItem.active) {
return axios
- .get(state.navigation[scope].count_link)
+ .get(navItem.count_link)
.then(({ data: { count } }) => {
- commit(types.RECEIVE_NAVIGATION_COUNT, { key: scope, count });
+ commit(types.RECEIVE_NAVIGATION_COUNT, { key: navItem.scope, count });
})
.catch((e) => logError(e));
}
@@ -124,12 +136,13 @@ export const fetchSidebarCount = ({ commit, state }) => {
return Promise.all(promises);
};
-export const fetchLanguageAggregation = ({ commit }) => {
+export const fetchLanguageAggregation = ({ commit, state }) => {
commit(types.REQUEST_AGGREGATIONS);
return axios
.get(getAggregationsUrl())
- .then(({ data }) => {
- commit(types.RECEIVE_AGGREGATIONS_SUCCESS, data);
+ .then((result) => {
+ const { data } = result;
+ commit(types.RECEIVE_AGGREGATIONS_SUCCESS, prepareSearchAggregations(state, data));
})
.catch((e) => {
logError(e);
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
index 0278239c144..36d98233e28 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,4 +1,6 @@
+import { findKey, has } from 'lodash';
import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
export const frequentGroups = (state) => {
@@ -9,10 +11,18 @@ export const frequentProjects = (state) => {
return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY];
};
-export const langugageAggregationBuckets = (state) => {
+export const languageAggregationBuckets = (state) => {
return (
state.aggregations.data.find(
(aggregation) => aggregation.name === languageFilterData.filterParam,
)?.buckets || []
);
};
+
+export const currentScope = (state) => findKey(state.navigation, { active: true });
+
+export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || [];
+
+export const currentUrlQueryHasLanguageFilters = (state) =>
+ has(state.urlQuery, languageFilterData.filterParam) &&
+ state.urlQuery[languageFilterData.filterParam]?.length > 0;
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index f9fd69d2211..b2f9f5ab225 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -24,7 +24,7 @@ export default {
state.projects = [];
},
[types.SET_QUERY](state, { key, value }) {
- state.query[key] = value;
+ state.query = { ...state.query, [key]: value };
},
[types.SET_SIDEBAR_DIRTY](state, value) {
state.sidebarDirty = value;
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index acb99c60426..8e484e69646 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -1,6 +1,8 @@
+import { isEqual, orderBy } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { formatNumber } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
+import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
import {
MAX_FREQUENT_ITEMS,
MAX_FREQUENCY,
@@ -8,6 +10,8 @@ import {
NUMBER_FORMATING_OPTIONS,
} from './constants';
+const LANGUAGE_AGGREGATION_NAME = languageFilterData.filterParam;
+
function extractKeys(object, keyList) {
return Object.fromEntries(keyList.map((key) => [key, object[key]]));
}
@@ -94,6 +98,10 @@ export const isSidebarDirty = (currentQuery, urlQuery) => {
const userAddedParam = !urlQuery[param] && currentQuery[param];
const userChangedExistingParam = urlQuery[param] && urlQuery[param] !== currentQuery[param];
+ if (Array.isArray(currentQuery[param]) || Array.isArray(urlQuery[param])) {
+ return !isEqual(currentQuery[param], urlQuery[param]);
+ }
+
return userAddedParam || userChangedExistingParam;
});
};
@@ -112,3 +120,27 @@ export const getAggregationsUrl = () => {
currentUrl.pathname = joinPaths('/search', 'aggregations');
return currentUrl.toString();
};
+
+const sortLanguages = (state, entries) => {
+ const queriedLanguages = state.query?.[LANGUAGE_AGGREGATION_NAME] || [];
+
+ if (!Array.isArray(queriedLanguages) || !queriedLanguages.length) {
+ return entries;
+ }
+
+ const queriedLanguagesSet = new Set(queriedLanguages);
+
+ return orderBy(entries, [({ key }) => queriedLanguagesSet.has(key), 'count'], ['desc', 'desc']);
+};
+
+export const prepareSearchAggregations = (state, aggregationData) =>
+ aggregationData.map((item) => {
+ if (item?.name === LANGUAGE_AGGREGATION_NAME) {
+ return {
+ ...item,
+ buckets: sortLanguages(state, item.buckets),
+ };
+ }
+
+ return item;
+ });
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index da6039f4758..16ff8c94885 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -86,45 +86,50 @@ export default {
</script>
<template>
- <section class="search-page-form gl-lg-display-flex gl-flex-direction-column">
- <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end">
- <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
- <div
- class="gl-sm-display-flex gl-flex-direction-row gl-justify-content-space-between gl-mb-4 gl-md-mb-0"
- >
- <label>{{ $options.i18n.searchLabel }}</label>
- <template v-if="showSyntaxOptions">
- <gl-button
- category="tertiary"
- variant="link"
- size="small"
- button-text-classes="gl-font-sm!"
- @click="onToggleDrawer"
- >{{ $options.i18n.syntaxOptionsLabel }}
- </gl-button>
- <markdown-drawer
- ref="markdownDrawer"
- :document-path="$options.SYNTAX_OPTIONS_DOCUMENT"
- />
- </template>
+ <section class="gl-p-5 gl-bg-gray-10 gl-border-b gl-border-t">
+ <div class="search-page-form gl-lg-display-flex gl-flex-direction-column">
+ <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end">
+ <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2">
+ <div
+ class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-mb-0 gl-md-mb-4"
+ >
+ <label class="gl-mb-1 gl-md-pb-2">{{ $options.i18n.searchLabel }}</label>
+ <template v-if="showSyntaxOptions">
+ <gl-button
+ category="tertiary"
+ variant="link"
+ size="small"
+ button-text-classes="gl-font-sm!"
+ @click="onToggleDrawer"
+ >{{ $options.i18n.syntaxOptionsLabel }}
+ </gl-button>
+ <markdown-drawer
+ ref="markdownDrawer"
+ :document-path="$options.SYNTAX_OPTIONS_DOCUMENT"
+ />
+ </template>
+ </div>
+ <gl-search-box-by-click
+ id="dashboard_search"
+ v-model="search"
+ name="search"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @submit="applyQuery"
+ />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-3">
+ <label class="gl-display-block gl-mb-1 gl-md-pb-2">{{
+ $options.i18n.groupFieldLabel
+ }}</label>
+ <group-filter :initial-data="groupInitialJson" />
+ </div>
+ <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-ml-3">
+ <label class="gl-display-block gl-mb-1 gl-md-pb-2">{{
+ $options.i18n.projectFieldLabel
+ }}</label>
+ <project-filter :initial-data="projectInitialJson" />
</div>
- <gl-search-box-by-click
- id="dashboard_search"
- v-model="search"
- name="search"
- :placeholder="$options.i18n.searchPlaceholder"
- @submit="applyQuery"
- />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-3">
- <label class="gl-display-block">{{ $options.i18n.groupFieldLabel }}</label>
- <group-filter :initial-data="groupInitialJson" />
- </div>
- <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-ml-3">
- <label class="gl-display-block">{{ $options.i18n.projectFieldLabel }}</label>
- <project-filter :initial-data="projectInitialJson" />
</div>
</div>
- <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100" />
</section>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index 3ebd21609a6..ccfaa678201 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import { parseErrorMessage } from '~/lib/utils/error_message';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue';
@@ -26,13 +27,16 @@ export const i18n = {
scanner will not be reflected as such until the pipeline has been
successfully executed and it has generated valid artifacts.`,
),
- securityConfiguration: __('Security Configuration'),
+ securityConfiguration: __('Security configuration'),
vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'),
securityTraining: s__('SecurityConfiguration|Security training'),
securityTrainingDescription: s__(
'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.',
),
securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'),
+ genericErrorText: s__(
+ `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`,
+ ),
};
export default {
@@ -124,8 +128,9 @@ export default {
dismissedProjects.add(this.projectFullPath);
this.autoDevopsEnabledAlertDismissedProjects = Array.from(dismissedProjects);
},
- onError(message) {
- this.errorMessage = message;
+ onError(error) {
+ const { message, userFacing } = parseErrorMessage(error);
+ this.errorMessage = userFacing ? message : i18n.genericErrorText;
},
dismissAlert() {
this.errorMessage = '';
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index c87dcef6a93..6beb6cd4d34 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -35,7 +35,7 @@ export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sas
});
export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning');
-export const SAST_IAC_SHORT_NAME = s__('ciReport|IaC Scanning');
+export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC');
export const SAST_IAC_DESCRIPTION = __(
'Analyze your infrastructure as code configuration files for known vulnerabilities.',
);
@@ -65,7 +65,6 @@ export const DAST_PROFILES_NAME = __('DAST profiles');
export const DAST_PROFILES_DESCRIPTION = s__(
'SecurityConfiguration|Manage profiles for use by DAST scans.',
);
-export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index');
export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles');
export const SECRET_DETECTION_NAME = __('Secret Detection');
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index d9e969e2278..e5a11487c90 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle, GlLink } from '@gitlab/ui';
+import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle, GlLink, GlAlert } from '@gitlab/ui';
import Vue from 'vue';
import { mapState, mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
@@ -17,6 +17,7 @@ export default {
GlModal,
GlToggle,
GlLink,
+ GlAlert,
},
directives: {
SafeHtml,
@@ -27,6 +28,7 @@ export default {
data() {
return {
modalId: 'delete-self-monitor-modal',
+ showDeprecationNotice: true,
};
},
computed: {
@@ -49,6 +51,20 @@ export default {
selfMonitorProjectFullUrl() {
return `${getBaseURL()}/${this.projectPath}`;
},
+ selfMonitoringDeprecationNotice() {
+ return sprintf(
+ s__(
+ 'SelfMonitoring|Self-monitoring was %{deprecation}deprecated%{link_end} in GitLab 14.9, and is %{removal}scheduled for removal%{link_end} in GitLab 16.0. For information on a possible replacement, %{opstrace}learn more about Opstrace%{link_end}.',
+ ),
+ {
+ deprecation: `<a href="${this.deprecationPath}">`,
+ removal: `<a href="https://gitlab.com/gitlab-org/gitlab/-/issues/348909">`,
+ opstrace: `<a href="https://gitlab.com/groups/gitlab-org/-/epics/6976">`,
+ link_end: `</a>`,
+ },
+ false,
+ );
+ },
selfMonitoringFormText() {
if (this.projectCreated) {
return sprintf(
@@ -70,6 +86,9 @@ export default {
helpDocsPath() {
return helpPagePath('administration/monitoring/gitlab_self_monitoring_project/index');
},
+ deprecationPath() {
+ return helpPagePath('update/deprecations.md', { anchor: 'gitlab-self-monitoring-project' });
+ },
},
watch: {
selfMonitorEnabled() {
@@ -123,6 +142,9 @@ export default {
viewSelfMonitorProject() {
visitUrl(this.selfMonitorProjectFullUrl);
},
+ hideDeprecationNotice() {
+ this.showDeprecationNotice = false;
+ },
},
};
</script>
@@ -140,6 +162,16 @@ export default {
<gl-link :href="helpDocsPath">{{ __('Learn more.') }}</gl-link>
</p>
</div>
+ <gl-alert
+ v-if="showDeprecationNotice"
+ class="gl-mb-3"
+ :title="s__('SelfMonitoring|Deprecation notice')"
+ :dismissible="true"
+ variant="danger"
+ @dismiss="hideDeprecationNotice"
+ >
+ <div v-safe-html="selfMonitoringDeprecationNotice"></div>
+ </gl-alert>
<div class="settings-content">
<form name="self-monitoring-form">
<p ref="selfMonitoringFormText" v-safe-html="selfMonitoringFormText"></p>
diff --git a/app/assets/javascripts/sentry/constants.js b/app/assets/javascripts/sentry/legacy_constants.js
index 5531c4f56db..d04011dab2f 100644
--- a/app/assets/javascripts/sentry/constants.js
+++ b/app/assets/javascripts/sentry/legacy_constants.js
@@ -1,6 +1,6 @@
import { __ } from '~/locale';
-// TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144
+// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
export const IGNORE_ERRORS = [
// Random plugins/extensions
'top.GLOBALS',
@@ -22,6 +22,8 @@ export const IGNORE_ERRORS = [
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
+ // Exclude errors from polling when navigating away from a page
+ 'TypeError: Failed to fetch',
];
export const DENY_URLS = [
diff --git a/app/assets/javascripts/sentry/legacy_sentry_config.js b/app/assets/javascripts/sentry/legacy_sentry_config.js
index 50a943886db..ae9ae327544 100644
--- a/app/assets/javascripts/sentry/legacy_sentry_config.js
+++ b/app/assets/javascripts/sentry/legacy_sentry_config.js
@@ -1,7 +1,7 @@
import * as Sentry5 from 'sentrybrowser5';
import $ from 'jquery';
import { __ } from '~/locale';
-import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants';
+import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './legacy_constants';
const SentryConfig = {
IGNORE_ERRORS,
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index ed8a55b7d44..80f087691f4 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -1,5 +1,4 @@
import * as Sentry from 'sentrybrowser7';
-import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants';
const SentryConfig = {
init(options = {}) {
@@ -17,9 +16,6 @@ const SentryConfig = {
release,
allowUrls,
environment,
- ignoreErrors: IGNORE_ERRORS,
- denyUrls: DENY_URLS,
- sampleRate: SAMPLE_RATE,
});
Sentry.setTags(tags);
diff --git a/app/assets/javascripts/service_ping_consent.js b/app/assets/javascripts/service_ping_consent.js
index 654263ba27b..7d6e7e81f3b 100644
--- a/app/assets/javascripts/service_ping_consent.js
+++ b/app/assets/javascripts/service_ping_consent.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { createAlert } from './flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index e7d028e8d23..270d7f0d182 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,7 +1,7 @@
<script>
import { GlToast, GlTooltipDirective, GlModal } from '@gitlab/ui';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index 323f6f23df6..d65c950b33a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
export default {
@@ -32,7 +32,7 @@ export default {
);
},
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
hasMergeIcon() {
const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index 73cd0044c16..2c6eb0e5001 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
@@ -73,7 +73,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
cannotMerge() {
const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge;
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index 93fcf2cf1c9..319699b88f3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,6 +1,5 @@
<script>
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType } from '~/issues/constants';
import { assigneesQueries } from '../../constants';
export default {
@@ -22,9 +21,6 @@ export default {
},
},
computed: {
- issuableClass() {
- return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
- },
issuableId() {
return this.issuable?.id;
},
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index d2f0ceb19c9..884edc97016 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import CollapsedAssignee from './collapsed_assignee.vue';
@@ -47,7 +47,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.issuableType === 'merge_request';
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
hasNoUsers() {
return !this.users.length;
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index caf3bb2f798..062f63175a7 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -1,6 +1,6 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 8893e90b1e5..ae81dcb95de 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -1,8 +1,8 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
import Vue from 'vue';
-import { createAlert } from '~/flash';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { createAlert } from '~/alert';
+import { TYPE_ALERT, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, n__ } from '~/locale';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -60,7 +60,7 @@ export default {
required: false,
default: TYPE_ISSUE,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest, IssuableType.Alert].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_ALERT].includes(value);
},
},
issuableId: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
index 28bc5afc1a4..b41d126be68 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
@@ -4,8 +4,6 @@ import { __ } from '~/locale';
export default {
displayText: __('Invite members'),
- dataTrackLabel: 'edit_assignee',
- dataTrackEvent: 'click_invite_members',
components: {
InviteMembersTrigger,
},
@@ -27,8 +25,6 @@ export default {
<invite-members-trigger
trigger-element="anchor"
:display-text="$options.displayText"
- :event="$options.dataTrackEvent"
- :label="$options.dataTrackLabel"
:trigger-source="triggerSource"
classes="gl-display-block gl-pl-0 gl-hover-text-decoration-none gl-hover-text-blue-800!"
/>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index ddbd8866680..8b40b48b54a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,6 +1,6 @@
<script>
import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { s__, sprintf } from '~/locale';
const AVAILABILITY_STATUS = {
@@ -39,7 +39,7 @@ export default {
);
},
hasCannotMergeIcon() {
- return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge;
+ return this.issuableType === TYPE_MERGE_REQUEST && !this.user.canMerge;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 71f349bb87e..b424d9074d0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -1,6 +1,6 @@
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -53,7 +53,7 @@ export default {
return `@${this.firstUser.username}`;
},
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
},
methods: {
@@ -61,7 +61,7 @@ export default {
this.showLess = !this.showLess;
},
userAvailability(u) {
- if (this.issuableType === IssuableType.MergeRequest) {
+ if (this.issuableType === TYPE_MERGE_REQUEST) {
return u?.availability || '';
}
return u?.status?.availability || '';
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
index 1eeb725d5c9..196a86a931a 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlAlert, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import { TYPE_EPIC, WorkspaceType } from '~/issues/constants';
+import { TYPE_EPIC, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { confidentialityInfoText } from '~/vue_shared/constants';
export default {
@@ -25,7 +25,7 @@ export default {
computed: {
confidentialBodyText() {
return confidentialityInfoText(
- this.issuableType === TYPE_EPIC ? WorkspaceType.group : WorkspaceType.project,
+ this.issuableType === TYPE_EPIC ? WORKSPACE_GROUP : WORKSPACE_PROJECT,
this.issuableType,
);
},
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index f7526bcff3d..3038cec03eb 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlButton } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import { confidentialityQueries } from '../../constants';
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index c2f239b56c7..9177baec246 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -1,7 +1,7 @@
<script>
import produce from 'immer';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import { confidentialityQueries, Tracking } from '../../constants';
import SidebarEditableItem from '../sidebar_editable_item.vue';
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
index c9ecaf4102f..916ff70a5ea 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql';
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index 77be8022ec0..190b8c1de62 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
@@ -54,6 +54,16 @@ export default {
type: Boolean,
default: false,
},
+ minDate: {
+ required: false,
+ type: Date,
+ default: null,
+ },
+ maxDate: {
+ required: false,
+ type: Date,
+ default: null,
+ },
},
data() {
return {
@@ -292,6 +302,8 @@ export default {
v-if="!isLoading"
ref="datePicker"
class="gl-relative"
+ :min-date="minDate"
+ :max-date="maxDate"
:default-date="parsedDate"
:first-day="firstDay"
show-clear-button
diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
index f7daad63f45..6db332a82da 100644
--- a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue';
import {
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
index b8afa67a947..227d85d952b 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue
@@ -92,10 +92,14 @@ export default {
/>
</div>
<div class="color-input-container gl-display-flex">
- <span
- class="dropdown-label-color-preview position-relative position-relative d-inline-block"
- :style="{ backgroundColor: selectedColor }"
- ></span>
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8"
+ type="color"
+ :value="selectedColor"
+ :placeholder="__('Open color picker')"
+ data-testid="selected-color"
+ />
<gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
index 2dab97826b9..06030003f3c 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
index cd671b4d8f5..852ef0c6283 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js
@@ -6,8 +6,3 @@ export const DropdownVariant = {
Standalone: 'standalone',
Embedded: 'embedded',
};
-
-export const LabelType = {
- group: 'group',
- project: 'project',
-};
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
index aa1184ed314..1174ec3f01e 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue
@@ -8,11 +8,11 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import produce from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP } from '~/issues/constants';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '../../../constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
-import { LabelType } from './constants';
const errorMessage = __('Error creating label.');
@@ -62,7 +62,7 @@ export default {
return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
},
mutationVariables() {
- const attributePath = this.labelCreateType === LabelType.group ? 'groupPath' : 'projectPath';
+ const attributePath = this.labelCreateType === WORKSPACE_GROUP ? 'groupPath' : 'projectPath';
return {
title: this.labelTitle,
@@ -163,11 +163,14 @@ export default {
/>
</div>
<div class="color-input-container gl-display-flex">
- <span
- class="dropdown-label-color-preview gl-relative gl-display-inline-block"
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-right-none gl-rounded-bottom-right-none gl-mr-n1 gl-mb-2 gl-w-8"
+ type="color"
+ :value="selectedColor"
+ :placeholder="__('Select color')"
data-testid="selected-color"
- :style="{ backgroundColor: selectedColor }"
- ></span>
+ />
<gl-form-input
v-model.trim="selectedColor"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
index c1939dc7785..e664d6b4bd6 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { workspaceLabelsQueries } from '../../../constants';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
index bf916e26a15..3aa4215443e 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
@@ -2,9 +2,9 @@
import { debounce } from 'lodash';
import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST, TYPE_TEST_CASE } from '~/issues/constants';
import { __ } from '~/locale';
import { issuableLabelsQueries } from '../../../constants';
@@ -166,7 +166,7 @@ export default {
fullPath: this.fullPath,
};
- if (this.issuableType === IssuableType.TestCase) {
+ if (this.issuableType === TYPE_TEST_CASE) {
queryVariables.types = ['TEST_CASE'];
}
@@ -262,9 +262,9 @@ export default {
switch (this.issuableType) {
case TYPE_ISSUE:
- case IssuableType.TestCase:
+ case TYPE_TEST_CASE:
return updateVariables;
- case IssuableType.MergeRequest:
+ case TYPE_MERGE_REQUEST:
return {
...updateVariables,
operationMode: MutationOperationMode.Replace,
@@ -319,12 +319,12 @@ export default {
switch (this.issuableType) {
case TYPE_ISSUE:
- case IssuableType.TestCase:
+ case TYPE_TEST_CASE:
return {
...removeVariables,
removeLabelIds: [labelId],
};
- case IssuableType.MergeRequest:
+ case TYPE_MERGE_REQUEST:
return {
...removeVariables,
labelIds: [labelId],
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index df03af346c0..606d374158b 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -2,7 +2,7 @@
import { GlButton } from '@gitlab/ui';
import $ from 'jquery';
import { mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import eventHub from '../../event_hub';
@@ -49,11 +49,11 @@ export default {
fullPath: this.fullPath,
})
.catch(() => {
- const flashMessage = __(
+ const alertMessage = __(
'Something went wrong trying to change the locked state of this %{issuableDisplayName}',
);
createAlert({
- message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }),
+ message: sprintf(alertMessage, { issuableDisplayName: this.issuableDisplayName }),
});
})
.finally(() => {
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 9d8f1304911..1eff4db3970 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,10 +1,10 @@
<script>
import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import toast from '~/vue_shared/plugins/global_toast';
import eventHub from '../../event_hub';
import EditForm from './edit_form.vue';
@@ -46,7 +46,9 @@ export default {
computed: {
...mapGetters(['getNoteableData']),
isMergeRequest() {
- return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.movedMrSidebar;
+ return (
+ this.getNoteableData.targetType === TYPE_MERGE_REQUEST && this.glFeatures.movedMrSidebar
+ );
},
issuableDisplayName() {
const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE;
@@ -92,11 +94,11 @@ export default {
}
})
.catch(() => {
- const flashMessage = __(
+ const alertMessage = __(
'Something went wrong trying to change the locked state of this %{issuableDisplayName}',
);
createAlert({
- message: sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName }),
+ message: sprintf(alertMessage, { issuableDisplayName: this.issuableDisplayName }),
});
})
.finally(() => {
diff --git a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
index 8072154cd28..24afb25e403 100644
--- a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
@@ -2,7 +2,12 @@
import { GlDropdownItem } from '@gitlab/ui';
import { TYPENAME_MILESTONE } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import {
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import { __ } from '~/locale';
import { IssuableAttributeType } from '../../constants';
import SidebarDropdown from '../sidebar_dropdown.vue';
@@ -37,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(value);
},
},
inputName: {
@@ -64,7 +69,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [WorkspaceType.group, WorkspaceType.project].includes(value);
+ return [WORKSPACE_GROUP, WORKSPACE_PROJECT].includes(value);
},
},
},
diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
index e1259fad6a7..76c47305369 100644
--- a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
+++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
@@ -1,7 +1,7 @@
<script>
import ProjectSelect from '~/sidebar/components/move/issuable_move_dropdown.vue';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { visitUrl } from '~/lib/utils/url_utility';
import moveIssueMutation from '../../queries/move_issue.mutation.graphql';
diff --git a/app/assets/javascripts/sidebar/components/move/move_issues_button.vue b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
index ab4ac9500ad..68c8b35c009 100644
--- a/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
+++ b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { logError } from '~/lib/logger';
import { s__ } from '~/locale';
import {
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index 2f25c2fd4b0..a9d102eb303 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -99,7 +99,7 @@ export default {
>
<gl-icon name="users" />
<gl-loading-icon v-if="loading" size="sm" />
- <span v-else data-testid="collapsed-count" class="gl-pt-2 gl-px-3 gl-font-sm">
+ <span v-else class="gl-pt-2 gl-px-3 gl-font-sm">
{{ participantCount }}
</span>
</div>
@@ -114,9 +114,12 @@ export default {
<div
v-for="participant in visibleParticipants"
:key="participant.id"
- class="participants-author gl-display-inline-block gl-pr-3 gl-pb-3"
+ class="participants-author gl-display-inline-block gl-mr-3 gl-mb-3"
>
- <a :href="participant.web_url || participant.webUrl" class="author-link">
+ <a
+ :href="participant.web_url || participant.webUrl"
+ class="author-link gl-display-inline-block gl-rounded-full"
+ >
<user-avatar-image
:lazy="lazy"
:img-src="participant.avatar_url || participant.avatarUrl"
@@ -133,7 +136,6 @@ export default {
<gl-button
variant="link"
button-text-classes="gl-text-secondary"
- data-testid="more-participants"
@click="toggleMoreParticipants"
>{{ toggleLabel }}</gl-button
>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index 56ac4c39e84..80c051f86b5 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -2,7 +2,7 @@
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import ReviewerAvatar from './reviewer_avatar.vue';
@@ -41,7 +41,9 @@ export default {
},
computed: {
cannotMerge() {
- return this.issuableType === 'merge_request' && !this.user.mergeRequestInteraction?.canMerge;
+ return (
+ this.issuableType === TYPE_MERGE_REQUEST && !this.user.mergeRequestInteraction?.canMerge
+ );
},
tooltipTitle() {
if (this.cannotMerge && this.tooltipHasName) {
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index 8dd58d33ecf..9c23f239b4c 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -3,7 +3,7 @@
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import Vue from 'vue';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
index ecb9a2809a0..55de0ceb388 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
@@ -1,9 +1,10 @@
<script>
import { GlDropdown, GlDropdownItem, GlTooltip, GlSprintf } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { TYPE_INCIDENT } from '~/issues/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql';
-import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants';
+import { INCIDENT_SEVERITY, SEVERITY_I18N as I18N } from '../../constants';
import SeverityToken from './severity.vue';
export default {
@@ -34,10 +35,10 @@ export default {
issuableType: {
type: String,
required: false,
- default: ISSUABLE_TYPES.INCIDENT,
+ default: TYPE_INCIDENT,
validator: (value) => {
// currently severity is supported only for incidents, but this list might be extended
- return [ISSUABLE_TYPES.INCIDENT].includes(value);
+ return [TYPE_INCIDENT].includes(value);
},
},
},
@@ -50,7 +51,7 @@ export default {
computed: {
severitiesList() {
switch (this.issuableType) {
- case ISSUABLE_TYPES.INCIDENT:
+ case TYPE_INCIDENT:
return Object.values(INCIDENT_SEVERITY);
default:
return [];
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
index d68e4974ea4..50b4284cde0 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
@@ -8,7 +8,13 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import {
+ TYPE_EPIC,
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import { __ } from '~/locale';
import {
defaultEpicSort,
@@ -21,7 +27,7 @@ import {
LocalizedIssuableAttributeType,
noAttributeId,
} from 'ee_else_ce/sidebar/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { PathIdSeparator } from '~/related_issues/constants';
export default {
@@ -70,15 +76,15 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(value);
},
},
workspaceType: {
type: String,
required: false,
- default: WorkspaceType.project,
+ default: WORKSPACE_PROJECT,
validator(value) {
- return [WorkspaceType.group, WorkspaceType.project].includes(value);
+ return [WORKSPACE_GROUP, WORKSPACE_PROJECT].includes(value);
},
},
},
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 5df65c4aaaf..19e72da65f2 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -71,7 +71,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(value);
},
},
icon: {
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index cbe839d1112..344fa880131 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -1,7 +1,12 @@
<script>
import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import { IssuableType, TYPE_EPIC } from '~/issues/constants';
+import { createAlert } from '~/alert';
+import {
+ TYPE_EPIC,
+ TYPE_MERGE_REQUEST,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -87,7 +92,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest && this.glFeatures.movedMrSidebar;
+ return this.issuableType === TYPE_MERGE_REQUEST && this.glFeatures.movedMrSidebar;
},
isLoading() {
return this.$apollo.queries?.subscribed?.loading || this.loading;
@@ -109,7 +114,7 @@ export default {
},
subscribeDisabledDescription() {
return sprintf(__('Disabled by %{parent} owner'), {
- parent: this.parentIsGroup ? 'group' : 'project',
+ parent: this.parentIsGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT,
});
},
isLoggedIn() {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
index 964da3b6138..9b582ba41ed 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
@@ -29,7 +29,11 @@ export default {
GlLink,
GlSprintf,
},
- inject: ['issuableType'],
+ inject: {
+ issuableType: {
+ default: null,
+ },
+ },
props: {
issuableId: {
type: String,
@@ -52,13 +56,11 @@ export default {
primaryProps() {
return {
text: s__('CreateTimelogForm|Save'),
- attributes: [
- {
- variant: 'confirm',
- disabled: this.submitDisabled,
- loading: this.isLoading,
- },
- ],
+ attributes: {
+ variant: 'confirm',
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
+ },
};
},
cancelProps() {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index cffbb6466f2..109e1af85ec 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -1,6 +1,6 @@
<script>
import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/issues/constants';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index c645b1649d2..f6968558122 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -8,7 +8,7 @@ import {
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, __ } from '~/locale';
@@ -173,7 +173,7 @@ export default {
return Boolean(this.showHelp);
},
isTimeReportSupported() {
- return [TYPE_ISSUE, IssuableType.MergeRequest].includes(this.issuableType) && this.issuableId;
+ return [TYPE_ISSUE, TYPE_MERGE_REQUEST].includes(this.issuableType) && this.issuableId;
},
timeTrackingIconTitle() {
return this.showHelpState ? '' : HOW_TO_TRACK_TIME;
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index b86ff279fd8..551d306a9c4 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -1,7 +1,8 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { produce } from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
@@ -83,7 +84,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.glFeatures.movedMrSidebar && this.issuableType === 'merge_request';
+ return this.glFeatures.movedMrSidebar && this.issuableType === TYPE_MERGE_REQUEST;
},
todoIdQuery() {
return todoQueries[this.issuableType].query;
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 14491226b15..7bca83c4142 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -3,7 +3,15 @@ import { s__, __, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
-import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import {
+ TYPE_ALERT,
+ TYPE_EPIC,
+ TYPE_ISSUE,
+ TYPE_MERGE_REQUEST,
+ TYPE_TEST_CASE,
+ WORKSPACE_GROUP,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql';
import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql';
@@ -69,11 +77,11 @@ export const assigneesQueries = {
subscription: issuableAssigneesSubscription,
mutation: updateIssueAssigneesMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: getMergeRequestAssignees,
mutation: updateMergeRequestAssigneesMutation,
},
- [IssuableType.Alert]: {
+ [TYPE_ALERT]: {
query: getAlertAssignees,
mutation: updateAlertAssigneesMutation,
},
@@ -83,13 +91,13 @@ export const participantsQueries = {
[TYPE_ISSUE]: {
query: issueParticipantsQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: getMergeRequestParticipants,
},
[TYPE_EPIC]: {
query: epicParticipantsQuery,
},
- [IssuableType.Alert]: {
+ [TYPE_ALERT]: {
query: '',
skipQuery: true,
},
@@ -99,7 +107,7 @@ export const userSearchQueries = {
[TYPE_ISSUE]: {
query: userSearchQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: userSearchWithMRPermissionsQuery,
},
};
@@ -119,7 +127,7 @@ export const referenceQueries = {
[TYPE_ISSUE]: {
query: issueReferenceQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestReferenceQuery,
},
[TYPE_EPIC]: {
@@ -128,10 +136,10 @@ export const referenceQueries = {
};
export const workspaceLabelsQueries = {
- [WorkspaceType.project]: {
+ [WORKSPACE_PROJECT]: {
query: projectLabelsQuery,
},
- [WorkspaceType.group]: {
+ [WORKSPACE_GROUP]: {
query: groupLabelsQuery,
},
};
@@ -142,7 +150,7 @@ export const issuableLabelsQueries = {
mutation: updateIssueLabelsMutation,
mutationName: 'updateIssue',
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
issuableQuery: mergeRequestLabelsQuery,
mutation: updateMergeRequestLabelsMutation,
mutationName: 'mergeRequestSetLabels',
@@ -152,7 +160,7 @@ export const issuableLabelsQueries = {
mutation: updateEpicLabelsMutation,
mutationName: 'updateEpic',
},
- [IssuableType.TestCase]: {
+ [TYPE_TEST_CASE]: {
issuableQuery: issueLabelsQuery,
mutation: updateTestCaseLabelsMutation,
mutationName: 'updateTestCaseLabels',
@@ -186,7 +194,7 @@ export const subscribedQueries = {
query: epicSubscribedQuery,
mutation: updateEpicSubscriptionMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestSubscribed,
mutation: updateMergeRequestSubscriptionMutation,
},
@@ -201,7 +209,7 @@ export const timeTrackingQueries = {
[TYPE_ISSUE]: {
query: issueTimeTrackingQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestTimeTrackingQuery,
},
};
@@ -228,7 +236,7 @@ export const timelogQueries = {
[TYPE_ISSUE]: {
query: getIssueTimelogsQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: getMrTimelogsQuery,
},
};
@@ -240,7 +248,7 @@ export const issuableMilestoneQueries = {
query: projectIssueMilestoneQuery,
mutation: projectIssueMilestoneMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestMilestone,
mutation: mergeRequestMilestoneMutation,
},
@@ -249,14 +257,14 @@ export const issuableMilestoneQueries = {
export const milestonesQueries = {
[TYPE_ISSUE]: {
query: {
- [WorkspaceType.group]: groupMilestonesQuery,
- [WorkspaceType.project]: projectMilestonesQuery,
+ [WORKSPACE_GROUP]: groupMilestonesQuery,
+ [WORKSPACE_PROJECT]: projectMilestonesQuery,
},
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: {
- [WorkspaceType.group]: groupMilestonesQuery,
- [WorkspaceType.project]: projectMilestonesQuery,
+ [WORKSPACE_GROUP]: groupMilestonesQuery,
+ [WORKSPACE_PROJECT]: projectMilestonesQuery,
},
},
};
@@ -289,7 +297,7 @@ export const todoQueries = {
[TYPE_ISSUE]: {
query: issueTodoQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestTodoQuery,
},
};
@@ -407,10 +415,6 @@ export const INCIDENT_SEVERITY = {
},
};
-export const ISSUABLE_TYPES = {
- INCIDENT: 'incident',
-};
-
export const MILESTONE_STATE = {
ACTIVE: 'active',
CLOSED: 'closed',
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index b908cf0cd9e..b0060e4c28d 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import { IssuableType } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import TimeTracker from './components/time_tracking/time_tracker.vue';
@@ -25,9 +24,6 @@ export default class SidebarMilestone {
components: {
TimeTracker,
},
- provide: {
- issuableType: IssuableType.Milestone,
- },
render: (createElement) =>
createElement('time-tracker', {
props: {
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index fb024d818da..99c3fdf82d4 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -4,7 +4,7 @@ import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constan
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST, WORKSPACE_PROJECT } from '~/issues/constants';
import { gqlClient } from '~/issues/list/graphql';
import {
isInDesignPage,
@@ -25,7 +25,6 @@ import CopyEmailToClipboard from './components/copy/copy_email_to_clipboard.vue'
import SidebarDueDateWidget from './components/date/sidebar_date_widget.vue';
import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue';
import { DropdownVariant } from './components/labels/labels_select_vue/constants';
-import { LabelType } from './components/labels/labels_select_widget/constants';
import LabelsSelectWidget from './components/labels/labels_select_widget/labels_select_root.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import MilestoneDropdown from './components/milestone/milestone_dropdown.vue';
@@ -81,7 +80,7 @@ function mountSidebarTodoWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -125,7 +124,7 @@ function mountSidebarAssigneesDeprecated(mediator) {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
issuableId: id,
assigneeAvailabilityStatus,
},
@@ -142,7 +141,7 @@ function mountSidebarAssigneesWidget() {
const { id, iid, fullPath, editable } = getSidebarOptions();
const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage();
- const issuableType = isIssuablePage ? TYPE_ISSUE : IssuableType.MergeRequest;
+ const issuableType = isIssuablePage ? TYPE_ISSUE : TYPE_MERGE_REQUEST;
// eslint-disable-next-line no-new
new Vue({
el,
@@ -204,8 +203,7 @@ function mountSidebarReviewers(mediator) {
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
- issuableType:
- isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest,
+ issuableType: isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : TYPE_MERGE_REQUEST,
},
}),
});
@@ -275,8 +273,7 @@ function mountSidebarMilestoneWidget() {
attrWorkspacePath: projectPath,
workspacePath: projectPath,
iid: issueIid,
- issuableType:
- isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest,
+ issuableType: isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : TYPE_MERGE_REQUEST,
issuableAttribute: IssuableAttributeType.Milestone,
icon: 'clock',
},
@@ -313,7 +310,7 @@ export function mountMilestoneDropdown() {
attrWorkspacePath: fullPath,
canAdminMilestone,
inputName,
- issuableType: isInIssuePage() ? TYPE_ISSUE : IssuableType.MergeRequest,
+ issuableType: isInIssuePage() ? TYPE_ISSUE : TYPE_MERGE_REQUEST,
milestoneId,
milestoneTitle,
projectMilestonesPath,
@@ -358,10 +355,10 @@ export function mountSidebarLabelsWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
- workspaceType: 'project',
+ : TYPE_MERGE_REQUEST,
+ workspaceType: WORKSPACE_PROJECT,
attrWorkspacePath: el.dataset.projectPath,
- labelCreateType: LabelType.project,
+ labelCreateType: WORKSPACE_PROJECT,
},
class: ['block labels js-labels-block'],
scopedSlots: {
@@ -398,7 +395,7 @@ function mountSidebarConfidentialityWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -454,7 +451,7 @@ function mountSidebarReferenceWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -506,7 +503,7 @@ function mountSidebarParticipantsWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -536,7 +533,7 @@ function mountSidebarSubscriptionsWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
index 9b0a8b4a8f7..690eb75f8f4 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
@@ -4,6 +4,7 @@ mutation updateEpicDueDate($input: UpdateEpicInput!) {
id
dueDateIsFixed
dueDateFixed
+ dueDate
dueDateFromMilestones
}
errors
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
index 9b4bb9159c3..d2a598a00fa 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
@@ -4,6 +4,7 @@ mutation updateEpicStartDate($input: UpdateEpicInput!) {
id
startDateIsFixed
startDateFixed
+ startDate
startDateFromMilestones
}
errors
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index c6a66ab2275..7353694a324 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import { visitUrl } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 6e5b2ce4dbe..b613e356a7a 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,7 +1,7 @@
/* eslint-disable consistent-return */
import $ from 'jquery';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { spriteIcon } from '~/lib/utils/common_utils';
import FilesCommentButton from './files_comment_button';
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 4a7528d9c8e..151c38d01dc 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -2,7 +2,7 @@
import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui';
import eventHub from '~/blob/components/eventhub';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { redirectTo, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import {
@@ -141,7 +141,7 @@ export default {
Object.assign(e, { returnValue });
return returnValue;
},
- flashAPIFailure(err) {
+ alertAPIFailure(err) {
const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR
: SNIPPET_UPDATE_MUTATION_ERROR;
@@ -190,7 +190,7 @@ export default {
const errors = baseObj?.errors;
if (errors?.length) {
- this.flashAPIFailure(errors[0]);
+ this.alertAPIFailure(errors[0]);
} else {
redirectTo(baseObj.snippet.webUrl);
}
@@ -199,7 +199,7 @@ export default {
// eslint-disable-next-line no-console
console.error('[gitlab] unexpected error while updating snippet', e);
- this.flashAPIFailure(getErrorMessage(e));
+ this.alertAPIFailure(getErrorMessage(e));
});
},
updateActions(actions) {
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index 7e80928cbea..021bd23781e 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { getBaseURL, joinPaths } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
@@ -60,9 +60,9 @@ export default {
.then((res) => {
this.notifyAboutUpdates({ content: res.data });
})
- .catch((e) => this.flashAPIFailure(e));
+ .catch((e) => this.alertAPIFailure(e));
},
- flashAPIFailure(err) {
+ alertAPIFailure(err) {
createAlert({ message: sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }) });
},
},
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 759a3f31a05..881e06113d9 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -19,7 +19,7 @@ import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/flash';
+import { createAlert, VARIANT_DANGER, VARIANT_SUCCESS } from '~/alert';
import DeleteSnippetMutation from '../mutations/delete_snippet.mutation.graphql';
@@ -78,6 +78,7 @@ export default {
isSubmittingSpam: false,
errorMessage: '',
canCreateSnippet: false,
+ isDeleteModalVisible: false,
};
},
computed: {
@@ -164,10 +165,10 @@ export default {
: `${gon.relative_url_root}dashboard/snippets`;
},
closeDeleteModal() {
- this.$refs.deleteModal.hide();
+ this.isDeleteModalVisible = false;
},
showDeleteModal() {
- this.$refs.deleteModal.show();
+ this.isDeleteModalVisible = true;
},
deleteSnippet() {
this.isLoading = true;
@@ -291,12 +292,22 @@ export default {
</div>
</div>
- <gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title">
+ <gl-modal
+ ref="deleteModal"
+ v-model="isDeleteModalVisible"
+ modal-id="delete-modal"
+ title="Example title"
+ >
<template #modal-title>{{ __('Delete snippet?') }}</template>
- <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{
- errorMessage
- }}</gl-alert>
+ <gl-alert
+ v-if="errorMessage"
+ variant="danger"
+ class="mb-2"
+ data-testid="delete-alert"
+ @dismiss="errorMessage = ''"
+ >{{ errorMessage }}</gl-alert
+ >
<gl-sprintf :message="__('Are you sure you want to delete %{name}?')">
<template #name>
@@ -311,6 +322,7 @@ export default {
category="primary"
:disabled="isLoading"
data-qa-selector="delete_snippet_button"
+ data-testid="delete-snippet"
@click="deleteSnippet"
>
<gl-loading-icon v-if="isLoading" size="sm" inline />
diff --git a/app/assets/javascripts/streaming/chunk_writer.js b/app/assets/javascripts/streaming/chunk_writer.js
new file mode 100644
index 00000000000..4bbd0a5f843
--- /dev/null
+++ b/app/assets/javascripts/streaming/chunk_writer.js
@@ -0,0 +1,144 @@
+import { throttle } from 'lodash';
+import { RenderBalancer } from '~/streaming/render_balancer';
+import {
+ BALANCE_RATE,
+ HIGH_FRAME_TIME,
+ LOW_FRAME_TIME,
+ MAX_CHUNK_SIZE,
+ MIN_CHUNK_SIZE,
+ TIMEOUT,
+} from '~/streaming/constants';
+
+const defaultConfig = {
+ balanceRate: BALANCE_RATE,
+ minChunkSize: MIN_CHUNK_SIZE,
+ maxChunkSize: MAX_CHUNK_SIZE,
+ lowFrameTime: LOW_FRAME_TIME,
+ highFrameTime: HIGH_FRAME_TIME,
+ timeout: TIMEOUT,
+};
+
+function concatUint8Arrays(a, b) {
+ const array = new Uint8Array(a.length + b.length);
+ array.set(a, 0);
+ array.set(b, a.length);
+ return array;
+}
+
+// This class is used to write chunks with a balanced size
+// to avoid blocking main thread for too long.
+//
+// A chunk can be:
+// 1. Too small
+// 2. Too large
+// 3. Delayed in time
+//
+// This class resolves all these problems by
+// 1. Splitting or concatenating chunks to met the size criteria
+// 2. Rendering current chunk buffer immediately if enough time has passed
+//
+// The size of the chunk is determined by RenderBalancer,
+// It measures execution time for each chunk write and adjusts next chunk size.
+export class ChunkWriter {
+ buffer = null;
+ decoder = new TextDecoder('utf-8');
+ timeout = null;
+
+ constructor(htmlStream, config) {
+ this.htmlStream = htmlStream;
+
+ const { balanceRate, minChunkSize, maxChunkSize, lowFrameTime, highFrameTime, timeout } = {
+ ...defaultConfig,
+ ...config,
+ };
+
+ // ensure we still render chunks over time if the size criteria is not met
+ this.scheduleAccumulatorFlush = throttle(this.flushAccumulator.bind(this), timeout);
+
+ const averageSize = Math.round((maxChunkSize + minChunkSize) / 2);
+ this.size = Math.max(averageSize, minChunkSize);
+
+ this.balancer = new RenderBalancer({
+ lowFrameTime,
+ highFrameTime,
+ decrease: () => {
+ this.size = Math.round(Math.max(this.size / balanceRate, minChunkSize));
+ },
+ increase: () => {
+ this.size = Math.round(Math.min(this.size * balanceRate, maxChunkSize));
+ },
+ });
+ }
+
+ write(chunk) {
+ this.scheduleAccumulatorFlush.cancel();
+
+ if (this.buffer) {
+ this.buffer = concatUint8Arrays(this.buffer, chunk);
+ } else {
+ this.buffer = chunk;
+ }
+
+ // accumulate chunks until the size is fulfilled
+ if (this.size > this.buffer.length) {
+ this.scheduleAccumulatorFlush();
+ return Promise.resolve();
+ }
+
+ return this.balancedWrite();
+ }
+
+ balancedWrite() {
+ let cursor = 0;
+
+ return this.balancer.render(() => {
+ const chunkPart = this.buffer.subarray(cursor, cursor + this.size);
+ // accumulate chunks until the size is fulfilled
+ // this is a hot path for the last chunkPart of the chunk
+ if (chunkPart.length < this.size) {
+ this.buffer = chunkPart;
+ this.scheduleAccumulatorFlush();
+ return false;
+ }
+
+ this.writeToDom(chunkPart);
+
+ cursor += this.size;
+ if (cursor >= this.buffer.length) {
+ this.buffer = null;
+ return false;
+ }
+ // continue render
+ return true;
+ });
+ }
+
+ writeToDom(chunk, stream = true) {
+ // stream: true allows us to split chunks with multi-part words
+ const decoded = this.decoder.decode(chunk, { stream });
+ this.htmlStream.write(decoded);
+ }
+
+ flushAccumulator() {
+ if (this.buffer) {
+ this.writeToDom(this.buffer);
+ this.buffer = null;
+ }
+ }
+
+ close() {
+ this.scheduleAccumulatorFlush.cancel();
+ if (this.buffer) {
+ // last chunk should have stream: false to indicate the end of the stream
+ this.writeToDom(this.buffer, false);
+ this.buffer = null;
+ }
+ this.htmlStream.close();
+ }
+
+ abort() {
+ this.scheduleAccumulatorFlush.cancel();
+ this.buffer = null;
+ this.htmlStream.abort();
+ }
+}
diff --git a/app/assets/javascripts/streaming/constants.js b/app/assets/javascripts/streaming/constants.js
new file mode 100644
index 00000000000..224d93a7ac1
--- /dev/null
+++ b/app/assets/javascripts/streaming/constants.js
@@ -0,0 +1,9 @@
+// Lower min chunk numbers can make the page loading take incredibly long
+export const MIN_CHUNK_SIZE = 128 * 1024;
+export const MAX_CHUNK_SIZE = 2048 * 1024;
+export const LOW_FRAME_TIME = 32;
+// Tasks that take more than 50ms are considered Long
+// https://web.dev/optimize-long-tasks/
+export const HIGH_FRAME_TIME = 64;
+export const BALANCE_RATE = 1.2;
+export const TIMEOUT = 500;
diff --git a/app/assets/javascripts/streaming/handle_streamed_anchor_link.js b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js
new file mode 100644
index 00000000000..315dc9bb0a0
--- /dev/null
+++ b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js
@@ -0,0 +1,26 @@
+import { throttle } from 'lodash';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import LineHighlighter from '~/blob/line_highlighter';
+
+const noop = () => {};
+
+export function handleStreamedAnchorLink(rootElement) {
+ // "#L100-200" → ['L100', 'L200']
+ const [anchorStart, end] = window.location.hash.substring(1).split('-');
+ const anchorEnd = end ? `L${end}` : anchorStart;
+ if (!anchorStart || document.getElementById(anchorEnd)) return noop;
+
+ const handler = throttle((mutationList, instance) => {
+ if (!document.getElementById(anchorEnd)) return;
+ scrollToElement(document.getElementById(anchorStart));
+ // eslint-disable-next-line no-new
+ new LineHighlighter();
+ instance.disconnect();
+ }, 300);
+
+ const observer = new MutationObserver(handler);
+
+ observer.observe(rootElement, { childList: true, subtree: true });
+
+ return () => observer.disconnect();
+}
diff --git a/app/assets/javascripts/streaming/html_stream.js b/app/assets/javascripts/streaming/html_stream.js
new file mode 100644
index 00000000000..8182f69a607
--- /dev/null
+++ b/app/assets/javascripts/streaming/html_stream.js
@@ -0,0 +1,33 @@
+import { ChunkWriter } from '~/streaming/chunk_writer';
+
+export class HtmlStream {
+ constructor(element) {
+ const streamDocument = document.implementation.createHTMLDocument('stream');
+
+ streamDocument.open();
+ streamDocument.write('<streaming-element>');
+
+ const virtualStreamingElement = streamDocument.querySelector('streaming-element');
+ element.appendChild(document.adoptNode(virtualStreamingElement));
+
+ this.streamDocument = streamDocument;
+ }
+
+ withChunkWriter(config) {
+ return new ChunkWriter(this, config);
+ }
+
+ write(chunk) {
+ // eslint-disable-next-line no-unsanitized/method
+ this.streamDocument.write(chunk);
+ }
+
+ close() {
+ this.streamDocument.write('</streaming-element>');
+ this.streamDocument.close();
+ }
+
+ abort() {
+ this.streamDocument.close();
+ }
+}
diff --git a/app/assets/javascripts/streaming/polyfills.js b/app/assets/javascripts/streaming/polyfills.js
new file mode 100644
index 00000000000..a9a044a3e99
--- /dev/null
+++ b/app/assets/javascripts/streaming/polyfills.js
@@ -0,0 +1,5 @@
+import { createReadableStreamWrapper } from '@mattiasbuelens/web-streams-adapter';
+import { ReadableStream as PolyfillReadableStream } from 'web-streams-polyfill';
+
+// TODO: remove this when our WebStreams API reaches 100% support
+export const toPolyfillReadable = createReadableStreamWrapper(PolyfillReadableStream);
diff --git a/app/assets/javascripts/streaming/rate_limit_stream_requests.js b/app/assets/javascripts/streaming/rate_limit_stream_requests.js
new file mode 100644
index 00000000000..04a592baa16
--- /dev/null
+++ b/app/assets/javascripts/streaming/rate_limit_stream_requests.js
@@ -0,0 +1,87 @@
+const consumeReadableStream = (stream) => {
+ return new Promise((resolve, reject) => {
+ stream.pipeTo(
+ new WritableStream({
+ close: resolve,
+ abort: reject,
+ }),
+ );
+ });
+};
+
+const wait = (timeout) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, timeout);
+ });
+
+// this rate-limiting approach is specific to Web Streams
+// because streams only resolve when they're fully consumed
+// so we need to split each stream into two pieces:
+// one for the rate-limiter (wait for all the bytes to be sent)
+// another for the original consumer
+export const rateLimitStreamRequests = ({
+ factory,
+ total,
+ maxConcurrentRequests,
+ immediateCount = maxConcurrentRequests,
+ timeout = 0,
+}) => {
+ if (total === 0) return [];
+
+ const unsettled = [];
+
+ const pushUnsettled = (promise) => {
+ let res;
+ let rej;
+ const consume = new Promise((resolve, reject) => {
+ res = resolve;
+ rej = reject;
+ });
+ unsettled.push(consume);
+ return promise.then((stream) => {
+ const [first, second] = stream.tee();
+ // eslint-disable-next-line promise/no-nesting
+ consumeReadableStream(first)
+ .then(() => {
+ unsettled.splice(unsettled.indexOf(consume), 1);
+ res();
+ })
+ .catch(rej);
+ return second;
+ }, rej);
+ };
+
+ const immediate = Array.from({ length: Math.min(immediateCount, total) }, (_, i) =>
+ pushUnsettled(factory(i)),
+ );
+
+ const queue = [];
+ const flushQueue = () => {
+ const promises =
+ unsettled.length > maxConcurrentRequests ? unsettled : [...unsettled, wait(timeout)];
+ // errors are handled by the caller
+ // eslint-disable-next-line promise/catch-or-return
+ Promise.race(promises).then(() => {
+ const cb = queue.shift();
+ cb?.();
+ if (queue.length !== 0) {
+ // wait for stream consumer promise to be removed from unsettled
+ queueMicrotask(flushQueue);
+ }
+ });
+ };
+
+ const throttled = Array.from({ length: total - immediateCount }, (_, i) => {
+ return new Promise((resolve, reject) => {
+ queue.push(() => {
+ pushUnsettled(factory(i + immediateCount))
+ .then(resolve)
+ .catch(reject);
+ });
+ });
+ });
+
+ flushQueue();
+
+ return [...immediate, ...throttled];
+};
diff --git a/app/assets/javascripts/streaming/render_balancer.js b/app/assets/javascripts/streaming/render_balancer.js
new file mode 100644
index 00000000000..66929ff3a54
--- /dev/null
+++ b/app/assets/javascripts/streaming/render_balancer.js
@@ -0,0 +1,36 @@
+export class RenderBalancer {
+ previousTimestamp = undefined;
+
+ constructor({ increase, decrease, highFrameTime, lowFrameTime }) {
+ this.increase = increase;
+ this.decrease = decrease;
+ this.highFrameTime = highFrameTime;
+ this.lowFrameTime = lowFrameTime;
+ }
+
+ render(fn) {
+ return new Promise((resolve) => {
+ const callback = (timestamp) => {
+ this.throttle(timestamp);
+ if (fn()) requestAnimationFrame(callback);
+ else resolve();
+ };
+ requestAnimationFrame(callback);
+ });
+ }
+
+ throttle(timestamp) {
+ const { previousTimestamp } = this;
+ this.previousTimestamp = timestamp;
+ if (previousTimestamp === undefined) return;
+
+ const duration = Math.round(timestamp - previousTimestamp);
+ if (!duration) return;
+
+ if (duration >= this.highFrameTime) {
+ this.decrease();
+ } else if (duration < this.lowFrameTime) {
+ this.increase();
+ }
+ }
+}
diff --git a/app/assets/javascripts/streaming/render_html_streams.js b/app/assets/javascripts/streaming/render_html_streams.js
new file mode 100644
index 00000000000..7201e541777
--- /dev/null
+++ b/app/assets/javascripts/streaming/render_html_streams.js
@@ -0,0 +1,40 @@
+import { HtmlStream } from '~/streaming/html_stream';
+
+async function pipeStreams(domWriter, streamPromises) {
+ try {
+ for await (const stream of streamPromises.slice(0, -1)) {
+ await stream.pipeTo(domWriter, { preventClose: true });
+ }
+ const stream = await streamPromises[streamPromises.length - 1];
+ await stream.pipeTo(domWriter);
+ } catch (error) {
+ domWriter.abort(error);
+ }
+}
+
+// this function (and the rest of the pipeline) expects polyfilled streams
+// do not pass native streams here unless our browser support allows for it
+// TODO: remove this notice when our WebStreams API support reaches 100%
+export function renderHtmlStreams(streamPromises, element, config) {
+ if (streamPromises.length === 0) return Promise.resolve();
+
+ const chunkedHtmlStream = new HtmlStream(element).withChunkWriter(config);
+
+ return new Promise((resolve, reject) => {
+ const domWriter = new WritableStream({
+ write(chunk) {
+ return chunkedHtmlStream.write(chunk);
+ },
+ close() {
+ chunkedHtmlStream.close();
+ resolve();
+ },
+ abort(error) {
+ chunkedHtmlStream.abort();
+ reject(error);
+ },
+ });
+
+ pipeStreams(domWriter, streamPromises);
+ });
+}
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
index f1ddb8290a0..b3d4ecdda47 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -1,82 +1,136 @@
<script>
-import { GlAvatar, GlSearchBoxByType } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { GlSearchBoxByType } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql';
import { contextSwitcherItems } from '../mock_data';
+import { trackContextAccess, formatContextSwitcherItems } from '../utils';
import NavItem from './nav_item.vue';
+import ProjectsList from './projects_list.vue';
+import GroupsList from './groups_list.vue';
export default {
+ i18n: {
+ contextNavigation: s__('Navigation|Context navigation'),
+ switchTo: s__('Navigation|Switch to...'),
+ searchPlaceholder: s__('Navigation|Search for projects or groups'),
+ },
+ apollo: {
+ groupsAndProjects: {
+ query: searchUserProjectsAndGroups,
+ debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ manual: true,
+ variables() {
+ return {
+ username: this.username,
+ search: this.searchString,
+ };
+ },
+ result(response) {
+ try {
+ const {
+ data: {
+ projects: { nodes: projects },
+ user: {
+ groups: { nodes: groups },
+ },
+ },
+ } = response;
+
+ this.projects = formatContextSwitcherItems(projects);
+ this.groups = formatContextSwitcherItems(groups);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ error(e) {
+ Sentry.captureException(e);
+ },
+ skip() {
+ return !this.searchString;
+ },
+ },
+ },
components: {
- GlAvatar,
GlSearchBoxByType,
NavItem,
+ ProjectsList,
+ GroupsList,
},
- i18n: {
- contextNavigation: s__('Navigation|Context navigation'),
- switchTo: s__('Navigation|Switch to...'),
- recentProjects: s__('Navigation|Recent projects'),
- recentGroups: s__('Navigation|Recent groups'),
+ props: {
+ username: {
+ type: String,
+ required: true,
+ },
+ projectsPath: {
+ type: String,
+ required: true,
+ },
+ groupsPath: {
+ type: String,
+ required: true,
+ },
+ currentContext: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
- contextSwitcherItems,
- viewAllProjectsItem: {
- title: s__('Navigation|View all projects'),
- link: '/projects',
- icon: 'project',
+ data() {
+ return {
+ searchString: '',
+ projects: [],
+ groups: [],
+ };
},
- viewAllGroupsItem: {
- title: s__('Navigation|View all groups'),
- link: '/groups',
- icon: 'group',
+ computed: {
+ isSearch() {
+ return Boolean(this.searchString);
+ },
+ },
+ contextSwitcherItems,
+ created() {
+ if (this.currentContext.namespace) {
+ trackContextAccess(this.username, this.currentContext);
+ }
},
};
</script>
<template>
<div>
- <gl-search-box-by-type />
+ <div class="gl-p-1 gl-border-b gl-border-gray-50 gl-bg-white">
+ <gl-search-box-by-type
+ v-model="searchString"
+ class="context-switcher-search-box"
+ :placeholder="$options.i18n.searchPlaceholder"
+ borderless
+ />
+ </div>
<nav :aria-label="$options.i18n.contextNavigation">
<ul class="gl-p-0 gl-list-style-none">
- <li>
+ <li v-if="!isSearch">
<div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
{{ $options.i18n.switchTo }}
</div>
<ul :aria-label="$options.i18n.switchTo" class="gl-p-0">
<nav-item :item="$options.contextSwitcherItems.yourWork" />
+ <nav-item :item="$options.contextSwitcherItems.explore" />
</ul>
</li>
- <li>
- <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
- {{ $options.i18n.recentProjects }}
- </div>
- <ul :aria-label="$options.i18n.recentProjects" class="gl-p-0">
- <nav-item
- v-for="project in $options.contextSwitcherItems.recentProjects"
- :key="project.title"
- :item="project"
- >
- <template #icon>
- <gl-avatar shape="rect" :size="32" :src="project.avatar" />
- </template>
- </nav-item>
- <nav-item :item="$options.viewAllProjectsItem" />
- </ul>
- </li>
- <li>
- <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
- {{ $options.i18n.recentGroups }}
- </div>
- <ul :aria-label="$options.i18n.recentGroups" class="gl-p-0">
- <nav-item
- v-for="project in $options.contextSwitcherItems.recentGroups"
- :key="project.title"
- :item="project"
- >
- <template #icon>
- <gl-avatar shape="rect" :size="32" :src="project.avatar" />
- </template>
- </nav-item>
- <nav-item :item="$options.viewAllGroupsItem" />
- </ul>
- </li>
+ <projects-list
+ :username="username"
+ :view-all-link="projectsPath"
+ :is-search="isSearch"
+ :search-results="projects"
+ />
+ <groups-list
+ :username="username"
+ :view-all-link="groupsPath"
+ :is-search="isSearch"
+ :search-results="groups"
+ />
</ul>
</nav>
</div>
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
index b6f058f7aee..e0b6870872c 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -11,6 +11,9 @@ export default {
CollapseToggle: GlCollapseToggleDirective,
},
props: {
+ /*
+ * Contains metadata about the current view, e.g. `id`, `title` and `avatar`
+ */
context: {
type: Object,
required: true,
@@ -24,6 +27,9 @@ export default {
collapseIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
+ avatarShape() {
+ return this.context.avatar_shape || 'rect';
+ },
},
};
</script>
@@ -32,13 +38,27 @@ export default {
<button
v-collapse-toggle.context-switcher
type="button"
- class="context-switcher-toggle gl-bg-transparent gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-pl-3 gl-pr-5 gl-h-8"
+ class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-h-8"
>
- <gl-avatar :size="32" shape="rect" :src="context.avatar" class="gl-mr-3" />
+ <span
+ v-if="context.icon"
+ class="gl-avatar avatar-container gl-bg-t-gray-a-08 icon-avatar rect-avatar s24 gl-mr-3 gl-ml-4"
+ >
+ <gl-icon :name="context.icon" :size="16" />
+ </span>
+ <gl-avatar
+ v-else
+ :size="24"
+ :shape="avatarShape"
+ :entity-name="context.title"
+ :entity-id="context.id"
+ :src="context.avatar"
+ class="gl-mr-3 gl-ml-4"
+ />
<div class="gl-overflow-auto">
<gl-truncate :text="context.title" />
</div>
- <span class="gl-flex-grow-1 gl-text-right">
+ <span class="gl-flex-grow-1 gl-text-right gl-mr-4">
<gl-icon :name="collapseIcon" />
</span>
</button>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
index 62a1e5a6b20..e79b609545e 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -40,7 +40,7 @@ export default {
:is="component"
:aria-label="ariaLabel"
:href="href"
- class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border gl-border-gray-a-08 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none"
+ class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none"
>
<gl-icon aria-hidden="true" :name="icon" />
<span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span>
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index e92a6cbf5f5..d3bb31a69fa 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -30,6 +30,7 @@ export default {
text-sr-only
:toggle-text="$options.i18n.createNew"
:toggle-id="$options.toggleId"
+ data-qa-selector="new_menu_toggle"
/>
<gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar">
{{ $options.i18n.createNew }}
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
new file mode 100644
index 00000000000..5269c7f8d5e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -0,0 +1,77 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
+import ItemsList from './items_list.vue';
+
+export default {
+ components: {
+ ItemsList,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ pristineText: {
+ type: String,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: true,
+ },
+ maxItems: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ cachedFrequentItems: [],
+ };
+ },
+ computed: {
+ isEmpty() {
+ return !this.cachedFrequentItems.length;
+ },
+ },
+ created() {
+ this.getItemsFromLocalStorage();
+ },
+ methods: {
+ getItemsFromLocalStorage() {
+ if (!AccessorUtilities.canUseLocalStorage()) {
+ return;
+ }
+ try {
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
+ const topFrequentItems = getTopFrequentItems(parsedCachedFrequentItems, this.maxItems);
+ this.cachedFrequentItems = formatContextSwitcherItems(topFrequentItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-border-t gl-border-gray-50 gl-mx-3 gl-py-3">
+ <div
+ data-testid="list-title"
+ aria-hidden="true"
+ class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ >
+ {{ title }}
+ </div>
+ <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
+ {{ pristineText }}
+ </div>
+ <items-list :aria-label="title" :items="cachedFrequentItems">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
+ </li>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
new file mode 100644
index 00000000000..6798607b954
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -0,0 +1,320 @@
+<script>
+import {
+ GlSearchBoxByType,
+ GlOutsideDirective as Outside,
+ GlIcon,
+ GlToken,
+ GlTooltipDirective,
+ GlResizeObserverDirective,
+} from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { debounce } from 'lodash';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { truncate } from '~/lib/utils/text_utility';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { sprintf } from '~/locale';
+import Tracking from '~/tracking';
+import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
+import {
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ KBD_HELP,
+} from '~/vue_shared/global_search/constants';
+import {
+ FIRST_DROPDOWN_INDEX,
+ SEARCH_BOX_INDEX,
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ SCOPE_TOKEN_MAX_LENGTH,
+ INPUT_FIELD_PADDING,
+ IS_SEARCHING,
+ IS_FOCUSED,
+ IS_NOT_FOCUSED,
+} from '../constants';
+import HeaderSearchAutocompleteItems from './global_search_autocomplete_items.vue';
+import HeaderSearchDefaultItems from './global_search_default_items.vue';
+import HeaderSearchScopedItems from './global_search_scoped_items.vue';
+
+export default {
+ name: 'HeaderSearchApp',
+ i18n: {
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ KBD_HELP,
+ },
+ directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
+ components: {
+ GlSearchBoxByType,
+ HeaderSearchDefaultItems,
+ HeaderSearchScopedItems,
+ HeaderSearchAutocompleteItems,
+ DropdownKeyboardNavigation,
+ GlIcon,
+ GlToken,
+ },
+ data() {
+ return {
+ showDropdown: false,
+ isFocused: false,
+ currentFocusIndex: SEARCH_BOX_INDEX,
+ };
+ },
+ computed: {
+ ...mapState(['search', 'loading', 'searchContext']),
+ ...mapGetters(['searchQuery', 'searchOptions']),
+ searchText: {
+ get() {
+ return this.search;
+ },
+ set(value) {
+ this.setSearch(value);
+ },
+ },
+ currentFocusedOption() {
+ return this.searchOptions[this.currentFocusIndex];
+ },
+ currentFocusedId() {
+ return this.currentFocusedOption?.html_id;
+ },
+ isLoggedIn() {
+ return Boolean(gon?.current_username);
+ },
+ showSearchDropdown() {
+ if (!this.showDropdown || !this.isLoggedIn) {
+ return false;
+ }
+ return this.searchOptions?.length > 0;
+ },
+ showDefaultItems() {
+ return !this.searchText;
+ },
+ searchTermOverMin() {
+ return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
+ },
+ defaultIndex() {
+ if (this.showDefaultItems) {
+ return SEARCH_BOX_INDEX;
+ }
+ return FIRST_DROPDOWN_INDEX;
+ },
+
+ searchInputDescribeBy() {
+ if (this.isLoggedIn) {
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
+ }
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN;
+ },
+ dropdownResultsDescription() {
+ if (!this.showSearchDropdown) {
+ return ''; // This allows aria-live to see register an update when the dropdown is shown
+ }
+
+ if (this.showDefaultItems) {
+ return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
+ count: this.searchOptions.length,
+ });
+ }
+
+ return this.loading
+ ? this.$options.i18n.SEARCH_RESULTS_LOADING
+ : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, {
+ count: this.searchOptions.length,
+ });
+ },
+ searchBarClasses() {
+ return {
+ [IS_SEARCHING]: this.searchTermOverMin,
+ [IS_FOCUSED]: this.isFocused,
+ [IS_NOT_FOCUSED]: !this.isFocused,
+ };
+ },
+ showScopeHelp() {
+ return this.searchTermOverMin && this.isFocused;
+ },
+ searchBarItem() {
+ return this.searchOptions?.[0];
+ },
+ infieldHelpContent() {
+ return this.searchBarItem?.scope || this.searchBarItem?.description;
+ },
+ infieldHelpIcon() {
+ return this.searchBarItem?.icon;
+ },
+ scopeTokenTitle() {
+ return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, {
+ scope: this.infieldHelpContent,
+ });
+ },
+ },
+ methods: {
+ ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
+ openDropdown() {
+ this.showDropdown = true;
+
+ // check isFocused state to avoid firing duplicate events
+ if (!this.isFocused) {
+ this.isFocused = true;
+ this.$emit('expandSearchBar', true);
+
+ Tracking.event(undefined, 'focus_input', {
+ label: 'global_search',
+ property: 'navigation_top',
+ });
+ }
+ },
+ closeDropdown() {
+ this.showDropdown = false;
+ },
+ collapseAndCloseSearchBar() {
+ // we need a delay on this method
+ // for the search bar not to remove
+ // the clear button from dom
+ // and register clicks on dropdown items
+ setTimeout(() => {
+ this.showDropdown = false;
+ this.isFocused = false;
+ this.$emit('collapseSearchBar');
+
+ Tracking.event(undefined, 'blur_input', {
+ label: 'global_search',
+ property: 'navigation_top',
+ });
+ }, 200);
+ },
+ submitSearch() {
+ if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
+ return null;
+ }
+ return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
+ },
+ getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
+ this.openDropdown();
+ if (!searchTerm) {
+ this.clearAutocomplete();
+ } else {
+ this.fetchAutocompleteOptions();
+ }
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
+ observeTokenWidth({ contentRect: { width } }) {
+ const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input');
+ if (!inputField) {
+ return;
+ }
+ inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
+ },
+ },
+ SEARCH_BOX_INDEX,
+ FIRST_DROPDOWN_INDEX,
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+};
+</script>
+
+<template>
+ <form
+ v-outside="closeDropdown"
+ role="search"
+ :aria-label="$options.i18n.SEARCH_GITLAB"
+ class="header-search gl-relative gl-rounded-base gl-w-full"
+ :class="searchBarClasses"
+ data-testid="header-search-form"
+ >
+ <gl-search-box-by-type
+ id="search"
+ ref="searchInputBox"
+ v-model="searchText"
+ role="searchbox"
+ class="gl-z-index-1"
+ data-qa-selector="search_term_field"
+ autocomplete="off"
+ :placeholder="$options.i18n.SEARCH_GITLAB"
+ :aria-activedescendant="currentFocusedId"
+ :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
+ @focus="openDropdown"
+ @click="openDropdown"
+ @blur="collapseAndCloseSearchBar"
+ @input="getAutocompleteOptions"
+ @keydown.enter.stop.prevent="submitSearch"
+ @keydown.esc.stop.prevent="closeDropdown"
+ />
+ <gl-token
+ v-if="showScopeHelp"
+ v-gl-resize-observer-directive="observeTokenWidth"
+ class="in-search-scope-help"
+ :view-only="true"
+ :title="scopeTokenTitle"
+ ><gl-icon
+ v-if="infieldHelpIcon"
+ class="gl-mr-2"
+ :aria-label="infieldHelpContent"
+ :name="infieldHelpIcon"
+ :size="16"
+ />{{
+ getTruncatedScope(
+ sprintf($options.i18n.SEARCH_RESULTS_SCOPE, {
+ scope: infieldHelpContent,
+ }),
+ )
+ }}
+ </gl-token>
+ <kbd
+ v-show="!isFocused"
+ v-gl-tooltip.bottom.hover.html
+ class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
+ :title="$options.i18n.KBD_HELP"
+ >/</kbd
+ >
+ <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
+ searchInputDescribeBy
+ }}</span>
+ <span
+ role="region"
+ :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
+ class="gl-sr-only"
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {{ dropdownResultsDescription }}
+ </span>
+ <div
+ v-if="showSearchDropdown"
+ data-testid="header-search-dropdown-menu"
+ class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3"
+ >
+ <div class="header-search-dropdown-content gl-py-2">
+ <dropdown-keyboard-navigation
+ v-model="currentFocusIndex"
+ :max="searchOptions.length - 1"
+ :min="$options.FIRST_DROPDOWN_INDEX"
+ :default-index="defaultIndex"
+ @tab="closeDropdown"
+ />
+ <header-search-default-items
+ v-if="showDefaultItems"
+ :current-focused-option="currentFocusedOption"
+ />
+ <template v-else>
+ <header-search-scoped-items
+ v-if="searchTermOverMin"
+ :current-focused-option="currentFocusedOption"
+ />
+ <header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
+ </template>
+ </div>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
new file mode 100644
index 00000000000..1838214def6
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -0,0 +1,167 @@
+<script>
+import {
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlAvatar,
+ GlAlert,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import highlight from '~/lib/utils/highlight';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+import {
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+ AUTOCOMPLETE_ERROR_MESSAGE,
+} from '~/vue_shared/global_search/constants';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
+
+export default {
+ name: 'HeaderSearchAutocompleteItems',
+ i18n: {
+ AUTOCOMPLETE_ERROR_MESSAGE,
+ },
+ components: {
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlAvatar,
+ GlAlert,
+ GlLoadingIcon,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ currentFocusedOption: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ },
+ computed: {
+ ...mapState(['search', 'loading', 'autocompleteError', 'searchContext']),
+ ...mapGetters(['autocompleteGroupedSearchOptions']),
+ },
+ watch: {
+ currentFocusedOption() {
+ const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el;
+
+ if (focusedElement) {
+ focusedElement.scrollIntoView(false);
+ }
+ },
+ },
+ methods: {
+ truncateNamespace(string) {
+ if (string.split(' / ').length > 2) {
+ return truncateNamespace(string);
+ }
+
+ return string;
+ },
+ highlightedName(val) {
+ return highlight(val, this.search);
+ },
+ avatarSize(data) {
+ if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) {
+ return LARGE_AVATAR_PX;
+ }
+
+ return SMALL_AVATAR_PX;
+ },
+ isOptionFocused(data) {
+ return this.currentFocusedOption?.html_id === data.html_id;
+ },
+ isProjectsCategory(data) {
+ return data.category === PROJECTS_CATEGORY;
+ },
+ getEntityId(data) {
+ switch (data.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return data.group_id || data.id || this.searchContext?.group?.id;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return data.project_id || data.id || this.searchContext?.project?.id;
+ default:
+ return data.id;
+ }
+ },
+ getEntitytName(data) {
+ switch (data.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return data.group_name || data.value || data.label || this.searchContext?.group?.name;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return data.project_name || data.value || data.label || this.searchContext?.project?.name;
+ default:
+ return data.label;
+ }
+ },
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+};
+</script>
+
+<template>
+ <div>
+ <template v-if="!loading">
+ <div v-for="(option, index) in autocompleteGroupedSearchOptions" :key="option.category">
+ <gl-dropdown-divider v-if="index > 0" />
+ <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="data in option.data"
+ :id="data.html_id"
+ :ref="data.html_id"
+ :key="data.html_id"
+ :class="{ 'gl-bg-gray-50': isOptionFocused(data) }"
+ :aria-selected="isOptionFocused(data)"
+ :aria-label="data.label"
+ tabindex="-1"
+ :href="data.url"
+ >
+ <div class="gl-display-flex gl-align-items-center" aria-hidden="true">
+ <gl-avatar
+ v-if="data.avatar_url !== undefined"
+ :src="data.avatar_url"
+ :entity-id="getEntityId(data)"
+ :entity-name="getEntitytName(data)"
+ :size="avatarSize(data)"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ />
+ <span class="gl-display-flex gl-flex-direction-column">
+ <span
+ v-safe-html="highlightedName(data.value || data.label)"
+ class="gl-text-gray-900"
+ ></span>
+ <span
+ v-if="data.value"
+ v-safe-html="truncateNamespace(data.label)"
+ class="gl-font-sm gl-text-gray-500"
+ ></span>
+ </span>
+ </div>
+ </gl-dropdown-item>
+ </div>
+ </template>
+ <gl-loading-icon v-else size="lg" class="my-4" />
+ <gl-alert
+ v-if="autocompleteError"
+ class="gl-text-body gl-mt-2"
+ :dismissible="false"
+ variant="danger"
+ >
+ {{ $options.i18n.AUTOCOMPLETE_ERROR_MESSAGE }}
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
new file mode 100644
index 00000000000..f0d398297e9
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'HeaderSearchDefaultItems',
+ i18n: {
+ ALL_GITLAB,
+ },
+ components: {
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ },
+ props: {
+ currentFocusedOption: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ },
+ computed: {
+ ...mapState(['searchContext']),
+ ...mapGetters(['defaultSearchOptions']),
+ sectionHeader() {
+ return (
+ this.searchContext?.project?.name ||
+ this.searchContext?.group?.name ||
+ this.$options.i18n.ALL_GITLAB
+ );
+ },
+ },
+ methods: {
+ isOptionFocused(option) {
+ return this.currentFocusedOption?.html_id === option.html_id;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="option in defaultSearchOptions"
+ :id="option.html_id"
+ :ref="option.html_id"
+ :key="option.html_id"
+ :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
+ :aria-selected="isOptionFocused(option)"
+ :aria-label="option.title"
+ tabindex="-1"
+ :href="option.url"
+ >
+ <span aria-hidden="true">{{ option.title }}</span>
+ </gl-dropdown-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
new file mode 100644
index 00000000000..1ef88492b23
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -0,0 +1,87 @@
+<script>
+import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { s__, sprintf } from '~/locale';
+import { truncate } from '~/lib/utils/text_utility';
+import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants';
+import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
+
+export default {
+ name: 'HeaderSearchScopedItems',
+ i18n: {
+ SCOPED_SEARCH_ITEM_ARIA_LABEL,
+ },
+ components: {
+ GlDropdownItem,
+ GlIcon,
+ GlToken,
+ },
+ props: {
+ currentFocusedOption: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ },
+ computed: {
+ ...mapState(['search']),
+ ...mapGetters(['scopedSearchOptions', 'autocompleteGroupedSearchOptions']),
+ },
+ methods: {
+ isOptionFocused(option) {
+ return this.currentFocusedOption?.html_id === option.html_id;
+ },
+ ariaLabel(option) {
+ return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, {
+ search: this.search,
+ description: option.description || option.icon,
+ scope: option.scope || '',
+ });
+ },
+ titleLabel(option) {
+ return sprintf(s__('GlobalSearch|in %{scope}'), {
+ search: this.search,
+ scope: option.scope || option.description,
+ });
+ },
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown-item
+ v-for="option in scopedSearchOptions"
+ :id="option.html_id"
+ :ref="option.html_id"
+ :key="option.html_id"
+ class="gl-max-w-full"
+ :class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
+ :aria-selected="isOptionFocused(option)"
+ :aria-label="ariaLabel(option)"
+ tabindex="-1"
+ :href="option.url"
+ :title="titleLabel(option)"
+ >
+ <span
+ ref="token-text-content"
+ class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full"
+ >
+ <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" />
+ <span class="gl-flex-grow-1 gl-relative">
+ <gl-token
+ class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!"
+ :view-only="true"
+ >
+ <gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" />
+ <span>{{ getTruncatedScope(titleLabel(option)) }}</span>
+ </gl-token>
+ {{ search }}
+ </span>
+ </span>
+ </gl-dropdown-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
new file mode 100644
index 00000000000..b9bb4e573fd
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -0,0 +1,33 @@
+export const ICON_PROJECT = 'project';
+
+export const ICON_GROUP = 'group';
+
+export const ICON_SUBGROUP = 'subgroup';
+
+export const LARGE_AVATAR_PX = 32;
+
+export const SMALL_AVATAR_PX = 16;
+
+export const FIRST_DROPDOWN_INDEX = 0;
+
+export const SEARCH_BOX_INDEX = -1;
+
+export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
+
+export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
+
+export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
+
+export const SCOPE_TOKEN_MAX_LENGTH = 36;
+
+export const INPUT_FIELD_PADDING = 52;
+
+export const HEADER_INIT_EVENTS = ['input', 'focus'];
+
+export const IS_SEARCHING = 'is-searching';
+export const IS_FOCUSED = 'is-focused';
+export const IS_NOT_FOCUSED = 'is-not-focused';
+
+export const FETCH_TYPES = ['generic', 'search'];
+
+export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js b/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js
new file mode 100644
index 00000000000..a0f9e594506
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js
@@ -0,0 +1,45 @@
+import { omitBy, isNil } from 'lodash';
+import { objectToQuery } from '~/lib/utils/url_utility';
+import axios from '~/lib/utils/axios_utils';
+import { FETCH_TYPES } from '../constants';
+import * as types from './mutation_types';
+
+export const autocompleteQuery = ({ state, fetchType }) => {
+ const query = omitBy(
+ {
+ term: state.search,
+ project_id: state.searchContext?.project?.id,
+ project_ref: state.searchContext?.ref,
+ filter: fetchType,
+ },
+ isNil,
+ );
+
+ return `${state.autocompletePath}?${objectToQuery(query)}`;
+};
+
+const doFetch = ({ commit, state, fetchType }) => {
+ return axios
+ .get(autocompleteQuery({ state, fetchType }))
+ .then(({ data }) => {
+ commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data);
+ })
+ .catch(() => {
+ commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
+ });
+};
+
+export const fetchAutocompleteOptions = ({ commit, state }) => {
+ commit(types.REQUEST_AUTOCOMPLETE);
+ const promises = FETCH_TYPES.map((fetchType) => doFetch({ commit, state, fetchType }));
+
+ return Promise.all(promises);
+};
+
+export const clearAutocomplete = ({ commit }) => {
+ commit(types.CLEAR_AUTOCOMPLETE);
+};
+
+export const setSearch = ({ commit }, value) => {
+ commit(types.SET_SEARCH, value);
+};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
new file mode 100644
index 00000000000..f86463b94d1
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -0,0 +1,220 @@
+import { omitBy, isNil } from 'lodash';
+import { objectToQuery } from '~/lib/utils/url_utility';
+
+import {
+ MSG_ISSUES_ASSIGNED_TO_ME,
+ MSG_ISSUES_IVE_CREATED,
+ MSG_MR_ASSIGNED_TO_ME,
+ MSG_MR_IM_REVIEWER,
+ MSG_MR_IVE_CREATED,
+ MSG_IN_ALL_GITLAB,
+ PROJECTS_CATEGORY,
+ GROUPS_CATEGORY,
+ DROPDOWN_ORDER,
+} from '~/vue_shared/global_search/constants';
+import {
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ ICON_PROJECT,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
+} from '../constants';
+
+export const searchQuery = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext?.project?.id,
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const scopedIssuesPath = (state) => {
+ if (state.searchContext?.project?.id && !state.searchContext?.project_metadata?.issues_path) {
+ return false;
+ }
+
+ return (
+ state.searchContext?.project_metadata?.issues_path ||
+ state.searchContext?.group_metadata?.issues_path ||
+ state.issuesPath
+ );
+};
+
+export const scopedMRPath = (state) => {
+ return (
+ state.searchContext?.project_metadata?.mr_path ||
+ state.searchContext?.group_metadata?.mr_path ||
+ state.mrPath
+ );
+};
+
+export const defaultSearchOptions = (state, getters) => {
+ const userName = gon.current_username;
+
+ const issues = [
+ {
+ html_id: 'default-issues-assigned',
+ title: MSG_ISSUES_ASSIGNED_TO_ME,
+ url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ html_id: 'default-issues-created',
+ title: MSG_ISSUES_IVE_CREATED,
+ url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+ },
+ ];
+
+ const mergeRequests = [
+ {
+ html_id: 'default-mrs-assigned',
+ title: MSG_MR_ASSIGNED_TO_ME,
+ url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+ },
+ {
+ html_id: 'default-mrs-reviewer',
+ title: MSG_MR_IM_REVIEWER,
+ url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+ },
+ {
+ html_id: 'default-mrs-created',
+ title: MSG_MR_IVE_CREATED,
+ url: `${getters.scopedMRPath}/?author_username=${userName}`,
+ },
+ ];
+ return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests];
+};
+
+export const projectUrl = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext?.project?.id,
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const groupUrl = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ group_id: state.searchContext?.group?.id,
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const allUrl = (state) => {
+ const query = omitBy(
+ {
+ search: state.search,
+ nav_source: 'navbar',
+ scope: state.searchContext?.scope,
+ snippets: state.searchContext?.for_snippets ? true : null,
+ search_code: state.searchContext?.code_search ? true : null,
+ repository_ref: state.searchContext?.ref,
+ },
+ isNil,
+ );
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const scopedSearchOptions = (state, getters) => {
+ const options = [];
+
+ if (state.searchContext?.project) {
+ options.push({
+ html_id: 'scoped-in-project',
+ scope: state.searchContext.project?.name || '',
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ url: getters.projectUrl,
+ });
+ }
+
+ if (state.searchContext?.group) {
+ options.push({
+ html_id: 'scoped-in-group',
+ scope: state.searchContext.group?.name || '',
+ scopeCategory: GROUPS_CATEGORY,
+ icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
+ url: getters.groupUrl,
+ });
+ }
+
+ options.push({
+ html_id: 'scoped-in-all',
+ description: MSG_IN_ALL_GITLAB,
+ url: getters.allUrl,
+ });
+
+ return options;
+};
+
+export const autocompleteGroupedSearchOptions = (state) => {
+ const groupedOptions = {};
+ const results = [];
+
+ state.autocompleteOptions.forEach((option) => {
+ const category = groupedOptions[option.category];
+
+ if (category) {
+ category.data.push(option);
+ } else {
+ groupedOptions[option.category] = {
+ category: option.category,
+ data: [option],
+ };
+
+ results.push(groupedOptions[option.category]);
+ }
+ });
+
+ return results.sort(
+ (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category),
+ );
+};
+
+export const searchOptions = (state, getters) => {
+ if (!state.search) {
+ return getters.defaultSearchOptions;
+ }
+
+ const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
+ (options, group) => {
+ return [...options, ...group.data];
+ },
+ [],
+ );
+
+ if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
+ return sortedAutocompleteOptions;
+ }
+
+ return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
+};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/index.js b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
new file mode 100644
index 00000000000..b83433c5b49
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const getStoreConfig = ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
+}) => ({
+ actions,
+ getters,
+ mutations,
+ state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }),
+});
+
+const createStore = (config) => new Vuex.Store(getStoreConfig(config));
+export default createStore;
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
new file mode 100644
index 00000000000..6e65345757f
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
@@ -0,0 +1,6 @@
+export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE';
+export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS';
+export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR';
+export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE';
+
+export const SET_SEARCH = 'SET_SEARCH';
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
new file mode 100644
index 00000000000..19b4d4ec330
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
@@ -0,0 +1,30 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_AUTOCOMPLETE](state) {
+ state.loading = true;
+ state.autocompleteOptions = [];
+ state.autocompleteError = false;
+ },
+ [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
+ state.loading = false;
+ state.autocompleteOptions = [...state.autocompleteOptions].concat(
+ data.map((d, i) => {
+ return { html_id: `autocomplete-${d.category}-${i}`, ...d };
+ }),
+ );
+ state.autocompleteError = false;
+ },
+ [types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
+ state.loading = false;
+ state.autocompleteOptions = [];
+ state.autocompleteError = true;
+ },
+ [types.CLEAR_AUTOCOMPLETE](state) {
+ state.autocompleteOptions = [];
+ state.autocompleteError = false;
+ },
+ [types.SET_SEARCH](state, value) {
+ state.search = value;
+ },
+};
diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/state.js b/app/assets/javascripts/super_sidebar/components/global_search/store/state.js
new file mode 100644
index 00000000000..bebdbc7b92e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/state.js
@@ -0,0 +1,19 @@
+const createState = ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
+}) => ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search,
+ autocompleteOptions: [],
+ autocompleteError: false,
+ loading: false,
+});
+export default createState;
diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue
new file mode 100644
index 00000000000..78b5ed2d31e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue
@@ -0,0 +1,78 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_GROUPS_COUNT } from '../constants';
+import FrequentItemsList from './frequent_items_list.vue';
+import SearchResults from './search_results.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ MAX_FREQUENT_GROUPS_COUNT,
+ components: {
+ FrequentItemsList,
+ SearchResults,
+ NavItem,
+ },
+ props: {
+ username: {
+ type: String,
+ required: true,
+ },
+ viewAllLink: {
+ type: String,
+ required: true,
+ },
+ isSearch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ storageKey() {
+ return `${this.username}/frequent-groups`;
+ },
+ viewAllItem() {
+ return {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all groups'),
+ icon: 'group',
+ };
+ },
+ },
+ i18n: {
+ title: s__('Navigation|Frequent groups'),
+ searchTitle: s__('Navigation|Groups'),
+ pristineText: s__('Navigation|Groups you visit often will appear here.'),
+ noResultsText: s__('Navigation|No group matches found'),
+ },
+};
+</script>
+
+<template>
+ <search-results
+ v-if="isSearch"
+ :title="$options.i18n.searchTitle"
+ :no-results-text="$options.i18n.noResultsText"
+ :search-results="searchResults"
+ >
+ <template #view-all-items>
+ <nav-item :item="viewAllItem" />
+ </template>
+ </search-results>
+ <frequent-items-list
+ v-else
+ :title="$options.i18n.title"
+ :storage-key="storageKey"
+ :max-items="$options.MAX_FREQUENT_GROUPS_COUNT"
+ :pristine-text="$options.i18n.pristineText"
+ >
+ <template #view-all-items>
+ <nav-item :item="viewAllItem" />
+ </template>
+ </frequent-items-list>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue
index 8e7c7efa631..fb23a4f2deb 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -68,6 +68,9 @@ export default {
{
text: this.$options.i18n.shortcuts,
action: this.showKeyboardShortcuts,
+ extraAttrs: {
+ class: 'js-shortcuts-modal-trigger',
+ },
shortcut: '?',
},
this.sidebarData.display_whats_new && {
@@ -96,15 +99,8 @@ export default {
return true;
},
- handleAction({ action }) {
- if (action) {
- action();
- }
- },
-
showKeyboardShortcuts() {
this.$refs.dropdown.close();
- window?.toggleShortcutsHelp();
},
async showWhatsNew() {
@@ -130,7 +126,7 @@ export default {
<gl-disclosure-dropdown ref="dropdown">
<template #toggle>
<gl-button category="tertiary" icon="question-o" class="btn-with-notification">
- <span v-if="showWhatsNewNotification" class="notification"></span>
+ <span v-if="showWhatsNewNotification" class="notification-dot-info"></span>
{{ $options.i18n.help }}
</gl-button>
</template>
@@ -140,11 +136,7 @@ export default {
:group="itemGroups.versionCheck"
>
<template #list-item="{ item }">
- <a
- :href="item.href"
- tabindex="-1"
- class="gl-display-flex gl-flex-direction-column gl-line-height-24 gl-text-gray-900 gl-hover-text-gray-900 gl-hover-text-decoration-none"
- >
+ <span class="gl-display-flex gl-flex-direction-column gl-line-height-24">
<span class="gl-font-sm gl-font-weight-bold">
{{ item.text }}
<gl-emoji data-name="rocket" />
@@ -153,7 +145,7 @@ export default {
<span class="gl-mr-2">{{ item.version }}</span>
<gitlab-version-check-badge v-if="updateSeverity" :status="updateSeverity" size="sm" />
</span>
- </a>
+ </span>
</template>
</gl-disclosure-dropdown-group>
@@ -162,16 +154,15 @@ export default {
:bordered="sidebarData.show_version_check"
/>
- <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered @action="handleAction">
+ <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered>
<template #list-item="{ item }">
- <button
- tabindex="-1"
- class="gl-bg-transparent gl-w-full gl-border-none gl-display-flex gl-justify-content-space-between gl-p-0 gl-text-gray-900"
+ <span
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-my-n1"
>
{{ item.text }}
<gl-badge v-if="item.count" pill size="sm" variant="info">{{ item.count }}</gl-badge>
<kbd v-else-if="item.shortcut" class="flat">?</kbd>
- </button>
+ </span>
</template>
</gl-disclosure-dropdown-group>
</gl-disclosure-dropdown>
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
new file mode 100644
index 00000000000..0a72105fcc4
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -0,0 +1,40 @@
+<script>
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ components: {
+ ProjectAvatar,
+ NavItem,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <nav-item
+ v-for="item in items"
+ :key="item.id"
+ :item="item"
+ :link-classes="{ 'gl-py-2!': true }"
+ >
+ <template #icon>
+ <project-avatar
+ :project-id="item.id"
+ :project-name="item.title"
+ :project-avatar-url="item.avatar"
+ :size="24"
+ aria-hidden="true"
+ />
+ </template>
+ </nav-item>
+ <slot name="view-all-items"></slot>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
index edc13e305cf..94fc6aedcc0 100644
--- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
@@ -12,29 +12,19 @@ export default {
required: true,
},
},
- methods: {
- navigate() {
- this.$refs.link.click();
- },
- },
};
</script>
<template>
- <gl-disclosure-dropdown :items="items" placement="center" @action="navigate">
+ <gl-disclosure-dropdown :items="items" placement="center">
<template #toggle>
<slot></slot>
</template>
<template #list-item="{ item }">
- <a
- ref="link"
- class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-hover-text-gray-900 gl-hover-text-decoration-none gl-text-gray-900"
- :href="item.href"
- tabindex="-1"
- >
+ <span class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
{{ item.text }}
<gl-badge pill size="sm" variant="neutral">{{ item.count || 0 }}</gl-badge>
- </a>
+ </span>
</template>
</gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue
index 4fd6918fd6f..cd5363ad7a5 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -1,37 +1,141 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { kebabCase } from 'lodash';
+import { GlCollapse, GlIcon, GlBadge } from '@gitlab/ui';
export default {
name: 'NavItem',
components: {
+ GlCollapse,
GlIcon,
+ GlBadge,
},
props: {
item: {
type: Object,
required: true,
},
+ linkClasses: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ expanded: this.item.is_active,
+ };
+ },
+ computed: {
+ elem() {
+ return this.isSection ? 'button' : 'a';
+ },
+ collapseIcon() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
+ },
+ isSection() {
+ return Boolean(this.item?.items?.length);
+ },
+ itemId() {
+ return kebabCase(this.item.title);
+ },
+ pillData() {
+ return this.item.pill_count;
+ },
+ hasPill() {
+ return (
+ Number.isFinite(this.pillData) ||
+ (typeof this.pillData === 'string' && this.pillData !== '')
+ );
+ },
+ isActive() {
+ if (this.isSection) {
+ return !this.expanded && this.item.is_active;
+ }
+ return this.item.is_active;
+ },
+ linkProps() {
+ if (this.isSection) {
+ return {
+ 'aria-controls': this.itemId,
+ 'aria-expanded': String(this.expanded),
+ };
+ }
+ return {
+ ...this.$attrs,
+ href: this.item.link,
+ 'aria-current': this.isActive ? 'page' : null,
+ };
+ },
+ computedLinkClasses() {
+ return {
+ // Reset user agent styles on <button>
+ 'gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left': this.isSection,
+ 'gl-w-full gl-focus': this.isSection,
+ 'gl-bg-t-gray-a-08': this.isActive,
+ ...this.linkClasses,
+ };
+ },
+ },
+ methods: {
+ click(event) {
+ if (this.isSection) {
+ event.preventDefault();
+ this.expanded = !this.expanded;
+ }
+ },
},
};
</script>
<template>
<li>
- <a
- :href="item.link"
- class="gl-display-flex gl-pl-3 gl-py-3 gl-line-height-normal gl-text-black-normal gl-hover-bg-t-gray-a-08"
+ <component
+ :is="elem"
+ v-bind="linkProps"
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-text-decoration-none!"
+ :class="computedLinkClasses"
+ data-qa-selector="sidebar_menu_link"
+ data-testid="nav-item-link"
+ :data-qa-menu-item="item.title"
+ @click="click"
>
- <div class="gl-mr-3">
+ <div
+ :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']"
+ class="gl-absolute gl-left-2 gl-top-2 gl-bottom-2 gl-transition-slow"
+ aria-hidden="true"
+ style="width: 3px; border-radius: 3px; margin-right: 1px"
+ ></div>
+ <div class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
<slot name="icon">
- <gl-icon v-if="item.icon" :name="item.icon" />
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2" />
</slot>
</div>
- <div class="gl-pr-3">
+ <div class="gl-pr-3 gl-text-gray-900">
{{ item.title }}
- <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-mt-1">
+ <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500">
{{ item.subtitle }}
</div>
</div>
- </a>
+ <span v-if="isSection || hasPill" class="gl-flex-grow-1 gl-text-right gl-mr-3">
+ <gl-badge v-if="hasPill" size="sm" variant="info">
+ {{ pillData }}
+ </gl-badge>
+ <gl-icon v-else-if="isSection" :name="collapseIcon" />
+ </span>
+ </component>
+ <gl-collapse
+ v-if="isSection"
+ :id="itemId"
+ v-model="expanded"
+ :aria-label="item.title"
+ class="gl-list-style-none gl-p-0"
+ tag="ul"
+ >
+ <nav-item
+ v-for="subItem of item.items"
+ :key="`${item.title}-${subItem.title}`"
+ :item="subItem"
+ />
+ </gl-collapse>
</li>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue
new file mode 100644
index 00000000000..a545de06bd4
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue
@@ -0,0 +1,79 @@
+<script>
+import { s__ } from '~/locale';
+import { MAX_FREQUENT_PROJECTS_COUNT } from '../constants';
+import FrequentItemsList from './frequent_items_list.vue';
+import SearchResults from './search_results.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ MAX_FREQUENT_PROJECTS_COUNT,
+ components: {
+ FrequentItemsList,
+ SearchResults,
+ NavItem,
+ },
+ props: {
+ username: {
+ type: String,
+ required: true,
+ },
+ viewAllLink: {
+ type: String,
+ required: true,
+ },
+ isSearch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ storageKey() {
+ return `${this.username}/frequent-projects`;
+ },
+ viewAllItem() {
+ return {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all projects'),
+ icon: 'project',
+ };
+ },
+ },
+ i18n: {
+ title: s__('Navigation|Frequent projects'),
+ searchTitle: s__('Navigation|Projects'),
+ pristineText: s__('Navigation|Projects you visit often will appear here.'),
+ noResultsText: s__('Navigation|No project matches found'),
+ },
+};
+</script>
+
+<template>
+ <search-results
+ v-if="isSearch"
+ class="gl-border-t-0"
+ :title="$options.i18n.searchTitle"
+ :no-results-text="$options.i18n.noResultsText"
+ :search-results="searchResults"
+ >
+ <template #view-all-items>
+ <nav-item :item="viewAllItem" />
+ </template>
+ </search-results>
+ <frequent-items-list
+ v-else
+ :title="$options.i18n.title"
+ :storage-key="storageKey"
+ :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT"
+ :pristine-text="$options.i18n.pristineText"
+ >
+ <template #view-all-items>
+ <nav-item :item="viewAllItem" />
+ </template>
+ </frequent-items-list>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue
new file mode 100644
index 00000000000..7c172110bad
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/search_results.vue
@@ -0,0 +1,49 @@
+<script>
+import ItemsList from './items_list.vue';
+
+export default {
+ components: {
+ ItemsList,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ noResultsText: {
+ type: String,
+ required: true,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ isEmpty() {
+ return !this.searchResults.length;
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-border-t gl-border-gray-50 gl-mx-3 gl-py-3">
+ <div
+ data-testid="list-title"
+ aria-hidden="true"
+ class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ >
+ {{ title }}
+ </div>
+ <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
+ {{ noResultsText }}
+ </div>
+ <items-list :aria-label="title" :items="searchResults">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
+ </li>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
new file mode 100644
index 00000000000..fc8968c50ea
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -0,0 +1,24 @@
+<script>
+import NavItem from './nav_item.vue';
+
+export default {
+ name: 'SidebarMenu',
+ components: {
+ NavItem,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <nav class="gl-py-2 gl-relative">
+ <ul class="gl-px-2 gl-list-style-none">
+ <nav-item v-for="item in items" :key="`menu-${item.title}`" :item="item" />
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue b/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue
new file mode 100644
index 00000000000..2a805c86a3b
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue
@@ -0,0 +1,30 @@
+<script>
+import { MountingPortal } from 'portal-vue';
+import { SIDEBAR_PORTAL_ID, portalState } from '../constants';
+
+/**
+ * Use this component to render content into the sidebar.
+ *
+ * Arbitrary content is allowed, but nav items should be added using a Ruby
+ * Sidebars::Panel subclass instead.
+ *
+ * Only one instance of this component on a given page is supported. This is to
+ * avoid ordering issues and cluttering the sidebar.
+ */
+export default {
+ components: {
+ MountingPortal,
+ },
+ data() {
+ // This is shared state, by design. Do not mutate this state here.
+ return portalState;
+ },
+ mountSelector: `#${SIDEBAR_PORTAL_ID}`,
+};
+</script>
+
+<template>
+ <mounting-portal v-if="ready" :mount-to="$options.mountSelector" append>
+ <slot></slot>
+ </mounting-portal>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue b/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue
new file mode 100644
index 00000000000..1154a4357e0
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue
@@ -0,0 +1,17 @@
+<script>
+import { SIDEBAR_PORTAL_ID, portalState } from '../constants';
+
+export default {
+ mounted() {
+ portalState.ready = true;
+ },
+ beforeDestroy() {
+ portalState.ready = false;
+ },
+ mountId: SIDEBAR_PORTAL_ID,
+};
+</script>
+
+<template>
+ <div v-once :id="$options.mountId"></div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index c4b769dcf24..e8df534346b 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -1,19 +1,27 @@
<script>
-import { GlCollapse } from '@gitlab/ui';
-import { context } from '../mock_data';
+import { GlButton, GlCollapse } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
+import SidebarPortalTarget from './sidebar_portal_target.vue';
import ContextSwitcherToggle from './context_switcher_toggle.vue';
import ContextSwitcher from './context_switcher.vue';
import HelpCenter from './help_center.vue';
+import SidebarMenu from './sidebar_menu.vue';
export default {
- context,
components: {
+ GlButton,
GlCollapse,
UserBar,
ContextSwitcherToggle,
ContextSwitcher,
HelpCenter,
+ SidebarMenu,
+ SidebarPortalTarget,
+ },
+ i18n: {
+ skipToMainContent: __('Skip to main content'),
},
props: {
sidebarData: {
@@ -24,28 +32,65 @@ export default {
data() {
return {
contextSwitcherOpened: false,
+ isCollapased: isCollapsed(),
};
},
+ computed: {
+ menuItems() {
+ return this.sidebarData.current_menu_items || [];
+ },
+ },
+ methods: {
+ collapseSidebar() {
+ toggleSuperSidebarCollapsed(true, false);
+ },
+ },
};
</script>
<template>
- <aside
- id="super-sidebar"
- class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08"
- data-testid="super-sidebar"
- >
- <user-bar :sidebar-data="sidebarData" />
- <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
- <div class="gl-flex-grow-1 gl-overflow-auto">
- <context-switcher-toggle :context="$options.context" :expanded="contextSwitcherOpened" />
- <gl-collapse id="context-switcher" v-model="contextSwitcherOpened">
- <context-switcher />
- </gl-collapse>
- </div>
- <div class="gl-p-3">
- <help-center :sidebar-data="sidebarData" />
+ <div>
+ <div class="super-sidebar-overlay" @click="collapseSidebar"></div>
+ <aside
+ id="super-sidebar"
+ :aria-hidden="String(isCollapased)"
+ class="super-sidebar"
+ data-testid="super-sidebar"
+ data-qa-selector="navbar"
+ :inert="isCollapased"
+ tabindex="-1"
+ >
+ <gl-button
+ class="super-sidebar-skip-to gl-sr-only-focusable gl-absolute gl-left-3 gl-right-3 gl-top-3"
+ href="#content-body"
+ variant="confirm"
+ >
+ {{ $options.i18n.skipToMainContent }}
+ </gl-button>
+ <user-bar :sidebar-data="sidebarData" />
+ <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden">
+ <div class="gl-flex-grow-1 gl-overflow-auto">
+ <context-switcher-toggle
+ :context="sidebarData.current_context_header"
+ :expanded="contextSwitcherOpened"
+ />
+ <gl-collapse id="context-switcher" v-model="contextSwitcherOpened">
+ <context-switcher
+ :username="sidebarData.username"
+ :projects-path="sidebarData.projects_path"
+ :groups-path="sidebarData.groups_path"
+ :current-context="sidebarData.current_context"
+ />
+ </gl-collapse>
+ <gl-collapse :visible="!contextSwitcherOpened">
+ <sidebar-menu :items="menuItems" />
+ <sidebar-portal-target />
+ </gl-collapse>
+ </div>
+ <div class="gl-p-3">
+ <help-center :sidebar-data="sidebarData" />
+ </div>
</div>
- </div>
- </aside>
+ </aside>
+ </div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index ee72e8eafb4..e27acb60372 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -1,60 +1,89 @@
<script>
-import { GlAvatar, GlDropdown, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
import logo from '../../../../views/shared/_logo.svg';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import CreateMenu from './create_menu.vue';
import Counter from './counter.vue';
import MergeRequestMenu from './merge_request_menu.vue';
+import UserMenu from './user_menu.vue';
export default {
+ // "GitLab Next" is a proper noun, so don't translate "Next"
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ NEXT_LABEL: 'Next',
logo,
components: {
- GlAvatar,
- GlDropdown,
- GlIcon,
- CreateMenu,
- NewNavToggle,
Counter,
+ CreateMenu,
+ GlBadge,
+ GlButton,
MergeRequestMenu,
+ UserMenu,
},
i18n: {
+ collapseSidebar: __('Collapse sidebar'),
createNew: __('Create new...'),
issues: __('Issues'),
mergeRequests: __('Merge requests'),
+ search: __('Search'),
todoList: __('To-Do list'),
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
- inject: ['rootPath', 'toggleNewNavEndpoint'],
+ inject: ['rootPath'],
props: {
sidebarData: {
type: Object,
required: true,
},
},
+ methods: {
+ collapseSidebar() {
+ toggleSuperSidebarCollapsed(true, true, true);
+ },
+ },
};
</script>
<template>
<div class="user-bar">
- <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-3">
- <div class="gl-flex-grow-1">
- <a v-safe-html="$options.logo" :href="rootPath"></a>
- </div>
+ <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-2">
+ <a :href="rootPath">
+ <img
+ v-if="sidebarData.logo_url"
+ data-testid="brand-header-custom-logo"
+ :src="sidebarData.logo_url"
+ class="gl-h-6"
+ />
+ <span v-else v-safe-html="$options.logo"></span>
+ </a>
+ <gl-badge
+ v-if="sidebarData.gitlab_com_and_canary"
+ variant="success"
+ :href="sidebarData.canary_toggle_com_url"
+ size="sm"
+ >{{ $options.NEXT_LABEL }}</gl-badge
+ >
+ <div class="gl-flex-grow-1"></div>
+ <gl-button
+ v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.collapseSidebar"
+ :aria-label="$options.i18n.collapseSidebar"
+ icon="sidebar"
+ category="tertiary"
+ @click="collapseSidebar"
+ />
<create-menu :groups="sidebarData.create_new_menu_groups" />
- <button class="gl-border-none">
- <gl-icon name="search" class="gl-vertical-align-middle" />
- </button>
- <gl-dropdown data-testid="user-dropdown" variant="link" no-caret>
- <template #button-content>
- <gl-avatar :entity-name="sidebarData.name" :src="sidebarData.avatar_url" :size="32" />
- </template>
- <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled />
- </gl-dropdown>
+ <gl-button
+ icon="search"
+ :aria-label="$options.i18n.search"
+ category="tertiary"
+ href="/search"
+ />
+ <user-menu :data="sidebarData" />
</div>
<div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
<counter
@@ -72,7 +101,6 @@ export default {
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests"
class="gl-w-full"
- tabindex="-1"
icon="merge-request-open"
:count="sidebarData.total_merge_requests_count"
:label="$options.i18n.mergeRequests"
@@ -85,6 +113,7 @@ export default {
:count="sidebarData.todos_pending_count"
href="/dashboard/todos"
:label="$options.i18n.todoList"
+ data-qa-selector="todos_shortcut_button"
/>
</div>
</div>
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
new file mode 100644
index 00000000000..34bbb3ce177
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -0,0 +1,291 @@
+<script>
+import {
+ GlAvatar,
+ GlBadge,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { s__, __, sprintf } from '~/locale';
+import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
+import Tracking from '~/tracking';
+import PersistentUserCallout from '~/persistent_user_callout';
+import UserNameGroup from './user_name_group.vue';
+
+export default {
+ feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391533',
+ i18n: {
+ newNavigation: {
+ badgeLabel: s__('NorthstarNavigation|Alpha'),
+ sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
+ },
+ setStatus: s__('SetStatusModal|Set status'),
+ editStatus: s__('SetStatusModal|Edit status'),
+ editProfile: s__('CurrentUser|Edit profile'),
+ preferences: s__('CurrentUser|Preferences'),
+ buyPipelineMinutes: s__('CurrentUser|Buy Pipeline minutes'),
+ oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'),
+ gitlabNext: s__('CurrentUser|Switch to GitLab Next'),
+ provideFeedback: s__('NorthstarNavigation|Provide feedback'),
+ startTrial: s__('CurrentUser|Start an Ultimate trial'),
+ signOut: __('Sign out'),
+ },
+ components: {
+ GlAvatar,
+ GlBadge,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ NewNavToggle,
+ UserNameGroup,
+ },
+ directives: {
+ SafeHtml,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['toggleNewNavEndpoint'],
+ props: {
+ data: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ toggleText() {
+ return sprintf(__('%{user} user’s menu'), { user: this.data.name });
+ },
+ statusItem() {
+ const { busy, customized } = this.data.status;
+
+ const statusLabel =
+ busy || customized ? this.$options.i18n.editStatus : this.$options.i18n.setStatus;
+
+ return {
+ text: statusLabel,
+ extraAttrs: {
+ class: 'js-set-status-modal-trigger',
+ },
+ };
+ },
+ trialItem() {
+ return {
+ text: this.$options.i18n.startTrial,
+ href: this.data.trial.url,
+ };
+ },
+ editProfileItem() {
+ return {
+ text: this.$options.i18n.editProfile,
+ href: this.data.settings.profile_path,
+ extraAttrs: {
+ 'data-qa-selector': 'edit_profile_link',
+ },
+ };
+ },
+ preferencesItem() {
+ return {
+ text: this.$options.i18n.preferences,
+ href: this.data.settings.profile_preferences_path,
+ };
+ },
+ addBuyPipelineMinutesMenuItem() {
+ return this.data.pipeline_minutes?.show_buy_pipeline_minutes;
+ },
+ buyPipelineMinutesItem() {
+ return {
+ text: this.$options.i18n.buyPipelineMinutes,
+ warningText: this.$options.i18n.oneOfGroupsRunningOutOfPipelineMinutes,
+ href: this.data.pipeline_minutes?.buy_pipeline_minutes_path,
+ extraAttrs: {
+ class: 'js-follow-link',
+ },
+ };
+ },
+ gitlabNextItem() {
+ return {
+ text: this.$options.i18n.gitlabNext,
+ href: this.data.canary_toggle_com_url,
+ };
+ },
+ feedbackItem() {
+ return {
+ text: this.$options.i18n.provideFeedback,
+ href: this.$options.feedbackUrl,
+ extraAttrs: {
+ target: '_blank',
+ },
+ };
+ },
+ signOutGroup() {
+ return {
+ items: [
+ {
+ text: this.$options.i18n.signOut,
+ href: this.data.sign_out_link,
+ extraAttrs: {
+ 'data-method': 'post',
+ 'data-qa-selector': 'sign_out_link',
+ class: 'sign-out-link',
+ },
+ },
+ ],
+ };
+ },
+ statusModalData() {
+ const defaultData = {
+ 'data-current-emoji': '',
+ 'data-current-message': '',
+ 'data-default-emoji': 'speech_balloon',
+ };
+
+ if (!this.data.status.customized) {
+ return defaultData;
+ }
+ return {
+ ...defaultData,
+ 'data-current-emoji': this.data.status.emoji,
+ 'data-current-message': this.data.status.message,
+ 'data-current-availability': this.data.status.availability,
+ 'data-current-clear-status-after': this.data.status.clear_after,
+ };
+ },
+ buyPipelineMinutesCalloutData() {
+ return this.showNotificationDot
+ ? {
+ 'data-feature-id': this.data.pipeline_minutes.callout_attrs.feature_id,
+ 'data-dismiss-endpoint': this.data.pipeline_minutes.callout_attrs.dismiss_endpoint,
+ }
+ : {};
+ },
+ showNotificationDot() {
+ return this.data.pipeline_minutes?.show_notification_dot;
+ },
+ },
+ methods: {
+ onShow() {
+ this.trackEvents();
+ this.initCallout();
+ },
+ initCallout() {
+ if (this.showNotificationDot) {
+ PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el);
+ }
+ },
+ trackEvents() {
+ if (this.addBuyPipelineMinutesMenuItem) {
+ const {
+ 'track-action': trackAction,
+ 'track-label': label,
+ 'track-property': property,
+ } = this.data.pipeline_minutes.tracking_attrs;
+ this.track(trackAction, { label, property });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-disclosure-dropdown
+ placement="right"
+ data-testid="user-dropdown"
+ data-qa-selector="user_menu"
+ @shown="onShow"
+ >
+ <template #toggle>
+ <button class="user-bar-item btn-with-notification">
+ <span class="gl-sr-only">{{ toggleText }}</span>
+ <gl-avatar
+ :size="24"
+ :entity-name="data.name"
+ :src="data.avatar_url"
+ aria-hidden="true"
+ data-qa-selector="user_avatar_content"
+ />
+ <span
+ v-if="showNotificationDot"
+ class="notification-dot-warning"
+ data-testid="buy-pipeline-minutes-notification-dot"
+ v-bind="data.pipeline_minutes.notification_dot_attrs"
+ >
+ </span>
+ </button>
+ </template>
+
+ <user-name-group :user="data" />
+ <gl-disclosure-dropdown-group bordered>
+ <gl-disclosure-dropdown-item
+ v-if="data.status.can_update"
+ :item="statusItem"
+ data-testid="status-item"
+ />
+
+ <gl-disclosure-dropdown-item
+ v-if="data.trial.has_start_trial"
+ :item="trialItem"
+ data-testid="start-trial-item"
+ >
+ <template #list-item>
+ {{ trialItem.text }}
+ <gl-emoji data-name="rocket" />
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item :item="editProfileItem" data-testid="edit-profile-item" />
+
+ <gl-disclosure-dropdown-item :item="preferencesItem" data-testid="preferences-item" />
+
+ <gl-disclosure-dropdown-item
+ v-if="addBuyPipelineMinutesMenuItem"
+ ref="buyPipelineMinutesNotificationCallout"
+ :item="buyPipelineMinutesItem"
+ v-bind="buyPipelineMinutesCalloutData"
+ data-testid="buy-pipeline-minutes-item"
+ >
+ <template #list-item>
+ <span class="gl-display-flex gl-flex-direction-column">
+ <span>{{ buyPipelineMinutesItem.text }} <gl-emoji data-name="clock9" /></span>
+ <span
+ v-if="data.pipeline_minutes.show_with_subtext"
+ class="gl-font-sm small gl-pt-2 gl-text-orange-800"
+ >{{ buyPipelineMinutesItem.warningText }}</span
+ >
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+
+ <gl-disclosure-dropdown-item
+ v-if="data.gitlab_com_but_not_canary"
+ :item="gitlabNextItem"
+ data-testid="gitlab-next-item"
+ />
+ </gl-disclosure-dropdown-group>
+
+ <gl-disclosure-dropdown-group bordered>
+ <template #group-label>
+ <span class="gl-font-sm">{{ $options.i18n.newNavigation.sectionTitle }}</span>
+ <gl-badge size="sm" variant="info"
+ >{{ $options.i18n.newNavigation.badgeLabel }}
+ </gl-badge>
+ </template>
+ <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled new-navigation />
+ <gl-disclosure-dropdown-item :item="feedbackItem" data-testid="feedback-item" />
+ </gl-disclosure-dropdown-group>
+
+ <gl-disclosure-dropdown-group
+ v-if="data.can_sign_out"
+ bordered
+ :group="signOutGroup"
+ data-testid="sign-out-group"
+ />
+ </gl-disclosure-dropdown>
+
+ <div
+ v-if="data.status.can_update"
+ class="js-set-status-modal-wrapper"
+ v-bind="statusModalData"
+ ></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
new file mode 100644
index 00000000000..2489f462122
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlTooltip } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ user: {
+ busy: s__('UserProfile|(Busy)'),
+ },
+ },
+ components: {
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlTooltip,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ user: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ menuItem() {
+ const item = {
+ text: this.user.name,
+ };
+ if (this.user.has_link_to_profile) {
+ item.href = this.user.link_to_profile;
+ }
+ return item;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown-group>
+ <gl-disclosure-dropdown-item :item="menuItem">
+ <template #list-item>
+ <span class="gl-display-flex gl-flex-direction-column">
+ <span>
+ <span class="gl-font-weight-bold">
+ {{ user.name }}
+ </span>
+ <span v-if="user.status.busy" class="gl-text-gray-500">{{
+ $options.i18n.user.busy
+ }}</span>
+ </span>
+
+ <span class="gl-text-gray-400">@{{ user.username }}</span>
+
+ <span
+ v-if="user.status.customized"
+ ref="statusTooltipTarget"
+ data-testid="user-menu-status"
+ class="gl-display-flex gl-align-items-center gl-mt-2 gl-font-sm"
+ >
+ <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" />
+ <span v-safe-html="user.status.message" class="gl-text-truncate"></span>
+ <gl-tooltip
+ :target="() => $refs.statusTooltipTarget"
+ boundary="viewport"
+ placement="bottom"
+ >
+ <span v-safe-html="user.status.message"></span>
+ </gl-tooltip>
+ </span>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js
new file mode 100644
index 00000000000..acc03bc48c7
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -0,0 +1,14 @@
+// Note: all constants defined here are considered internal implementation
+// details for the sidebar. They should not be imported by anything outside of
+// the super_sidebar directory.
+
+import Vue from 'vue';
+
+export const SIDEBAR_PORTAL_ID = 'sidebar-portal-mount';
+
+export const portalState = Vue.observable({
+ ready: false,
+});
+
+export const MAX_FREQUENT_PROJECTS_COUNT = 5;
+export const MAX_FREQUENT_GROUPS_COUNT = 3;
diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql
new file mode 100644
index 00000000000..4b1e65be3fa
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql
@@ -0,0 +1,24 @@
+query searchUserProjectsAndGroups($username: String!, $search: String) {
+ projects(search: $search, sort: "latest_activity_desc", membership: true, first: 20) {
+ nodes {
+ id
+ name
+ namespace: nameWithNamespace
+ webUrl
+ avatarUrl
+ }
+ }
+
+ user(username: $username) {
+ id
+ groups(search: $search, first: 20) {
+ nodes {
+ id
+ name
+ namespace: fullPath
+ webUrl
+ avatarUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js
index 0d1ac006df7..5e5ad97eb68 100644
--- a/app/assets/javascripts/super_sidebar/mock_data.js
+++ b/app/assets/javascripts/super_sidebar/mock_data.js
@@ -1,13 +1,8 @@
import { s__ } from '~/locale';
-export const context = {
- title: 'Typeahead.js',
- link: '/',
- avatar: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png?width=32',
-};
-
export const contextSwitcherItems = {
yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' },
+ explore: { title: s__('Navigation|Explore'), link: '/explore', icon: 'compass' },
recentProjects: [
{
// eslint-disable-next-line @gitlab/require-i18n-strings
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index b9c7073df8c..4395cc2f5f0 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -1,16 +1,33 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { initStatusTriggers } from '../header';
+import {
+ bindSuperSidebarCollapsedEvents,
+ initSuperSidebarCollapsedState,
+} from './super_sidebar_collapsed_state_manager';
import SuperSidebar from './components/super_sidebar.vue';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
export const initSuperSidebar = () => {
const el = document.querySelector('.js-super-sidebar');
if (!el) return false;
+ bindSuperSidebarCollapsedEvents();
+ initSuperSidebarCollapsedState();
+
const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset;
return new Vue({
el,
name: 'SuperSidebarRoot',
+ apolloProvider,
provide: {
rootPath,
toggleNewNavEndpoint,
@@ -24,3 +41,5 @@ export const initSuperSidebar = () => {
},
});
};
+
+requestIdleCallback(initStatusTriggers);
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
new file mode 100644
index 00000000000..549c6c17e44
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -0,0 +1,51 @@
+import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
+import { debounce } from 'lodash';
+import { setCookie, getCookie } from '~/lib/utils/common_utils';
+
+export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed';
+export const SIDEBAR_COLLAPSED_COOKIE = 'super_sidebar_collapsed';
+export const SIDEBAR_COLLAPSED_COOKIE_EXPIRATION = 365 * 10;
+
+export const findPage = () => document.querySelector('.page-with-super-sidebar');
+export const findSidebar = () => document.querySelector('.super-sidebar');
+export const findToggles = () => document.querySelectorAll('.js-super-sidebar-toggle');
+
+export const isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS);
+
+// See documentation: https://design.gitlab.com/patterns/navigation#left-sidebar
+// NOTE: at 1200px nav sidebar should not overlap the content
+// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110
+export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl;
+
+export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true';
+
+export const toggleSuperSidebarCollapsed = (collapsed, saveCookie, isUserAction) => {
+ const sidebar = findSidebar();
+ sidebar.ariaHidden = collapsed;
+ sidebar.inert = collapsed;
+
+ if (!collapsed && isUserAction) sidebar.focus();
+
+ findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
+
+ if (saveCookie && isDesktopBreakpoint()) {
+ setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
+ expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
+ });
+ }
+};
+
+export const initSuperSidebarCollapsedState = () => {
+ const collapsed = isDesktopBreakpoint() ? getCollapsedCookie() : true;
+ toggleSuperSidebarCollapsed(collapsed, false);
+};
+
+export const bindSuperSidebarCollapsedEvents = () => {
+ findToggles().forEach((elem) => {
+ elem.addEventListener('click', () => {
+ toggleSuperSidebarCollapsed(!isCollapsed(), true, true);
+ });
+ });
+
+ window.addEventListener('resize', debounce(initSuperSidebarCollapsedState, 100));
+};
diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js
new file mode 100644
index 00000000000..8e4250d0e39
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/utils.js
@@ -0,0 +1,83 @@
+import AccessorUtilities from '~/lib/utils/accessor';
+import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+
+/**
+ * This takes an array of project or groups that were stored in the local storage, to be shown in
+ * the context switcher, and sorts them by frequency and last access date.
+ * In the resulting array, the most popular item (highest frequency and most recent access date) is
+ * placed at the first index, while the least popular is at the last index.
+ *
+ * @param {Array} items The projects or groups stored in the local storage
+ * @returns The items, sorted by frequency and last access date
+ */
+const sortItemsByFrequencyAndLastAccess = (items) =>
+ items.sort((itemA, itemB) => {
+ // Sort all frequent items in decending order of frequency
+ // and then by lastAccessedOn with recent most first
+ if (itemA.frequency !== itemB.frequency) {
+ return itemB.frequency - itemA.frequency;
+ } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) {
+ return itemB.lastAccessedOn - itemA.lastAccessedOn;
+ }
+
+ return 0;
+ });
+
+// This imitates getTopFrequentItems from app/assets/javascripts/frequent_items/utils.js, but
+// adjusts the rules to accommodate for the context switcher's designs.
+export const getTopFrequentItems = (items = [], maxCount) => {
+ const frequentItems = items.filter((item) => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY);
+ sortItemsByFrequencyAndLastAccess(frequentItems);
+
+ return frequentItems.slice(0, maxCount);
+};
+
+const updateItemAccess = (item) => {
+ const now = Date.now();
+ const neverAccessed = !item.lastAccessedOn;
+ const shouldUpdate =
+ neverAccessed || Math.abs(now - item.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1;
+ const currentFrequency = item.frequency ?? 0;
+
+ return {
+ ...item,
+ frequency: shouldUpdate ? currentFrequency + 1 : currentFrequency,
+ lastAccessedOn: shouldUpdate ? now : item.lastAccessedOn,
+ };
+};
+
+export const trackContextAccess = (username, context) => {
+ if (!AccessorUtilities.canUseLocalStorage()) {
+ return false;
+ }
+
+ const storageKey = `${username}/frequent-${context.namespace}`;
+ const storedRawItems = localStorage.getItem(storageKey);
+ const storedItems = storedRawItems ? JSON.parse(storedRawItems) : [];
+ const existingItemIndex = storedItems.findIndex(
+ (cachedItem) => cachedItem.id === context.item.id,
+ );
+
+ if (existingItemIndex > -1) {
+ storedItems[existingItemIndex] = updateItemAccess(storedItems[existingItemIndex]);
+ } else {
+ const newItem = updateItemAccess(context.item);
+ if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) {
+ sortItemsByFrequencyAndLastAccess(storedItems);
+ storedItems.pop();
+ }
+ storedItems.push(newItem);
+ }
+
+ return localStorage.setItem(storageKey, JSON.stringify(storedItems));
+};
+
+export const formatContextSwitcherItems = (items) =>
+ items.map(({ id, name: title, namespace, avatarUrl: avatar, webUrl: link }) => ({
+ id,
+ title,
+ subtitle: truncateNamespace(namespace),
+ avatar,
+ link,
+ }));
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index cb2bf24abc7..065e1080897 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -15,6 +15,10 @@ export default function syntaxHighlight($els = null) {
const els = $els.get ? $els.get() : $els;
const handler = (el) => {
+ if (el.classList === undefined) {
+ return el;
+ }
+
if (el.classList.contains('js-syntax-highlight')) {
// Given the element itself, apply highlighting
return el.classList.add(gon.user_color_scheme);
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index a7760ad5d0b..bb344ade344 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import 'deckar01-task_list';
import { __ } from '~/locale';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
export default class TaskList {
diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue
index 0d8a883972f..ad7f4774bd2 100644
--- a/app/assets/javascripts/terraform/components/init_command_modal.vue
+++ b/app/assets/javascripts/terraform/components/init_command_modal.vue
@@ -33,7 +33,7 @@ export default {
closeModalProps() {
return {
text: this.$options.i18n.closeText,
- attributes: [],
+ attributes: {},
};
},
},
diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue
index 773ecf1d5d5..586b3e96e44 100644
--- a/app/assets/javascripts/terraform/components/states_table_actions.vue
+++ b/app/assets/javascripts/terraform/components/states_table_actions.vue
@@ -69,7 +69,7 @@ export default {
cancelModalProps() {
return {
text: this.$options.i18n.modalCancel,
- attributes: [],
+ attributes: {},
};
},
disableModalSubmit() {
@@ -81,7 +81,7 @@ export default {
primaryModalProps() {
return {
text: this.$options.i18n.modalRemove,
- attributes: [{ disabled: this.disableModalSubmit }, { variant: 'danger' }],
+ attributes: { disabled: this.disableModalSubmit, variant: 'danger' },
};
},
commandModalId() {
diff --git a/app/assets/javascripts/toggles/index.js b/app/assets/javascripts/toggles/index.js
index 5848b3a424c..500fe8c1150 100644
--- a/app/assets/javascripts/toggles/index.js
+++ b/app/assets/javascripts/toggles/index.js
@@ -17,20 +17,12 @@ export const initToggle = (el) => {
return new Vue({
el,
- props: {
- disabled: {
- type: Boolean,
- required: false,
- default: parseBoolean(disabled),
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: parseBoolean(isLoading),
- },
- },
+ name: 'ToggleFromHtml',
+
data() {
return {
+ disabled: parseBoolean(disabled),
+ isLoading: parseBoolean(isLoading),
value: parseBoolean(isChecked),
};
},
diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue
index feaf9072ee2..1904846fcbc 100644
--- a/app/assets/javascripts/token_access/components/inbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
GlToggle,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import inboundAddProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql';
diff --git a/app/assets/javascripts/token_access/components/opt_in_jwt.vue b/app/assets/javascripts/token_access/components/opt_in_jwt.vue
index c774f37b1e4..9485e0c3667 100644
--- a/app/assets/javascripts/token_access/components/opt_in_jwt.vue
+++ b/app/assets/javascripts/token_access/components/opt_in_jwt.vue
@@ -1,7 +1,7 @@
<script>
import { GlLink, GlLoadingIcon, GlSprintf, GlToggle } from '@gitlab/ui';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import updateOptInJwtMutation from '../graphql/mutations/update_opt_in_jwt.mutation.graphql';
import getOptInJwtSettingQuery from '../graphql/queries/get_opt_in_jwt_setting.query.graphql';
diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue
index 0deae1a1d82..d9c23c6c7f3 100644
--- a/app/assets/javascripts/token_access/components/outbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
GlToggle,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue
index 59d59757735..089159ac87b 100644
--- a/app/assets/javascripts/token_access/components/token_access_app.vue
+++ b/app/assets/javascripts/token_access/components/token_access_app.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import OutboundTokenAccess from './outbound_token_access.vue';
import InboundTokenAccess from './inbound_token_access.vue';
import OptInJwt from './opt_in_jwt.vue';
@@ -10,17 +9,11 @@ export default {
InboundTokenAccess,
OptInJwt,
},
- mixins: [glFeatureFlagMixin()],
- computed: {
- inboundTokenAccessEnabled() {
- return this.glFeatures.ciInboundJobTokenScope;
- },
- },
};
</script>
<template>
<div>
- <inbound-token-access v-if="inboundTokenAccessEnabled" class="gl-pb-5" />
+ <inbound-token-access class="gl-pb-5" />
<outbound-token-access class="gl-py-5" />
<opt-in-jwt />
</div>
diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue
index c00dd882895..ee88b4ec339 100644
--- a/app/assets/javascripts/token_access/components/token_projects_table.vue
+++ b/app/assets/javascripts/token_access/components/token_projects_table.vue
@@ -29,6 +29,9 @@ export default {
removeProject(project) {
this.$emit('removeProject', project);
},
+ namespaceFallback(namespace) {
+ return namespace?.fullPath || '';
+ },
},
};
</script>
@@ -51,7 +54,9 @@ export default {
</template>
<template #cell(namespace)="{ item }">
- <span data-testid="token-access-project-namespace">{{ item.namespace.fullPath }}</span>
+ <span data-testid="token-access-project-namespace">
+ {{ namespaceFallback(item.namespace) }}
+ </span>
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
index fab18cefc60..bd8cd372ecf 100644
--- a/app/assets/javascripts/usage_quotas/storage/constants.js
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -26,7 +26,6 @@ export const uploadsPopoverContent = s__(
'NamespaceStorage|Uploads are not counted in namespace storage quotas.',
);
-export const PROJECT_TABLE_LABEL_PROJECT = __('Project');
export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type');
export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue
index e982d10f63b..37c9548ad64 100644
--- a/app/assets/javascripts/user_lists/components/add_user_modal.vue
+++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue
@@ -19,11 +19,11 @@ export default {
modalOptions: {
actionPrimary: {
text: s__('UserLists|Add'),
- attributes: [{ 'data-testid': 'confirm-add-user-ids', variant: 'confirm' }],
+ attributes: { 'data-testid': 'confirm-add-user-ids', variant: 'confirm' },
},
actionCancel: {
text: s__('UserLists|Cancel'),
- attributes: [{ 'data-testid': 'cancel-add-user-ids' }],
+ attributes: { 'data-testid': 'cancel-add-user-ids' },
},
modalId: ADD_USER_MODAL_ID,
static: true,
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 1af47b020f7..a401a9bbf2f 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -6,6 +6,7 @@ import $ from 'jquery';
import { escape, template, uniqBy } from 'lodash';
import { AJAX_USERS_SELECT_PARAMS_MAP } from 'ee_else_ce/users_select/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { TYPE_MERGE_REQUEST } from '~/issues/constants';
import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
import axios from '~/lib/utils/axios_utils';
@@ -647,7 +648,7 @@ UsersSelect.prototype.users = function (query, options, callback) {
...getAjaxUsersSelectParams(options, AJAX_USERS_SELECT_PARAMS_MAP),
};
- const isMergeRequest = options.issuableType === 'merge_request';
+ const isMergeRequest = options.issuableType === TYPE_MERGE_REQUEST;
const isEditMergeRequest = !options.issuableType && options.iid && options.targetBranch;
const isNewMergeRequest = !options.issuableType && !options.iid && options.targetBranch;
@@ -684,7 +685,7 @@ UsersSelect.prototype.renderRow = function (
img,
elsClassName,
) {
- const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : '';
+ const tooltip = issuableType === TYPE_MERGE_REQUEST && !user.can_merge ? __('Cannot merge') : '';
const tooltipClass = tooltip ? `has-tooltip` : '';
const selectedClass = selected === true ? 'is-active' : '';
const linkClasses = `${selectedClass} ${tooltipClass}`;
@@ -725,7 +726,7 @@ UsersSelect.prototype.renderRowAvatar = function (issuableType, user, img) {
}
const mergeIcon =
- issuableType === 'merge_request' && !user.can_merge
+ issuableType === TYPE_MERGE_REQUEST && !user.can_merge
? spriteIcon('warning-solid', 's12 merge-icon')
: '';
diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/validators/length_validator.js
index b2074fb1e39..6ce453fe40b 100644
--- a/app/assets/javascripts/pages/sessions/new/length_validator.js
+++ b/app/assets/javascripts/validators/length_validator.js
@@ -2,6 +2,16 @@ import InputValidator from '~/validators/input_validator';
const errorMessageClass = 'gl-field-error';
+export const isAboveMaxLength = (str, maxLength) => {
+ return str.length > parseInt(maxLength, 10);
+};
+
+export const isBelowMinLength = (value, minLength, allowEmpty) => {
+ const isValueNotAllowedOrNotEmpty = allowEmpty !== 'true' || value.length !== 0;
+ const isValueBelowMinLength = value.length < parseInt(minLength, 10);
+ return isValueBelowMinLength && isValueNotAllowedOrNotEmpty;
+};
+
export default class LengthValidator extends InputValidator {
constructor(opts = {}) {
super();
@@ -26,16 +36,17 @@ export default class LengthValidator extends InputValidator {
minLengthMessage,
maxLengthMessage,
maxLength,
+ allowEmpty,
} = this.inputDomElement.dataset;
this.invalidInput = false;
- if (value.length > parseInt(maxLength, 10)) {
+ if (isAboveMaxLength(value, maxLength)) {
this.invalidInput = true;
this.errorMessage = maxLengthMessage;
}
- if (value.length < parseInt(minLength, 10)) {
+ if (isBelowMinLength(value, minLength, allowEmpty)) {
this.invalidInput = true;
this.errorMessage = minLengthMessage;
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
index 917ed259dd0..f2ec8f589ce 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
@@ -142,7 +142,7 @@ export default {
:title="setTooltip(btn)"
:href="btn.href"
:target="btn.target"
- :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
+ :class="[{ 'gl-mr-1': index !== tertiaryButtons.length - 1 }, btn.class]"
:data-clipboard-text="btn.dataClipboardText"
:data-qa-selector="actionButtonQaSelector(btn)"
:data-method="btn.dataMethod"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
index 4b65d6fd9ac..74922dd922c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
@@ -1,10 +1,11 @@
<script>
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/alert';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__, __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
import MrWidgetContainer from '../mr_widget_container.vue';
@@ -12,8 +13,7 @@ import MrWidgetIcon from '../mr_widget_icon.vue';
import { INVALID_RULES_DOCS_PATH } from '../../constants';
import ApprovalsSummary from './approvals_summary.vue';
import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
-import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
-import { humanizeInvalidApproversRules } from './humanized_text';
+import { FETCH_LOADING, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
export default {
name: 'MRWidgetApprovals',
@@ -24,7 +24,6 @@ export default {
ApprovalsSummaryOptional,
GlButton,
GlSprintf,
- GlLink,
},
mixins: [approvalsMixin, glFeatureFlagsMixin()],
props: {
@@ -59,10 +58,8 @@ export default {
},
data() {
return {
- fetchingApprovals: true,
hasApprovalAuthError: false,
isApproving: false,
- updatedCount: 0,
};
},
computed: {
@@ -70,7 +67,7 @@ export default {
return this.mr.approvalsWidgetType === 'base';
},
isApproved() {
- return Boolean(this.approvals.approved);
+ return Boolean(this.approvals.approved || this.approvedBy.length);
},
isOptional() {
return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length;
@@ -78,26 +75,25 @@ export default {
hasAction() {
return Boolean(this.action);
},
- approvals() {
- return this.mr.approvals || {};
- },
invalidRules() {
- return this.approvals.invalid_approvers_rules || [];
+ return this.approvals.approvalState?.invalidApproversRules || [];
},
hasInvalidRules() {
- return this.approvals.merge_request_approvers_available && this.invalidRules.length;
+ return this.mr.mergeRequestApproversAvailable && this.invalidRules.length;
},
invalidRulesText() {
- return humanizeInvalidApproversRules(this.invalidRules);
+ return this.invalidRules.length;
},
approvedBy() {
- return this.approvals.approved_by ? this.approvals.approved_by.map((x) => x.user) : [];
+ return this.approvals.approvedBy?.nodes || [];
},
userHasApproved() {
- return Boolean(this.approvals.user_has_approved);
+ return this.approvedBy.some(
+ (approver) => getIdFromGraphQLId(approver.id) === gon.current_user_id,
+ );
},
userCanApprove() {
- return Boolean(this.approvals.user_can_approve);
+ return Boolean(this.approvals.userPermissions.canApprove);
},
showApprove() {
return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
@@ -135,19 +131,6 @@ export default {
: this.$options.i18n.invalidRuleSingular;
},
},
- created() {
- this.refreshApprovals()
- .then(() => {
- this.fetchingApprovals = false;
- })
- .catch(() =>
- this.alerts.push(
- createAlert({
- message: FETCH_ERROR,
- }),
- ),
- );
- },
methods: {
approve() {
if (this.requirePasswordToApprove) {
@@ -196,16 +179,14 @@ export default {
this.isApproving = true;
this.clearError();
return serviceFn()
- .then((data) => {
- this.mr.setApprovals(data);
- this.updatedCount += 1;
-
+ .then(() => {
if (!window.gon?.features?.realtimeMrStatusChange) {
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('ApprovalUpdated');
}
- this.$emit('updated');
+ // TODO: Remove this line when we move to Apollo subscriptions
+ this.$apollo.queries.approvals.refetch();
})
.catch(errFn)
.then(() => {
@@ -217,10 +198,10 @@ export default {
linkToInvalidRules: INVALID_RULES_DOCS_PATH,
i18n: {
invalidRuleSingular: s__(
- 'mrWidget|Approval rule %{rules} is invalid. GitLab has approved this rule automatically to unblock the merge request. %{link}',
+ 'mrWidget|%{rules} invalid rule has been approved automatically, as no one can approve it.',
),
invalidRulesPlural: s__(
- 'mrWidget|Approval rules %{rules} are invalid. GitLab has approved these rules automatically to unblock the merge request. %{link}',
+ 'mrWidget|%{rules} invalid rules have been approved automatically, as no one can approve them.',
),
learnMore: __('Learn more.'),
},
@@ -230,7 +211,7 @@ export default {
<mr-widget-container>
<div class="js-mr-approvals d-flex align-items-start align-items-md-center">
<mr-widget-icon name="approval" />
- <div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div>
+ <div v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</div>
<template v-else>
<div class="gl-display-flex gl-flex-direction-column">
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
@@ -252,22 +233,13 @@ export default {
/>
<approvals-summary
v-else
- :project-path="mr.targetProjectFullPath"
- :iid="`${mr.iid}`"
- :updated-count="updatedCount"
+ :approval-state="approvals"
:multiple-approval-rules-available="mr.multipleApprovalRulesAvailable"
/>
</div>
<div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules">
<gl-sprintf :message="pluralizedRuleText">
- <template #rules>
- {{ invalidRulesText }}
- </template>
- <template #link>
- <gl-link :href="$options.linkToInvalidRules" target="_blank">
- {{ $options.i18n.learnMore }}
- </gl-link>
- </template>
+ <template #rules>{{ invalidRulesText }}</template>
</gl-sprintf>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index 697d953874c..2af033bb80f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -1,5 +1,4 @@
<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
import { toNounSeriesText } from '~/lib/utils/grammar';
import { n__, sprintf } from '~/locale';
import {
@@ -10,49 +9,21 @@ import {
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/mappers';
-import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql';
export default {
- apollo: {
- approvalState: {
- query: approvedByQuery,
- variables() {
- return {
- projectPath: this.projectPath,
- iid: this.iid,
- };
- },
- update: (data) => data.project.mergeRequest,
- },
- },
components: {
- GlSkeletonLoader,
UserAvatarList,
},
props: {
- projectPath: {
- type: String,
- required: true,
- },
- iid: {
- type: String,
- required: true,
- },
- updatedCount: {
- type: Number,
- required: false,
- default: 0,
- },
multipleApprovalRulesAvailable: {
type: Boolean,
required: false,
default: false,
},
- },
- data() {
- return {
- approvalState: {},
- };
+ approvalState: {
+ type: Object,
+ required: true,
+ },
},
computed: {
approvers() {
@@ -134,37 +105,20 @@ export default {
return gon.current_user_id;
},
},
- watch: {
- updatedCount() {
- this.$apollo.queries.approvalState.refetch();
- },
- },
};
</script>
<template>
<div data-qa-selector="approvals_summary_content">
- <div
- v-if="$apollo.queries.approvalState.loading"
- class="gl-display-inline-block gl-vertical-align-middle"
- style="width: 132px; height: 24px"
- >
- <gl-skeleton-loader :width="132" :height="24">
- <rect width="100" height="24" x="0" y="0" rx="4" />
- <circle cx="120" cy="12" r="12" />
- </gl-skeleton-loader>
- </div>
- <template v-else>
- <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span>
- <template v-if="hasApprovers">
- <span v-if="approvalLeftMessage">{{ message }}</span>
- <span v-else class="gl-font-weight-bold">{{ message }}</span>
- <user-avatar-list
- class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
- :img-size="24"
- :items="approvers"
- />
- </template>
+ <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span>
+ <template v-if="hasApprovers">
+ <span v-if="approvalLeftMessage">{{ message }}</span>
+ <span v-else class="gl-font-weight-bold">{{ message }}</span>
+ <user-avatar-list
+ class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
+ :img-size="24"
+ :items="approvers"
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql
index c8cae6a8885..437ae578cd0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
query approvedBy($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
id
@@ -5,12 +7,12 @@ query approvedBy($projectPath: ID!, $iid: String!) {
id
approvedBy {
nodes {
- id
- name
- avatarUrl
- webUrl
+ ...User
}
}
+ userPermissions {
+ canApprove
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
index d6d1cae4029..306ed664326 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index b78293a9815..028f5370028 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -313,7 +313,7 @@ export default {
:status="statusIconName"
:is-loading="isLoadingSummary"
:class="{ 'gl-cursor-pointer': isCollapsible }"
- class="gl-p-5"
+ class="gl-pl-5 gl-pr-4 gl-py-4"
@mousedown="onRowMouseDown"
@mouseup="onRowMouseUp"
>
@@ -381,7 +381,7 @@ export default {
v-else-if="hasFullData"
:items="fullData"
:min-item-size="32"
- class="report-block-container gl-px-5 gl-py-0"
+ class="report-block-container gl-p-0"
>
<template #default="{ item, index, active }">
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
@@ -389,7 +389,7 @@ export default {
:class="{
'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
}"
- class="gl-py-3 gl-pl-7"
+ class="gl-py-3 gl-pl-9"
data-testid="extension-list-item"
>
<gl-intersection-observer
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
index c762922d890..b192ccfa379 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue
@@ -31,7 +31,7 @@ export default {
};
</script>
<template>
- <h4 class="js-mr-widget-author">
+ <h4 class="js-mr-widget-author gl-flex-grow-1">
{{ actionText }}
<mr-widget-author :author="author" />
<span class="sr-only">{{ dateReadable }} ({{ dateTitle }})</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
index f7d6f7b4345..3e79c49994f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
@@ -62,7 +62,7 @@ export default {
<slot name="loading">
<div class="gl-display-flex">
<status-icon status="loading" />
- <div class="media-body">
+ <div class="media-body gl-display-flex gl-align-items-center">
<slot></slot>
</div>
</div>
@@ -78,7 +78,7 @@ export default {
'gl-display-flex gl-align-items-center': actions.length,
'gl-md-display-flex gl-align-items-center gl-flex-wrap gl-gap-3': !actions.length,
}"
- class="media-body gl-line-height-24"
+ class="media-body gl-line-height-normal"
>
<slot></slot>
<div
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 38f7d3d2c96..bcae1a12344 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -2,7 +2,7 @@
import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 46392565088..4e2b12799d0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__, __ } from '~/locale';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
import modalEventHub from '~/projects/commit/event_hub';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index ec6c2cf34c0..fac8d37712a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -1,6 +1,7 @@
<script>
-import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { GlButton, GlLink, GlModal, GlSkeletonLoader } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import simplePoll from '~/lib/utils/simple_poll';
@@ -19,6 +20,28 @@ const i18n = {
export default {
name: 'MRWidgetRebase',
i18n,
+ modal: {
+ id: 'rebase-security-risk-modal',
+ title: s__('mrWidget|Are you sure you want to rebase?'),
+ actionPrimary: {
+ text: s__('mrWidget|Rebase'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ runPipelinesInTheParentProjectHelpPath: helpPagePath(
+ '/ci/pipelines/merge_request_pipelines.html',
+ {
+ anchor: 'run-pipelines-in-the-parent-project',
+ },
+ ),
apollo: {
state: {
query: rebaseQuery,
@@ -30,11 +53,18 @@ export default {
},
components: {
BoldText,
- GlSkeletonLoader,
GlButton,
+ GlLink,
+ GlModal,
+ GlSkeletonLoader,
StateContainer,
},
mixins: [mergeRequestQueryVariablesMixin],
+ inject: {
+ canCreatePipelineInTargetProject: {
+ default: false,
+ },
+ },
props: {
mr: {
type: Object,
@@ -84,6 +114,21 @@ export default {
(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline)
);
},
+ isForkMergeRequest() {
+ return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath;
+ },
+ isLatestPipelineCreatedInTargetProject() {
+ const latestPipeline = this.state.pipelines.nodes[0];
+
+ return latestPipeline?.project?.fullPath === this.mr.targetProjectFullPath;
+ },
+ shouldShowSecurityWarning() {
+ return (
+ this.canCreatePipelineInTargetProject &&
+ this.isForkMergeRequest &&
+ !this.isLatestPipelineCreatedInTargetProject
+ );
+ },
},
methods: {
rebase({ skipCi = false } = {}) {
@@ -110,6 +155,13 @@ export default {
rebaseWithoutCi() {
return this.rebase({ skipCi: true });
},
+ tryRebase() {
+ if (this.shouldShowSecurityWarning) {
+ this.$refs.modal.show();
+ } else {
+ this.rebase();
+ }
+ },
checkRebaseStatus(continuePolling, stopPolling) {
this.service
.poll()
@@ -142,71 +194,103 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" :status="status" :is-loading="isLoading">
- <template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="5" width="302" height="20" rx="4" />
- </gl-skeleton-loader>
- </template>
- <template v-if="!isLoading">
- <span
- v-if="rebaseInProgress || isMakingRequest"
- class="gl-ml-0! gl-text-body!"
- data-testid="rebase-message"
- >{{ s__('mrWidget|Rebase in progress') }}</span
- >
- <span
- v-if="!rebaseInProgress && !canPushToSourceBranch"
- class="gl-text-body! gl-ml-0!"
- data-testid="rebase-message"
- >
- <bold-text :message="$options.i18n.rebaseError" />
- </span>
- <div
- v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
- class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1"
- >
+ <div>
+ <state-container :mr="mr" :status="status" :is-loading="isLoading">
+ <template #loading>
+ <gl-skeleton-loader :width="334" :height="30">
+ <rect x="0" y="3" width="24" height="24" rx="4" />
+ <rect x="32" y="5" width="302" height="20" rx="4" />
+ </gl-skeleton-loader>
+ </template>
+ <template v-if="!isLoading">
<span
- v-if="!rebasingError"
- class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
+ v-if="rebaseInProgress || isMakingRequest"
+ class="gl-ml-0! gl-text-body!"
data-testid="rebase-message"
- data-qa-selector="no_fast_forward_message_content"
+ >{{ s__('mrWidget|Rebase in progress') }}</span
>
- <bold-text :message="$options.i18n.rebaseError" />
- </span>
<span
- v-else
- class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3"
+ v-if="!rebaseInProgress && !canPushToSourceBranch"
+ class="gl-text-body! gl-ml-0!"
data-testid="rebase-message"
- >{{ rebasingError }}</span
>
- </div>
- </template>
- <template v-if="!isLoading" #actions>
- <gl-button
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- data-qa-selector="mr_rebase_button"
- data-testid="standard-rebase-button"
- class="gl-align-self-start"
- @click="rebase"
- >
- {{ s__('mrWidget|Rebase') }}
- </gl-button>
- <gl-button
- v-if="showRebaseWithoutPipeline"
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- category="secondary"
- data-testid="rebase-without-ci-button"
- class="gl-align-self-start gl-mr-2"
- @click="rebaseWithoutCi"
- >
- {{ s__('mrWidget|Rebase without pipeline') }}
- </gl-button>
- </template>
- </state-container>
+ <bold-text :message="$options.i18n.rebaseError" />
+ </span>
+ <div
+ v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
+ class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1"
+ >
+ <span
+ v-if="!rebasingError"
+ class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
+ data-testid="rebase-message"
+ data-qa-selector="no_fast_forward_message_content"
+ >
+ <bold-text :message="$options.i18n.rebaseError" />
+ </span>
+ <span
+ v-else
+ class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3"
+ data-testid="rebase-message"
+ >{{ rebasingError }}</span
+ >
+ </div>
+ </template>
+ <template v-if="!isLoading" #actions>
+ <gl-button
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ data-qa-selector="mr_rebase_button"
+ data-testid="standard-rebase-button"
+ class="gl-align-self-start"
+ @click="tryRebase"
+ >
+ {{ s__('mrWidget|Rebase') }}
+ </gl-button>
+ <gl-button
+ v-if="showRebaseWithoutPipeline"
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ class="gl-align-self-start gl-mr-2"
+ @click="rebaseWithoutCi"
+ >
+ {{ s__('mrWidget|Rebase without pipeline') }}
+ </gl-button>
+ </template>
+ </state-container>
+
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modal.id"
+ :title="$options.modal.title"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="rebase"
+ >
+ <p>
+ {{
+ s__(
+ 'Pipelines|Rebasing creates a pipeline that runs code originating from a forked project merge request. Consequently there are potential security implications, such as the exposure of CI variables.',
+ )
+ }}
+ </p>
+ <p>
+ {{
+ s__(
+ "Pipelines|You should review the code thoroughly before running this pipeline with the parent project's CI/CD resources.",
+ )
+ }}
+ </p>
+ <p>
+ {{ s__('Pipelines|If you are unsure, ask a project maintainer to review it for you.') }}
+ </p>
+ <gl-link :href="$options.runPipelinesInTheParentProjectHelpPath" target="_blank">
+ {{ s__('Pipelines|More Information') }}
+ </gl-link>
+ </gl-modal>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index 850a4e2fd56..e6a0b5fd8be 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/merge_requests.svg?url';
import api from '~/api';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -12,25 +11,19 @@ export default {
GlSprintf,
GlLink,
},
- directives: {
- SafeHtml,
- },
props: {
mr: {
type: Object,
required: true,
},
},
- data() {
- return { emptyStateSVG };
- },
methods: {
onClickNewFile() {
api.trackRedisHllUserEvent('i_code_review_widget_nothing_merge_click_new_file');
},
},
ciHelpPage: helpPagePath('ci/quick_start/index.html'),
- safeHtmlConfig: { ADD_TAGS: ['use'] },
+ EMPTY_STATE_SVG_URL,
};
</script>
@@ -38,15 +31,18 @@ export default {
<div class="mr-widget-body mr-widget-empty-state">
<div class="row">
<div
- class="artwork col-md-5 order-md-last col-12 text-center d-flex justify-content-center align-items-center"
+ class="col-md-5 order-md-last col-12 text-center d-flex justify-content-center align-items-center svg-content svg-250 pb-0"
>
- <span v-safe-html:[$options.safeHtmlConfig]="emptyStateSVG"></span>
+ <img
+ :alt="s__('mrWidgetNothingToMerge|This merge request contains no changes.')"
+ :src="$options.EMPTY_STATE_SVG_URL"
+ />
</div>
<div class="text col-md-7 order-md-first col-12">
<p class="highlight">
{{ s__('mrWidgetNothingToMerge|This merge request contains no changes.') }}
</p>
- <p>
+ <p data-testid="nothing-to-merge-body">
<gl-sprintf
:message="
s__(
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index bb8990a48b1..9e67791afc0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -11,10 +11,10 @@ import {
GlTooltipDirective,
GlSkeletonLoader,
} from '@gitlab/ui';
-import { isEmpty } from 'lodash';
+import { isEmpty, isNil } from 'lodash';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
@@ -86,7 +86,7 @@ export default {
this.squashCommitMessage = this.state.defaultSquashCommitMessage;
}
- if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) {
+ if (!isNil(this.state.mergeTrainsCount) && !this.pollingInterval) {
this.initPolling();
}
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 02d4f2499fe..7163e54985e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import { produce } from 'immer';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __, s__ } from '~/locale';
import MergeRequest from '~/merge_request';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
index 73129a86877..a754d4e80ea 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
@@ -287,7 +287,7 @@ export default {
<template>
<section class="media-section" data-testid="widget-extension">
- <div class="gl-p-5 gl-align-items-center gl-display-flex">
+ <div class="gl-px-5 gl-py-4 gl-align-items-center gl-display-flex">
<status-icon
:level="1"
:name="widgetName"
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 85ae298fcea..18503720814 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -2,6 +2,11 @@ import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps';
+export const FOUR_MINUTES_IN_MS = 1000 * 60 * 4;
+
+export const STATE_QUERY_POLLING_INTERVAL_DEFAULT = 5000;
+export const STATE_QUERY_POLLING_INTERVAL_BACKOFF = 2;
+
export const SUCCESS = 'success';
export const WARNING = 'warning';
export const INFO = 'info';
@@ -163,9 +168,6 @@ export const EXTENSION_ICON_CLASS = {
severityUnknown: 'gl-text-gray-400',
};
-export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
-export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
-
export const TELEMETRY_WIDGET_VIEWED = 'WIDGET_VIEWED';
export const TELEMETRY_WIDGET_EXPANDED = 'WIDGET_EXPANDED';
export const TELEMETRY_WIDGET_FULL_REPORT_CLICKED = 'WIDGET_FULL_REPORT_CLICKED';
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 8d596465970..183f450854a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -13,7 +13,18 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ typePolicies: {
+ MergeRequestApprovalState: {
+ merge: true,
+ },
+ },
+ },
+ },
+ ),
});
export default () => {
@@ -29,6 +40,9 @@ export default () => {
artifactsEndpointPlaceholder: gl.mrWidgetData.artifacts_endpoint_placeholder,
falsePositiveDocUrl: gl.mrWidgetData.false_positive_doc_url,
canViewFalsePositive: parseBoolean(gl.mrWidgetData.can_view_false_positive),
+ canCreatePipelineInTargetProject: parseBoolean(
+ gl.mrWidgetData.can_create_pipeline_in_target_project,
+ ),
},
...MrWidgetOptions,
apolloProvider,
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
index 7d0871f696b..ae9111b9504 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -1,7 +1,34 @@
+import { createAlert } from '~/alert';
+import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql';
+import { FETCH_ERROR } from '../components/approvals/messages';
+
export default {
+ apollo: {
+ approvals: {
+ query: approvedByQuery,
+ variables() {
+ return {
+ projectPath: this.mr.targetProjectFullPath,
+ iid: `${this.mr.iid}`,
+ };
+ },
+ update: (data) => data.project.mergeRequest,
+ result({ data }) {
+ const { mergeRequest } = data.project;
+
+ this.mr.setApprovals(mergeRequest);
+ },
+ error() {
+ createAlert({
+ message: FETCH_ERROR,
+ });
+ },
+ },
+ },
data() {
return {
alerts: [],
+ approvals: {},
};
},
methods: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index ecbee6544ab..bbad2c13220 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -1,5 +1,5 @@
<script>
-import { isEmpty } from 'lodash';
+import { isEmpty, clamp } from 'lodash';
import {
registerExtension,
registeredExtensions,
@@ -9,8 +9,7 @@ import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/ap
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
-import { createAlert } from '~/flash';
-import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
+import { createAlert } from '~/alert';
import notify from '~/lib/utils/notify';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
@@ -44,7 +43,13 @@ import UnresolvedDiscussionsState from './components/states/unresolved_discussio
import WorkInProgressState from './components/states/work_in_progress.vue';
import ExtensionsContainer from './components/extensions/container';
import WidgetContainer from './components/widget/app.vue';
-import { STATE_MACHINE, stateToComponentMap } from './constants';
+import {
+ STATE_MACHINE,
+ stateToComponentMap,
+ STATE_QUERY_POLLING_INTERVAL_DEFAULT,
+ STATE_QUERY_POLLING_INTERVAL_BACKOFF,
+ FOUR_MINUTES_IN_MS,
+} from './constants';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
@@ -99,6 +104,7 @@ export default {
apollo: {
state: {
query: getStateQuery,
+ notifyOnNetworkStatusChange: true,
manual: true,
skip() {
return !this.mr;
@@ -106,10 +112,19 @@ export default {
variables() {
return this.mergeRequestQueryVariables;
},
- result({ data: { project } }) {
- if (project) {
- this.mr.setGraphqlData(project);
- this.loading = false;
+ pollInterval() {
+ return this.pollInterval;
+ },
+ result(response) {
+ if (!response.loading) {
+ this.pollInterval = this.apolloStateQueryPollingInterval;
+
+ if (response.data?.project) {
+ this.mr.setGraphqlData(response.data.project);
+ this.loading = false;
+ }
+ } else {
+ this.checkStatus(undefined, undefined, false);
}
},
subscribeToMore: {
@@ -158,9 +173,27 @@ export default {
loading: true,
recomputeComponentName: 0,
issuableId: false,
+ startingPollInterval: STATE_QUERY_POLLING_INTERVAL_DEFAULT,
+ pollInterval: STATE_QUERY_POLLING_INTERVAL_DEFAULT,
};
},
computed: {
+ apolloStateQueryMaxPollingInterval() {
+ return this.startingPollInterval + FOUR_MINUTES_IN_MS;
+ },
+ apolloStateQueryPollingInterval() {
+ if (this.startingPollInterval < 0) {
+ return 0;
+ }
+
+ const unboundedInterval = STATE_QUERY_POLLING_INTERVAL_BACKOFF * this.pollInterval;
+
+ return clamp(
+ unboundedInterval,
+ this.startingPollInterval,
+ this.apolloStateQueryMaxPollingInterval,
+ );
+ },
shouldRenderApprovals() {
return this.mr.state !== 'nothingToMerge';
},
@@ -284,7 +317,8 @@ export default {
mounted() {
MRWidgetService.fetchInitialData()
.then(({ data, headers }) => {
- this.startingPollInterval = Number(headers['POLL-INTERVAL']);
+ this.startingPollInterval =
+ Number(headers['POLL-INTERVAL']) || STATE_QUERY_POLLING_INTERVAL_DEFAULT;
this.initWidget(data);
})
.catch(() =>
@@ -295,9 +329,6 @@ export default {
},
beforeDestroy() {
eventHub.$off('mr.discussion.updated', this.checkStatus);
- if (this.pollingInterval) {
- this.pollingInterval.destroy();
- }
if (this.deploymentsInterval) {
this.deploymentsInterval.destroy();
@@ -332,7 +363,6 @@ export default {
this.initPostMergeDeploymentsPolling();
}
- this.initPolling();
this.bindEventHubListeners();
eventHub.$on('mr.discussion.updated', this.checkStatus);
@@ -363,8 +393,10 @@ export default {
createService(store) {
return new MRWidgetService(this.getServiceEndpoints(store));
},
- checkStatus(cb, isRebased) {
- this.$apollo.queries.state.refetch();
+ checkStatus(cb, isRebased, refetch = true) {
+ if (refetch) {
+ this.$apollo.queries.state.refetch();
+ }
return this.service
.checkStatus()
@@ -389,17 +421,6 @@ export default {
}
return Promise.resolve();
},
- initPolling() {
- if (this.startingPollInterval <= 0) return;
-
- this.pollingInterval = new SmartInterval({
- callback: this.checkStatus,
- startingInterval: this.startingPollInterval,
- maxInterval: this.startingPollInterval + secondsToMilliseconds(4 * 60),
- hiddenInterval: secondsToMilliseconds(6 * 60),
- incrementByFactorOf: 2,
- });
- },
initDeploymentsPolling() {
this.deploymentsInterval = this.deploymentsPoll(this.fetchPreMergeDeployments);
},
@@ -476,10 +497,10 @@ export default {
notify.notifyMe(title, message, this.mr.gitlabLogo);
},
resumePolling() {
- this.pollingInterval?.resume();
+ this.$apollo.queries.state.startPolling(this.pollInterval);
},
stopPolling() {
- this.pollingInterval?.stopTimer();
+ this.$apollo.queries.state.stopPolling();
},
bindEventHubListeners() {
eventHub.$on('MRWidgetUpdateRequested', (cb) => {
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
index c7b53db1221..a6b35f20776 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql
@@ -1,6 +1,7 @@
subscription getStateSubscription($issuableId: IssuableID!) {
mergeRequestMergeStatusUpdated(issuableId: $issuableId) {
... on MergeRequest {
+ id
detailedMergeStatus
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
index 283177267d4..79ac87b7c37 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql
@@ -8,6 +8,15 @@ query rebaseQuery($projectPath: ID!, $iid: String!) {
userPermissions {
pushToSourceBranch
}
+ pipelines {
+ nodes {
+ id
+ project {
+ id
+ fullPath
+ }
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 81cb20475cc..cead42b12ae 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -32,5 +32,5 @@ export default function deviseState() {
) {
return stateKey.readyToMerge;
}
- return null;
+ return stateKey.checking;
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index f6a7ef58c10..13009651550 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -294,6 +294,9 @@ export default class MergeRequestStore {
// Security reports
this.sastComparisonPath = data.sast_comparison_path;
this.secretDetectionComparisonPath = data.secret_detection_comparison_path;
+
+ this.sastComparisonPathV2 = data.new_sast_comparison_path;
+ this.secretDetectionComparisonPathV2 = data.new_secret_detection_comparison_path;
}
get isNothingToMergeState() {
@@ -356,12 +359,11 @@ export default class MergeRequestStore {
initApprovals() {
this.isApproved = this.isApproved || false;
- this.approvals = this.approvals || null;
}
setApprovals(data) {
- this.approvals = data;
this.isApproved = data.approved || false;
+ this.approvals = true;
this.setState();
}
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
index 634b7da3def..93581dbbd40 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
@@ -33,7 +33,11 @@ export default {
</script>
<template>
- <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!">
+ <li
+ :id="noteAnchorId"
+ class="timeline-entry note system-note note-wrapper gl-p-0!"
+ data-qa-selector="alert_system_note_container"
+ >
<div class="gl-display-inline-flex gl-align-items-center gl-relative">
<div
class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6"
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index 52a5d6e1b86..7b5ded9348f 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -71,7 +71,7 @@ export default {
<ci-icon :status="status" />
<template v-if="showText">
- <span class="gl-ml-2">{{ status.text }}</span>
+ <span class="gl-ml-2 gl-white-space-nowrap">{{ status.text }}</span>
</template>
</gl-link>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
index fb7105bd416..c89e843b660 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
@@ -1,13 +1,11 @@
<script>
import { GlAreaChart } from '@gitlab/ui/dist/charts';
-import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { CHART_CONTAINER_HEIGHT } from './constants';
export default {
name: 'CiCdAnalyticsAreaChart',
components: {
GlAreaChart,
- ResizableChartContainer,
},
props: {
chartData: {
@@ -27,24 +25,21 @@ export default {
<p>
<slot></slot>
</p>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- v-bind="$attrs"
- :width="width"
- :height="$options.chartContainerHeight"
- :data="chartData"
- :include-legend-avg-max="false"
- :option="areaChartOptions"
- >
- <template #tooltip-title>
- <slot name="tooltip-title"></slot>
- </template>
- <template #tooltip-content>
- <slot name="tooltip-content"></slot>
- </template>
- </gl-area-chart>
+ <gl-area-chart
+ v-bind="$attrs"
+ responsive
+ width="auto"
+ :height="$options.chartContainerHeight"
+ :data="chartData"
+ :include-legend-avg-max="false"
+ :option="areaChartOptions"
+ >
+ <template #tooltip-title>
+ <slot name="tooltip-title"></slot>
</template>
- </resizable-chart-container>
+ <template #tooltip-content>
+ <slot name="tooltip-content"></slot>
+ </template>
+ </gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
index 75386a3cd01..2f28ae5e0e2 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
@@ -1,6 +1,6 @@
<script>
import { isString } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { DEFAULT_COLOR, COLOR_WIDGET_COLOR, DROPDOWN_VARIANT, ISSUABLE_COLORS } from './constants';
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
index d0a634d8e54..65a601ed927 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
@@ -60,13 +60,11 @@ export default {
actionPrimary() {
return {
text: this.confirmButtonText,
- attributes: [
- {
- variant: 'danger',
- disabled: !this.isValid,
- 'data-qa-selector': 'confirm_danger_modal_button',
- },
- ],
+ attributes: {
+ variant: 'danger',
+ disabled: !this.isValid,
+ 'data-qa-selector': 'confirm_danger_modal_button',
+ },
};
},
actionCancel() {
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index dfeb12d5cf5..721f87ff4d6 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -168,7 +168,7 @@ export default {
.file-row {
display: flex;
align-items: center;
- height: 32px;
+ height: var(--file-row-height, 32px);
padding: 4px 8px;
margin-left: -8px;
margin-right: -8px;
diff --git a/app/assets/javascripts/vue_shared/components/file_row_header.vue b/app/assets/javascripts/vue_shared/components/file_row_header.vue
index 5afb2408c7e..b436872e463 100644
--- a/app/assets/javascripts/vue_shared/components/file_row_header.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row_header.vue
@@ -15,7 +15,7 @@ export default {
</script>
<template>
- <div class="file-row-header bg-white sticky-top p-2 js-file-row-header" :title="path">
+ <div class="file-row-header bg-white sticky-top gl-px-2 js-file-row-header" :title="path">
<gl-truncate :text="path" position="middle" class="bold" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 34f64dddc41..fe4f2d407f7 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -12,7 +12,7 @@ import {
import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { SORT_DIRECTION } from './constants';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
index 8a6053b7001..f3d46de3437 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
index 741395b3193..fff8a95c193 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
index c8aeac75645..63ffded9e8e 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
@@ -1,10 +1,10 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { ITEM_TYPE } from '~/groups/constants';
import { TYPENAME_CRM_CONTACT } from '~/graphql_shared/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql';
@@ -43,7 +43,7 @@ export default {
return this.config.defaultContacts || OPTIONS_NONE_ANY;
},
namespace() {
- return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ return this.config.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP;
},
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
index ff0571031b5..126066fbbbe 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
@@ -1,10 +1,10 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { ITEM_TYPE } from '~/groups/constants';
import { TYPENAME_CRM_ORGANIZATION } from '~/graphql_shared/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql';
@@ -43,7 +43,7 @@ export default {
return this.config.defaultOrganizations || OPTIONS_NONE_ANY;
},
namespace() {
- return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ return this.config.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP;
},
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
index 9c30ec67d5a..c69a2927ec9 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { OPTIONS_NONE_ANY } from '../constants';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 9449e071a0d..6a7dd6131e2 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -1,7 +1,7 @@
<script>
import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index b9ee4d51db1..81b8a6c78fc 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
index 6d681aab3ca..a251035b683 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { OPTIONS_NONE_ANY } from '../constants';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue
index 28e65c1185f..c294c23abfc 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue
@@ -1,7 +1,7 @@
<script>
import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { compact } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import { OPTIONS_NONE_ANY } from '../constants';
diff --git a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
index 26c50345c19..fe221d2fefa 100644
--- a/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
+++ b/app/assets/javascripts/vue_shared/components/form/form_footer_actions.vue
@@ -1,5 +1,4 @@
-<!-- eslint-disable-next-line vue/no-deprecated-functional-template -->
-<template functional>
+<template>
<footer class="form-actions d-flex justify-content-between">
<div><slot name="prepend"></slot></div>
<div><slot></slot></div>
diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
index 9a88ab44f3d..e3eacf4495d 100644
--- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
+++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
@@ -1,5 +1,4 @@
-import { issuableTypes } from '~/boards/constants';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import blockingIssuesQuery from './graphql/blocking_issues.query.graphql';
import blockingEpicsQuery from './graphql/blocking_epics.query.graphql';
@@ -7,7 +6,7 @@ export const blockingIssuablesQueries = {
[TYPE_ISSUE]: {
query: blockingIssuesQuery,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
query: blockingEpicsQuery,
},
};
diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
index f5b4870d59f..7bea4409c03 100644
--- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
@@ -1,9 +1,8 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
-import { issuableTypes } from '~/boards/constants';
import { TYPENAME_ISSUE, TYPENAME_EPIC } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
import { blockingIssuablesQueries } from './constants';
@@ -12,12 +11,12 @@ export default {
i18n: {
issuableType: {
[TYPE_ISSUE]: __('issue'),
- [issuableTypes.epic]: __('epic'),
+ [TYPE_EPIC]: __('epic'),
},
},
graphQLIdType: {
[TYPE_ISSUE]: TYPENAME_ISSUE,
- [issuableTypes.epic]: TYPENAME_EPIC,
+ [TYPE_EPIC]: TYPENAME_EPIC,
},
referenceFormatter: {
[TYPE_ISSUE]: (r) => r.split('/')[1],
@@ -43,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [TYPE_ISSUE, issuableTypes.epic].includes(value);
+ return [TYPE_ISSUE, TYPE_EPIC].includes(value);
},
},
},
@@ -88,7 +87,7 @@ export default {
},
computed: {
isEpic() {
- return this.issuableType === issuableTypes.epic;
+ return this.issuableType === TYPE_EPIC;
},
displayedIssuables() {
const { defaultDisplayLimit, referenceFormatter } = this.$options;
diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
index bc6b5d3176f..0f8ff5291a4 100644
--- a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
+++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlListbox } from '@gitlab/ui';
+import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
const MIN_ITEMS_COUNT_FOR_SEARCHING = 10;
@@ -10,9 +10,9 @@ export default {
},
components: {
GlFormGroup,
- GlListbox,
+ GlCollapsibleListbox,
},
- model: GlListbox.model,
+ model: GlCollapsibleListbox.model,
props: {
label: {
type: String,
@@ -39,7 +39,7 @@ export default {
default: null,
},
items: {
- type: GlListbox.props.items.type,
+ type: GlCollapsibleListbox.props.items.type,
required: true,
},
disabled: {
@@ -116,7 +116,7 @@ export default {
<template>
<component :is="wrapperComponent" :label="label" :description="description" v-bind="$attrs">
- <gl-listbox
+ <gl-collapsible-listbox
:selected="selected"
:toggle-text="toggleText"
:items="filteredItems"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue
new file mode 100644
index 00000000000..a66becb5c92
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue
@@ -0,0 +1,48 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import { create } from '~/drawio/markdown_field_editor_facade';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ uploadsPath: {
+ type: String,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ getTextArea() {
+ return document.querySelector('.js-gfm-input');
+ },
+ launchDrawioEditor() {
+ launchDrawioEditor({
+ editorFacade: create({
+ uploadsPath: this.uploadsPath,
+ textArea: this.getTextArea(),
+ markdownPreviewPath: this.markdownPreviewPath,
+ }),
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ v-gl-tooltip
+ :title="__('Insert or edit diagram')"
+ :aria-label="__('Insert or edit diagram')"
+ category="tertiary"
+ icon="diagram"
+ @click="launchDrawioEditor"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
index 6702a81e747..9ebf782a1d9 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
@@ -23,7 +23,7 @@ export default {
return this.value === 'markdown';
},
text() {
- return this.markdownEditorSelected ? __('View rich text') : __('View markdown');
+ return this.markdownEditorSelected ? __('Viewing markdown') : __('Viewing rich text');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 6f4cddbdfa2..9623c51d51c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -2,7 +2,7 @@
import { GlIcon } from '@gitlab/ui';
import $ from 'jquery';
import { debounce, unescape } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import GLForm from '~/gl_form';
import SafeHtml from '~/vue_shared/directives/safe_html';
import axios from '~/lib/utils/axios_utils';
@@ -132,6 +132,11 @@ export default {
required: false,
default: false,
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -355,6 +360,10 @@ export default {
:enable-preview="enablePreview"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
+ :uploads-path="uploadsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ :drawio-enabled="drawioEnabled"
+ data-testid="markdownHeader"
:restricted-tool-bar-items="restrictedToolBarItems"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index e83441e59a2..eeeb0fce55d 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -10,11 +10,14 @@ import {
INDENT_LINE,
OUTDENT_LINE,
} from '~/behaviors/shortcuts/keybindings';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getModifierKey } from '~/constants';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
+import DrawioToolbarButton from './drawio_toolbar_button.vue';
+import SavedRepliesDropdown from './saved_replies_dropdown.vue';
export default {
components: {
@@ -23,10 +26,18 @@ export default {
GlButton,
GlTabs,
GlTab,
+ DrawioToolbarButton,
+ SavedRepliesDropdown,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ newSavedRepliesPath: {
+ default: null,
+ },
+ },
props: {
previewMarkdown: {
type: Boolean,
@@ -62,6 +73,21 @@ export default {
required: false,
default: () => [],
},
+ uploadsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -369,6 +395,15 @@ export default {
icon="paperclip"
@click="handleAttachFile"
/>
+ <drawio-toolbar-button
+ v-if="drawioEnabled"
+ :uploads-path="uploadsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ />
+ <saved-replies-dropdown
+ v-if="newSavedRepliesPath && glFeatures.savedReplies"
+ :new-saved-replies-path="newSavedRepliesPath"
+ />
<toolbar-button
v-if="!restrictedToolBarItems.includes('full-screen')"
class="js-zen-enter"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index 7e6b0e4a63b..93583907a11 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -1,6 +1,8 @@
<script>
+import Autosize from 'autosize';
import axios from '~/lib/utils/axios_utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { updateDraft, clearDraft, getDraft } from '~/lib/utils/autosave';
import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants';
import MarkdownField from './field.vue';
@@ -22,15 +24,6 @@ export default {
type: String,
required: true,
},
- markdownDocsPath: {
- type: String,
- required: true,
- },
- quickActionsDocsPath: {
- type: String,
- required: false,
- default: '',
- },
uploadsPath: {
type: String,
required: false,
@@ -41,21 +34,6 @@ export default {
required: false,
default: true,
},
- enablePreview: {
- type: Boolean,
- required: false,
- default: true,
- },
- autocompleteDataSources: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- enableAutocomplete: {
- type: Boolean,
- required: false,
- default: true,
- },
formFieldProps: {
type: Object,
required: true,
@@ -71,7 +49,22 @@ export default {
required: false,
default: false,
},
- useBottomToolbar: {
+ autosaveKey: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ disabled: {
type: Boolean,
required: false,
default: false,
@@ -79,6 +72,7 @@ export default {
},
data() {
return {
+ markdown: this.value || (this.autosaveKey ? getDraft(this.autosaveKey) : '') || '',
editingMode: EDITING_MODE_MARKDOWN_FIELD,
autofocused: false,
};
@@ -92,15 +86,32 @@ export default {
return this.autofocus && !this.autofocused ? 'end' : false;
},
},
+ watch: {
+ value(val) {
+ this.markdown = val;
+
+ this.saveDraft();
+ this.autosizeTextarea();
+ },
+ },
mounted() {
this.autofocusTextarea();
+
+ this.saveDraft();
},
methods: {
updateMarkdownFromContentEditor({ markdown }) {
+ this.markdown = markdown;
this.$emit('input', markdown);
+
+ this.saveDraft();
},
updateMarkdownFromMarkdownField({ target }) {
+ this.markdown = target.value;
this.$emit('input', target.value);
+
+ this.saveDraft();
+ this.autosizeTextarea();
},
renderMarkdown(markdown) {
return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body);
@@ -126,6 +137,23 @@ export default {
setEditorAsAutofocused() {
this.autofocused = true;
},
+ saveDraft() {
+ if (!this.autosaveKey) return;
+ if (this.markdown) updateDraft(this.autosaveKey, this.markdown);
+ else clearDraft(this.autosaveKey);
+ },
+ togglePreview(value) {
+ if (this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
+ this.$refs.markdownField.previewMarkdown = value;
+ }
+ },
+ autosizeTextarea() {
+ if (this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
+ this.$nextTick(() => {
+ Autosize.update(this.$refs.textarea);
+ });
+ }
+ },
},
};
</script>
@@ -138,16 +166,16 @@ export default {
/>
<markdown-field
v-if="!isContentEditorActive"
+ ref="markdownField"
+ v-bind="$attrs"
+ data-testid="markdown-field"
:markdown-preview-path="renderMarkdownPath"
can-attach-file
- :enable-autocomplete="enableAutocomplete"
- :textarea-value="value"
- :markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
- :autocomplete-data-sources="autocompleteDataSources"
+ :textarea-value="markdown"
:uploads-path="uploadsPath"
- :enable-preview="enablePreview"
- show-content-editor-switcher
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :show-content-editor-switcher="enableContentEditor"
+ :drawio-enabled="drawioEnabled"
class="bordered-box"
@enableContentEditor="onEditingModeChange('contentEditor')"
>
@@ -155,11 +183,12 @@ export default {
<textarea
v-bind="formFieldProps"
ref="textarea"
- :value="value"
- class="note-textarea js-gfm-input js-autosize markdown-area"
+ :value="markdown"
+ class="note-textarea js-gfm-input markdown-area"
dir="auto"
:data-supports-quick-actions="supportsQuickActions"
- data-qa-selector="markdown_editor_form_field"
+ :data-qa-selector="formFieldProps['data-qa-selector'] || 'markdown_editor_form_field'"
+ :disabled="disabled"
@input="updateMarkdownFromMarkdownField"
@keydown="$emit('keydown', $event)"
>
@@ -168,11 +197,15 @@ export default {
</markdown-field>
<div v-else>
<content-editor
+ ref="contentEditor"
:render-markdown="renderMarkdown"
:uploads-path="uploadsPath"
- :markdown="value"
+ :markdown="markdown"
+ :quick-actions-docs-path="quickActionsDocsPath"
:autofocus="contentEditorAutofocused"
- :use-bottom-toolbar="useBottomToolbar"
+ :placeholder="formFieldProps.placeholder"
+ :drawio-enabled="drawioEnabled"
+ :editable="!disabled"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@keydown="$emit('keydown', $event)"
@@ -180,7 +213,7 @@ export default {
/>
<input
v-bind="formFieldProps"
- :value="value"
+ :value="markdown"
data-qa-selector="markdown_editor_form_field"
type="hidden"
/>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql b/app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql
new file mode 100644
index 00000000000..9b9d4c89254
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql
@@ -0,0 +1,12 @@
+query getSavedReplies {
+ currentUser {
+ id
+ savedReplies {
+ nodes {
+ id
+ name
+ content
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue
new file mode 100644
index 00000000000..989b14f8711
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/saved_replies_dropdown.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlCollapsibleListbox, GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import savedRepliesQuery from './saved_replies.query.graphql';
+
+export default {
+ apollo: {
+ savedReplies: {
+ query: savedRepliesQuery,
+ update: (r) => r.currentUser?.savedReplies?.nodes,
+ skip() {
+ return !this.shouldFetchSavedReplies;
+ },
+ },
+ },
+ components: {
+ GlCollapsibleListbox,
+ GlIcon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ newSavedRepliesPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ shouldFetchSavedReplies: false,
+ savedReplies: [],
+ savedRepliesSearch: '',
+ loadingSavedReplies: false,
+ };
+ },
+ computed: {
+ filteredSavedReplies() {
+ const savedReplies = this.savedRepliesSearch
+ ? fuzzaldrinPlus.filter(this.savedReplies, this.savedRepliesSearch, { key: ['name'] })
+ : this.savedReplies;
+
+ return savedReplies.map((r) => ({ value: r.id, text: r.name, content: r.content }));
+ },
+ },
+ methods: {
+ fetchSavedReplies() {
+ this.shouldFetchSavedReplies = true;
+ },
+ setSavedRepliesSearch(search) {
+ this.savedRepliesSearch = search;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-collapsible-listbox
+ :header-text="__('Insert saved reply')"
+ :items="filteredSavedReplies"
+ placement="right"
+ searchable
+ class="saved-replies-dropdown"
+ :searching="$apollo.queries.savedReplies.loading"
+ @shown="fetchSavedReplies"
+ @search="setSavedRepliesSearch"
+ >
+ <template #toggle>
+ <gl-button
+ v-gl-tooltip
+ :title="__('Insert saved reply')"
+ :aria-label="__('Insert saved reply')"
+ category="tertiary"
+ class="gl-px-3!"
+ data-testid="saved-replies-dropdown-toggle"
+ >
+ <gl-icon name="symlink" class="gl-mr-0!" />
+ <gl-icon name="chevron-down" />
+ </gl-button>
+ </template>
+ <template #list-item="{ item }">
+ <div
+ class="gl-display-flex js-saved-reply-content"
+ :data-md-tag="item.content"
+ data-md-cursor-offset="0"
+ data-md-prepend="true"
+ data-testid="saved-reply-dropdown-item"
+ >
+ <div class="gl-text-truncate">
+ <strong>{{ item.text }}</strong
+ ><span class="gl-ml-2">{{ item.content }}</span>
+ </div>
+ </div>
+ </template>
+ <template #footer>
+ <div
+ class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-display-flex gl-justify-content-center gl-p-3"
+ >
+ <gl-button
+ :href="newSavedRepliesPath"
+ category="tertiary"
+ block
+ class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!"
+ >{{ __('Add a new saved reply') }}</gl-button
+ >
+ </div>
+ </template>
+ </gl-collapsible-listbox>
+</template>
+
+<style>
+.saved-replies-dropdown .gl-new-dropdown-panel {
+ width: 350px;
+}
+
+.saved-replies-dropdown .gl-new-dropdown-item-check-icon {
+ display: none;
+}
+</style>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index c307601e670..49eb11f8081 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -1,7 +1,7 @@
<script>
import Vue from 'vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { __ } from '~/locale';
import SuggestionDiff from './suggestion_diff.vue';
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
index 1c4e8d332a9..6f91463365b 100644
--- a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import * as types from './mutation_types';
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
index b079181bd10..e09a6e2e811 100644
--- a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
@@ -6,7 +6,7 @@ import {
GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
diff --git a/app/assets/javascripts/vue_shared/components/pagination/constants.js b/app/assets/javascripts/vue_shared/components/pagination/constants.js
index 748ad178c70..f8a6d37dea1 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/constants.js
+++ b/app/assets/javascripts/vue_shared/components/pagination/constants.js
@@ -1,8 +1,5 @@
import { s__ } from '~/locale';
-export const PAGINATION_UI_BUTTON_LIMIT = 4;
-export const UI_LIMIT = 6;
-export const SPREAD = '...';
export const PREV = s__('Pagination|Prev');
export const NEXT = s__('Pagination|Next');
export const FIRST = s__('Pagination|« First');
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 16bc8070dc1..bdc8ffee90a 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -52,7 +52,7 @@ export default {
<div
class="gl-display-flex gl-align-items-center gl-flex-wrap project-namespace-name-container"
>
- <gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" />
+ <gl-icon v-if="selected" data-testid="selected-icon" name="mobile-issue-close" />
<project-avatar
:project-id="project.id"
:project-avatar-url="projectAvatarUrl"
@@ -61,16 +61,18 @@ export default {
/>
<div
v-if="truncatedNamespace"
+ data-testid="project-namespace"
:title="projectNameWithNamespace"
- class="text-secondary text-truncate js-project-namespace"
+ class="text-secondary text-truncate"
>
{{ truncatedNamespace }}
<span v-if="truncatedNamespace" class="text-secondary">/&nbsp;</span>
</div>
<div
v-safe-html="highlightedProjectName"
+ data-testid="project-name"
:title="project.name"
- class="js-project-name text-truncate"
+ class="text-truncate"
></div>
</div>
</gl-button>
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
deleted file mode 100644
index 02cb7785ef4..00000000000
--- a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-<script>
-import $ from 'jquery';
-import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
-
-export default {
- data() {
- return {
- width: 0,
- height: 0,
- };
- },
- beforeDestroy() {
- this.contentResizeHandler.off('content.resize', this.debouncedResize);
- window.removeEventListener('resize', this.debouncedResize);
- },
- created() {
- this.debouncedResize = debounceByAnimationFrame(this.onResize);
-
- // Handle when we explicictly trigger a custom resize event
- this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize);
-
- // Handle window resize
- window.addEventListener('resize', this.debouncedResize);
- },
- methods: {
- onResize() {
- // Slot dimensions
- const { clientWidth, clientHeight } = this.$refs.chartWrapper;
- this.width = clientWidth;
- this.height = clientHeight;
- },
- },
-};
-</script>
-
-<template>
- <div ref="chartWrapper">
- <slot :width="width" :height="height"> </slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index 1925c5d4064..7b7d3d48d9e 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -2,6 +2,7 @@
import { debounce, isEmpty } from 'lodash';
import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants';
import Editor from '~/editor/source_editor';
+import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
function initSourceEditor({ el, ...args }) {
const editor = new Editor({
@@ -10,10 +11,12 @@ function initSourceEditor({ el, ...args }) {
},
});
- return editor.createInstance({
- el,
- ...args,
- });
+ return markRaw(
+ editor.createInstance({
+ el,
+ ...args,
+ }),
+ );
}
export default {
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 15335ea6edc..514b626ed95 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -141,8 +141,6 @@ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip';
export const BIDI_CHAR_TOOLTIP = 'Potentially unwanted character detected: Unicode BiDi Control';
-export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
-
// We fallback to highlighting these languages with Rouge, see the following issue for more detail:
// https://gitlab.com/gitlab-org/gitlab/-/issues/384375#note_1212752013
export const LEGACY_FALLBACKS = ['python'];
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
index 09414e679bb..bda88a48e48 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue
@@ -21,6 +21,11 @@ export default {
required: false,
default: 'top',
},
+ boundary: {
+ type: String,
+ required: false,
+ default: '',
+ },
truncateTarget: {
type: [String, Function],
required: false,
@@ -44,6 +49,8 @@ export default {
title: this.title,
placement: this.placement,
disabled: this.tooltipDisabled,
+ // Only set the tooltip boundary if it's truthy
+ ...(this.boundary && { boundary: this.boundary }),
};
},
},
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index a001b6bdf24..23fbf211d54 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -149,7 +149,7 @@ export default {
>
<slot>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-px-5 gl-py-4 gl-mb-0"
type="button"
@click="openFileUpload"
>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 1a81da3eb0d..ab308d11a79 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
@@ -105,7 +105,7 @@ export default {
v-gl-tooltip
:title="tooltipText"
:tooltip-placement="tooltipPlacement"
- class="gl-ml-3"
+ class="gl-ml-1"
data-testid="user-avatar-link-username"
>
{{ username }}
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index d06bc7b8f98..dd9d2ce66cd 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
@@ -10,7 +10,7 @@ import {
} from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { glEmojiTag } from '~/emoji';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { followUser, unfollowUser } from '~/rest_api';
import { isUserBusy } from '~/set_status_modal/utils';
import Tracking from '~/tracking';
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index edcfabe7da3..abd3575d020 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -11,7 +11,7 @@ import {
} from '@gitlab/ui';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { participantsQueries, userSearchQueries } from '~/sidebar/constants';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
@@ -149,7 +149,7 @@ export default {
},
computed: {
isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest;
+ return this.issuableType === TYPE_MERGE_REQUEST;
},
searchUsersVariables() {
const variables = {
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index fd151751372..29a31503840 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -1,5 +1,5 @@
import { __, n__, sprintf } from '~/locale';
-import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
+import { TYPE_ISSUE, WORKSPACE_PROJECT } from '~/issues/constants';
const INTERVALS = {
minute: 'minute',
@@ -75,8 +75,6 @@ export const timeRanges = [
/* eslint-enable @gitlab/require-i18n-strings */
export const defaultTimeRange = timeRanges.find((tr) => tr.default);
-export const getTimeWindow = (timeWindowName) =>
- timeRanges.find((tr) => tr.name === timeWindowName);
export const AVATAR_SHAPE_OPTION_CIRCLE = 'circle';
export const AVATAR_SHAPE_OPTION_RECT = 'rect';
@@ -87,7 +85,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
'Only %{workspaceType} members with %{permissions} can view or be notified about this %{issuableType}.',
),
{
- workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'),
+ workspaceType: workspaceType === WORKSPACE_PROJECT ? __('project') : __('group'),
issuableType: issuableType === TYPE_ISSUE ? __('issue') : __('epic'),
permissions:
issuableType === TYPE_ISSUE
diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
index c12ffaac40a..79946ebaecd 100644
--- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
+++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
@@ -1,12 +1,14 @@
export default (Vue) => {
Vue.mixin({
- provide: {
- glFeatures:
- {
- ...window.gon?.features,
- // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460
- ...window.gon?.licensed_features,
- } || {},
+ provide() {
+ return {
+ glFeatures:
+ {
+ ...window.gon?.features,
+ // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460
+ ...window.gon?.licensed_features,
+ } || {},
+ };
},
});
};
diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js
new file mode 100644
index 00000000000..388e7c92f03
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -0,0 +1,73 @@
+import { s__, __, sprintf } from '~/locale';
+
+export const AUTOCOMPLETE_ERROR_MESSAGE = s__(
+ 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
+);
+
+export const ALL_GITLAB = __('All GitLab');
+export const SEARCH_GITLAB = s__('GlobalSearch|Search GitLab');
+
+export const SEARCH_DESCRIBED_BY_DEFAULT = s__(
+ 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
+);
+export const SEARCH_DESCRIBED_BY_WITH_RESULTS = s__(
+ 'GlobalSearch|Type for new suggestions to appear below.',
+);
+export const SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN = s__(
+ 'GlobalSearch|Type and press the enter key to submit search.',
+);
+export const SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN = SEARCH_DESCRIBED_BY_WITH_RESULTS;
+export const SEARCH_DESCRIBED_BY_UPDATED = s__(
+ 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
+);
+export const SEARCH_RESULTS_LOADING = s__('GlobalSearch|Search results are loading');
+export const SEARCH_RESULTS_SCOPE = s__('GlobalSearch|in %{scope}');
+export const KBD_HELP = sprintf(
+ s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
+ { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
+ false,
+);
+export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}');
+
+export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
+
+export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created");
+
+export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me');
+
+export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer");
+
+export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
+
+export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
+
+export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
+
+export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
+
+export const USERS_CATEGORY = s__('GlobalSearch|Users');
+
+export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
+
+export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
+
+export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
+
+export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
+
+export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
+
+export const HELP_CATEGORY = s__('GlobalSearch|Help');
+
+export const SEARCH_RESULTS_ORDER = [
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ USERS_CATEGORY,
+ IN_THIS_PROJECT_CATEGORY,
+ SETTINGS_CATEGORY,
+ HELP_CATEGORY,
+];
+export const DROPDOWN_ORDER = SEARCH_RESULTS_ORDER;
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index f6b864dfde0..1b71819bdc2 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -1,27 +1,22 @@
+import { STATUS_ALL, STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import { __ } from '~/locale';
-export const IssuableStates = {
- Opened: 'opened',
- Closed: 'closed',
- All: 'all',
-};
-
export const IssuableListTabs = [
{
id: 'state-opened',
- name: IssuableStates.Opened,
+ name: STATUS_OPEN,
title: __('Open'),
titleTooltip: __('Filter by issues that are currently opened.'),
},
{
id: 'state-closed',
- name: IssuableStates.Closed,
+ name: STATUS_CLOSED,
title: __('Closed'),
titleTooltip: __('Filter by issues that are currently closed.'),
},
{
id: 'state-all',
- name: IssuableStates.All,
+ name: STATUS_ALL,
title: __('All'),
titleTooltip: __('Show all issues.'),
},
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
index d78530239a5..a8d5f72373c 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
@@ -3,6 +3,7 @@ import { GlLink } from '@gitlab/ui';
import TaskList from '~/task_list';
+import { TYPE_ISSUE } from '~/issues/constants';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import IssuableDescription from './issuable_description.vue';
@@ -112,7 +113,7 @@ export default {
* task lists in Issue, Test Cases and Incidents
* as all of those are derived from `issue`.
*/
- dataType: 'issue',
+ dataType: TYPE_ISSUE,
fieldName: 'description',
lockVersion: this.taskListLockVersion,
selector: '.js-detail-page-description',
@@ -138,7 +139,7 @@ export default {
<template>
<div class="issue-details issuable-details">
- <div class="detail-page-description js-detail-page-description content-block">
+ <div class="detail-page-description js-detail-page-description content-block gl-pt-2">
<issuable-edit-form
v-if="editFormVisible"
:issuable="issuable"
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index 1f23fdfaafd..3d4eebb9524 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -9,11 +9,11 @@ import {
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { STATUS_OPEN } from '~/issues/constants';
import { isExternal } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
-import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export default {
components: {
@@ -80,7 +80,7 @@ export default {
},
computed: {
badgeVariant() {
- return this.issuableState === IssuableStates.Opened ? 'success' : 'info';
+ return this.issuableState === STATUS_OPEN ? 'success' : 'info';
},
authorId() {
return getIdFromGraphQLId(`${this.author.id}`);
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index fd94245b7c9..c33e803c7e1 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon, GlBadge, GlButton, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
+import { STATUS_OPEN } from '~/issues/constants';
import { __ } from '~/locale';
-import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export default {
i18n: {
@@ -39,7 +39,7 @@ export default {
},
computed: {
badgeVariant() {
- return this.issuable.state === IssuableStates.Opened ? 'success' : 'info';
+ return this.issuable.state === STATUS_OPEN ? 'success' : 'info';
},
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index 318adec2319..2533b3b5489 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
@@ -29,8 +29,8 @@ export default {
type: String,
required: true,
},
- initialBreadcrumb: {
- type: String,
+ initialBreadcrumbs: {
+ type: Array,
required: true,
},
panels: {
@@ -60,6 +60,10 @@ export default {
return this.panels.find((p) => p.name === this.activePanelName);
},
+ detailProps() {
+ return this.activePanel.detailProps || {};
+ },
+
details() {
return this.activePanel.details || this.activePanel.description;
},
@@ -69,14 +73,15 @@ export default {
},
breadcrumbs() {
- if (!this.activePanel) {
- return null;
- }
-
- return [
- { text: this.initialBreadcrumb, href: '#' },
- { text: this.activePanel.title, href: `#${this.activePanel.name}` },
- ];
+ return this.activePanel
+ ? [
+ ...this.initialBreadcrumbs,
+ {
+ text: this.activePanel.title,
+ href: `#${this.activePanel.name}`,
+ },
+ ]
+ : this.initialBreadcrumbs;
},
shouldVerify() {
@@ -125,24 +130,29 @@ export default {
<template>
<credit-card-verification v-if="shouldVerify" @verified="onVerified" />
- <welcome-page v-else-if="!activePanelName" :panels="panels" :title="title">
- <template #footer>
- <slot name="welcome-footer"> </slot>
- </template>
- </welcome-page>
- <div v-else class="row">
- <div class="col-lg-3">
- <div v-safe-html="activePanel.illustration" class="gl-text-white"></div>
- <h4>{{ activePanel.title }}</h4>
-
- <p v-if="hasTextDetails">{{ details }}</p>
- <component :is="details" v-else v-bind="activePanel.detailProps || {}" />
+ <div v-else-if="!activePanelName">
+ <gl-breadcrumb :items="breadcrumbs" />
+ <welcome-page :panels="panels" :title="title">
+ <template #footer>
+ <slot name="welcome-footer"> </slot>
+ </template>
+ </welcome-page>
+ </div>
+ <div v-else>
+ <gl-breadcrumb :items="breadcrumbs" />
+ <div class="gl-display-flex gl-py-5 gl-align-items-center">
+ <div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div>
+ <div class="col">
+ <h4>{{ activePanel.title }}</h4>
+
+ <p v-if="hasTextDetails">{{ details }}</p>
+ <component :is="details" v-else v-bind="detailProps" />
+ </div>
<slot name="extra-description"></slot>
</div>
- <div class="col-lg-9">
+ <div>
<new-top-level-group-alert v-if="showNewTopLevelGroupAlert" />
- <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs" />
<legacy-container :key="activePanel.name" :selector="activePanel.selector" />
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/plugins/global_toast.js b/app/assets/javascripts/vue_shared/plugins/global_toast.js
index fb52b31c2c8..bfea2bedd40 100644
--- a/app/assets/javascripts/vue_shared/plugins/global_toast.js
+++ b/app/assets/javascripts/vue_shared/plugins/global_toast.js
@@ -2,7 +2,7 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
Vue.use(GlToast);
-export const instance = new Vue();
+const instance = new Vue();
export default function showGlobalToast(...args) {
return instance.$toast.show(...args);
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
index a4fb30a03a1..4c2b082242b 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue
@@ -1,6 +1,6 @@
<script>
import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/security_reports/constants';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index b739baad5d7..0cff5edf628 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import ReportSection from '~/ci/reports/components/report_section.vue';
import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/ci/reports/constants';
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 8ec8482657d..8bb8b6101d4 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -63,7 +63,7 @@ export default {
:options="$options.states"
:disabled="disabled"
data-testid="work-item-state-select"
- class="gl-w-auto hide-select-decoration gl-pl-3"
+ class="gl-w-auto hide-select-decoration gl-pl-4 gl-my-1"
:class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }"
@change="setState"
/>
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 1c0fed2dde9..1dc6d341811 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -48,6 +48,7 @@ export default {
id="item-title"
ref="titleEl"
role="textbox"
+ data-testid="work-item-title"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
diff --git a/app/assets/javascripts/work_items/components/notes/activity_filter.vue b/app/assets/javascripts/work_items/components/notes/activity_filter.vue
deleted file mode 100644
index 71784d3a807..00000000000
--- a/app/assets/javascripts/work_items/components/notes/activity_filter.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { __ } from '~/locale';
-import Tracking from '~/tracking';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { ASC, DESC } from '~/notes/constants';
-import { TRACKING_CATEGORY_SHOW, WORK_ITEM_NOTES_SORT_ORDER_KEY } from '~/work_items/constants';
-
-const SORT_OPTIONS = [
- { key: DESC, text: __('Newest first'), dataid: 'js-newest-first' },
- { key: ASC, text: __('Oldest first'), dataid: 'js-oldest-first' },
-];
-
-export default {
- SORT_OPTIONS,
- components: {
- GlDropdown,
- GlDropdownItem,
- LocalStorageSync,
- },
- mixins: [Tracking.mixin()],
- props: {
- sortOrder: {
- type: String,
- default: ASC,
- required: false,
- },
- loading: {
- type: Boolean,
- default: false,
- required: false,
- },
- workItemType: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- persistSortOrder: true,
- };
- },
- computed: {
- tracking() {
- return {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_track_notes_sorting',
- property: `type_${this.workItemType}`,
- };
- },
- selectedSortOption() {
- const isSortOptionValid = this.sortOrder === ASC || this.sortOrder === DESC;
- return isSortOptionValid ? SORT_OPTIONS.find(({ key }) => this.sortOrder === key) : ASC;
- },
- getDropdownSelectedText() {
- return this.selectedSortOption.text;
- },
- },
- methods: {
- setDiscussionSortDirection(direction) {
- this.$emit('updateSavedSortOrder', direction);
- },
- fetchSortedDiscussions(direction) {
- if (this.isSortDropdownItemActive(direction)) {
- return;
- }
- this.track('notes_sort_order_changed');
- this.$emit('changeSortOrder', direction);
- },
- isSortDropdownItemActive(sortDir) {
- return sortDir === this.sortOrder;
- },
- },
- WORK_ITEM_NOTES_SORT_ORDER_KEY,
-};
-</script>
-
-<template>
- <div
- id="discussion-preferences"
- data-testid="discussion-preferences"
- class="gl-display-inline-block gl-vertical-align-bottom gl-w-full gl-sm-w-auto"
- >
- <local-storage-sync
- :value="sortOrder"
- :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY"
- :persist="persistSortOrder"
- as-string
- @input="setDiscussionSortDirection"
- />
- <gl-dropdown
- :id="`discussion-preferences-dropdown-${workItemType}`"
- class="gl-xs-w-full"
- size="small"
- :text="getDropdownSelectedText"
- :disabled="loading"
- right
- >
- <div id="discussion-sort">
- <gl-dropdown-item
- v-for="{ text, key, dataid } in $options.SORT_OPTIONS"
- :key="text"
- :data-testid="dataid"
- is-check-item
- :is-checked="isSortDropdownItemActive(key)"
- @click="fetchSortedDiscussions(key)"
- >
- {{ text }}
- </gl-dropdown-item>
- </div>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue
index bca061f5e01..ab4691a4a4e 100644
--- a/app/assets/javascripts/work_items/components/notes/system_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/system_note.vue
@@ -70,7 +70,7 @@ export default {
return [];
},
noteAnchorId() {
- return `note_${this.note.id}`;
+ return `note_${this.noteId}`;
},
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue
new file mode 100644
index 00000000000..1ead16c944b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_activity_sort_filter.vue
@@ -0,0 +1,116 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import Tracking from '~/tracking';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ LocalStorageSync,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ loading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ sortFilterProp: {
+ type: String,
+ required: true,
+ },
+ filterOptions: {
+ type: Array,
+ required: true,
+ },
+ trackingLabel: {
+ type: String,
+ required: true,
+ },
+ trackingAction: {
+ type: String,
+ required: true,
+ },
+ filterEvent: {
+ type: String,
+ required: true,
+ },
+ defaultSortFilterProp: {
+ type: String,
+ required: true,
+ },
+ storageKey: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: this.trackingLabel,
+ property: `type_${this.workItemType}`,
+ };
+ },
+ getDropdownSelectedText() {
+ return this.selectedSortOption.text;
+ },
+ selectedSortOption() {
+ return (
+ this.filterOptions.find(({ key }) => this.sortFilterProp === key) ||
+ this.defaultSortFilterProp
+ );
+ },
+ },
+ methods: {
+ setDiscussionFilterOption(filterValue) {
+ this.$emit(this.filterEvent, filterValue);
+ },
+ fetchFilteredDiscussions(filterValue) {
+ if (this.isSortDropdownItemActive(filterValue)) {
+ return;
+ }
+ this.track(this.trackingAction);
+ this.$emit(this.filterEvent, filterValue);
+ },
+ isSortDropdownItemActive(value) {
+ return value === this.sortFilterProp;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-inline-block gl-vertical-align-bottom">
+ <local-storage-sync
+ :value="sortFilterProp"
+ :storage-key="storageKey"
+ as-string
+ @input="setDiscussionFilterOption"
+ />
+ <gl-dropdown
+ class="gl-xs-w-full"
+ size="small"
+ :text="getDropdownSelectedText"
+ :disabled="loading"
+ right
+ >
+ <gl-dropdown-item
+ v-for="{ text, key, testid } in filterOptions"
+ :key="text"
+ :data-testid="testid"
+ is-check-item
+ :is-checked="isSortDropdownItemActive(key)"
+ @click="fetchFilteredDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
index b3f17aff2ae..1762344ea9e 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
@@ -5,7 +5,6 @@ import { clearDraft } from '~/lib/utils/autosave';
import Tracking from '~/tracking';
import { ASC } from '~/notes/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { updateCommentState } from '~/work_items/graphql/cache_utils';
import { getWorkItemQuery } from '../../utils';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants';
@@ -115,10 +114,35 @@ export default {
this.workItemType
}`;
},
+ isLockedOutOrSignedOut() {
+ return !this.signedIn || !this.canUpdate;
+ },
+ lockedOutUserWarningInReplies() {
+ return this.addPadding && this.isLockedOutOrSignedOut;
+ },
timelineEntryClass() {
return {
- 'timeline-entry gl-mb-3': true,
- 'gl-p-4': this.addPadding,
+ 'timeline-entry gl-mb-3 note note-wrapper note-comment': true,
+ 'gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-p-5! gl-mx-n3 gl-mb-n2!': this
+ .lockedOutUserWarningInReplies,
+ };
+ },
+ timelineEntryInnerClass() {
+ return {
+ 'timeline-entry-inner': true,
+ 'gl-pb-3': this.addPadding,
+ };
+ },
+ timelineContentClass() {
+ return {
+ 'timeline-content': true,
+ 'gl-border-0! gl-pl-0!': !this.addPadding,
+ };
+ },
+ parentClass() {
+ return {
+ 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap': !this
+ .isEditing,
};
},
isProjectArchived() {
@@ -142,7 +166,6 @@ export default {
async updateWorkItem(commentText) {
this.isSubmitting = true;
this.$emit('replying', commentText);
- const { queryVariables, fetchByIid } = this;
try {
this.track('add_work_item_comment');
@@ -160,7 +183,6 @@ export default {
if (createNoteData.data?.createNote?.errors?.length) {
throw new Error(createNoteData.data?.createNote?.errors[0]);
}
- updateCommentState(store, createNoteData, fetchByIid, queryVariables);
},
});
clearDraft(this.autosaveKey);
@@ -189,23 +211,29 @@ export default {
:work-item-type="workItemType"
:is-project-archived="isProjectArchived"
/>
- <div v-else class="gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
- <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
- <work-item-comment-form
- v-if="isEditing"
- :work-item-type="workItemType"
- :aria-label="__('Add a comment')"
- :is-submitting="isSubmitting"
- :autosave-key="autosaveKey"
- @submitForm="updateWorkItem"
- @cancelEditing="cancelEditing"
- />
- <gl-button
- v-else
- class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
- @click="isEditing = true"
- >{{ __('Add a comment') }}</gl-button
- >
+ <div v-else :class="timelineEntryInnerClass">
+ <div class="timeline-avatar gl-float-left">
+ <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
+ </div>
+ <div :class="timelineContentClass">
+ <div :class="parentClass">
+ <work-item-comment-form
+ v-if="isEditing"
+ :work-item-type="workItemType"
+ :aria-label="__('Add a reply')"
+ :is-submitting="isSubmitting"
+ :autosave-key="autosaveKey"
+ @submitForm="updateWorkItem"
+ @cancelEditing="cancelEditing"
+ />
+ <gl-button
+ v-else
+ class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
+ @click="isEditing = true"
+ >{{ __('Add a reply') }}</gl-button
+ >
+ </div>
+ </div>
</div>
</li>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
index fd407fd9d9f..a3ebd51f76d 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -96,31 +96,39 @@ export default {
</script>
<template>
- <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
- <markdown-editor
- :value="commentText"
- :render-markdown-path="markdownPreviewPath"
- :markdown-docs-path="$options.constantOptions.markdownDocsPath"
- :form-field-props="formFieldProps"
- data-testid="work-item-add-comment"
- class="gl-mb-3"
- autofocus
- use-bottom-toolbar
- @input="setCommentText"
- @keydown.meta.enter="$emit('submitForm', commentText)"
- @keydown.ctrl.enter="$emit('submitForm', commentText)"
- @keydown.esc.stop="cancelEditing"
- />
- <gl-button
- category="primary"
- variant="confirm"
- data-testid="confirm-button"
- :loading="isSubmitting"
- @click="$emit('submitForm', commentText)"
- >{{ commentButtonText }}
- </gl-button>
- <gl-button data-testid="cancel-button" category="primary" class="gl-ml-3" @click="cancelEditing"
- >{{ __('Cancel') }}
- </gl-button>
- </form>
+ <div class="timeline-discussion-body">
+ <div class="note-body">
+ <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
+ <markdown-editor
+ :value="commentText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.constantOptions.markdownDocsPath"
+ :form-field-props="formFieldProps"
+ data-testid="work-item-add-comment"
+ class="gl-mb-3"
+ autofocus
+ use-bottom-toolbar
+ @input="setCommentText"
+ @keydown.meta.enter="$emit('submitForm', commentText)"
+ @keydown.ctrl.enter="$emit('submitForm', commentText)"
+ @keydown.esc.stop="cancelEditing"
+ />
+ <gl-button
+ category="primary"
+ variant="confirm"
+ data-testid="confirm-button"
+ :loading="isSubmitting"
+ @click="$emit('submitForm', commentText)"
+ >{{ commentButtonText }}
+ </gl-button>
+ <gl-button
+ data-testid="cancel-button"
+ category="primary"
+ class="gl-ml-3"
+ @click="cancelEditing"
+ >{{ __('Cancel') }}
+ </gl-button>
+ </form>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
index f837d025b7f..c1b6903cf17 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
@@ -45,8 +45,10 @@ export default {
</script>
<template>
- <div class="disabled-comment text-center">
- <span class="issuable-note-warning gl-display-inline-block">
+ <div class="disabled-comment gl-text-center gl-relative gl-mt-3">
+ <span
+ class="issuable-note-warning gl-display-inline-block gl-w-full gl-px-5 gl-py-4 gl-rounded-base"
+ >
<gl-icon name="lock" class="gl-mr-2" />
<template v-if="isProjectArchived">
{{ $options.constantOptions.projectArchivedWarning }}
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
index bda00f978b9..1e08fecaf3d 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
@@ -1,5 +1,6 @@
<script>
-import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
+import { getLocationHash } from '~/lib/utils/url_utility';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ASC } from '~/notes/constants';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue';
@@ -11,8 +12,6 @@ import WorkItemAddNote from './work_item_add_note.vue';
export default {
components: {
TimelineEntryItem,
- GlAvatarLink,
- GlAvatar,
WorkItemNote,
WorkItemAddNote,
ToggleRepliesWidget,
@@ -50,13 +49,19 @@ export default {
default: ASC,
required: false,
},
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
- isExpanded: false,
+ isExpanded: true,
autofocus: false,
isReplying: false,
replyingText: '',
+ showForm: false,
};
},
computed: {
@@ -66,11 +71,20 @@ export default {
author() {
return this.note.author;
},
+ noteId() {
+ return getIdFromGraphQLId(this.note.id);
+ },
noteAnchorId() {
- return `note_${this.note.id}`;
+ return `note_${this.noteId}`;
+ },
+ isTarget() {
+ return this.targetNoteHash === this.noteAnchorId;
+ },
+ targetNoteHash() {
+ return getLocationHash();
},
hasReplies() {
- return this.replies?.length;
+ return Boolean(this.replies?.length);
},
replies() {
if (this.discussion?.length > 1) {
@@ -81,19 +95,26 @@ export default {
discussionId() {
return this.discussion[0]?.discussion?.id || '';
},
+ shouldShowReplyForm() {
+ return this.showForm || this.hasReplies;
+ },
+ isOnlyCommentOfAThread() {
+ return !this.hasReplies && !this.showForm;
+ },
},
methods: {
showReplyForm() {
+ this.showForm = true;
this.isExpanded = true;
this.autofocus = true;
},
hideReplyForm() {
+ this.showForm = false;
this.isExpanded = this.hasReplies;
this.autofocus = false;
},
toggleDiscussion() {
this.isExpanded = !this.isExpanded;
- this.autofocus = this.isExpanded;
},
threadKey(note) {
/* eslint-disable @gitlab/require-i18n-strings */
@@ -113,76 +134,85 @@ export default {
</script>
<template>
+ <work-item-note
+ v-if="isOnlyCommentOfAThread"
+ :is-first-note="true"
+ :note="note"
+ :discussion-id="discussionId"
+ :has-replies="hasReplies"
+ :work-item-type="workItemType"
+ :is-modal="isModal"
+ :class="{ 'gl-mb-4': hasReplies }"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', note)"
+ @error="$emit('error', $event)"
+ />
<timeline-entry-item
- :id="noteAnchorId"
+ v-else
:class="{ 'internal-note': note.internal }"
- :data-note-id="note.id"
- class="note note-wrapper note-comment gl-px-0"
+ :data-note-id="noteId"
+ class="note note-discussion gl-px-0"
>
- <div class="timeline-avatar gl-float-left">
- <gl-avatar-link :href="author.webUrl">
- <gl-avatar
- :src="author.avatarUrl"
- :entity-name="author.username"
- :alt="author.name"
- :size="32"
- />
- </gl-avatar-link>
- </div>
-
<div class="timeline-content">
- <div class="discussion-body">
- <div class="discussion-wrapper">
- <div class="discussion-notes">
- <ul class="notes">
- <work-item-note
- :is-first-note="true"
- :note="note"
- :discussion-id="discussionId"
- :work-item-type="workItemType"
- :class="{ 'gl-mb-5': hasReplies }"
- @startReplying="showReplyForm"
- @deleteNote="$emit('deleteNote', note)"
- @error="$emit('error', $event)"
- />
- <discussion-notes-replies-wrapper>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="!isExpanded"
- :replies="replies"
- @toggle="toggleDiscussion({ discussionId })"
+ <div class="discussion">
+ <div class="discussion-body">
+ <div class="discussion-wrapper">
+ <div class="discussion-notes">
+ <ul class="notes">
+ <work-item-note
+ :is-first-note="true"
+ :note="note"
+ :discussion-id="discussionId"
+ :has-replies="hasReplies"
+ :work-item-type="workItemType"
+ :is-modal="isModal"
+ :class="{ 'gl-mb-4': hasReplies }"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', note)"
+ @error="$emit('error', $event)"
/>
- <template v-if="isExpanded">
- <template v-for="reply in replies">
- <work-item-note
- :key="threadKey(reply)"
+ <discussion-notes-replies-wrapper>
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ @toggle="toggleDiscussion({ discussionId })"
+ />
+ <template v-if="isExpanded">
+ <template v-for="reply in replies">
+ <work-item-note
+ :key="threadKey(reply)"
+ :discussion-id="discussionId"
+ :note="reply"
+ :work-item-type="workItemType"
+ :is-modal="isModal"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', reply)"
+ @error="$emit('error', $event)"
+ />
+ </template>
+ <work-item-note-replying v-if="isReplying" :body="replyingText" />
+ <work-item-add-note
+ v-if="shouldShowReplyForm"
+ :notes-form="false"
+ :autofocus="autofocus"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :work-item-id="workItemId"
+ :fetch-by-iid="fetchByIid"
:discussion-id="discussionId"
- :note="reply"
:work-item-type="workItemType"
- @startReplying="showReplyForm"
- @deleteNote="$emit('deleteNote', reply)"
+ :sort-order="sortOrder"
+ :add-padding="true"
+ @cancelEditing="hideReplyForm"
+ @replied="onReplied"
+ @replying="onReplying"
@error="$emit('error', $event)"
/>
</template>
- <work-item-note-replying v-if="isReplying" :body="replyingText" />
- <work-item-add-note
- :autofocus="autofocus"
- :query-variables="queryVariables"
- :full-path="fullPath"
- :work-item-id="workItemId"
- :fetch-by-iid="fetchByIid"
- :discussion-id="discussionId"
- :work-item-type="workItemType"
- :sort-order="sortOrder"
- :add-padding="true"
- @cancelEditing="hideReplyForm"
- @replied="onReplied"
- @replying="onReplying"
- @error="$emit('error', $event)"
- />
- </template>
- </discussion-notes-replies-wrapper>
- </ul>
+ </discussion-notes-replies-wrapper>
+ </ul>
+ </div>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
new file mode 100644
index 00000000000..07e25312f87
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlButton, GlIcon, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+} from '~/work_items/constants';
+
+export default {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+ i18n: {
+ information: s__(
+ "WorkItem|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options.",
+ ),
+ },
+ components: {
+ GlButton,
+ GlIcon,
+ GlSprintf,
+ },
+ methods: {
+ selectFilter(value) {
+ this.$emit('changeFilter', value);
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="timeline-entry note note-wrapper discussion-filter-note">
+ <div class="timeline-icon gl-display-none gl-lg-display-flex">
+ <gl-icon name="comment" />
+ </div>
+ <div class="timeline-content gl-pl-8">
+ <gl-sprintf :message="$options.i18n.information">
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+
+ <div class="discussion-filter-actions">
+ <gl-button
+ class="gl-mr-2 gl-mt-3"
+ data-testid="show-all-activity"
+ @click="selectFilter($options.WORK_ITEM_NOTES_FILTER_ALL_NOTES)"
+ >
+ {{ __('Show all activity') }}
+ </gl-button>
+ <gl-button
+ class="gl-mt-3"
+ data-testid="show-comments-only"
+ @click="selectFilter($options.WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS)"
+ >
+ {{ __('Show comments only') }}
+ </gl-button>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index 5dd21a5f76f..dcb6557600e 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -1,9 +1,12 @@
<script>
import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
import { updateDraft, clearDraft } from '~/lib/utils/autosave';
import { renderMarkdown } from '~/notes/utils';
+import { getLocationHash } from '~/lib/utils/url_utility';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import EditedAt from '~/issues/show/components/edited.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
@@ -17,6 +20,7 @@ export default {
i18n: {
moreActionsText: __('More actions'),
deleteNoteText: __('Delete comment'),
+ copyLinkText: __('Copy link'),
},
components: {
TimelineEntryItem,
@@ -43,10 +47,20 @@ export default {
required: false,
default: false,
},
+ hasReplies: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
workItemType: {
type: String,
required: true,
},
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -60,12 +74,18 @@ export default {
entryClass() {
return {
'note note-wrapper note-comment': true,
- 'gl-p-4': !this.isFirstNote,
+ target: this.isTarget,
+ 'inner-target': this.isTarget && !this.isFirstNote,
};
},
showReply() {
return this.note.userPermissions.createNote && this.isFirstNote;
},
+ noteHeaderClass() {
+ return {
+ 'note-header': true,
+ };
+ },
autosaveKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.note.id}-comment`;
@@ -76,6 +96,21 @@ export default {
hasAdminPermission() {
return this.note.userPermissions.adminNote;
},
+ noteAnchorId() {
+ return `note_${getIdFromGraphQLId(this.note.id)}`;
+ },
+ isTarget() {
+ return this.targetNoteHash === this.noteAnchorId;
+ },
+ targetNoteHash() {
+ return getLocationHash();
+ },
+ noteUrl() {
+ return this.note.url;
+ },
+ hasAwardEmojiPermission() {
+ return this.note.userPermissions.awardEmoji;
+ },
},
methods: {
showReplyForm() {
@@ -114,13 +149,19 @@ export default {
Sentry.captureException(error);
}
},
+ notifyCopyDone() {
+ if (this.isModal) {
+ navigator.clipboard.writeText(this.noteUrl);
+ }
+ toast(__('Link copied to clipboard.'));
+ },
},
};
</script>
<template>
- <timeline-entry-item :class="entryClass">
- <div v-if="!isFirstNote" :key="note.id" class="timeline-avatar gl-float-left">
+ <timeline-entry-item :id="noteAnchorId" :class="entryClass">
+ <div :key="note.id" class="timeline-avatar gl-float-left">
<gl-avatar-link :href="author.webUrl">
<gl-avatar
:src="author.avatarUrl"
@@ -130,57 +171,73 @@ export default {
/>
</gl-avatar-link>
</div>
- <work-item-comment-form
- v-if="isEditing"
- :work-item-type="workItemType"
- :aria-label="__('Edit comment')"
- :autosave-key="autosaveKey"
- :initial-value="note.body"
- :comment-button-text="__('Save comment')"
- :class="{ 'gl-pl-8': !isFirstNote }"
- @cancelEditing="isEditing = false"
- @submitForm="updateNote"
- />
- <div v-else class="timeline-content-inner" data-testid="note-wrapper">
- <div class="note-header">
- <note-header :author="author" :created-at="note.createdAt" :note-id="note.id" />
- <note-actions
- :show-reply="showReply"
- :show-edit="hasAdminPermission"
- @startReplying="showReplyForm"
- @startEditing="startEditing"
- />
- <!-- v-if condition should be moved to "delete" dropdown item as soon as we implement copying the link -->
- <gl-dropdown
- v-if="hasAdminPermission"
- v-gl-tooltip
- icon="ellipsis_v"
- text-sr-only
- right
- :text="$options.i18n.moreActionsText"
- :title="$options.i18n.moreActionsText"
- category="tertiary"
- no-caret
- >
- <gl-dropdown-item
- variant="danger"
- data-testid="delete-note-action"
- @click="$emit('deleteNote')"
+ <div class="timeline-content">
+ <work-item-comment-form
+ v-if="isEditing"
+ :work-item-type="workItemType"
+ :aria-label="__('Edit comment')"
+ :autosave-key="autosaveKey"
+ :initial-value="note.body"
+ :comment-button-text="__('Save comment')"
+ @cancelEditing="isEditing = false"
+ @submitForm="updateNote"
+ />
+ <div v-else data-testid="note-wrapper">
+ <div :class="noteHeaderClass">
+ <note-header
+ :author="author"
+ :created-at="note.createdAt"
+ :note-id="note.id"
+ :note-url="note.url"
>
- {{ $options.i18n.deleteNoteText }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
- <div class="timeline-discussion-body">
- <note-body ref="noteBody" :note="note" />
+ <span v-if="note.createdAt" class="d-none d-sm-inline">&middot;</span>
+ </note-header>
+ <div class="gl-display-inline-flex">
+ <note-actions
+ :show-award-emoji="hasAwardEmojiPermission"
+ :note-url="noteUrl"
+ :show-reply="showReply"
+ :show-edit="hasAdminPermission"
+ :note-id="note.id"
+ @startReplying="showReplyForm"
+ @startEditing="startEditing"
+ @error="($event) => $emit('error', $event)"
+ />
+ <gl-dropdown
+ v-gl-tooltip
+ icon="ellipsis_v"
+ text-sr-only
+ right
+ :text="$options.i18n.moreActionsText"
+ :title="$options.i18n.moreActionsText"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item :data-clipboard-text="noteUrl" @click="notifyCopyDone">
+ <span>{{ $options.i18n.copyLinkText }}</span>
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="hasAdminPermission"
+ variant="danger"
+ data-testid="delete-note-action"
+ @click="$emit('deleteNote')"
+ >
+ {{ $options.i18n.deleteNoteText }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </div>
+ <div class="timeline-discussion-body">
+ <note-body ref="noteBody" :note="note" :has-replies="hasReplies" />
+ </div>
+ <edited-at
+ v-if="note.lastEditedBy"
+ :updated-at="note.lastEditedAt"
+ :updated-by-name="lastEditedBy.name"
+ :updated-by-path="lastEditedBy.webPath"
+ :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'"
+ />
</div>
- <edited-at
- v-if="note.lastEditedBy"
- :updated-at="note.lastEditedAt"
- :updated-by-name="lastEditedBy.name"
- :updated-by-path="lastEditedBy.webPath"
- :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'"
- />
</div>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
index c17e855e527..6bea7953698 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,7 +1,10 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { __, s__ } from '~/locale';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import addAwardEmojiMutation from '../../graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
export default {
name: 'WorkItemNoteActions',
@@ -10,11 +13,14 @@ export default {
},
components: {
GlButton,
+ GlIcon,
ReplyButton,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
showReply: {
type: Boolean,
@@ -24,12 +30,63 @@ export default {
type: Boolean,
required: true,
},
+ noteId: {
+ type: String,
+ required: true,
+ },
+ showAwardEmoji: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ async setAwardEmoji(name) {
+ try {
+ const {
+ data: {
+ awardEmojiAdd: { errors = [] },
+ },
+ } = await this.$apollo.mutate({
+ mutation: addAwardEmojiMutation,
+ variables: {
+ awardableId: this.noteId,
+ name,
+ },
+ });
+
+ if (errors.length > 0) {
+ throw new Error(errors[0].message);
+ }
+ } catch (error) {
+ this.$emit('error', s__('WorkItem|Failed to award emoji'));
+ Sentry.captureException(error);
+ }
+ },
},
};
</script>
<template>
<div class="note-actions">
+ <emoji-picker
+ v-if="showAwardEmoji && glFeatures.workItemsMvc2"
+ toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
+ data-testid="note-emoji-button"
+ @click="setAwardEmoji"
+ >
+ <template #button-content>
+ <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" />
+ <gl-icon
+ class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!"
+ name="smiley"
+ />
+ <gl-icon
+ class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!"
+ name="smile"
+ />
+ </template>
+ </emoji-picker>
<reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" />
<gl-button
v-if="showEdit"
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
index 95397b58925..bec902fd325 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
@@ -12,6 +12,11 @@ export default {
type: Object,
required: true,
},
+ hasReplies: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
'note.bodyHtml': {
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
index 46f61ccd204..f053f6e1d7c 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
@@ -41,13 +41,23 @@ export default {
</script>
<template>
- <timeline-entry-item class="note note-wrapper note-comment gl-p-4 being-posted">
+ <timeline-entry-item class="note note-wrapper note-comment being-posted">
<div class="timeline-avatar gl-float-left">
- <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
+ <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" />
</div>
- <div class="note-header">
- <note-header :author="author" />
+ <div class="timeline-content" data-testid="note-wrapper">
+ <div class="note-header">
+ <note-header :author="author" />
+ </div>
+ <div ref="note-body" class="timeline-discussion-body">
+ <div class="note-body">
+ <div
+ v-safe-html:[$options.safeHtmlConfig]="body"
+ class="note-text md"
+ data-testid="work-item-note-body"
+ ></div>
+ </div>
+ </div>
</div>
- <div ref="note-body" v-safe-html:[$options.safeHtmlConfig]="body" class="note-body"></div>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
index 3ef4a16bc57..bccbec903b4 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
@@ -27,5 +27,5 @@ export default {
</script>
<template>
- <div v-safe-html="signedOutText" class="disabled-comment gl-text-center"></div>
+ <div v-safe-html="signedOutText" class="disabled-comment gl-text-center gl-relative"></div>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue
new file mode 100644
index 00000000000..0c1419e983f
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_notes_activity_header.vue
@@ -0,0 +1,91 @@
+<script>
+import WorkItemActivitySortFilter from '~/work_items/components/notes/work_item_activity_sort_filter.vue';
+import { s__ } from '~/locale';
+import { ASC } from '~/notes/constants';
+import {
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_ACTIVITY_FILTER_OPTIONS,
+ WORK_ITEM_NOTES_FILTER_KEY,
+ WORK_ITEM_ACTIVITY_SORT_OPTIONS,
+ WORK_ITEM_NOTES_SORT_ORDER_KEY,
+} from '~/work_items/constants';
+
+export default {
+ i18n: {
+ activityLabel: s__('WorkItem|Activity'),
+ },
+ components: {
+ WorkItemActivitySortFilter,
+ },
+ props: {
+ disableActivityFilterSort: {
+ type: Boolean,
+ required: true,
+ },
+ sortOrder: {
+ type: String,
+ default: ASC,
+ required: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ discussionFilter: {
+ type: String,
+ default: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ required: false,
+ },
+ },
+ methods: {
+ changeNotesSortOrder(direction) {
+ this.$emit('changeSort', direction);
+ },
+ filterDiscussions(filterValue) {
+ this.$emit('changeFilter', filterValue);
+ },
+ },
+ WORK_ITEM_ACTIVITY_FILTER_OPTIONS,
+ WORK_ITEM_NOTES_FILTER_KEY,
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_ACTIVITY_SORT_OPTIONS,
+ WORK_ITEM_NOTES_SORT_ORDER_KEY,
+ ASC,
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-pb-3 gl-align-items-center"
+ >
+ <h3 class="gl-font-base gl-m-0">{{ $options.i18n.activityLabel }}</h3>
+ <div class="gl-display-flex gl-gap-3">
+ <work-item-activity-sort-filter
+ :work-item-type="workItemType"
+ :loading="disableActivityFilterSort"
+ :sort-filter-prop="discussionFilter"
+ :filter-options="$options.WORK_ITEM_ACTIVITY_FILTER_OPTIONS"
+ :storage-key="$options.WORK_ITEM_NOTES_FILTER_KEY"
+ :default-sort-filter-prop="$options.WORK_ITEM_NOTES_FILTER_ALL_NOTES"
+ tracking-action="work_item_notes_filter_changed"
+ tracking-label="item_track_notes_filtering"
+ filter-event="changeFilter"
+ data-testid="work-item-filter"
+ @changeFilter="filterDiscussions"
+ />
+ <work-item-activity-sort-filter
+ :work-item-type="workItemType"
+ :loading="disableActivityFilterSort"
+ :sort-filter-prop="sortOrder"
+ :filter-options="$options.WORK_ITEM_ACTIVITY_SORT_OPTIONS"
+ :storage-key="$options.WORK_ITEM_NOTES_SORT_ORDER_KEY"
+ :default-sort-filter-prop="$options.ASC"
+ tracking-action="work_item_notes_sort_order_changed"
+ tracking-label="item_track_notes_sorting"
+ filter-event="changeSort"
+ data-testid="work-item-sort"
+ @changeSort="changeNotesSortOrder"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue
index 355f17e970b..db36b4e1bbe 100644
--- a/app/assets/javascripts/work_items/components/widget_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -44,8 +44,10 @@ export default {
<template>
<div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4">
<div
- class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
- :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
+ class="gl-pl-5 gl-pr-4 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base"
+ :class="{
+ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!': isOpen,
+ }"
>
<div class="gl-display-flex gl-flex-grow-1">
<h5 class="gl-m-0 gl-line-height-24">
@@ -71,7 +73,7 @@ export default {
<div
v-if="isOpen"
class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
- :class="{ 'gl-p-5 gl-pb-3': !error }"
+ :class="{ 'gl-p-3': !error }"
data-testid="widget-body"
>
<slot name="body"></slot>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 9f9d94ec3c2..3c56b627673 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -115,6 +115,7 @@ export default {
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
data-testid="delete-action"
+ variant="danger"
>{{ i18n.deleteWorkItem }}</gl-dropdown-item
>
</gl-dropdown>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index fc4c05d96b2..95527dda1d4 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -298,7 +298,7 @@ export default {
<div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap">
<span
:id="assigneesTitleId"
- class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
+ class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
data-testid="assignees-title"
>{{ assigneeText }}</span
>
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 399c220bc96..ddfaa376028 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -255,7 +255,6 @@ export default {
enable-autocomplete
supports-quick-actions
init-on-autofocus
- use-bottom-toolbar
@input="setDescriptionText"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 262c093a1d0..ad7a54aaf16 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -215,6 +215,9 @@ export default {
workItemType() {
return this.workItem.workItemType?.name;
},
+ workItemBreadcrumbReference() {
+ return this.workItemType ? `${this.workItemType} #${this.workItem.iid}` : '';
+ },
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
@@ -245,6 +248,9 @@ export default {
parentWorkItemConfidentiality() {
return this.parentWorkItem?.confidential;
},
+ parentWorkItemReference() {
+ return this.parentWorkItem ? `${this.parentWorkItem.title} #${this.parentWorkItem.iid}` : '';
+ },
parentUrl() {
// Once more types are moved to have Work Items involved
// we need to handle this properly.
@@ -293,10 +299,7 @@ export default {
return this.isWidgetPresent(WIDGET_TYPE_NOTES);
},
fetchByIid() {
- return (
- (this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'))) ||
- false
- );
+ return parseBoolean(getParameterByName('iid_path'));
},
queryVariables() {
return this.fetchByIid
@@ -314,6 +317,11 @@ export default {
);
return widgetHierarchy.children.nodes;
},
+ workItemBodyClass() {
+ return {
+ 'gl-pt-5': !this.updateError && !this.isModal,
+ };
+ },
},
mounted() {
if (this.modalWorkItemId || this.modalWorkItemIid) {
@@ -445,6 +453,9 @@ export default {
Sentry.captureException(error);
}
},
+ updateHasNotes() {
+ this.$emit('has-notes');
+ },
updateUrl(modalWorkItem) {
const params = this.fetchByIid
? { work_item_iid: modalWorkItem?.iid }
@@ -480,221 +491,217 @@ export default {
</script>
<template>
- <section class="gl-pt-5">
- <gl-alert
- v-if="updateError"
- class="gl-mb-3"
- variant="danger"
- @dismiss="updateError = undefined"
- >
- {{ updateError }}
- </gl-alert>
-
- <div v-if="workItemLoading" class="gl-max-w-26 gl-py-5">
- <gl-skeleton-loader :height="65" :width="240">
- <rect width="240" height="20" x="5" y="0" rx="4" />
- <rect width="100" height="20" x="5" y="45" rx="4" />
- </gl-skeleton-loader>
- </div>
- <template v-else>
- <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
- <ul
- v-if="parentWorkItem"
- class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0"
- data-testid="work-item-parent"
- >
- <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden">
- <gl-button
- v-gl-tooltip.hover
- class="gl-text-truncate gl-max-w-full"
- :icon="parentWorkItemIconName"
- category="tertiary"
- :href="parentUrl"
- :title="parentWorkItem.title"
- @click="openInModal($event, parentWorkItem)"
- >{{ parentWorkItem.title }}</gl-button
+ <section>
+ <section v-if="updateError" class="flash-container flash-container-page sticky">
+ <gl-alert class="gl-mb-3" variant="danger" @dismiss="updateError = undefined">
+ {{ updateError }}
+ </gl-alert>
+ </section>
+ <section :class="workItemBodyClass">
+ <div v-if="workItemLoading" class="gl-max-w-26 gl-py-5">
+ <gl-skeleton-loader :height="65" :width="240">
+ <rect width="240" height="20" x="5" y="0" rx="4" />
+ <rect width="100" height="20" x="5" y="45" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ <template v-else>
+ <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
+ <ul
+ v-if="parentWorkItem"
+ class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0"
+ data-testid="work-item-parent"
+ >
+ <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden">
+ <gl-button
+ v-gl-tooltip.hover
+ class="gl-text-truncate gl-max-w-full"
+ :icon="parentWorkItemIconName"
+ category="tertiary"
+ :href="parentUrl"
+ :title="parentWorkItemReference"
+ @click="openInModal($event, parentWorkItem)"
+ >{{ parentWorkItemReference }}</gl-button
+ >
+ <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
+ </li>
+ <li
+ class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0"
>
- <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
- </li>
- <li
- class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0"
+ <work-item-type-icon
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ />
+ {{ workItemBreadcrumbReference }}
+ </li>
+ </ul>
+ <work-item-type-icon
+ v-else-if="!error"
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ show-text
+ class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
+ data-testid="work-item-type"
+ />
+ <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
+ <gl-badge
+ v-if="workItem.confidential"
+ v-gl-tooltip.bottom
+ :title="confidentialTooltip"
+ variant="warning"
+ icon="eye-slash"
+ class="gl-mr-3 gl-cursor-help"
+ >{{ __('Confidential') }}</gl-badge
>
- <work-item-type-icon
- :work-item-icon-name="workItemIconName"
- :work-item-type="workItemType && workItemType.toUpperCase()"
- />
- {{ workItemType }}
- </li>
- </ul>
- <work-item-type-icon
- v-else-if="!error"
- :work-item-icon-name="workItemIconName"
- :work-item-type="workItemType && workItemType.toUpperCase()"
- show-text
- class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
- data-testid="work-item-type"
+ <work-item-actions
+ v-if="canUpdate || canDelete"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ :can-delete="canDelete"
+ :can-update="canUpdate"
+ :is-confidential="workItem.confidential"
+ :is-parent-confidential="parentWorkItemConfidentiality"
+ @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
+ @toggleWorkItemConfidentiality="toggleConfidentiality"
+ @error="updateError = $event"
+ />
+ <gl-button
+ v-if="isModal"
+ category="tertiary"
+ data-testid="work-item-close"
+ icon="close"
+ :aria-label="__('Close')"
+ @click="$emit('close')"
+ />
+ </div>
+ <work-item-title
+ v-if="workItem.title"
+ :work-item-id="workItem.id"
+ :work-item-title="workItem.title"
+ :work-item-type="workItemType"
+ :work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
+ @error="updateError = $event"
+ />
+ <work-item-created-updated
+ :work-item-id="workItem.id"
+ :work-item-iid="workItemIid"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
+ />
+ <work-item-state
+ :work-item="workItem"
+ :work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
+ @error="updateError = $event"
/>
- <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
- <gl-badge
- v-if="workItem.confidential"
- v-gl-tooltip.bottom
- :title="confidentialTooltip"
- variant="warning"
- icon="eye-slash"
- class="gl-mr-3 gl-cursor-help"
- >{{ __('Confidential') }}</gl-badge
- >
- <work-item-actions
- v-if="canUpdate || canDelete"
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
:work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
:work-item-type="workItemType"
- :can-delete="canDelete"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ :full-path="fullPath"
+ @error="updateError = $event"
+ />
+ <work-item-labels
+ v-if="workItemLabels"
+ :work-item-id="workItem.id"
:can-update="canUpdate"
- :is-confidential="workItem.confidential"
- :is-parent-confidential="parentWorkItemConfidentiality"
- @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
- @toggleWorkItemConfidentiality="toggleConfidentiality"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
@error="updateError = $event"
/>
- <gl-button
- v-if="isModal"
- category="tertiary"
- data-testid="work-item-close"
- icon="close"
- :aria-label="__('Close')"
- @click="$emit('close')"
+ <work-item-due-date
+ v-if="workItemDueDate"
+ :can-update="canUpdate"
+ :due-date="workItemDueDate.dueDate"
+ :start-date="workItemDueDate.startDate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-milestone
+ v-if="workItemMilestone"
+ :work-item-id="workItem.id"
+ :work-item-milestone="workItemMilestone.milestone"
+ :work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ :can-update="canUpdate"
+ :full-path="fullPath"
+ @error="updateError = $event"
+ />
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ @error="updateError = $event"
+ />
+ <work-item-progress
+ v-if="workItemProgress"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :progress="workItemProgress.progress"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ @error="updateError = $event"
+ />
+ <work-item-iteration
+ v-if="workItemIteration"
+ class="gl-mb-5"
+ :iteration="workItemIteration.iteration"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ @error="updateError = $event"
+ />
+ <work-item-health-status
+ v-if="workItemHealthStatus"
+ class="gl-mb-5"
+ :health-status="workItemHealthStatus.healthStatus"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ @error="updateError = $event"
+ />
+ <work-item-description
+ v-if="hasDescriptionWidget"
+ :work-item-id="workItem.id"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
+ :query-variables="queryVariables"
+ class="gl-pt-5"
+ @error="updateError = $event"
+ />
+ <work-item-tree
+ v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
+ :work-item-type="workItemType"
+ :parent-work-item-type="workItem.workItemType.name"
+ :work-item-id="workItem.id"
+ :children="children"
+ :can-update="canUpdate"
+ :project-path="fullPath"
+ :confidential="workItem.confidential"
+ @addWorkItemChild="addChild"
+ @removeChild="removeChild"
+ @show-modal="openInModal"
/>
- </div>
- <work-item-title
- v-if="workItem.title"
- :work-item-id="workItem.id"
- :work-item-title="workItem.title"
- :work-item-type="workItemType"
- :work-item-parent-id="workItemParentId"
- :can-update="canUpdate"
- @error="updateError = $event"
- />
- <work-item-created-updated
- :work-item-id="workItem.id"
- :work-item-iid="workItemIid"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
- />
- <work-item-state
- :work-item="workItem"
- :work-item-parent-id="workItemParentId"
- :can-update="canUpdate"
- @error="updateError = $event"
- />
- <work-item-assignees
- v-if="workItemAssignees"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.assignees.nodes"
- :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
- :work-item-type="workItemType"
- :can-invite-members="workItemAssignees.canInviteMembers"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-labels
- v-if="workItemLabels"
- :work-item-id="workItem.id"
- :can-update="canUpdate"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- @error="updateError = $event"
- />
- <work-item-due-date
- v-if="workItemDueDate"
- :can-update="canUpdate"
- :due-date="workItemDueDate.dueDate"
- :start-date="workItemDueDate.startDate"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- @error="updateError = $event"
- />
- <work-item-milestone
- v-if="workItemMilestone"
- :work-item-id="workItem.id"
- :work-item-milestone="workItemMilestone.milestone"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- :can-update="canUpdate"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-weight
- v-if="workItemWeight"
- class="gl-mb-5"
- :can-update="canUpdate"
- :weight="workItemWeight.weight"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- @error="updateError = $event"
- />
- <work-item-progress
- v-if="workItemProgress"
- class="gl-mb-5"
- :can-update="canUpdate"
- :progress="workItemProgress.progress"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- @error="updateError = $event"
- />
- <work-item-iteration
- v-if="workItemIteration"
- class="gl-mb-5"
- :iteration="workItemIteration.iteration"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-health-status
- v-if="workItemHealthStatus"
- class="gl-mb-5"
- :health-status="workItemHealthStatus.healthStatus"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- :full-path="fullPath"
- @error="updateError = $event"
- />
- <work-item-description
- v-if="hasDescriptionWidget"
- :work-item-id="workItem.id"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
- :query-variables="queryVariables"
- class="gl-pt-5"
- @error="updateError = $event"
- />
- <work-item-tree
- v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
- :work-item-type="workItemType"
- :parent-work-item-type="workItem.workItemType.name"
- :work-item-id="workItem.id"
- :children="children"
- :can-update="canUpdate"
- :project-path="fullPath"
- :confidential="workItem.confidential"
- @addWorkItemChild="addChild"
- @removeChild="removeChild"
- @show-modal="openInModal"
- />
- <template v-if="workItemsMvcEnabled">
<work-item-notes
v-if="workItemNotes"
:work-item-id="workItem.id"
@@ -705,21 +712,21 @@ export default {
class="gl-pt-5"
@error="updateError = $event"
/>
+ <gl-empty-state
+ v-if="error"
+ :title="$options.i18n.fetchErrorTitle"
+ :description="error"
+ :svg-path="noAccessSvgPath"
+ />
</template>
- <gl-empty-state
- v-if="error"
- :title="$options.i18n.fetchErrorTitle"
- :description="error"
- :svg-path="noAccessSvgPath"
+ <work-item-detail-modal
+ v-if="!isModal"
+ ref="modal"
+ :work-item-id="modalWorkItemId"
+ :work-item-iid="modalWorkItemIid"
+ :show="true"
+ @close="updateUrl"
/>
- </template>
- <work-item-detail-modal
- v-if="!isModal"
- ref="modal"
- :work-item-id="modalWorkItemId"
- :work-item-iid="modalWorkItemIid"
- :show="true"
- @close="updateUrl"
- />
+ </section>
</section>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index 1b8e97bf717..730bdb4e7c7 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -1,10 +1,12 @@
<script>
import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
export default {
+ WORK_ITEM_DETAIL_MODAL_ID: 'work-item-detail-modal',
i18n: {
errorMessage: s__('WorkItem|Something went wrong when deleting the task. Please try again.'),
},
@@ -51,6 +53,8 @@ export default {
error: undefined,
updatedWorkItemId: null,
updatedWorkItemIid: null,
+ isModalShown: false,
+ hasNotes: false,
};
},
computed: {
@@ -61,6 +65,13 @@ export default {
return this.updatedWorkItemIid || this.workItemIid;
},
},
+ watch: {
+ hasNotes(newVal) {
+ if (newVal && this.isModalShown) {
+ scrollToTargetOnResize({ containerId: this.$options.WORK_ITEM_DETAIL_MODAL_ID });
+ }
+ },
+ },
methods: {
deleteWorkItem() {
if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
@@ -128,6 +139,7 @@ export default {
this.updatedWorkItemId = null;
this.updatedWorkItemIid = null;
this.error = '';
+ this.isModalShown = false;
this.$emit('close');
},
hide() {
@@ -144,6 +156,12 @@ export default {
this.updatedWorkItemIid = workItem.iid;
this.$emit('update-modal', $event, workItem);
},
+ onModalShow() {
+ this.isModalShown = true;
+ },
+ updateHasNotes() {
+ this.hasNotes = true;
+ },
},
};
</script>
@@ -151,13 +169,15 @@ export default {
<template>
<gl-modal
ref="modal"
+ static
hide-footer
size="lg"
- modal-id="work-item-detail-modal"
+ :modal-id="$options.WORK_ITEM_DETAIL_MODAL_ID"
header-class="gl-p-0 gl-pb-2!"
scrollable
- data-testid="work-item-detail-modal"
+ :data-testid="$options.WORK_ITEM_DETAIL_MODAL_ID"
@hide="closeModal"
+ @shown="onModalShow"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
@@ -172,6 +192,7 @@ export default {
@close="hide"
@deleteWorkItem="deleteWorkItem"
@update-modal="updateModal"
+ @has-notes="updateHasNotes"
/>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue
index 03c5b7096b2..3e546598dc2 100644
--- a/app/assets/javascripts/work_items/components/work_item_due_date.vue
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -223,7 +223,12 @@ export default {
@clear="clearStartDatePicker"
@close="handleStartDateInput"
/>
- <gl-button v-if="showStartDateButton" category="tertiary" @click="clickShowStartDate">
+ <gl-button
+ v-if="showStartDateButton"
+ category="tertiary"
+ class="gl-text-gray-500!"
+ @click="clickShowStartDate"
+ >
{{ $options.i18n.addStartDate }}
</gl-button>
</gl-form-group>
@@ -250,7 +255,12 @@ export default {
@clear="clearDueDatePicker"
@close="updateDates"
/>
- <gl-button v-if="showDueDateButton" category="tertiary" @click="clickShowDueDate">
+ <gl-button
+ v-if="showDueDateButton"
+ category="tertiary"
+ class="gl-text-gray-500!"
+ @click="clickShowDueDate"
+ >
{{ $options.i18n.addDueDate }}
</gl-button>
</gl-form-group>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
index a7405b6d86c..6b097f6b1ed 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -15,7 +15,6 @@ export default function initWorkItemLinks() {
const {
projectPath,
wiHasIssueWeightsFeature,
- iid,
wiHasIterationsFeature,
wiHasIssuableHealthStatusFeature,
registerPath,
@@ -32,7 +31,6 @@ export default function initWorkItemLinks() {
},
provide: {
projectPath,
- iid,
fullPath: projectPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
index 3a3a846bce5..d119cdc2785 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
@@ -2,8 +2,7 @@
import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/alert';
import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue';
@@ -110,7 +109,9 @@ export default {
return this.isItemOpen ? __('Created') : __('Closed');
},
childPath() {
- return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`;
+ return `${gon?.relative_url_root || ''}/${this.projectPath}/-/work_items/${
+ this.childItem.iid
+ }?iid_path=true`;
},
hasChildren() {
return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren;
@@ -172,7 +173,7 @@ export default {
<template>
<div>
<div
- class="gl-display-flex gl-align-items-flex-start gl-mb-3"
+ class="gl-display-flex gl-align-items-flex-start"
:class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }"
>
<gl-button
@@ -182,18 +183,20 @@ export default {
:aria-label="chevronTooltip"
:icon="chevronType"
category="tertiary"
+ size="small"
:loading="isLoadingChildren"
class="gl-px-0! gl-py-3! gl-mr-3"
data-testid="expand-child"
@click="toggleItem"
/>
<div
- class="gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-bg-white gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
+ class="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-rounded-base"
+ :class="[hasMetadata ? 'gl-py-3' : 'gl-py-0']"
data-testid="links-child"
>
<span
:id="`stateIcon-${childItem.id}`"
- class="gl-mr-3"
+ class="gl-cursor-help gl-mr-3 gl-line-height-32"
:class="{ 'gl-display-flex': hasMetadata }"
data-testid="item-status-icon"
>
@@ -240,7 +243,7 @@ export default {
<work-item-link-child-metadata
v-if="hasMetadata"
:metadata-widgets="metadataWidgets"
- class="gl-mt-3"
+ class="gl-mt-1"
/>
</div>
<div
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
index 6974804523a..80802cb3858 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
@@ -70,7 +70,7 @@ export default {
<item-milestone
v-if="milestone"
:milestone="milestone"
- class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
+ class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
/>
<gl-avatars-inline
v-if="assignees.length"
@@ -97,7 +97,7 @@ export default {
:background-color="label.color"
:description="label.description"
:scoped="showScopedLabel(label)"
- class="gl-mt-3 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm"
+ class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
tooltip-placement="top"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index e8578a6d49a..8f0e429234f 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -4,7 +4,7 @@ import { isEmpty } from 'lodash';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils';
@@ -42,7 +42,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagMixin()],
- inject: ['projectPath', 'iid'],
+ inject: ['projectPath'],
props: {
workItemId: {
type: String,
@@ -63,6 +63,9 @@ export default {
id: this.issuableGid,
};
},
+ context: {
+ isSingleRequest: true,
+ },
skip() {
return !this.issuableId;
},
@@ -86,13 +89,10 @@ export default {
query: getIssueDetailsQuery,
variables() {
return {
- fullPath: this.projectPath,
- iid: String(this.iid),
+ id: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId),
};
},
- update(data) {
- return data.workspace?.issuable;
- },
+ update: (data) => data.issue,
},
},
data() {
@@ -143,7 +143,7 @@ export default {
return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
},
fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
+ return parseBoolean(getParameterByName('iid_path'));
},
childUrlParams() {
const params = {};
@@ -304,10 +304,10 @@ export default {
<template #header>{{ $options.i18n.title }}</template>
<template #header-suffix>
<span
- class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3"
+ class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3 gl-font-weight-bold gl-text-gray-500"
data-testid="children-count"
>
- <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" />
+ <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2" />
{{ childrenCountLabel }}
</span>
</template>
@@ -334,11 +334,11 @@ export default {
</gl-dropdown>
</template>
<template #body>
- <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
+ <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" />
<template v-else>
<div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty">
- <p class="gl-mb-3">
+ <p class="gl-px-3 gl-py-2 gl-mb-0 gl-text-gray-500">
{{ $options.i18n.emptyStateMessage }}
</p>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 5169a77dd33..af475496075 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
@@ -340,7 +340,7 @@ export default {
<template>
<gl-form
- class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
+ class="gl-bg-white gl-mt-1 gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
@submit.prevent="addOrCreateMethod"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
index 1aa4a433a58..fb3ed7af736 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
@@ -11,8 +11,8 @@ export default {
</script>
<template>
- <span class="gl-ml-2">
- <gl-dropdown category="tertiary" toggle-class="btn-icon" :right="true">
+ <span class="gl-ml-5">
+ <gl-dropdown category="tertiary" toggle-class="btn-icon btn-sm" :right="true">
<template #button-content>
<gl-icon name="ellipsis_v" :size="14" />
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index aa12df424f1..97eaf2c0422 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
@@ -78,7 +78,7 @@ export default {
},
computed: {
fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
+ return parseBoolean(getParameterByName('iid_path'));
},
childrenIds() {
return this.children.map((c) => c.id);
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
index 71de6867680..e233a2219fa 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue
@@ -1,5 +1,5 @@
<script>
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue
index 6ed230b8ad4..e75a429ebec 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -234,6 +234,7 @@ export default {
<gl-dropdown
v-else
id="milestone-value"
+ data-testid="work-item-milestone-dropdown"
class="gl-pl-0 gl-max-w-full"
:toggle-class="dropdownClasses"
:text="dropdownText"
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index 02b94c5331c..4ca8054fa5f 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -1,21 +1,34 @@
<script>
import { GlSkeletonLoader, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { s__, __ } from '~/locale';
+import { __ } from '~/locale';
+import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants';
import SystemNote from '~/work_items/components/notes/system_note.vue';
-import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
-import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants';
+import WorkItemNotesActivityHeader from '~/work_items/components/notes/work_item_notes_activity_header.vue';
+import {
+ i18n,
+ DEFAULT_PAGE_SIZE_NOTES,
+ WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+ WORK_ITEM_NOTES_FILTER_ONLY_HISTORY,
+} from '~/work_items/constants';
import { ASC, DESC } from '~/notes/constants';
import { getWorkItemNotesQuery } from '~/work_items/utils';
+import {
+ updateCacheAfterCreatingNote,
+ updateCacheAfterDeletingNote,
+} from '~/work_items/graphql/cache_utils';
+import { getLocationHash } from '~/lib/utils/url_utility';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
+import WorkItemHistoryOnlyFilterNote from '~/work_items/components/notes/work_item_history_only_filter_note.vue';
+import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_item_note_created.subscription.graphql';
+import workItemNoteUpdatedSubscription from '~/work_items/graphql/notes/work_item_note_updated.subscription.graphql';
+import workItemNoteDeletedSubscription from '~/work_items/graphql/notes/work_item_note_deleted.subscription.graphql';
import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql';
import WorkItemAddNote from './notes/work_item_add_note.vue';
export default {
- i18n: {
- ACTIVITY_LABEL: s__('WorkItem|Activity'),
- },
loader: {
repeat: 10,
width: 1000,
@@ -24,10 +37,11 @@ export default {
components: {
GlSkeletonLoader,
GlModal,
- ActivityFilter,
SystemNote,
WorkItemAddNote,
WorkItemDiscussion,
+ WorkItemNotesActivityHeader,
+ WorkItemHistoryOnlyFilterNote,
},
props: {
workItemId: {
@@ -51,6 +65,11 @@ export default {
required: false,
default: false,
},
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -58,6 +77,7 @@ export default {
perPage: DEFAULT_PAGE_SIZE_NOTES,
sortOrder: ASC,
noteToDelete: null,
+ discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
};
},
computed: {
@@ -76,7 +96,7 @@ export default {
showLoadingMoreSkeleton() {
return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading;
},
- disableActivityFilter() {
+ disableActivityFilterSort() {
return this.initialLoading || this.isLoadingMore;
},
formAtTop() {
@@ -95,10 +115,30 @@ export default {
notesArray() {
const notes = this.workItemNotes?.nodes || [];
+ const visibleNotes = notes.filter((note) => {
+ const isSystemNote = this.isSystemNote(note);
+
+ if (this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS && isSystemNote) {
+ return false;
+ }
+
+ if (this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_HISTORY && !isSystemNote) {
+ return false;
+ }
+
+ return true;
+ });
+
if (this.sortOrder === DESC) {
- return [...notes].reverse();
+ return [...visibleNotes].reverse();
}
- return notes;
+ return visibleNotes;
+ },
+ commentsDisabled() {
+ return this.discussionFilter === WORK_ITEM_NOTES_FILTER_ONLY_HISTORY;
+ },
+ targetNoteHash() {
+ return getLocationHash();
},
},
apollo: {
@@ -135,8 +175,55 @@ export default {
if (this.hasNextPage) {
this.fetchMoreNotes();
+ } else if (this.targetNoteHash) {
+ if (this.isModal) {
+ this.$emit('has-notes');
+ } else {
+ scrollToTargetOnResize();
+ }
}
},
+ subscribeToMore: [
+ {
+ document: workItemNoteCreatedSubscription,
+ updateQuery(previousResult, { subscriptionData }) {
+ return updateCacheAfterCreatingNote(previousResult, subscriptionData, this.fetchByIid);
+ },
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId || this.hasNextPage;
+ },
+ },
+ {
+ document: workItemNoteDeletedSubscription,
+ updateQuery(previousResult, { subscriptionData }) {
+ return updateCacheAfterDeletingNote(previousResult, subscriptionData, this.fetchByIid);
+ },
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId || this.hasNextPage;
+ },
+ },
+ {
+ document: workItemNoteUpdatedSubscription,
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ },
+ ],
},
},
methods: {
@@ -162,6 +249,9 @@ export default {
changeNotesSortOrder(direction) {
this.sortOrder = direction;
},
+ filterDiscussions(filterValue) {
+ this.discussionFilter = filterValue;
+ },
async fetchMoreNotes() {
this.isLoadingMore = true;
// copied from discussions batch logic - every fetchMore call has a higher
@@ -223,17 +313,14 @@ export default {
<template>
<div class="gl-border-t gl-mt-5 work-item-notes">
- <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
- <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label>
- <activity-filter
- class="gl-min-h-5 gl-pb-3"
- :loading="disableActivityFilter"
- :sort-order="sortOrder"
- :work-item-type="workItemType"
- @changeSortOrder="changeNotesSortOrder"
- @updateSavedSortOrder="changeNotesSortOrder"
- />
- </div>
+ <work-item-notes-activity-header
+ :sort-order="sortOrder"
+ :disable-activity-filter-sort="disableActivityFilterSort"
+ :work-item-type="workItemType"
+ :discussion-filter="discussionFilter"
+ @changeSort="changeNotesSortOrder"
+ @changeFilter="filterDiscussions"
+ />
<div v-if="initialLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
@@ -250,11 +337,10 @@ export default {
<template v-if="!initialLoading">
<ul class="notes main-notes-list timeline gl-clearfix!">
<work-item-add-note
- v-if="formAtTop"
+ v-if="formAtTop && !commentsDisabled"
v-bind="workItemCommentFormProps"
@error="$emit('error', $event)"
/>
-
<template v-for="discussion in notesArray">
<system-note
v-if="isSystemNote(discussion)"
@@ -270,6 +356,7 @@ export default {
:work-item-id="workItemId"
:fetch-by-iid="fetchByIid"
:work-item-type="workItemType"
+ :is-modal="isModal"
@deleteNote="showDeleteNoteModal($event, discussion)"
@error="$emit('error', $event)"
/>
@@ -277,10 +364,15 @@ export default {
</template>
<work-item-add-note
- v-if="!formAtTop"
+ v-if="!formAtTop && !commentsDisabled"
v-bind="workItemCommentFormProps"
@error="$emit('error', $event)"
/>
+
+ <work-item-history-only-filter-note
+ v-if="commentsDisabled"
+ @changeFilter="filterDiscussions"
+ />
</ul>
</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 81f9bf04bc8..bbcf78e23aa 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -1,5 +1,6 @@
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { ASC, DESC } from '~/notes/constants';
export const STATE_OPEN = 'OPEN';
export const STATE_CLOSED = 'CLOSED';
@@ -176,3 +177,31 @@ export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
export const DEFAULT_PAGE_SIZE_NOTES = 30;
export const WORK_ITEM_NOTES_SORT_ORDER_KEY = 'sort_direction_work_item';
+
+export const WORK_ITEM_NOTES_FILTER_ALL_NOTES = 'ALL_NOTES';
+export const WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS = 'ONLY_COMMENTS';
+export const WORK_ITEM_NOTES_FILTER_ONLY_HISTORY = 'ONLY_HISTORY';
+
+export const WORK_ITEM_NOTES_FILTER_KEY = 'filter_key_work_item';
+
+export const WORK_ITEM_ACTIVITY_FILTER_OPTIONS = [
+ {
+ key: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ text: s__('WorkItem|All activity'),
+ },
+ {
+ key: WORK_ITEM_NOTES_FILTER_ONLY_COMMENTS,
+ text: s__('WorkItem|Comments only'),
+ testid: 'comments-activity',
+ },
+ {
+ key: WORK_ITEM_NOTES_FILTER_ONLY_HISTORY,
+ text: s__('WorkItem|History only'),
+ testid: 'history-activity',
+ },
+];
+
+export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [
+ { key: DESC, text: __('Newest first'), testid: 'newest-first' },
+ { key: ASC, text: __('Oldest first') },
+];
diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js
index 16b892b3476..95d68b69745 100644
--- a/app/assets/javascripts/work_items/graphql/cache_utils.js
+++ b/app/assets/javascripts/work_items/graphql/cache_utils.js
@@ -1,62 +1,100 @@
import { produce } from 'immer';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
-import { getWorkItemNotesQuery } from '~/work_items/utils';
+
+const isNotesWidget = (widget) => widget.type === WIDGET_TYPE_NOTES;
+
+const getNotesWidgetFromSourceData = (draftData, fetchByIid) => {
+ return fetchByIid
+ ? draftData.workspace.workItems.nodes[0].widgets.find(isNotesWidget)
+ : draftData.workItem.widgets.find(isNotesWidget);
+};
+
+const updateNotesWidgetDataInDraftData = (draftData, notesWidget, fetchByIid) => {
+ const noteWidgetIndex = fetchByIid
+ ? draftData.workspace.workItems.nodes[0].widgets.findIndex(isNotesWidget)
+ : draftData.workItem.widgets.findIndex(isNotesWidget);
+
+ if (fetchByIid) {
+ draftData.workspace.workItems.nodes[0].widgets[noteWidgetIndex] = notesWidget;
+ } else {
+ draftData.workItem.widgets[noteWidgetIndex] = notesWidget;
+ }
+};
/**
- * Updates the cache manually when adding a main comment
+ * Work Item note create subscription update query callback
*
- * @param store
- * @param createNoteData
+ * @param currentNotes
+ * @param subscriptionData
* @param fetchByIid
- * @param queryVariables
- * @param sortOrder
*/
-export const updateCommentState = (store, { data: { createNote } }, fetchByIid, queryVariables) => {
- const notesQuery = getWorkItemNotesQuery(fetchByIid);
- const variables = {
- ...queryVariables,
- pageSize: 100,
- };
- const sourceData = store.readQuery({
- query: notesQuery,
- variables,
+
+export const updateCacheAfterCreatingNote = (currentNotes, subscriptionData, fetchByIid) => {
+ if (!subscriptionData.data?.workItemNoteCreated) {
+ return currentNotes;
+ }
+ const newNote = subscriptionData.data.workItemNoteCreated;
+
+ return produce(currentNotes, (draftData) => {
+ const notesWidget = getNotesWidgetFromSourceData(draftData, fetchByIid);
+
+ if (!notesWidget.discussions) {
+ return;
+ }
+
+ const discussion = notesWidget.discussions.nodes.find((d) => d.id === newNote.discussion.id);
+
+ // handle the case where discussion already exists - we don't need to do anything, update will happen automatically
+ if (discussion) {
+ return;
+ }
+
+ notesWidget.discussions.nodes.push(newNote.discussion);
+ updateNotesWidgetDataInDraftData(draftData, notesWidget, fetchByIid);
});
+};
+
+/**
+ * Work Item note delete subscription update query callback
+ *
+ * @param currentNotes
+ * @param subscriptionData
+ * @param fetchByIid
+ */
- const finalData = produce(sourceData, (draftData) => {
- const notesWidget = fetchByIid
- ? draftData.workspace.workItems.nodes[0].widgets.find(
- (widget) => widget.type === WIDGET_TYPE_NOTES,
- )
- : draftData.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_NOTES);
-
- // as notes are currently sorted/reversed on the frontend rather than in the query
- // we only ever push.
- // const arrayPushMethod = sortOrder === ASC ? 'push' : 'unshift';
- const arrayPushMethod = 'push';
-
- // manual update of cache with a completely new discussion
- if (createNote.note.discussion.notes.nodes.length === 1) {
- notesWidget.discussions.nodes[arrayPushMethod]({
- id: createNote.note.discussion.id,
- notes: {
- nodes: createNote.note.discussion.notes.nodes,
- __typename: 'NoteConnection',
- },
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'Discussion',
- });
+export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData, fetchByIid) => {
+ if (!subscriptionData.data?.workItemNoteDeleted) {
+ return currentNotes;
+ }
+ const deletedNote = subscriptionData.data.workItemNoteDeleted;
+ const { id, discussionId, lastDiscussionNote } = deletedNote;
+
+ return produce(currentNotes, (draftData) => {
+ const notesWidget = getNotesWidgetFromSourceData(draftData, fetchByIid);
+
+ if (!notesWidget.discussions) {
+ return;
+ }
+
+ const discussionIndex = notesWidget.discussions.nodes.findIndex(
+ (discussion) => discussion.id === discussionId,
+ );
+
+ if (discussionIndex === -1) {
+ return;
}
- if (fetchByIid) {
- draftData.workspace.workItems.nodes[0].widgets[6] = notesWidget;
+ if (lastDiscussionNote) {
+ notesWidget.discussions.nodes.splice(discussionIndex, 1);
} else {
- draftData.workItem.widgets[6] = notesWidget;
+ const deletedThreadDiscussion = notesWidget.discussions.nodes[discussionIndex];
+ const deletedThreadIndex = deletedThreadDiscussion.notes.nodes.findIndex(
+ (note) => note.id === id,
+ );
+ deletedThreadDiscussion.notes.nodes.splice(deletedThreadIndex, 1);
+ notesWidget.discussions.nodes[discussionIndex] = deletedThreadDiscussion;
}
- });
- store.writeQuery({
- query: notesQuery,
- variables,
- data: finalData,
+ updateNotesWidgetDataInDraftData(draftData, notesWidget, fetchByIid);
});
};
diff --git a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
index daeb58c0947..43dbe8fc2dd 100644
--- a/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/get_issue_details.query.graphql
@@ -1,12 +1,9 @@
-query issuableDetails($fullPath: ID!, $iid: String) {
- workspace: project(fullPath: $fullPath) {
+query issuableDetails($id: IssueID!) {
+ issue(id: $id) {
id
- issuable: issue(iid: $iid) {
+ confidential
+ milestone {
id
- confidential
- milestone {
- id
- }
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
index 52a7a1f8e23..93616c39e55 100644
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
@@ -9,6 +9,7 @@ fragment WorkItemNote on Note {
systemNoteIconName
createdAt
lastEditedAt
+ url
lastEditedBy {
...User
webPath
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql
new file mode 100644
index 00000000000..dc51c53428b
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
+mutation workItemNoteAddAwardEmoji($awardableId: AwardableID!, $name: String!) {
+ awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) {
+ awardEmoji {
+ name
+ description
+ unicode
+ emoji
+ unicodeVersion
+ user {
+ ...User
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
index 7d7bb9c7fc5..b9d4bb29bbf 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -1,40 +1,7 @@
+#import "./work_item.fragment.graphql"
+
query workItemLinksQuery($id: WorkItemID!) {
workItem(id: $id) {
- id
- workItemType {
- id
- name
- }
- title
- userPermissions {
- deleteWorkItem
- updateWorkItem
- }
- confidential
- widgets {
- type
- ... on WorkItemWidgetHierarchy {
- type
- parent {
- id
- }
- children {
- nodes {
- id
- iid
- confidential
- workItemType {
- id
- name
- iconName
- }
- title
- state
- createdAt
- closedAt
- }
- }
- }
- }
+ ...WorkItem
}
}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 6aa63aae172..95709b36594 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -18,6 +18,7 @@ export const initWorkItemsRoot = () => {
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
+ savedRepliesNewPath,
} = el.dataset;
return new Vue({
@@ -35,6 +36,7 @@ export const initWorkItemsRoot = () => {
signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ newSavedRepliesPath: savedRepliesNewPath,
},
render(createElement) {
return createElement(App);
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 2245f984174..ed0163ced3c 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -74,7 +74,7 @@ export default {
return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType);
},
fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath;
+ return true;
},
},
methods: {
diff --git a/app/assets/javascripts/work_items/router/index.js b/app/assets/javascripts/work_items/router/index.js
index 777badeb5be..8d67bcaf84f 100644
--- a/app/assets/javascripts/work_items/router/index.js
+++ b/app/assets/javascripts/work_items/router/index.js
@@ -11,6 +11,6 @@ export function createRouter(fullPath) {
return new VueRouter({
routes: routes(),
mode: 'history',
- base: joinPaths(fullPath, '-', 'work_items'),
+ base: joinPaths(gon?.relative_url_root, fullPath, '-', 'work_items'),
});
}
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 134c2858849..1aa3baca165 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -5,6 +5,7 @@
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
+import autosize from 'autosize';
import Dropzone from 'dropzone';
import $ from 'jquery';
import Mousetrap from 'mousetrap';
@@ -39,6 +40,7 @@ export default class ZenMode {
constructor() {
this.active_backdrop = null;
this.active_textarea = null;
+ this.storedStyle = null;
$(document).on('click', '.js-zen-enter', (e) => {
e.preventDefault();
return $(e.currentTarget).trigger('zen_mode:enter');
@@ -68,6 +70,7 @@ export default class ZenMode {
this.active_backdrop.addClass('fullscreen');
this.active_textarea = this.active_backdrop.find('textarea');
// Prevent a user-resized textarea from persisting to fullscreen
+ this.storedStyle = this.active_textarea.attr('style');
this.active_textarea.removeAttr('style');
this.active_textarea.focus();
}
@@ -77,6 +80,11 @@ export default class ZenMode {
Mousetrap.unpause();
this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen');
scrollToElement(this.active_textarea, { duration: 0, offset: -100 });
+ this.active_textarea.attr('style', this.storedStyle);
+
+ autosize(this.active_textarea);
+ autosize.update(this.active_textarea);
+
this.active_textarea = null;
this.active_backdrop = null;