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:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-05-17 19:05:49 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-05-17 19:05:49 +0300
commit43a25d93ebdabea52f99b05e15b06250cd8f07d7 (patch)
treedceebdc68925362117480a5d672bcff122fb625b /app/assets/javascripts
parent20c84b99005abd1c82101dfeff264ac50d2df211 (diff)
Add latest changes from gitlab-org/gitlab@16-0-stable-eev16.0.0-rc42
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_level/constants.js20
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue2
-rw-r--r--app/assets/javascripts/achievements/components/achievements_app.vue31
-rw-r--r--app/assets/javascripts/achievements/constants.js7
-rw-r--r--app/assets/javascripts/achievements/routes.js16
-rw-r--r--app/assets/javascripts/activities.js2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue126
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/date_option.vue17
-rw-r--r--app/assets/javascripts/add_context_commits_modal/components/token.vue28
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/actions.js30
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/index.js2
-rw-r--r--app/assets/javascripts/add_context_commits_modal/store/state.js1
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue35
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/history_items.vue51
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/report_header.vue46
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/reported_content.vue141
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/user_detail.vue27
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/user_details.vue115
-rw-r--r--app/assets/javascripts/admin/abuse_report/constants.js61
-rw-r--r--app/assets/javascripts/admin/abuse_report/index.js27
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue177
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue56
-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.js90
-rw-r--r--app/assets/javascripts/admin/abuse_reports/index.js31
-rw-r--r--app/assets/javascripts/admin/abuse_reports/utils.js9
-rw-r--r--app/assets/javascripts/admin/application_settings/network_outbound.js28
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/base.vue6
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form.vue109
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue34
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue24
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/constants.js2
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/edit.js4
-rw-r--r--app/assets/javascripts/admin/broadcast_messages/index.js11
-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.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/approve.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/ban.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/block.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/deactivate.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue16
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue24
-rw-r--r--app/assets/javascripts/admin/users/components/actions/reject.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unban.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unlock.vue14
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue77
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue4
-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/alert_management/components/alert_management_table.vue3
-rw-r--r--app/assets/javascripts/alert_management/constants.js4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue2
-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.js4
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js3
-rw-r--r--app/assets/javascripts/alerts_settings/utils/cache_updates.js2
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/bundle.js1
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/base.vue41
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue17
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue11
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue4
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue8
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/constants.js10
-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.js44
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_tile.vue4
-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.vue31
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js39
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js34
-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/projects_api.js9
-rw-r--r--app/assets/javascripts/api/user_api.js22
-rw-r--r--app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql4
-rw-r--r--app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue2
-rw-r--r--app/assets/javascripts/authentication/mount_2fa.js26
-rw-r--r--app/assets/javascripts/authentication/password/components/password_input.vue97
-rw-r--r--app/assets/javascripts/authentication/password/constants.js4
-rw-r--r--app/assets/javascripts/authentication/password/index.js36
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/manage_two_factor_form.vue12
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue2
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/index.js2
-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.vue33
-rw-r--r--app/assets/javascripts/batch_comments/index.js13
-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.js6
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js1
-rw-r--r--app/assets/javascripts/behaviors/components/json_table.vue1
-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.js7
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js6
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_json_table.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js23
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_observability.js57
-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.js37
-rw-r--r--app/assets/javascripts/behaviors/shortcuts.js6
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js95
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js123
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js8
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js20
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js22
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js58
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js16
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js6
-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/components/blob_edit_header.vue4
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue2
-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/openapi/index.js13
-rw-r--r--app/assets/javascripts/blob/sketch_viewer.js3
-rw-r--r--app/assets/javascripts/blob/template_selector.js3
-rw-r--r--app/assets/javascripts/blob/viewer/index.js2
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js7
-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.vue124
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue39
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue124
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue148
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue42
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue30
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue28
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue233
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue96
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue45
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue39
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue9
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue28
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue9
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue38
-rw-r--r--app/assets/javascripts/boards/constants.js29
-rw-r--r--app/assets/javascripts/boards/graphql/board_lists.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql9
-rw-r--r--app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql7
-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/components/delete_merged_branches.vue45
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js2
-rw-r--r--app/assets/javascripts/branches/init_new_branch_ref_selector.js1
-rw-r--r--app/assets/javascripts/build_artifacts.js3
-rw-r--r--app/assets/javascripts/ci/artifacts/components/app.vue (renamed from app/assets/javascripts/artifacts/components/app.vue)8
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue (renamed from app/assets/javascripts/artifacts/components/artifact_delete_modal.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifact_row.vue (renamed from app/assets/javascripts/artifacts/components/artifact_row.vue)56
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue78
-rw-r--r--app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue (renamed from app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue)18
-rw-r--r--app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue73
-rw-r--r--app/assets/javascripts/ci/artifacts/components/feedback_banner.vue (renamed from app/assets/javascripts/artifacts/components/feedback_banner.vue)0
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue (renamed from app/assets/javascripts/artifacts/components/job_artifacts_table.vue)186
-rw-r--r--app/assets/javascripts/ci/artifacts/components/job_checkbox.vue70
-rw-r--r--app/assets/javascripts/ci/artifacts/constants.js (renamed from app/assets/javascripts/artifacts/constants.js)43
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/cache_update.js (renamed from app/assets/javascripts/artifacts/graphql/cache_update.js)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql7
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql (renamed from app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql (renamed from app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql (renamed from app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/artifacts/index.js (renamed from app/assets/javascripts/artifacts/index.js)10
-rw-r--r--app/assets/javascripts/ci/artifacts/utils.js (renamed from app/assets/javascripts/artifacts/utils.js)0
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue97
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue17
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue14
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue116
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue45
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js21
-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_environments.query.graphql4
-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.js11
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue9
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue26
-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/artifacts_and_cache_item.vue104
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue50
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue90
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue105
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue90
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js113
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue168
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js53
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue11
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/constants.js18
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/event_hub.js5
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql8
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/index.js6
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue26
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue32
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue93
-rw-r--r--app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql2
-rw-r--r--app/assets/javascripts/ci/pipeline_new/index.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_new/utils/format_refs.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/cells/pipeline_schedule_last_pipeline.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_schedules/components/table/pipeline_schedules_table.vue12
-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/codequality_report/constants.js2
-rw-r--r--app/assets/javascripts/ci/reports/components/report_item.vue2
-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.vue84
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/index.js8
-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.vue6
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue30
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/index.js4
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue71
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue2
-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_compatibility_alert.vue50
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue80
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue41
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue256
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token.vue6
-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.js81
-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.vue120
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs.vue7
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue27
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_table.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue27
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_pause_button.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue12
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue26
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_projects.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_update_form.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue2
-rw-r--r--app/assets/javascripts/ci/runner/constants.js72
-rw-r--r--app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql7
-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/graphql/show/runner_details_shared.fragment.graphql2
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue83
-rw-r--r--app/assets/javascripts/ci/runner/group_new_runner/index.js32
-rw-r--r--app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue69
-rw-r--r--app/assets/javascripts/ci/runner/group_register_runner/index.js36
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue6
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue46
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/index.js6
-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/project_new_runner/index.js32
-rw-r--r--app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue83
-rw-r--r--app/assets/javascripts/ci/runner/project_register_runner/index.js36
-rw-r--r--app/assets/javascripts/ci/runner/project_register_runner/project_register_runner_app.vue69
-rw-r--r--app/assets/javascripts/ci/runner/project_runners/register/index.js39
-rw-r--r--app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/activity_events_list.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/create_token_modal.vue2
-rw-r--r--app/assets/javascripts/clusters/agents/components/revoke_token_button.vue14
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue3
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js1
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql9
-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/agent_table.vue42
-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/code_review/signals.js51
-rw-r--r--app/assets/javascripts/comment_templates/components/app.vue (renamed from app/assets/javascripts/saved_replies/components/app.vue)6
-rw-r--r--app/assets/javascripts/comment_templates/components/form.vue182
-rw-r--r--app/assets/javascripts/comment_templates/components/list.vue67
-rw-r--r--app/assets/javascripts/comment_templates/components/list_item.vue116
-rw-r--r--app/assets/javascripts/comment_templates/index.js (renamed from app/assets/javascripts/saved_replies/index.js)4
-rw-r--r--app/assets/javascripts/comment_templates/pages/edit.vue68
-rw-r--r--app/assets/javascripts/comment_templates/pages/index.vue67
-rw-r--r--app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql10
-rw-r--r--app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql5
-rw-r--r--app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql10
-rw-r--r--app/assets/javascripts/comment_templates/queries/saved_replies.query.graphql (renamed from app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql)4
-rw-r--r--app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql10
-rw-r--r--app/assets/javascripts/comment_templates/routes.js (renamed from app/assets/javascripts/saved_replies/routes.js)7
-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/pipelines/pipelines_bundle.js11
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue1
-rw-r--r--app/assets/javascripts/commit_merge_requests.js2
-rw-r--r--app/assets/javascripts/commons/bootstrap.js4
-rw-r--r--app/assets/javascripts/commons/nav/user_merge_requests.js12
-rw-r--r--app/assets/javascripts/commons/vue.js2
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue2
-rw-r--r--app/assets/javascripts/constants.js3
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue133
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue167
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue83
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue109
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_alert.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue5
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue15
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue191
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue135
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue64
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue5
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue109
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue129
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue10
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/details.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference.vue45
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference_label.vue (renamed from app/assets/javascripts/content_editor/components/wrappers/label.vue)0
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue4
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js11
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js36
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/bullet_list.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/color_chip.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_item.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_list.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/details_content.js12
-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/figure.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/figure_caption.js12
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_reference.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnotes_section.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/heading.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js26
-rw-r--r--app/assets/javascripts/content_editor/extensions/list_item.js13
-rw-r--r--app/assets/javascripts/content_editor/extensions/loading.js24
-rw-r--r--app/assets/javascripts/content_editor/extensions/ordered_list.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/paragraph.js13
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js34
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_label.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/selection.js26
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js52
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_item.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_list.js13
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js16
-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.js6
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js2
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js24
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js262
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js22
-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_keys/components/key.vue22
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue6
-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_jquery_dropdown/gl_dropdown_filter.js8
-rw-r--r--app/assets/javascripts/deprecated_notes.js74
-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/list/item.vue10
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue3
-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.vue85
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue21
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js11
-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.vue189
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue34
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality_item.vue54
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue12
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue54
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue40
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue12
-rw-r--r--app/assets/javascripts/diffs/components/file_row_stats.vue2
-rw-r--r--app/assets/javascripts/diffs/components/hidden_files_warning.vue22
-rw-r--r--app/assets/javascripts/diffs/components/shared/findings_drawer.vue110
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue113
-rw-r--r--app/assets/javascripts/diffs/constants.js12
-rw-r--r--app/assets/javascripts/diffs/i18n.js6
-rw-r--r--app/assets/javascripts/diffs/index.js18
-rw-r--r--app/assets/javascripts/diffs/store/actions.js164
-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/diff_file.js4
-rw-r--r--app/assets/javascripts/diffs/utils/merge_request.js23
-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/docs/docs_bundle.js2
-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.js46
-rw-r--r--app/assets/javascripts/editor/schema/ci.json81
-rw-r--r--app/assets/javascripts/editor/utils.js9
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js6
-rw-r--r--app/assets/javascripts/emoji/components/emoji_group.vue5
-rw-r--r--app/assets/javascripts/emoji/index.js4
-rw-r--r--app/assets/javascripts/entrypoints/super_sidebar.js6
-rw-r--r--app/assets/javascripts/entrypoints/tracker.js50
-rw-r--r--app/assets/javascripts/environments/components/canary_update_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue26
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue4
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue2
-rw-r--r--app/assets/javascripts/environments/components/deploy_freeze_alert.vue79
-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/empty_state.vue51
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue49
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue17
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue14
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue97
-rw-r--r--app/assets/javascripts/environments/components/environments_detail_header.vue161
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_agent_info.vue99
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_overview.vue117
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_pods.vue111
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_summary.vue180
-rw-r--r--app/assets/javascripts/environments/components/kubernetes_tabs.vue166
-rw-r--r--app/assets/javascripts/environments/components/new_environment.vue2
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue52
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue7
-rw-r--r--app/assets/javascripts/environments/constants.js4
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_actions.vue93
-rw-r--r--app/assets/javascripts/environments/environment_details/constants.js7
-rw-r--r--app/assets/javascripts/environments/environment_details/deployments_table.vue14
-rw-r--r--app/assets/javascripts/environments/environment_details/index.vue42
-rw-r--r--app/assets/javascripts/environments/graphql/client.js79
-rw-r--r--app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql6
-rw-r--r--app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql3
-rw-r--r--app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql2
-rw-r--r--app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql12
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql27
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql15
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql7
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql15
-rw-r--r--app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql50
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js137
-rw-r--r--app/assets/javascripts/environments/graphql/typedefs.graphql104
-rw-r--r--app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js56
-rw-r--r--app/assets/javascripts/environments/helpers/k8s_integration_helper.js141
-rw-r--r--app/assets/javascripts/environments/index.js3
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js2
-rw-r--r--app/assets/javascripts/environments/mount_show.js18
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue90
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details_info.vue174
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue16
-rw-r--r--app/assets/javascripts/error_tracking/events_tracking.js60
-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/utils.js36
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js2
-rw-r--r--app/assets/javascripts/featurable/constants.js6
-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/feature_flags.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue25
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue4
-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.js38
-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/droplab/plugins/input_setter.js16
-rw-r--r--app/assets/javascripts/filtered_search/droplab/utils.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js15
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js4
-rw-r--r--app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue4
-rw-r--r--app/assets/javascripts/gl_field_error.js2
-rw-r--r--app/assets/javascripts/google_cloud/aiml/panel.vue63
-rw-r--r--app/assets/javascripts/google_cloud/aiml/service_table.vue115
-rw-r--r--app/assets/javascripts/google_cloud/components/google_cloud_menu.vue21
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js11
-rw-r--r--app/assets/javascripts/gpg_badges.js2
-rw-r--r--app/assets/javascripts/grafana_integration/index.js3
-rw-r--r--app/assets/javascripts/grafana_integration/store/actions.js4
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js3
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js65
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json28
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_users_by_usernames.query.graphql9
-rw-r--r--app/assets/javascripts/graphql_shared/queries/merge_request.query.graphql9
-rw-r--r--app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql8
-rw-r--r--app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql (renamed from app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql)4
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js52
-rw-r--r--app/assets/javascripts/group.js2
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue38
-rw-r--r--app/assets/javascripts/group_settings/constants.js11
-rw-r--r--app/assets/javascripts/group_settings/mount_shared_runners.js5
-rw-r--r--app/assets/javascripts/groups/components/app.vue4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue4
-rw-r--r--app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue1
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue8
-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/components/item_stats.vue16
-rw-r--r--app/assets/javascripts/groups/constants.js36
-rw-r--r--app/assets/javascripts/groups/index.js2
-rw-r--r--app/assets/javascripts/groups/init_group_readme.js26
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js2
-rw-r--r--app/assets/javascripts/groups/settings/components/access_dropdown.vue2
-rw-r--r--app/assets/javascripts/groups/settings/components/group_settings_readme.vue147
-rw-r--r--app/assets/javascripts/groups/settings/constants.js4
-rw-r--r--app/assets/javascripts/groups/settings/init_group_settings_readme.js24
-rw-r--r--app/assets/javascripts/groups/store/utils.js6
-rw-r--r--app/assets/javascripts/header.js8
-rw-r--r--app/assets/javascripts/header_search/components/app.vue109
-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.js50
-rw-r--r--app/assets/javascripts/header_search/init.js36
-rw-r--r--app/assets/javascripts/header_search/store/getters.js20
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue6
-rw-r--r--app/assets/javascripts/ide/components/pipelines/empty_state.vue1
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue26
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue1
-rw-r--r--app/assets/javascripts/ide/components/shared/commit_message_field.vue2
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js1
-rw-r--r--app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js14
-rw-r--r--app/assets/javascripts/ide/lib/languages/codeowners.js39
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions.js2
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js5
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js2
-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/import/constants.js28
-rw-r--r--app/assets/javascripts/import/details/api.js11
-rw-r--r--app/assets/javascripts/import/details/components/import_details_app.vue18
-rw-r--r--app/assets/javascripts/import/details/components/import_details_table.vue160
-rw-r--r--app/assets/javascripts/import/details/index.js23
-rw-r--r--app/assets/javascripts/import_entities/components/group_dropdown.vue6
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue84
-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.vue26
-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/components/github_organizations_box.vue73
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue104
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue111
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue96
-rw-r--r--app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql18
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js11
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/actions.js14
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js4
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/state.js2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue8
-rw-r--r--app/assets/javascripts/incidents/constants.js10
-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/init_diff_stats_dropdown.js7
-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.vue12
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue21
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_notification.vue20
-rw-r--r--app/assets/javascripts/invite_members/components/invite_groups_modal.vue8
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue107
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue80
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue42
-rw-r--r--app/assets/javascripts/invite_members/components/project_select.vue4
-rw-r--r--app/assets/javascripts/invite_members/constants.js28
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js10
-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.vue83
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue12
-rw-r--r--app/assets/javascripts/issuable/components/issue_assignees.vue1
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue119
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue20
-rw-r--r--app/assets/javascripts/issuable/constants.js10
-rw-r--r--app/assets/javascripts/issuable/index.js4
-rw-r--r--app/assets/javascripts/issuable/issuable_bulk_update_actions.js2
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js35
-rw-r--r--app/assets/javascripts/issuable/issuable_label_selector.js13
-rw-r--r--app/assets/javascripts/issuable/mixins/related_issuable_mixin.js12
-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/issuable/popover/index.js1
-rw-r--r--app/assets/javascripts/issues/constants.js35
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js129
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue57
-rw-r--r--app/assets/javascripts/issues/index.js19
-rw-r--r--app/assets/javascripts/issues/issue.js2
-rw-r--r--app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue18
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue15
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue106
-rw-r--r--app/assets/javascripts/issues/list/constants.js34
-rw-r--r--app/assets/javascripts/issues/list/graphql.js14
-rw-r--r--app/assets/javascripts/issues/list/index.js8
-rw-r--r--app/assets/javascripts/issues/list/utils.js107
-rw-r--r--app/assets/javascripts/issues/manual_ordering.js2
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions_item.vue3
-rw-r--r--app/assets/javascripts/issues/new/components/type_select.vue113
-rw-r--r--app/assets/javascripts/issues/new/index.js27
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue33
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/store/actions.js2
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue70
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue196
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue12
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue78
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description_template.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue16
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue32
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue197
-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.vue8
-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/new_header_actions_popover.vue82
-rw-r--r--app/assets/javascripts/issues/show/components/task_list_item_actions.vue42
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue28
-rw-r--r--app/assets/javascripts/issues/show/constants.js3
-rw-r--r--app/assets/javascripts/issues/show/index.js18
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/api.js9
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue48
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue28
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue40
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue39
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue15
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue21
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue22
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue9
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue101
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/actions.js32
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/mutations.js9
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/state.js3
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/utils.js27
-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.vue150
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue8
-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_detail_row.vue22
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue10
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue22
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue22
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/duration_cell.vue4
-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.vue17
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue96
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue51
-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/jobs/store/mutations.js3
-rw-r--r--app/assets/javascripts/jobs/store/utils.js30
-rw-r--r--app/assets/javascripts/labels/components/delete_label_modal.vue11
-rw-r--r--app/assets/javascripts/labels/components/promote_label_modal.vue4
-rw-r--r--app/assets/javascripts/labels/create_label_dropdown.js25
-rw-r--r--app/assets/javascripts/labels/group_label_subscription.js5
-rw-r--r--app/assets/javascripts/labels/index.js5
-rw-r--r--app/assets/javascripts/labels/label_manager.js4
-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/apollo/indexed_db_persistent_storage.js97
-rw-r--r--app/assets/javascripts/lib/apollo/local_db.js14
-rw-r--r--app/assets/javascripts/lib/graphql.js69
-rw-r--r--app/assets/javascripts/lib/mermaid.js11
-rw-r--r--app/assets/javascripts/lib/mousetrap.js59
-rw-r--r--app/assets/javascripts/lib/swagger.js9
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js38
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js28
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js2
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue7
-rw-r--r--app/assets/javascripts/lib/utils/constants.js5
-rw-r--r--app/assets/javascripts/lib/utils/css_utils.js25
-rw-r--r--app/assets/javascripts/lib/utils/datetime/constants.js7
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/datetime/time_spent_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime/timeago_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/error_message.js15
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js7
-rw-r--r--app/assets/javascripts/lib/utils/keys.js4
-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/secret_detection.js45
-rw-r--r--app/assets/javascripts/lib/utils/sticky.js60
-rw-r--r--app/assets/javascripts/lib/utils/tappable_promise.js49
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js57
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js45
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js14
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/mark_raw.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/normalize_children.js11
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js78
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_router.js115
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vuex.js38
-rw-r--r--app/assets/javascripts/lib/utils/web_ide_navigator.js24
-rw-r--r--app/assets/javascripts/listbox/redirect_behavior.js4
-rw-r--r--app/assets/javascripts/locale/ensure_single_line.cjs2
-rw-r--r--app/assets/javascripts/locale/sprintf.js4
-rw-r--r--app/assets/javascripts/main.js14
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue16
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue23
-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/merge_conflict_resolver_app.vue15
-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.js69
-rw-r--r--app/assets/javascripts/merge_requests/components/compare_dropdown.vue8
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue30
-rw-r--r--app/assets/javascripts/merge_requests/queries/title.subscription.graphql8
-rw-r--r--app/assets/javascripts/milestones/components/delete_milestone_modal.vue8
-rw-r--r--app/assets/javascripts/milestones/components/promote_milestone_modal.vue6
-rw-r--r--app/assets/javascripts/milestones/index.js9
-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/components/delete_button.vue104
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue115
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue200
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue35
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/constants.js19
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue41
-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.vue123
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js17
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue26
-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.vue254
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js24
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue5
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue61
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue6
-rw-r--r--app/assets/javascripts/monitoring/constants.js19
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js4
-rw-r--r--app/assets/javascripts/monitoring/utils.js9
-rw-r--r--app/assets/javascripts/mr_notes/index.js23
-rw-r--r--app/assets/javascripts/mr_notes/init.js4
-rw-r--r--app/assets/javascripts/mr_notes/init_mr_notes.js22
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js11
-rw-r--r--app/assets/javascripts/mr_notes/mount_app.js6
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/actions.js5
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/getters.js1
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/index.js13
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js3
-rw-r--r--app/assets/javascripts/mr_notes/stores/drawer/mutations.js7
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js2
-rw-r--r--app/assets/javascripts/namespaces/leave_by_url.js2
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue35
-rw-r--r--app/assets/javascripts/nav/components/top_nav_new_dropdown.vue17
-rw-r--r--app/assets/javascripts/new_branch_form.js5
-rw-r--r--app/assets/javascripts/new_commit_form.js3
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe.vue46
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe_util.js108
-rw-r--r--app/assets/javascripts/notebook/cells/output/error.vue40
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue14
-rw-r--r--app/assets/javascripts/notebook/index.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_field_layout.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue144
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue76
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue3
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue3
-rw-r--r--app/assets/javascripts/notes/components/mr_discussion_filter.vue109
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue101
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue16
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue46
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue121
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue43
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue16
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue16
-rw-r--r--app/assets/javascripts/notes/components/notes_activity_header.vue29
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue14
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue6
-rw-r--r--app/assets/javascripts/notes/constants.js86
-rw-r--r--app/assets/javascripts/notes/i18n.js5
-rw-r--r--app/assets/javascripts/notes/index.js11
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js30
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js2
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js10
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js38
-rw-r--r--app/assets/javascripts/notes/stores/getters.js41
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js8
-rw-r--r--app/assets/javascripts/notes/utils.js8
-rw-r--r--app/assets/javascripts/oauth_application/components/oauth_secret.vue106
-rw-r--r--app/assets/javascripts/oauth_application/constants.js20
-rw-r--r--app/assets/javascripts/oauth_application/index.js21
-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/index.js2
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_modal.vue (renamed from app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue)42
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue18
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue93
-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/constants/details.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql1
-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/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue75
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue50
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue59
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue44
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue47
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql1
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/index.js3
-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/infrastructure_title.vue2
-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.vue92
-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.vue140
-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.vue21
-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.vue11
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue80
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js42
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql8
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js23
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql39
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql38
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue121
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue48
-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/components/packages_forwarding_settings.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js11
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue8
-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/expiration_toggle.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/settings/project/constants.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/utils.js9
-rw-r--r--app/assets/javascripts/pages/abuse_reports/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue39
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js2
-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/applications/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue (renamed from app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue)0
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue (renamed from app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue)8
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/constants.js29
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue26
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue259
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue28
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue39
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js62
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql85
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql5
-rw-r--r--app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql5
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/constants.js12
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue19
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js17
-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/achievements/index.js43
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue30
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js14
-rw-r--r--app/assets/javascripts/pages/groups/runners/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/runners/register/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js6
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js2
-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/github/details/index.js3
-rw-r--r--app/assets/javascripts/pages/import/github/status/index.js9
-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/import/phabricator/new/index.js3
-rw-r--r--app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js7
-rw-r--r--app/assets/javascripts/pages/oauth/applications/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/comment_templates/index.js3
-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/saved_replies/index.js3
-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/artifacts/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/blame/streaming/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js88
-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/compare/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/cycle_analytics/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue8
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue4
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue8
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js1
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js6
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/page.js8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/ml/candidates/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/index/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js32
-rw-r--r--app/assets/javascripts/pages/projects/network/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue2
-rw-r--r--app/assets/javascripts/pages/projects/project.js147
-rw-r--r--app/assets/javascripts/pages/projects/runners/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/runners/register/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js4
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue9
-rw-r--r--app/assets/javascripts/pages/projects/shared/web_ide_link/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/usage_quotas/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/wikis/diff/index.js4
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js9
-rw-r--r--app/assets/javascripts/pages/sessions/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.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.vue6
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js11
-rw-r--r--app/assets/javascripts/pages/time_tracking/timelogs/index.js3
-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/performance_bar/components/add_request.vue11
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue105
-rw-r--r--app/assets/javascripts/performance_bar/index.js16
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js9
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js35
-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/graph_component_wrapper.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js39
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue36
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/utils.js33
-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/pipeline_tabs.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue152
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue82
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue6
-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.js7
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql6
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql24
-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.js13
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js15
-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.vue33
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue36
-rw-r--r--app/assets/javascripts/profile/components/user_achievements.vue98
-rw-r--r--app/assets/javascripts/profile/constants.js7
-rw-r--r--app/assets/javascripts/profile/gl_crop.js17
-rw-r--r--app/assets/javascripts/profile/index.js38
-rw-r--r--app/assets/javascripts/profile/preferences/components/diffs_colors.vue10
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue6
-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/commit_options_dropdown.vue2
-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/commit_box/info/index.js3
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_details_button.js18
-rw-r--r--app/assets/javascripts/projects/commit_box/info/load_branches.js3
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue6
-rw-r--r--app/assets/javascripts/projects/commits/index.js18
-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/default_project_templates.js8
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue54
-rw-r--r--app/assets/javascripts/projects/new/index.js10
-rw-r--r--app/assets/javascripts/projects/project_find_file.js2
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js33
-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.vue77
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue3
-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/components/shared_runners_toggle.vue25
-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.vue13
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue19
-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/settings_service_desk/components/service_desk_root.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue6
-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.js11
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js63
-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/ref/components/ref_selector.vue9
-rw-r--r--app/assets/javascripts/ref/stores/actions.js4
-rw-r--r--app/assets/javascripts/ref/stores/index.js8
-rw-r--r--app/assets/javascripts/ref/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ref/stores/mutations.js3
-rw-r--r--app/assets/javascripts/ref/stores/state.js1
-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.vue119
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue14
-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/related_issues/index.js1
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue20
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue18
-rw-r--r--app/assets/javascripts/releases/components/tag_create.vue91
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue283
-rw-r--r--app/assets/javascripts/releases/components/tag_search.vue121
-rw-r--r--app/assets/javascripts/releases/constants.js5
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql1
-rw-r--r--app/assets/javascripts/releases/mount_new.js2
-rw-r--r--app/assets/javascripts/releases/release_notification_service.js25
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js24
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/constants.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js31
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js6
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js14
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js4
-rw-r--r--app/assets/javascripts/releases/util.js2
-rw-r--r--app/assets/javascripts/repository/commits_service.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue18
-rw-r--r--app/assets/javascripts/repository/components/blob_controls.vue3
-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.vue249
-rw-r--r--app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue137
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue18
-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.js10
-rw-r--r--app/assets/javascripts/repository/event_hub.js3
-rw-r--r--app/assets/javascripts/repository/index.js33
-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.js8
-rw-r--r--app/assets/javascripts/saved_replies/components/list.vue57
-rw-r--r--app/assets/javascripts/saved_replies/components/list_item.vue19
-rw-r--r--app/assets/javascripts/saved_replies/pages/index.vue15
-rw-r--r--app/assets/javascripts/search/index.js1
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue32
-rw-r--r--app/assets/javascripts/search/sidebar/components/checkbox_filter.vue45
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/data.js (renamed from app/assets/javascripts/search/sidebar/constants/language_filter_data.js)0
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/index.vue (renamed from app/assets/javascripts/search/sidebar/components/language_filter.vue)88
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter/tracking.js39
-rw-r--r--app/assets/javascripts/search/sidebar/components/radio_filter.vue12
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue15
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue28
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue40
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue8
-rw-r--r--app/assets/javascripts/search/sidebar/constants/index.js4
-rw-r--r--app/assets/javascripts/search/sidebar/utils.js29
-rw-r--r--app/assets/javascripts/search/store/actions.js28
-rw-r--r--app/assets/javascripts/search/store/constants.js14
-rw-r--r--app/assets/javascripts/search/store/getters.js27
-rw-r--r--app/assets/javascripts/search/store/index.js4
-rw-r--r--app/assets/javascripts/search/store/mutations.js2
-rw-r--r--app/assets/javascripts/search/store/state.js3
-rw-r--r--app/assets/javascripts/search/store/utils.js36
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue79
-rw-r--r--app/assets/javascripts/search_autocomplete.js520
-rw-r--r--app/assets/javascripts/search_autocomplete_utils.js19
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue53
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js68
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue58
-rw-r--r--app/assets/javascripts/security_configuration/components/upgrade_banner.vue61
-rw-r--r--app/assets/javascripts/security_configuration/index.js6
-rw-r--r--app/assets/javascripts/security_configuration/utils.js8
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue174
-rw-r--r--app/assets/javascripts/self_monitor/index.js20
-rw-r--r--app/assets/javascripts/self_monitor/store/actions.js127
-rw-r--r--app/assets/javascripts/self_monitor/store/index.js21
-rw-r--r--app/assets/javascripts/self_monitor/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/self_monitor/store/mutations.js22
-rw-r--r--app/assets/javascripts/self_monitor/store/state.js15
-rw-r--r--app/assets/javascripts/sentry/index.js5
-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.vue31
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue17
-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.vue34
-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/constants.js5
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue16
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue4
-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_vue/store/getters.js12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js4
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js14
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue29
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js8
-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.vue29
-rw-r--r--app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issue_button.vue42
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issues_button.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue12
-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/reviewers/uncollapsed_reviewer_list.vue69
-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.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue14
-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/components/toggle/toggle_sidebar.vue13
-rw-r--r--app/assets/javascripts/sidebar/constants.js56
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js4
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js66
-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/sidebar/stores/sidebar_store.js4
-rw-r--r--app/assets/javascripts/sidebar/utils.js2
-rw-r--r--app/assets/javascripts/single_file_diff.js6
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue14
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue26
-rw-r--r--app/assets/javascripts/snippets/components/snippet_visibility_edit.vue2
-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.vue255
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue37
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue13
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue77
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue96
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue316
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue90
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue38
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue54
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/constants.js28
-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.js222
-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.js5
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js26
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/store/state.js19
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/utils.js81
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue81
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue165
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue58
-rw-r--r--app/assets/javascripts/super_sidebar/components/menu_section.vue122
-rw-r--r--app/assets/javascripts/super_sidebar/components/merge_request_menu.vue23
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item.vue159
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item_link.vue35
-rw-r--r--app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue37
-rw-r--r--app/assets/javascripts/super_sidebar/components/pinned_section.vue101
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue82
-rw-r--r--app/assets/javascripts/super_sidebar/components/search_results.vue99
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_menu.vue178
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue122
-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.vue164
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue80
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue185
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue340
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_name_group.vue90
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js54
-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.js59
-rw-r--r--app/assets/javascripts/super_sidebar/popper_max_size_modifier.js43
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_bundle.js109
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js61
-rw-r--r--app/assets/javascripts/super_sidebar/user_counts_manager.js69
-rw-r--r--app/assets/javascripts/super_sidebar/utils.js87
-rw-r--r--app/assets/javascripts/syntax_highlight.js7
-rw-r--r--app/assets/javascripts/tags/init_new_tag_ref_selector.js1
-rw-r--r--app/assets/javascripts/task_list.js2
-rw-r--r--app/assets/javascripts/terraform/components/empty_state.vue29
-rw-r--r--app/assets/javascripts/terraform/components/init_command_modal.vue9
-rw-r--r--app/assets/javascripts/terraform/components/states_table_actions.vue4
-rw-r--r--app/assets/javascripts/terraform/components/terraform_list.vue2
-rw-r--r--app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql69
-rw-r--r--app/assets/javascripts/time_tracking/components/timelog_source_cell.vue50
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_app.vue229
-rw-r--r--app/assets/javascripts/time_tracking/components/timelogs_table.vue105
-rw-r--r--app/assets/javascripts/time_tracking/index.js32
-rw-r--r--app/assets/javascripts/toggles/index.js16
-rw-r--r--app/assets/javascripts/token_access/components/inbound_token_access.vue4
-rw-r--r--app/assets/javascripts/token_access/components/opt_in_jwt.vue125
-rw-r--r--app/assets/javascripts/token_access/components/outbound_token_access.vue53
-rw-r--r--app/assets/javascripts/token_access/components/token_access_app.vue13
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue7
-rw-r--r--app/assets/javascripts/token_access/constants.js14
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql4
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql4
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql8
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql8
-rw-r--r--app/assets/javascripts/token_access/index.js1
-rw-r--r--app/assets/javascripts/tracking/constants.js4
-rw-r--r--app/assets/javascripts/tracking/dispatch_snowplow_event.js9
-rw-r--r--app/assets/javascripts/tracking/index.js12
-rw-r--r--app/assets/javascripts/tracking/tracker.js10
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue12
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue1
-rw-r--r--app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue39
-rw-r--r--app/assets/javascripts/usage_quotas/storage/constants.js38
-rw-r--r--app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql1
-rw-r--r--app/assets/javascripts/user_lists/components/add_user_modal.vue4
-rw-r--r--app/assets/javascripts/user_lists/store/edit/actions.js4
-rw-r--r--app/assets/javascripts/user_lists/store/new/actions.js4
-rw-r--r--app/assets/javascripts/users_select/index.js18
-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/visibility_level/constants.js32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue141
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue153
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue95
-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/approvals/queries/approvals.subscription.graphql17
-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/deployment/deployment_view_button.vue46
-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/extensions/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js104
-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/mr_widget_how_to_merge_modal.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue218
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue89
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js67
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue92
-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.js21
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue56
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue4
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue6
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/router.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue12
-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.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue9
-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/diff_stats_dropdown.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/utils.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/project_select.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue19
-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.vue9
-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/base_token.vue4
-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.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue1
-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/comment_templates_dropdown.vue134
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/eventhub.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue511
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue176
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js116
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/saved_replies.query.graphql12
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/actions.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue10
-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/notes/noteable_warning.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue257
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/history_item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue12
-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/truncated_text/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue81
-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.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue127
-rw-r--r--app/assets/javascripts/vue_shared/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js19
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js16
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js77
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue36
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue8
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js17
-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/mixins/timeago.js4
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue2
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue100
-rw-r--r--app/assets/javascripts/vue_shared/plugins/global_toast.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue31
-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/components/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue2
-rw-r--r--app/assets/javascripts/webhooks/components/test_dropdown.vue45
-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.vue8
-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.vue180
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue190
-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.vue218
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue63
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue288
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue137
-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.vue26
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue182
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_award_emoji.vue144
-rw-r--r--app/assets/javascripts/work_items/components/work_item_created_updated.vue41
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue45
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue603
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue100
-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_labels.vue29
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue242
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue233
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue34
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue191
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue10
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue107
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue32
-rw-r--r--app/assets/javascripts/work_items/components/work_item_milestone.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue258
-rw-r--r--app/assets/javascripts/work_items/components/work_item_todos.vue116
-rw-r--r--app/assets/javascripts/work_items/constants.js59
-rw-r--r--app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/cache_utils.js120
-rw-r--r--app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql9
-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/notes/work_item_notes.query.graphql27
-rw-r--r--app/assets/javascripts/work_items/graphql/project_work_items.query.graphql4
-rw-r--r--app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql40
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql25
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql24
-rw-r--r--app/assets/javascripts/work_items/index.js5
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue61
-rw-r--r--app/assets/javascripts/work_items/router/index.js2
-rw-r--r--app/assets/javascripts/work_items/utils.js56
-rw-r--r--app/assets/javascripts/zen_mode.js13
1653 files changed, 36352 insertions, 13997 deletions
diff --git a/app/assets/javascripts/access_level/constants.js b/app/assets/javascripts/access_level/constants.js
new file mode 100644
index 00000000000..02a4a3c2f15
--- /dev/null
+++ b/app/assets/javascripts/access_level/constants.js
@@ -0,0 +1,20 @@
+import { __ } from '~/locale';
+
+// Matches `lib/gitlab/access.rb`
+export const ACCESS_LEVEL_NO_ACCESS_INTEGER = 0;
+export const ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER = 5;
+export const ACCESS_LEVEL_GUEST_INTEGER = 10;
+export const ACCESS_LEVEL_REPORTER_INTEGER = 20;
+export const ACCESS_LEVEL_DEVELOPER_INTEGER = 30;
+export const ACCESS_LEVEL_MAINTAINER_INTEGER = 40;
+export const ACCESS_LEVEL_OWNER_INTEGER = 50;
+
+export const ACCESS_LEVEL_LABELS = {
+ [ACCESS_LEVEL_NO_ACCESS_INTEGER]: __('No access'),
+ [ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER]: __('Minimal Access'),
+ [ACCESS_LEVEL_GUEST_INTEGER]: __('Guest'),
+ [ACCESS_LEVEL_REPORTER_INTEGER]: __('Reporter'),
+ [ACCESS_LEVEL_DEVELOPER_INTEGER]: __('Developer'),
+ [ACCESS_LEVEL_MAINTAINER_INTEGER]: __('Maintainer'),
+ [ACCESS_LEVEL_OWNER_INTEGER]: __('Owner'),
+};
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/achievements/components/achievements_app.vue b/app/assets/javascripts/achievements/components/achievements_app.vue
new file mode 100644
index 00000000000..5f40231856f
--- /dev/null
+++ b/app/assets/javascripts/achievements/components/achievements_app.vue
@@ -0,0 +1,31 @@
+<script>
+export default {
+ inject: {
+ canAdminAchievement: {
+ type: Boolean,
+ required: true,
+ },
+ canAwardAchievement: {
+ type: Boolean,
+ required: true,
+ },
+ groupFullPath: {
+ type: String,
+ required: true,
+ },
+ groupId: {
+ type: Number,
+ required: true,
+ },
+ textQuery: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/achievements/constants.js b/app/assets/javascripts/achievements/constants.js
new file mode 100644
index 00000000000..82a56588c96
--- /dev/null
+++ b/app/assets/javascripts/achievements/constants.js
@@ -0,0 +1,7 @@
+export const INDEX_ROUTE_NAME = 'index';
+export const NEW_ROUTE_NAME = 'new';
+export const EDIT_ROUTE_NAME = 'edit';
+export const trackViewsOptions = {
+ category: 'Achievements' /* eslint-disable-line @gitlab/require-i18n-strings */,
+ action: 'view_achievements_list',
+};
diff --git a/app/assets/javascripts/achievements/routes.js b/app/assets/javascripts/achievements/routes.js
new file mode 100644
index 00000000000..12aa17d73b6
--- /dev/null
+++ b/app/assets/javascripts/achievements/routes.js
@@ -0,0 +1,16 @@
+import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants';
+
+export default [
+ {
+ name: INDEX_ROUTE_NAME,
+ path: '/',
+ },
+ {
+ name: NEW_ROUTE_NAME,
+ path: '/new',
+ },
+ {
+ name: EDIT_ROUTE_NAME,
+ path: '/:id/edit',
+ },
+];
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..a9fb692b299 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
@@ -1,10 +1,15 @@
<script>
-import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf, GlBadge } from '@gitlab/ui';
+import { GlModal, GlTabs, GlTab, GlSprintf, GlBadge, GlFilteredSearch } 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 { __, s__ } from '~/locale';
+import {
+ OPERATORS_IS,
+ TOKEN_TYPE_AUTHOR,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue';
import eventHub from '../event_hub';
import {
findCommitIndex,
@@ -12,6 +17,8 @@ import {
removeIfReadyToBeRemoved,
removeIfPresent,
} from '../utils';
+import Token from './token.vue';
+import DateOption from './date_option.vue';
export default {
components: {
@@ -19,9 +26,9 @@ export default {
GlTabs,
GlTab,
ReviewTabContainer,
- GlSearchBoxByType,
GlSprintf,
GlBadge,
+ GlFilteredSearch,
},
props: {
contextCommitsPath: {
@@ -41,6 +48,51 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ availableTokens: [
+ {
+ icon: 'pencil',
+ title: __('Author'),
+ type: TOKEN_TYPE_AUTHOR,
+ operators: OPERATORS_IS,
+ token: UserToken,
+ defaultAuthors: [],
+ unique: true,
+ fetchAuthors: this.fetchAuthors,
+ initialAuthors: [],
+ },
+ {
+ formattedKey: __('Committed-before'),
+ key: 'committed-before',
+ type: 'committed-before-date',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'committed_before',
+ title: __('Committed-before'),
+ operators: OPERATORS_IS,
+ token: Token,
+ unique: true,
+ optionComponent: DateOption,
+ },
+ {
+ formattedKey: __('Committed-after'),
+ key: 'committed-after',
+ type: 'committed-after-date',
+ param: '',
+ symbol: '',
+ icon: 'clock',
+ tag: 'committed_after',
+ title: __('Committed-after'),
+ operators: OPERATORS_IS,
+ token: Token,
+ unique: true,
+ optionComponent: DateOption,
+ },
+ ],
+ };
+ },
computed: {
...mapState([
'tabIndex',
@@ -98,8 +150,6 @@ export default {
},
beforeDestroy() {
eventHub.$off('openModal', this.openModal);
- clearTimeout(this.timeout);
- this.timeout = null;
},
methods: {
...mapActions([
@@ -114,10 +164,8 @@ export default {
'setSearchText',
'setToRemoveCommits',
'resetModalState',
+ 'fetchAuthors',
]),
- focusSearch() {
- this.$refs.searchInput.focusInput();
- },
openModal() {
this.searchCommits();
this.fetchContextCommits();
@@ -125,7 +173,6 @@ export default {
},
handleTabChange(tabIndex) {
if (tabIndex === 0) {
- this.focusSearch();
if (this.shouldPurge) {
this.setSelectedCommits(
[...this.commits, ...this.selectedCommits].filter((commit) => commit.isSelected),
@@ -133,17 +180,36 @@ export default {
}
}
},
- handleSearchCommits(value) {
- // We only call the service, if we have 3 characters or we don't have any characters
- if (value.length >= 3) {
- clearTimeout(this.timeout);
- this.timeout = setTimeout(() => {
- this.searchCommits(value);
- }, 500);
- } else if (value.length === 0) {
- this.searchCommits();
+ blurSearchInput() {
+ const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector(
+ '.gl-filtered-search-token-segment-input',
+ );
+ if (searchInputEl) {
+ searchInputEl.blur();
}
- this.setSearchText(value);
+ },
+ handleSearchCommits(value = []) {
+ const searchValues = value.reduce((acc, searchFilter) => {
+ const isEqualSearch = searchFilter?.value?.operator === '=';
+
+ if (!isEqualSearch && typeof searchFilter === 'object') return acc;
+
+ if (typeof searchFilter === 'string' && searchFilter.length >= 3) {
+ acc.searchText = searchFilter;
+ } else if (searchFilter?.type === 'author' && searchFilter?.value?.data?.length >= 3) {
+ acc.author = searchFilter?.value?.data;
+ } else if (searchFilter?.type === 'committed-before-date') {
+ acc.committed_before = searchFilter?.value?.data;
+ } else if (searchFilter?.type === 'committed-after-date') {
+ acc.committed_after = searchFilter?.value?.data;
+ }
+
+ return acc;
+ }, {});
+
+ this.searchCommits(searchValues);
+ this.blurSearchInput();
+ this.setSearchText(searchValues.searchText);
},
handleCommitRowSelect(event) {
const index = event[0];
@@ -208,11 +274,12 @@ export default {
},
handleModalClose() {
this.resetModalState();
- clearTimeout(this.timeout);
},
handleModalHide() {
this.resetModalState();
- clearTimeout(this.timeout);
+ },
+ shouldShowInputDateFormat(value) {
+ return ['Committed-before', 'Committed-after'].indexOf(value) !== -1;
},
},
};
@@ -223,13 +290,14 @@ export default {
ref="modal"
cancel-variant="light"
size="md"
+ no-focus-on-show
+ modal-class="add-review-item-modal"
body-class="add-review-item pt-0"
:scrollable="true"
:ok-title="__('Save changes')"
modal-id="add-review-item"
:title="__('Add or remove previously merged commits')"
:ok-disabled="disableSaveButton"
- @shown="focusSearch"
@ok="handleCreateContextCommits"
@cancel="handleModalClose"
@close="handleModalClose"
@@ -245,11 +313,15 @@ export default {
</gl-sprintf>
</template>
<div class="gl-mt-3">
- <gl-search-box-by-type
- ref="searchInput"
- :placeholder="__(`Search by commit title or SHA`)"
- @input="handleSearchCommits"
+ <gl-filtered-search
+ ref="filteredSearchInput"
+ class="flex-grow-1"
+ :placeholder="__(`Search or filter commits`)"
+ :available-tokens="availableTokens"
+ @clear="handleSearchCommits"
+ @submit="handleSearchCommits"
/>
+
<review-tab-container
:is-loading="isLoadingCommits"
:loading-error="commitsLoadingError"
diff --git a/app/assets/javascripts/add_context_commits_modal/components/date_option.vue b/app/assets/javascripts/add_context_commits_modal/components/date_option.vue
new file mode 100644
index 00000000000..1945e048029
--- /dev/null
+++ b/app/assets/javascripts/add_context_commits_modal/components/date_option.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+ props: {
+ option: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ >{{ option.title }}
+ <span class="title-hint-text">&lt;{{ __('yyyy-mm-dd') }}&gt;</span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/add_context_commits_modal/components/token.vue b/app/assets/javascripts/add_context_commits_modal/components/token.vue
new file mode 100644
index 00000000000..c403adbbf60
--- /dev/null
+++ b/app/assets/javascripts/add_context_commits_modal/components/token.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlFilteredSearchToken } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ val: '',
+ };
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" />
+</template>
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..f085b0d0e5e 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/actions.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js
@@ -1,8 +1,11 @@
import _ from 'lodash';
+import * as Sentry from '@sentry/browser';
import Api from '~/api';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { ACTIVE_AND_BLOCKED_USER_STATES } from '~/users_select/constants';
import * as types from './mutation_types';
export const setBaseConfig = ({ commit }, options) => {
@@ -11,14 +14,14 @@ export const setBaseConfig = ({ commit }, options) => {
export const setTabIndex = ({ commit }, tabIndex) => commit(types.SET_TABINDEX, tabIndex);
-export const searchCommits = ({ dispatch, commit, state }, searchText) => {
+export const searchCommits = ({ dispatch, commit, state }, search = {}) => {
commit(types.FETCH_COMMITS);
let params = {};
- if (searchText) {
+ if (search) {
params = {
params: {
- search: searchText,
+ ...search,
per_page: 40,
},
};
@@ -37,7 +40,7 @@ export const searchCommits = ({ dispatch, commit, state }, searchText) => {
}
return c;
});
- if (!searchText) {
+ if (!search) {
dispatch('setCommits', { commits: [...commits, ...state.contextCommits] });
} else {
dispatch('setCommits', { commits });
@@ -131,6 +134,23 @@ export const setSelectedCommits = ({ commit }, selected) => {
commit(types.SET_SELECTED_COMMITS, selectedCommits);
};
+export const fetchAuthors = ({ dispatch, state }, author = null) => {
+ const { projectId } = state;
+ return axios
+ .get(joinPaths(gon.relative_url_root || '', '/-/autocomplete/users.json'), {
+ params: {
+ project_id: projectId,
+ states: ACTIVE_AND_BLOCKED_USER_STATES,
+ search: author,
+ },
+ })
+ .then(({ data }) => data)
+ .catch((error) => {
+ Sentry.captureException(error);
+ dispatch('receiveAuthorsError');
+ });
+};
+
export const setSearchText = ({ commit }, searchText) => commit(types.SET_SEARCH_TEXT, searchText);
export const setToRemoveCommits = ({ commit }, data) => commit(types.SET_TO_REMOVE_COMMITS, data);
diff --git a/app/assets/javascripts/add_context_commits_modal/store/index.js b/app/assets/javascripts/add_context_commits_modal/store/index.js
index 0bf3441379b..560834a26ae 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/index.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
@@ -12,4 +13,5 @@ export default () =>
state: state(),
actions,
mutations,
+ modules: { filters },
});
diff --git a/app/assets/javascripts/add_context_commits_modal/store/state.js b/app/assets/javascripts/add_context_commits_modal/store/state.js
index 37239adccbb..fed3148bc9e 100644
--- a/app/assets/javascripts/add_context_commits_modal/store/state.js
+++ b/app/assets/javascripts/add_context_commits_modal/store/state.js
@@ -1,4 +1,5 @@
export default () => ({
+ projectId: '',
contextCommitsPath: '',
tabIndex: 0,
isLoadingCommits: false,
diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue
new file mode 100644
index 00000000000..9355c1c788f
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue
@@ -0,0 +1,35 @@
+<script>
+import ReportHeader from './report_header.vue';
+import UserDetails from './user_details.vue';
+import ReportedContent from './reported_content.vue';
+import HistoryItems from './history_items.vue';
+
+export default {
+ name: 'AbuseReportApp',
+ components: {
+ ReportHeader,
+ UserDetails,
+ ReportedContent,
+ HistoryItems,
+ },
+ props: {
+ abuseReport: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <report-header
+ v-if="abuseReport.user"
+ :user="abuseReport.user"
+ :actions="abuseReport.actions"
+ />
+ <user-details v-if="abuseReport.user" :user="abuseReport.user" />
+ <reported-content :report="abuseReport.report" :reporter="abuseReport.reporter" />
+ <history-items :report="abuseReport.report" :reporter="abuseReport.reporter" />
+ </section>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/history_items.vue b/app/assets/javascripts/admin/abuse_report/components/history_items.vue
new file mode 100644
index 00000000000..28b66db84a2
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/history_items.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
+import { HISTORY_ITEMS_I18N } from '../constants';
+
+export default {
+ name: 'HistoryItems',
+ components: {
+ GlSprintf,
+ TimeAgoTooltip,
+ HistoryItem,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ reporter: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ reporterName() {
+ return this.reporter?.name || this.$options.i18n.deletedReporter;
+ },
+ },
+ i18n: HISTORY_ITEMS_I18N,
+};
+</script>
+
+<template>
+ <!-- The styles `issuable-discussion`, `timeline`, `main-notes-list` and `notes` used below
+ are declared in app/assets/stylesheets/pages/notes.scss -->
+ <section class="gl-pt-6 issuable-discussion">
+ <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2">{{ $options.i18n.activity }}</h2>
+ <ul class="timeline main-notes-list notes">
+ <history-item icon="warning">
+ <div class="gl-display-flex gl-xs-flex-direction-column">
+ <gl-sprintf :message="$options.i18n.reportedByForCategory">
+ <template #name>{{ reporterName }}</template>
+ <template #category>{{ report.category }}</template>
+ </gl-sprintf>
+ <time-ago-tooltip :time="report.reportedAt" class="gl-text-secondary gl-sm-ml-3" />
+ </div>
+ </history-item>
+ </ul>
+ </section>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue
new file mode 100644
index 00000000000..54586041354
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlAvatar, GlButton, GlLink } from '@gitlab/ui';
+import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue';
+import { REPORT_HEADER_I18N } from '../constants';
+
+export default {
+ name: 'ReportHeader',
+ components: {
+ GlAvatar,
+ GlButton,
+ GlLink,
+ AbuseReportActions,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ actions: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: REPORT_HEADER_I18N,
+};
+</script>
+
+<template>
+ <header
+ class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-avatar :size="48" :src="user.avatarUrl" />
+ <h1 class="gl-font-size-h-display gl-my-0 gl-ml-3">
+ {{ user.name }}
+ </h1>
+ <gl-link :href="user.path" class="gl-ml-3"> @{{ user.username }} </gl-link>
+ </div>
+ <nav class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0">
+ <gl-button :href="user.adminPath" class="flex-grow-1">
+ {{ $options.i18n.adminProfile }}
+ </gl-button>
+ <abuse-report-actions :report="actions" class="gl-sm-ml-3" />
+ </nav>
+ </header>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue
new file mode 100644
index 00000000000..b5ffba26360
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue
@@ -0,0 +1,141 @@
+<script>
+import { GlButton, GlModal, GlCard, GlLink, GlAvatar } from '@gitlab/ui';
+import { __ } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
+import { REPORTED_CONTENT_I18N } from '../constants';
+
+export default {
+ name: 'ReportedContent',
+ components: {
+ GlButton,
+ GlModal,
+ GlCard,
+ GlLink,
+ GlAvatar,
+ TimeAgoTooltip,
+ TruncatedText,
+ },
+ modalId: 'abuse-report-screenshot-modal',
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ reporter: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ showScreenshotModal: false,
+ };
+ },
+ computed: {
+ reporterName() {
+ return this.reporter?.name || this.$options.i18n.deletedReporter;
+ },
+ reportType() {
+ return this.report.type || 'unknown';
+ },
+ },
+ mounted() {
+ renderGFM(this.$refs.gfmContent);
+ },
+ methods: {
+ toggleScreenshotModal() {
+ this.showScreenshotModal = !this.showScreenshotModal;
+ },
+ },
+ i18n: REPORTED_CONTENT_I18N,
+ screenshotModalButtonAttributes: {
+ text: __('Close'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+};
+</script>
+
+<template>
+ <div class="gl-pt-6">
+ <div
+ class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column"
+ >
+ <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2">
+ {{ $options.i18n.reportTypes[reportType] }}
+ </h2>
+ <div
+ class="gl-display-flex gl-align-items-stretch gl-xs-flex-direction-column gl-mt-3 gl-sm-mt-0"
+ >
+ <template v-if="report.screenshot">
+ <gl-button data-testid="screenshot-button" @click="toggleScreenshotModal">
+ {{ $options.i18n.viewScreenshot }}
+ </gl-button>
+ <gl-modal
+ v-model="showScreenshotModal"
+ :title="$options.i18n.screenshotTitle"
+ :modal-id="$options.modalId"
+ :action-primary="$options.screenshotModalButtonAttributes"
+ >
+ <img
+ :src="report.screenshot"
+ :alt="$options.i18n.screenshotTitle"
+ class="gl-w-full gl-h-auto"
+ />
+ </gl-modal>
+ </template>
+ <gl-button
+ v-if="report.url"
+ data-testid="report-url-button"
+ :href="report.url"
+ class="gl-sm-ml-3 gl-mt-3 gl-sm-mt-0"
+ >
+ {{ $options.i18n.goToType[reportType] }}
+ </gl-button>
+ </div>
+ </div>
+ <gl-card
+ header-class="gl-bg-white js-test-card-header"
+ body-class="gl-bg-gray-50 gl-px-5 gl-py-3 js-test-card-body"
+ footer-class="gl-bg-white js-test-card-footer"
+ >
+ <template v-if="report.content" #header>
+ <truncated-text>
+ <div
+ ref="gfmContent"
+ v-safe-html:[$options.safeHtmlConfig]="report.content"
+ class="md"
+ ></div>
+ </truncated-text>
+ </template>
+ {{ $options.i18n.reportedBy }}
+ <template #footer>
+ <div class="gl-display-flex gl-align-items-center gl-mb-2">
+ <gl-avatar :size="32" :src="reporter && reporter.avatarUrl" />
+ <div class="gl-display-flex gl-flex-wrap">
+ <span class="gl-ml-3 gl-font-weight-bold">
+ {{ reporterName }}
+ </span>
+ <gl-link v-if="reporter" :href="reporter.path" class="gl-ml-3">
+ @{{ reporter.username }}
+ </gl-link>
+ <time-ago-tooltip
+ :time="report.reportedAt"
+ class="gl-ml-3 gl-text-secondary gl-xs-w-full"
+ />
+ </div>
+ </div>
+ <p v-if="report.message" class="gl-pl-8 gl-mb-0">{{ report.message }}</p>
+ </template>
+ </gl-card>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/user_detail.vue b/app/assets/javascripts/admin/abuse_report/components/user_detail.vue
new file mode 100644
index 00000000000..0aeee5e05f8
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/user_detail.vue
@@ -0,0 +1,27 @@
+<script>
+export default {
+ name: 'UserDetail',
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-mb-4">
+ <p class="gl-font-weight-bold gl-flex-grow-1 gl-flex-basis-0 gl-mb-0">
+ {{ label }}
+ </p>
+ <div class="gl-flex-grow-1 gl-flex-basis-two-thirds">
+ <slot>{{ value }}</slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/components/user_details.vue b/app/assets/javascripts/admin/abuse_report/components/user_details.vue
new file mode 100644
index 00000000000..3dc03a8748f
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/components/user_details.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { formatNumber } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { USER_DETAILS_I18N } from '../constants';
+import UserDetail from './user_detail.vue';
+
+export default {
+ name: 'UserDetails',
+ components: {
+ GlLink,
+ GlSprintf,
+ TimeAgoTooltip,
+ UserDetail,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ verificationState() {
+ return Object.entries(this.user.verificationState)
+ .filter(([, v]) => v)
+ .map(([k]) => this.$options.i18n.verificationMethods[k])
+ .join(', ');
+ },
+ showSimilarRecords() {
+ return this.user.creditCard.similarRecordsCount > 1;
+ },
+ similarRecordsCount() {
+ return formatNumber(this.user.creditCard.similarRecordsCount);
+ },
+ },
+ i18n: USER_DETAILS_I18N,
+};
+</script>
+
+<template>
+ <div class="gl-mt-6">
+ <user-detail data-testid="createdAt" :label="$options.i18n.createdAt">
+ <time-ago-tooltip :time="user.createdAt" />
+ </user-detail>
+ <user-detail data-testid="email" :label="$options.i18n.email">
+ <gl-link :href="`mailto:${user.email}`">{{ user.email }}</gl-link>
+ </user-detail>
+ <user-detail data-testid="plan" :label="$options.i18n.plan" :value="user.plan" />
+ <user-detail
+ data-testid="verification"
+ :label="$options.i18n.verification"
+ :value="verificationState"
+ />
+ <user-detail v-if="user.creditCard" data-testid="creditCard" :label="$options.i18n.creditCard">
+ <gl-sprintf :message="$options.i18n.registeredWith">
+ <template #name>{{ user.creditCard.name }}</template>
+ </gl-sprintf>
+ <gl-sprintf v-if="showSimilarRecords" :message="$options.i18n.similarRecords">
+ <template #cardMatchesLink="{ content }">
+ <gl-link :href="user.creditCard.cardMatchesLink">
+ <gl-sprintf :message="content">
+ <template #count>{{ similarRecordsCount }}</template>
+ </gl-sprintf>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </user-detail>
+ <user-detail
+ v-if="user.otherReports.length"
+ data-testid="otherReports"
+ :label="$options.i18n.otherReports"
+ >
+ <div
+ v-for="(report, index) in user.otherReports"
+ :key="index"
+ :data-testid="`other-report-${index}`"
+ >
+ <gl-sprintf :message="$options.i18n.otherReport">
+ <template #reportLink="{ content }">
+ <gl-link :href="report.reportPath">{{ content }}</gl-link>
+ </template>
+ <template #category>{{ report.category }}</template>
+ <template #timeAgo>
+ <time-ago-tooltip :time="report.createdAt" />
+ </template>
+ </gl-sprintf>
+ </div>
+ </user-detail>
+ <user-detail
+ data-testid="normalLocation"
+ :label="$options.i18n.normalLocation"
+ :value="user.mostUsedIp || user.lastSignInIp"
+ />
+ <user-detail
+ data-testid="lastSignInIp"
+ :label="$options.i18n.lastSignInIp"
+ :value="user.lastSignInIp"
+ />
+ <user-detail
+ data-testid="snippets"
+ :label="$options.i18n.snippets"
+ :value="$options.i18n.snippetsCount(user.snippetsCount)"
+ />
+ <user-detail
+ data-testid="groups"
+ :label="$options.i18n.groups"
+ :value="$options.i18n.groupsCount(user.groupsCount)"
+ />
+ <user-detail
+ data-testid="notes"
+ :label="$options.i18n.notes"
+ :value="$options.i18n.notesCount(user.notesCount)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js
new file mode 100644
index 00000000000..a59e10b5d4a
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/constants.js
@@ -0,0 +1,61 @@
+import { s__, n__ } from '~/locale';
+
+export const REPORT_HEADER_I18N = {
+ adminProfile: s__('AbuseReport|Admin profile'),
+};
+
+export const USER_DETAILS_I18N = {
+ createdAt: s__('AbuseReport|Member since'),
+ email: s__('AbuseReport|Email'),
+ plan: s__('AbuseReport|Tier'),
+ verification: s__('AbuseReport|Verification'),
+ creditCard: s__('AbuseReport|Credit card'),
+ otherReports: s__('AbuseReport|Abuse reports'),
+ normalLocation: s__('AbuseReport|Normal location'),
+ lastSignInIp: s__('AbuseReport|Last login'),
+ snippets: s__('AbuseReport|Snippets'),
+ groups: s__('AbuseReport|Groups'),
+ notes: s__('AbuseReport|Comments'),
+ snippetsCount: (count) => n__(`%d snippet`, `%d snippets`, count),
+ groupsCount: (count) => n__(`%d group`, `%d groups`, count),
+ notesCount: (count) => n__(`%d comment`, `%d comments`, count),
+ verificationMethods: {
+ email: s__('AbuseReport|Email'),
+ phone: s__('AbuseReport|Phone'),
+ creditCard: s__('AbuseReport|Credit card'),
+ },
+ otherReport: s__(
+ 'AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}.',
+ ),
+ registeredWith: s__('AbuseReport|Registered with name %{name}.'),
+ similarRecords: s__(
+ 'AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}',
+ ),
+};
+
+export const REPORTED_CONTENT_I18N = {
+ reportTypes: {
+ profile: s__('AbuseReport|Reported profile'),
+ comment: s__('AbuseReport|Reported comment'),
+ issue: s__('AbuseReport|Reported issue'),
+ merge_request: s__('AbuseReport|Reported merge request'),
+ unknown: s__('AbuseReport|Reported content'),
+ },
+ viewScreenshot: s__('AbuseReport|View screenshot'),
+ screenshotTitle: s__('AbuseReport|Screenshot of reported abuse'),
+ goToType: {
+ profile: s__('AbuseReport|Go to profile'),
+ comment: s__('AbuseReport|Go to comment'),
+ issue: s__('AbuseReport|Go to issue'),
+ merge_request: s__('AbuseReport|Go to merge request'),
+ unknown: s__('AbuseReport|Go to content'),
+ },
+ reportedBy: s__('AbuseReport|Reported by'),
+ deletedReporter: s__('AbuseReport|No user found'),
+};
+
+export const HISTORY_ITEMS_I18N = {
+ activity: s__('AbuseReport|Activity'),
+ reportedByForCategory: s__('AbuseReport|Reported by %{name} for %{category}.'),
+ deletedReporter: s__('AbuseReport|No user found'),
+};
diff --git a/app/assets/javascripts/admin/abuse_report/index.js b/app/assets/javascripts/admin/abuse_report/index.js
new file mode 100644
index 00000000000..8ff3e690127
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_report/index.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AbuseReportApp from './components/abuse_report_app.vue';
+
+export const initAbuseReportApp = () => {
+ const el = document.querySelector('#js-abuse-reports-detail-view');
+
+ if (!el) {
+ return null;
+ }
+
+ const { abuseReportData } = el.dataset;
+ const abuseReport = convertObjectPropsToCamelCase(JSON.parse(abuseReportData), {
+ deep: true,
+ });
+
+ return new Vue({
+ el,
+ name: 'AbuseReportAppRoot',
+ render: (createElement) =>
+ createElement(AbuseReportApp, {
+ props: {
+ abuseReport,
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
new file mode 100644
index 00000000000..5d42caa75ab
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue
@@ -0,0 +1,177 @@
+<script>
+import { GlDisclosureDropdown, GlModal } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, sprintf } from '~/locale';
+import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { ACTIONS_I18N } from '../constants';
+
+const modalActionButtonAttributes = {
+ block: {
+ text: __('OK'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ removeUserAndReport: {
+ text: __('OK'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ secondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+};
+const BLOCK_ACTION = 'block';
+const REMOVE_USER_AND_REPORT_ACTION = 'removeUserAndReport';
+
+export default {
+ name: 'AbuseReportActions',
+ components: {
+ GlDisclosureDropdown,
+ GlModal,
+ },
+ modalId: 'abuse-report-row-action-confirm-modal',
+ modalActionButtonAttributes,
+ i18n: ACTIONS_I18N,
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ userBlocked: this.report.userBlocked,
+ confirmModalShown: false,
+ actionToConfirm: 'block',
+ };
+ },
+ computed: {
+ blockUserButtonText() {
+ const { alreadyBlocked, blockUser } = this.$options.i18n;
+
+ return this.userBlocked ? alreadyBlocked : blockUser;
+ },
+ removeUserAndReportConfirmText() {
+ return sprintf(this.$options.i18n.removeUserAndReportConfirm, {
+ user: this.report.reportedUser.name,
+ });
+ },
+ modalData() {
+ return {
+ [BLOCK_ACTION]: {
+ action: this.blockUser,
+ confirmText: this.$options.i18n.blockUserConfirm,
+ },
+ [REMOVE_USER_AND_REPORT_ACTION]: {
+ action: this.removeUserAndReport,
+ confirmText: this.removeUserAndReportConfirmText,
+ },
+ };
+ },
+ reportActionsDropdownItems() {
+ return [
+ {
+ text: this.$options.i18n.removeUserAndReport,
+ action: () => {
+ this.showConfirmModal(REMOVE_USER_AND_REPORT_ACTION);
+ },
+ extraAttrs: { class: 'gl-text-red-500!' },
+ },
+ {
+ text: this.blockUserButtonText,
+ action: () => {
+ this.showConfirmModal(BLOCK_ACTION);
+ },
+ extraAttrs: {
+ disabled: this.userBlocked,
+ 'data-testid': 'block-user-button',
+ },
+ },
+ {
+ text: this.$options.i18n.removeReport,
+ action: () => {
+ this.removeReport();
+ },
+ },
+ ];
+ },
+ },
+ methods: {
+ showConfirmModal(action) {
+ this.confirmModalShown = true;
+ this.actionToConfirm = action;
+ },
+ blockUser() {
+ axios
+ .put(this.report.blockUserPath)
+ .then(this.handleBlockUserResponse)
+ .catch(this.handleError);
+ },
+ removeUserAndReport() {
+ axios
+ .delete(this.report.removeUserAndReportPath)
+ .then(this.handleRemoveReportResponse)
+ .catch(this.handleError);
+ },
+ removeReport() {
+ axios
+ .delete(this.report.removeReportPath)
+ .then(this.handleRemoveReportResponse)
+ .catch(this.handleError);
+ },
+ handleRemoveReportResponse() {
+ // eslint-disable-next-line import/no-deprecated
+ if (this.report.redirectPath) redirectTo(this.report.redirectPath);
+ else refreshCurrentPage();
+ },
+ handleBlockUserResponse({ data }) {
+ const message = data?.error || data?.notice;
+ const alertOptions = data?.notice ? { variant: VARIANT_SUCCESS } : {};
+
+ if (message) {
+ createAlert({ message, ...alertOptions });
+ }
+
+ if (!data?.error) {
+ this.userBlocked = true;
+ }
+ },
+ handleError(error) {
+ createAlert({
+ message: __('Something went wrong. Please try again.'),
+ captureError: true,
+ error,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-disclosure-dropdown
+ :toggle-text="$options.i18n.actionsToggleText"
+ text-sr-only
+ icon="ellipsis_v"
+ category="tertiary"
+ no-caret
+ placement="right"
+ :items="reportActionsDropdownItems"
+ />
+ <gl-modal
+ v-model="confirmModalShown"
+ :modal-id="$options.modalId"
+ :title="modalData[actionToConfirm].confirmText"
+ size="sm"
+ :action-primary="$options.modalActionButtonAttributes[actionToConfirm]"
+ :action-secondary="$options.modalActionButtonAttributes.secondary"
+ @primary="modalData[actionToConfirm].action"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
new file mode 100644
index 00000000000..b8a4640de59
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_row.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import { queryToObject } from '~/lib/utils/url_utility';
+import { s__, __, sprintf } from '~/locale';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { SORT_UPDATED_AT } from '../constants';
+
+export default {
+ name: 'AbuseReportRow',
+ components: {
+ GlLink,
+ ListItem,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ displayDate() {
+ const { sort } = queryToObject(window.location.search);
+ const { createdAt, updatedAt } = this.report;
+ const { template, timeAgo } = Object.values(SORT_UPDATED_AT.sortDirection).includes(sort)
+ ? { template: __('Updated %{timeAgo}'), timeAgo: updatedAt }
+ : { template: __('Created %{timeAgo}'), timeAgo: createdAt };
+
+ return sprintf(template, { timeAgo: getTimeago().format(timeAgo) });
+ },
+ title() {
+ const { reportedUser, category, reporter } = this.report;
+ const template = s__('AbuseReports|%{reportedUser} reported for %{category} by %{reporter}');
+ return sprintf(template, {
+ reportedUser: reportedUser?.name || s__('AbuseReports|Deleted user'),
+ reporter: reporter?.name || s__('AbuseReports|Deleted user'),
+ category,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item data-testid="abuse-report-row">
+ <template #left-primary>
+ <gl-link :href="report.reportPath" class="gl-font-weight-normal gl-mb-2" data-testid="title">
+ {{ title }}
+ </gl-link>
+ </template>
+
+ <template #right-secondary>
+ <div data-testid="abuse-report-date">{{ displayDate }}</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..b1eb5371a35
--- /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'; // eslint-disable-line import/no-deprecated
+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, isValidStatus } 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 (!isValidStatus(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)); // eslint-disable-line import/no-deprecated
+ },
+ handleSort(sort) {
+ const { page, ...query } = queryToObject(window.location.search);
+
+ redirectTo(setUrlParams({ ...query, sort }, window.location.href, true)); // eslint-disable-line import/no-deprecated
+ },
+ },
+ 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..7dd60e9da95
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/constants.js
@@ -0,0 +1,90 @@
+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_UPDATED_AT = Object.freeze({
+ id: 20,
+ title: __('Updated date'),
+ sortDirection: {
+ descending: 'updated_at_desc',
+ ascending: 'updated_at_asc',
+ },
+});
+const SORT_CREATED_AT = Object.freeze({
+ id: 10,
+ title: __('Created date'),
+ sortDirection: {
+ descending: DEFAULT_SORT,
+ ascending: 'created_at_asc',
+ },
+});
+
+export const SORT_OPTIONS = [SORT_CREATED_AT, SORT_UPDATED_AT];
+
+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,
+];
+
+export const ACTIONS_I18N = {
+ blockUserConfirm: __('USER WILL BE BLOCKED! Are you sure?'),
+ blockUser: __('Block user'),
+ alreadyBlocked: __('Already blocked'),
+ removeUserAndReportConfirm: __('USER %{user} WILL BE REMOVED! Are you sure?'),
+ removeUserAndReport: __('Remove user & report'),
+ removeReport: __('Remove report'),
+ actionsToggleText: __('Actions'),
+};
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..d30e8fb0ae5
--- /dev/null
+++ b/app/assets/javascripts/admin/abuse_reports/utils.js
@@ -0,0 +1,9 @@
+import { FILTERED_SEARCH_TOKEN_CATEGORY, FILTERED_SEARCH_TOKEN_STATUS } from './constants';
+
+export const buildFilteredSearchCategoryToken = (categories) => {
+ const options = categories.map((c) => ({ value: c, title: c }));
+ return { ...FILTERED_SEARCH_TOKEN_CATEGORY, options };
+};
+
+export const isValidStatus = (status) =>
+ FILTERED_SEARCH_TOKEN_STATUS.options.map((o) => o.value).includes(status);
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..667ab4c34f5 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/base.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue
@@ -1,8 +1,8 @@
<script>
import { GlPagination } from '@gitlab/ui';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
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';
@@ -66,7 +66,7 @@ export default {
// stranded on page 2 when deleting the last message.
// Force a page reload to avoid this edge case.
if (newVal === PER_PAGE && oldVal === PER_PAGE + 1) {
- redirectTo(this.buildPageUrl(1));
+ redirectTo(this.buildPageUrl(1)); // eslint-disable-line import/no-deprecated
}
},
},
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..022f5df9c96 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue
@@ -3,6 +3,7 @@ import {
GlButton,
GlBroadcastMessage,
GlForm,
+ GlFormGroup,
GlFormCheckbox,
GlFormCheckboxGroup,
GlFormInput,
@@ -12,32 +13,45 @@ import {
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
-import { createAlert, VARIANT_DANGER } from '~/flash';
-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 MessageFormGroup from './message_form_group.vue';
+import { createAlert, VARIANT_DANGER } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { THEMES, TYPES, TYPE_BANNER } from '../constants';
import 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,
GlButton,
GlBroadcastMessage,
GlForm,
+ GlFormGroup,
GlFormCheckbox,
GlFormCheckboxGroup,
GlFormInput,
GlFormSelect,
GlFormText,
GlFormTextarea,
- MessageFormGroup,
},
- mixins: [glFeatureFlagsMixin()],
- inject: ['targetAccessLevelOptions'],
+ directives: {
+ SafeHtml,
+ },
+ inject: {
+ targetAccessLevelOptions: {
+ default: [[]],
+ },
+ messagesPath: {
+ default: '',
+ },
+ previewPath: {
+ default: '',
+ },
+ },
i18n: {
message: s__('BroadcastMessages|Message'),
messagePlaceholder: s__('BroadcastMessages|Your message here'),
@@ -81,6 +95,7 @@ export default {
})),
startsAt: new Date(this.broadcastMessage.startsAt.getTime()),
endsAt: new Date(this.broadcastMessage.endsAt.getTime()),
+ renderedMessage: '',
};
},
computed: {
@@ -91,15 +106,15 @@ 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;
},
formPath() {
return this.isAddForm
- ? BROADCAST_MESSAGES_PATH
- : `${BROADCAST_MESSAGES_PATH}/${this.broadcastMessage.id}`;
+ ? this.messagesPath
+ : `${this.messagesPath}/${this.broadcastMessage.id}`;
},
formPayload() {
return JSON.stringify({
@@ -114,13 +129,21 @@ export default {
});
},
},
+ watch: {
+ message: {
+ handler() {
+ this.renderPreview();
+ },
+ immediate: true,
+ },
+ },
methods: {
async onSubmit() {
this.loading = true;
const success = await this.submitForm();
if (success) {
- redirectTo(BROADCAST_MESSAGES_PATH);
+ redirectTo(this.messagesPath); // eslint-disable-line import/no-deprecated
} else {
this.loading = false;
}
@@ -140,39 +163,59 @@ export default {
}
return true;
},
+
+ async renderPreview() {
+ try {
+ const res = await axios.post(this.previewPath, 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 }}
+ <gl-broadcast-message
+ class="gl-my-6"
+ :type="type"
+ :theme="theme"
+ :dismissible="dismissable"
+ data-testid="preview-broadcast-message"
+ >
+ <div v-safe-html:[$options.safeHtmlConfig]="messagePreview"></div>
</gl-broadcast-message>
- <message-form-group :label="$options.i18n.message" label-for="message-textarea">
+ <gl-form-group :label="$options.i18n.message" label-for="message-textarea">
<gl-form-textarea
id="message-textarea"
v-model="message"
size="sm"
+ :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS"
:placeholder="$options.i18n.messagePlaceholder"
+ data-testid="message-input"
/>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.type" label-for="type-select">
+ <gl-form-group :label="$options.i18n.type" label-for="type-select">
<gl-form-select id="type-select" v-model="type" :options="$options.messageTypes" />
- </message-form-group>
+ </gl-form-group>
<template v-if="isBanner">
- <message-form-group :label="$options.i18n.theme" label-for="theme-select">
+ <gl-form-group :label="$options.i18n.theme" label-for="theme-select">
<gl-form-select
id="theme-select"
v-model="theme"
:options="$options.messageThemes"
data-testid="theme-select"
/>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox">
+ <gl-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox">
<gl-form-checkbox
id="dismissable-checkbox"
v-model="dismissable"
@@ -181,36 +224,32 @@ export default {
>
<span>{{ $options.i18n.dismissableDescription }}</span>
</gl-form-checkbox>
- </message-form-group>
+ </gl-form-group>
</template>
- <message-form-group
- v-if="glFeatures.roleTargetedBroadcastMessages"
- :label="$options.i18n.targetRoles"
- data-testid="target-roles-checkboxes"
- >
+ <gl-form-group :label="$options.i18n.targetRoles" data-testid="target-roles-checkboxes">
<gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" />
<gl-form-text>
{{ $options.i18n.targetRolesDescription }}
</gl-form-text>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.targetPath" label-for="target-path-input">
+ <gl-form-group :label="$options.i18n.targetPath" label-for="target-path-input">
<gl-form-input id="target-path-input" v-model="targetPath" />
<gl-form-text>
{{ $options.i18n.targetPathDescription }}
</gl-form-text>
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.startsAt">
+ <gl-form-group :label="$options.i18n.startsAt">
<datetime-picker v-model="startsAt" />
- </message-form-group>
+ </gl-form-group>
- <message-form-group :label="$options.i18n.endsAt">
+ <gl-form-group :label="$options.i18n.endsAt">
<datetime-picker v-model="endsAt" />
- </message-form-group>
+ </gl-form-group>
- <div class="form-actions gl-mb-3">
+ <div class="gl-my-5">
<gl-button
type="submit"
variant="confirm"
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue
deleted file mode 100644
index eec51c0c28b..00000000000
--- a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-<script>
-import { GlFormGroup } from '@gitlab/ui';
-
-export default {
- name: 'MessageFormGroup',
- components: {
- GlFormGroup,
- },
- props: {
- label: {
- type: String,
- required: true,
- },
- labelFor: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-form-group
- :label="label"
- :label-for="labelFor"
- label-cols-sm="2"
- label-class="gl-mt-3"
- label-align-sm="right"
- >
- <slot></slot>
- </gl-form-group>
- </div>
-</template>
diff --git a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
index a523dd3b391..c95d4c96ea9 100644
--- a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
+++ b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue
@@ -1,22 +1,21 @@
<script>
-import { GlButton, GlTableLite } from '@gitlab/ui';
+import { GlBroadcastMessage, GlButton, GlTableLite } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const DEFAULT_TD_CLASSES = 'gl-vertical-align-middle!';
export default {
name: 'MessagesTable',
components: {
+ GlBroadcastMessage,
GlButton,
GlTableLite,
},
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagsMixin()],
i18n: {
edit: __('Edit'),
delete: __('Delete'),
@@ -27,13 +26,7 @@ export default {
required: true,
},
},
- computed: {
- fields() {
- if (this.glFeatures.roleTargetedBroadcastMessages) return this.$options.allFields;
- return this.$options.allFields.filter((f) => f.key !== 'target_roles');
- },
- },
- allFields: [
+ fields: [
{
key: 'status',
label: __('Status'),
@@ -76,9 +69,6 @@ export default {
tdClass: `${DEFAULT_TD_CLASSES} gl-white-space-nowrap`,
},
],
- safeHtmlConfig: {
- ADD_TAGS: ['use'],
- },
methods: {
formatDate(dateString) {
return formatDate(new Date(dateString));
@@ -89,12 +79,14 @@ export default {
<template>
<gl-table-lite
:items="messages"
- :fields="fields"
+ :fields="$options.fields"
:tbody-tr-attr="{ 'data-testid': 'message-row' }"
stacked="md"
>
- <template #cell(preview)="{ item: { preview } }">
- <div v-safe-html:[$options.safeHtmlConfig]="preview"></div>
+ <template #cell(preview)="{ item: { message, theme, broadcast_type, dismissable } }">
+ <gl-broadcast-message :theme="theme" :type="broadcast_type" :dismissible="dismissable">
+ {{ message }}
+ </gl-broadcast-message>
</template>
<template #cell(starts_at)="{ item: { starts_at } }">
diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js
index 6250d5a943d..9f64b2dcaa0 100644
--- a/app/assets/javascripts/admin/broadcast_messages/constants.js
+++ b/app/assets/javascripts/admin/broadcast_messages/constants.js
@@ -1,7 +1,5 @@
import { s__ } from '~/locale';
-export const BROADCAST_MESSAGES_PATH = '/admin/broadcast_messages';
-
export const TYPE_BANNER = 'banner';
export const TYPE_NOTIFICATION = 'notification';
diff --git a/app/assets/javascripts/admin/broadcast_messages/edit.js b/app/assets/javascripts/admin/broadcast_messages/edit.js
index 70a270f7a56..91dae949d45 100644
--- a/app/assets/javascripts/admin/broadcast_messages/edit.js
+++ b/app/assets/javascripts/admin/broadcast_messages/edit.js
@@ -11,6 +11,8 @@ export default () => {
dismissable,
targetAccessLevels,
targetAccessLevelOptions,
+ messagesPath,
+ previewPath,
targetPath,
startsAt,
endsAt,
@@ -21,6 +23,8 @@ export default () => {
name: 'EditBroadcastMessage',
provide: {
targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions),
+ messagesPath,
+ previewPath,
},
render(createElement) {
return createElement(MessageForm, {
diff --git a/app/assets/javascripts/admin/broadcast_messages/index.js b/app/assets/javascripts/admin/broadcast_messages/index.js
index fd8b2aad4ec..29021043b1a 100644
--- a/app/assets/javascripts/admin/broadcast_messages/index.js
+++ b/app/assets/javascripts/admin/broadcast_messages/index.js
@@ -3,13 +3,22 @@ import BroadcastMessagesBase from './components/base.vue';
export default () => {
const el = document.querySelector('#js-broadcast-messages');
- const { page, targetAccessLevelOptions, messagesCount, messages } = el.dataset;
+ const {
+ page,
+ targetAccessLevelOptions,
+ messagesPath,
+ previewPath,
+ messagesCount,
+ messages,
+ } = el.dataset;
return new Vue({
el,
name: 'BroadcastMessages',
provide: {
targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions),
+ messagesPath,
+ previewPath,
},
render(createElement) {
return createElement(BroadcastMessagesBase, {
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..af09c7618e2 100644
--- a/app/assets/javascripts/admin/users/components/actions/activate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/activate.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -15,7 +15,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -41,7 +41,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.activate,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -52,7 +52,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue
index 5a8c675822d..2060528c7a0 100644
--- a/app/assets/javascripts/admin/users/components/actions/approve.vue
+++ b/app/assets/javascripts/admin/users/components/actions/approve.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -17,7 +17,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -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,
},
@@ -54,7 +54,9 @@ export default {
</script>
<template>
- <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item data-qa-selector="approve_user_button" @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue
index 898a688c203..d7bdceb4798 100644
--- a/app/assets/javascripts/admin/users/components/actions/ban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/ban.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
@@ -30,7 +30,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -56,7 +56,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.ban,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -67,7 +67,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue
index d25dd400f9b..534e1c76b8f 100644
--- a/app/assets/javascripts/admin/users/components/actions/block.vue
+++ b/app/assets/javascripts/admin/users/components/actions/block.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -18,7 +18,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -42,7 +42,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.block,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -53,7 +53,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
index c85f3f01675..40911131d6d 100644
--- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue
+++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -25,7 +25,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -51,7 +51,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.deactivate,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -62,7 +62,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index d4f9ff4e529..83aa78c9f03 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -49,9 +49,11 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <span class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <span class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
index 413804c9a3b..24f0cac73f5 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { associationsCount } from '~/api/user_api';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
@@ -9,7 +9,7 @@ export default {
loading: __('Loading'),
},
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
GlLoadingIcon,
},
props: {
@@ -71,13 +71,15 @@ export default {
</script>
<template>
- <gl-dropdown-item :disabled="loading" :aria-busy="loading" @click.capture.native.stop="onClick">
- <div v-if="loading" class="gl-display-flex gl-align-items-center">
- <gl-loading-icon class="gl-mr-3" />
- {{ $options.i18n.loading }}
- </div>
- <span v-else class="gl-text-red-500">
- <slot></slot>
- </span>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item :disabled="loading" :aria-busy="loading" @action="onClick">
+ <template #list-item>
+ <div v-if="loading" class="gl-display-flex gl-align-items-center">
+ <gl-loading-icon class="gl-mr-3" />
+ {{ $options.i18n.loading }}
+ </div>
+ <span v-else class="gl-text-red-500">
+ <slot></slot>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue
index bac08de1d5e..7f786991709 100644
--- a/app/assets/javascripts/admin/users/components/actions/reject.vue
+++ b/app/assets/javascripts/admin/users/components/actions/reject.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
@@ -28,7 +28,7 @@ const messageHtml = `
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -54,7 +54,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.reject,
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
messageHtml,
},
@@ -65,7 +65,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue
index beede2d37d7..f84c7594f87 100644
--- a/app/assets/javascripts/admin/users/components/actions/unban.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unban.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
@@ -11,7 +11,7 @@ const messageHtml = `<p>${s__(
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -37,7 +37,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unban,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
messageHtml,
},
@@ -48,7 +48,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index 720f2efd932..064f05ef8b1 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -32,7 +32,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unblock,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
},
});
@@ -42,7 +42,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue
index 55ea3e0aba7..039ab3d651e 100644
--- a/app/assets/javascripts/admin/users/components/actions/unlock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
username: {
@@ -31,7 +31,7 @@ export default {
},
actionPrimary: {
text: I18N_USER_ACTIONS.unlock,
- attributes: [{ variant: 'confirm' }],
+ attributes: { variant: 'confirm' },
},
},
});
@@ -41,7 +41,9 @@ export default {
</script>
<template>
- <gl-dropdown-item @click="onClick">
- <slot></slot>
- </gl-dropdown-item>
+ <gl-disclosure-dropdown-item @action="onClick">
+ <template #list-item>
+ <slot></slot>
+ </template>
+ </gl-disclosure-dropdown-item>
</template>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index c1fb80959cf..38c7d3f9b90 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -1,10 +1,9 @@
<script>
import {
GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
GlTooltipDirective,
} from '@gitlab/ui';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
@@ -17,10 +16,9 @@ import Actions from './actions';
export default {
components: {
GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlDropdownDivider,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
...Actions,
},
directives: {
@@ -63,6 +61,9 @@ export default {
hasEditAction() {
return this.userActions.includes('edit');
},
+ hasEditActionOnly() {
+ return this.hasEditAction === true && this.hasDeleteActions === false;
+ },
userPaths() {
return generateUserPaths(this.paths, this.user.username);
},
@@ -93,10 +94,13 @@ export default {
class="gl-display-flex gl-justify-content-end gl-my-n2 gl-mx-n2"
:data-testid="`user-actions-${user.id}`"
>
- <div v-if="hasEditAction" class="gl-p-2">
- <gl-button v-if="showButtonLabels" v-bind="editButtonAttrs" icon="pencil-square">{{
- $options.i18n.edit
- }}</gl-button>
+ <div v-if="hasEditAction" class="gl-p-2" :class="{ 'gl-mr-3': hasEditActionOnly }">
+ <gl-button
+ v-if="showButtonLabels"
+ v-bind="editButtonAttrs"
+ :class="{ 'gl-mr-7': hasEditActionOnly }"
+ >{{ $options.i18n.edit }}</gl-button
+ >
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
@@ -107,12 +111,15 @@ export default {
</div>
<div v-if="hasDropdownActions" class="gl-p-2">
- <gl-dropdown
- :text="$options.i18n.userAdministration"
+ <gl-disclosure-dropdown
+ icon="ellipsis_v"
+ category="tertiary"
+ :toggle-text="$options.i18n.userAdministration"
+ text-sr-only
data-testid="dropdown-toggle"
data-qa-selector="user_actions_dropdown_toggle"
:data-qa-username="user.username"
- left
+ no-caret
>
<template v-for="action in dropdownSafeActions">
<component
@@ -125,28 +132,32 @@ export default {
>
{{ $options.i18n[action] }}
</component>
- <gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action">
- {{ $options.i18n[action] }}
- </gl-dropdown-item>
- </template>
-
- <gl-dropdown-divider v-if="hasDeleteActions" />
-
- <template v-for="action in dropdownDeleteActions">
- <component
- :is="getActionComponent(action)"
- v-if="getActionComponent(action)"
+ <gl-disclosure-dropdown-item
+ v-else-if="isLdapAction(action)"
:key="action"
- :paths="userPaths"
- :username="user.name"
- :user-id="user.id"
- :user-deletion-obstacles="obstaclesForUserDeletion"
- :data-testid="`delete-${action}`"
+ :data-testid="action"
>
{{ $options.i18n[action] }}
- </component>
+ </gl-disclosure-dropdown-item>
</template>
- </gl-dropdown>
+
+ <gl-disclosure-dropdown-group v-if="hasDeleteActions" bordered>
+ <template v-for="action in dropdownDeleteActions">
+ <component
+ :is="getActionComponent(action)"
+ v-if="getActionComponent(action)"
+ :key="action"
+ :paths="userPaths"
+ :username="user.name"
+ :user-id="user.id"
+ :user-deletion-obstacles="obstaclesForUserDeletion"
+ :data-testid="`delete-${action}`"
+ >
+ {{ $options.i18n[action] }}
+ </component>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index f569cda0a4b..2d2c598f953 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';
@@ -135,7 +135,7 @@ export default {
</template>
<template #cell(settings)="{ item: user }">
- <user-actions :user="user" :paths="paths" />
+ <user-actions :user="user" :paths="paths" :show-button-labels="true" />
</template>
</gl-table>
</div>
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/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 5229d4c9ae2..170bd6895aa 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -12,6 +12,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
+import { STATUS_CLOSED } from '~/issues/constants';
import { sortObjectToString } from '~/lib/utils/table_utility';
import { fetchPolicies } from '~/lib/graphql';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
@@ -229,7 +230,7 @@ export default {
},
getIssueMeta({ issue: { iid, state } }) {
return {
- state: state === 'closed' ? `(${this.$options.i18n.closed})` : '',
+ state: state === STATUS_CLOSED ? `(${this.$options.i18n.closed})` : '',
link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid),
};
},
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
index c98d3865621..4ab772e4523 100644
--- a/app/assets/javascripts/alert_management/constants.js
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -37,12 +37,10 @@ export const ALERTS_STATUS_TABS = [
},
];
-/* eslint-disable @gitlab/require-i18n-strings */
-
/**
* Tracks snowplow event when user views alerts list
*/
export const trackAlertListViewsOptions = {
- category: 'Alert Management',
+ category: 'Alert Management', // eslint-disable-line @gitlab/require-i18n-strings
action: 'view_alerts_list',
};
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 2733a59f62d..1a586bd1e91 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -142,7 +142,7 @@ export default {
{{ $options.i18n.columns.fallbackKeyTitle }}
<gl-icon
v-gl-tooltip
- name="question"
+ name="question-o"
class="gl-text-gray-500"
:title="$options.i18n.fallbackTooltip"
/>
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..6d914fe8361 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -183,8 +183,8 @@ 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';
+ '/help/operations/incident_management/alerts#trigger-actions-from-alerts';
export const ISSUE_TEMPLATES_DOCS_LINK =
'/help/user/project/description_templates#create-an-issue-template';
diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js
index e45ea772ddd..4df5ed425a5 100644
--- a/app/assets/javascripts/alerts_settings/services/index.js
+++ b/app/assets/javascripts/alerts_settings/services/index.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import axios from '~/lib/utils/axios_utils';
export default {
@@ -9,7 +8,7 @@ export default {
return axios.post(endpoint, data, {
headers: {
'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
+ Authorization: `Bearer ${token}`, // eslint-disable-line @gitlab/require-i18n-strings
},
});
},
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/bundle.js b/app/assets/javascripts/analytics/cycle_analytics/bundle.js
new file mode 100644
index 00000000000..9fe31620938
--- /dev/null
+++ b/app/assets/javascripts/analytics/cycle_analytics/bundle.js
@@ -0,0 +1 @@
+export { default } from '~/analytics/cycle_analytics';
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
index a688e2f497b..39da3484dfe 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue
@@ -4,12 +4,12 @@ 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';
import UrlSync from '~/vue_shared/components/url_sync.vue';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
@@ -48,12 +48,13 @@ export default {
'selectedStageEvents',
'selectedStageError',
'stageCounts',
- 'endpoints',
'features',
'createdBefore',
'createdAfter',
'pagination',
'hasNoAccessError',
+ 'groupPath',
+ 'namespace',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
isLoaded() {
@@ -78,7 +79,9 @@ export default {
}
return this.selectedStageError
? this.selectedStageError
- : __("We don't have enough data to show this stage.");
+ : s__(
+ 'ValueStreamAnalyticsStage|There are 0 items to show in this stage, for these filters, within this time range.',
+ );
},
emptyStageText() {
if (this.displayNoAccess) {
@@ -90,16 +93,24 @@ export default {
},
selectedStageCount() {
if (this.selectedStage) {
- const {
- stageCounts,
- selectedStage: { id },
- } = this;
- return stageCounts[id];
+ return this.stageCounts[this.selectedStage.id];
}
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.groupPath);
+ },
+ dashboardsPath() {
+ const { fullPath } = this.namespace;
+ return this.showLinkToDashboard
+ ? generateValueStreamsDashboardLink(this.groupPath, [fullPath])
+ : null;
},
query() {
return {
@@ -111,6 +122,9 @@ export default {
page: this.pagination?.page || null,
};
},
+ filterBarNamespacePath() {
+ return this.groupPath || this.namespace.fullPath;
+ },
},
methods: {
...mapActions([
@@ -150,11 +164,11 @@ export default {
<div>
<h3>{{ $options.i18n.pageTitle }}</h3>
<value-stream-filters
- :group-id="endpoints.groupId"
- :group-path="endpoints.groupPath"
+ :namespace-path="filterBarNamespacePath"
:has-project-filter="false"
:start-date="createdAfter"
:end-date="createdBefore"
+ :group-path="groupPath"
@setDateRange="onSetDateRange"
/>
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
@@ -169,10 +183,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/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
index 54b632968e2..133513d6c21 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue
@@ -30,7 +30,7 @@ export default {
UrlSync,
},
props: {
- groupPath: {
+ namespacePath: {
type: String,
required: true,
},
@@ -141,7 +141,7 @@ export default {
<div>
<filtered-search-bar
class="gl-flex-grow-1"
- :namespace="groupPath"
+ :namespace="namespacePath"
recent-searches-storage-key="value-stream-analytics"
:search-input-placeholder="__('Filter results')"
:tokens="tokens"
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/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
index 78ac29426d9..1e158baa925 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue
@@ -11,6 +11,7 @@ import {
import FormattedStageCount from '~/analytics/cycle_analytics/components/formatted_stage_count.vue';
import { __ } from '~/locale';
import Tracking from '~/tracking';
+import { scrollToElement } from '~/lib/utils/common_utils';
import {
NOT_ENOUGH_DATA_ERROR,
FIELD_KEY_TITLE,
@@ -102,9 +103,7 @@ export default {
},
data() {
if (this.pagination) {
- const {
- pagination: { sort, direction },
- } = this;
+ const { sort, direction } = this.pagination;
return {
sort,
direction,
@@ -173,6 +172,7 @@ export default {
const { sort, direction } = this.pagination;
this.track('click_button', { label: 'pagination' });
this.$emit('handleUpdatePagination', { sort, direction, page });
+ this.scrollToTop();
},
onSort({ sortBy, sortDesc }) {
const direction = sortDesc ? PAGINATION_SORT_DIRECTION_DESC : PAGINATION_SORT_DIRECTION_ASC;
@@ -181,11 +181,14 @@ export default {
this.$emit('handleUpdatePagination', { sort: sortBy, direction });
this.track('click_button', { label: `sort_${sortBy}_${direction}` });
},
+ scrollToTop() {
+ scrollToElement(this.$el);
+ },
},
};
</script>
<template>
- <div data-testid="vsa-stage-table">
+ <div data-testid="vsa-stage-table" :class="{ 'gl-min-h-100vh': isLoading || !isEmptyStage }">
<gl-loading-icon v-if="isLoading" class="gl-mt-4" size="lg" />
<gl-empty-state
v-else-if="isEmptyStage"
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue
index 725952c3518..662c5c64bba 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue
+++ b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue
@@ -14,9 +14,7 @@ export default {
return Object.keys(this.time).length;
},
calculatedTime() {
- const {
- time: { days = null, mins = null, hours = null, seconds = null },
- } = this;
+ const { days = null, mins = null, hours = null, seconds = null } = this.time;
if (days) {
return {
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..b9d1c4b0fe0 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,8 +31,8 @@ export default {
required: false,
default: true,
},
- groupId: {
- type: Number,
+ namespacePath: {
+ type: String,
required: true,
},
groupPath: {
@@ -73,7 +73,7 @@ export default {
<filter-bar
data-testid="vsa-filter-bar"
class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none"
- :group-path="groupPath"
+ :namespace-path="namespacePath"
/>
<div
v-if="hasDateRangeFilter || hasProjectFilter"
@@ -82,9 +82,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..bea562fb18c 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/constants.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js
@@ -14,7 +14,7 @@ export const DEFAULT_VALUE_STREAM = {
};
export const NOT_ENOUGH_DATA_ERROR = s__(
- "ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
+ 'ValueStreamAnalyticsStage|There are 0 items to show in this stage, for these filters, within this time range.',
);
export const PAGINATION_TYPE = 'keyset';
@@ -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..d7c3804113e 100644
--- a/app/assets/javascripts/analytics/cycle_analytics/utils.js
+++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js
@@ -1,5 +1,7 @@
+import { extractVSAFeaturesFromGON } from '~/analytics/shared/utils';
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
@@ -63,55 +65,33 @@ export const filterStagesByHiddenStatus = (stages = [], isHidden = true) =>
stages.filter(({ hidden = false }) => hidden === isHidden);
/**
- * @typedef {Object} MetricData
- * @property {String} title - Title of the metric measured
- * @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} [unit] - String representing the decimal point value, e.g '1.5'
- *
- * @typedef {Object} TransformedMetricData
- * @property {String} label - Title of the metric measured
- * @property {String} value - String representing the decimal point value, e.g '1.5'
- * @property {String} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute
- * @property {String} description - String to display for a description
- * @property {String} unit - String representing the decimal point value, e.g '1.5'
- */
-
-const extractFeatures = (gon) => ({
- cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
-});
-
-/**
* Builds the initial data object for Value Stream Analytics with data loaded from the backend
*
* @param {Object} dataset - dataset object paseed to the frontend via data-* properties
* @returns {Object} - The initial data to load the app with
*/
export const buildCycleAnalyticsInitialData = ({
- fullPath,
- requestPath,
projectId,
- groupId,
groupPath,
- labelsPath,
- milestonesPath,
stage,
createdAfter,
createdBefore,
- gon,
+ namespaceName,
+ namespaceFullPath,
} = {}) => {
return {
projectId: parseInt(projectId, 10),
- endpoints: {
- requestPath,
- fullPath,
- labelsPath,
- milestonesPath,
- groupId: parseInt(groupId, 10),
- groupPath,
+ groupPath,
+ namespace: {
+ name: namespaceName,
+ fullPath: namespaceFullPath,
},
createdAfter: new Date(createdAfter),
createdBefore: new Date(createdBefore),
selectedStage: stage ? JSON.parse(stage) : null,
- features: extractFeatures(gon),
+ features: extractVSAFeaturesFromGON(),
};
};
+
+export const constructPathWithNamespace = ({ fullPath }, endpoint) =>
+ joinPaths('/', fullPath, endpoint);
diff --git a/app/assets/javascripts/analytics/shared/components/metric_tile.vue b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
index 845a3386f6c..54dbe329c7a 100644
--- a/app/assets/javascripts/analytics/shared/components/metric_tile.vue
+++ b/app/assets/javascripts/analytics/shared/components/metric_tile.vue
@@ -1,6 +1,6 @@
<script>
import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import MetricPopover from './metric_popover.vue';
export default {
@@ -27,7 +27,7 @@ export default {
methods: {
clickHandler({ links }) {
if (this.hasLinks) {
- redirectTo(links[0].url);
+ redirectTo(links[0].url); // eslint-disable-line import/no-deprecated
}
},
},
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..6c79c8af54a
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/value_streams_dashboard_link.vue
@@ -0,0 +1,31 @@
+<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'),
+ // eslint-disable-next-line @gitlab/require-valid-i18n-helpers
+ 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/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index 7ced658f483..c98cf90f406 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -30,18 +30,21 @@ export const DORA_METRICS = {
CHANGE_FAILURE_RATE: 'change_failure_rate',
};
-export const VSA_METRICS_GROUPS = [
- {
- key: 'key_metrics',
- title: s__('ValueStreamAnalytics|Key metrics'),
- keys: Object.values(KEY_METRICS),
- },
- {
- key: 'dora_metrics',
- title: s__('ValueStreamAnalytics|DORA metrics'),
- keys: Object.values(DORA_METRICS),
- },
-];
+const VSA_FLOW_METRICS_GROUP = {
+ key: 'key_metrics',
+ title: s__('ValueStreamAnalytics|Key metrics'),
+ keys: Object.values(KEY_METRICS),
+};
+
+export const VSA_METRICS_GROUPS = [VSA_FLOW_METRICS_GROUP];
+
+export const VULNERABILITY_CRITICAL_TYPE = 'vulnerability_critical';
+export const VULNERABILITY_HIGH_TYPE = 'vulnerability_high';
+
+export const VULNERABILITY_METRICS = {
+ CRITICAL: VULNERABILITY_CRITICAL_TYPE,
+ HIGH: VULNERABILITY_HIGH_TYPE,
+};
export const METRIC_TOOLTIPS = {
[DORA_METRICS.DEPLOYMENT_FREQUENCY]: {
@@ -106,6 +109,18 @@ export const METRIC_TOOLTIPS = {
projectLink: '-/analytics/merge_request_analytics',
docsLink: helpPagePath('user/analytics/merge_request_analytics'),
},
+ [VULNERABILITY_METRICS.CRITICAL]: {
+ description: s__('ValueStreamAnalytics|Critical vulnerabilities over time.'),
+ groupLink: '-/security/vulnerabilities',
+ projectLink: '-/security/vulnerability_report',
+ docsLink: helpPagePath('user/application_security/vulnerability_report/index'),
+ },
+ [VULNERABILITY_METRICS.HIGH]: {
+ description: s__('ValueStreamAnalytics|High vulnerabilities over time.'),
+ groupLink: '-/security/vulnerabilities',
+ projectLink: '-/security/vulnerability_report',
+ docsLink: helpPagePath('user/application_security/vulnerability_report/index'),
+ },
};
// TODO: Remove this once the migration to METRIC_TOOLTIPS is complete
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index aafbf642766..8d7e8546626 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,36 @@ 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 = (namespacePath, projectPaths = []) => {
+ if (namespacePath.length) {
+ const query = projectPaths.length ? `?query=${projectPaths.join(',')}` : '';
+ const dashboardsSlug = '/-/analytics/dashboards/value_streams_dashboard';
+ const segments = [gon.relative_url_root || '', '/', namespacePath, dashboardsSlug];
+ return joinPaths(...segments).concat(query);
+ }
+ return '';
+};
+
+/**
+ * Extracts the relevant feature and license flags needed for VSA
+ *
+ * @param {Object} gon the global `window.gon` object populated when the page loads
+ * @returns an object containing the extracted feature flags and their boolean status
+ */
+export const extractVSAFeaturesFromGON = () => ({
+ // licensed feature toggles
+ cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
+ cycleAnalyticsForProjects: Boolean(gon?.licensed_features?.cycleAnalyticsForProjects),
+ groupLevelAnalyticsDashboard: Boolean(gon?.licensed_features?.groupLevelAnalyticsDashboard),
+ // feature flags
+ vsaGroupAndProjectParity: Boolean(gon?.features?.vsaGroupAndProjectParity),
+});
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/projects_api.js b/app/assets/javascripts/api/projects_api.js
index 5c0d101ef5b..c72a913aacd 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -21,7 +21,7 @@ export function getProjects(query, options, callback = () => {}) {
defaults.membership = true;
}
- if (gon.features.fullPathProjectSearch && query?.includes('/')) {
+ if (query?.includes('/')) {
defaults.search_namespaces = true;
}
@@ -35,6 +35,13 @@ export function getProjects(query, options, callback = () => {}) {
});
}
+export function createProject(projectData) {
+ const url = buildApiUrl(PROJECTS_PATH);
+ return axios.post(url, projectData).then(({ data }) => {
+ return data;
+ });
+}
+
export function importProjectMembers(sourceId, targetId) {
const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH)
.replace(':id', sourceId)
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 45fddc3a696..3ebb07807d2 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -1,6 +1,4 @@
import { DEFAULT_PER_PAGE } from '~/api';
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
@@ -44,22 +42,12 @@ export function getUserStatus(id, options) {
});
}
-export function getUserProjects(userId, query, options, callback) {
+export function getUserProjects(userId, options) {
const url = buildApiUrl(USER_PROJECTS_PATH).replace(':id', userId);
- const defaults = {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- };
- return axios
- .get(url, {
- params: { ...defaults, ...options },
- })
- .then(({ data }) => callback(data))
- .catch(() =>
- createAlert({
- message: __('Something went wrong while fetching projects'),
- }),
- );
+
+ return axios.get(url, {
+ params: options,
+ });
}
export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
diff --git a/app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql b/app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql
index d50fd665c16..b1664a4ed42 100644
--- a/app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql
+++ b/app/assets/javascripts/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql
@@ -1,5 +1,7 @@
mutation updateKeepLatestArtifactProjectSetting($fullPath: ID!, $keepLatestArtifact: Boolean!) {
- ciCdSettingsUpdate(input: { fullPath: $fullPath, keepLatestArtifact: $keepLatestArtifact }) {
+ projectCiCdSettingsUpdate(
+ input: { fullPath: $fullPath, keepLatestArtifact: $keepLatestArtifact }
+ ) {
errors
}
}
diff --git a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue
index d797469dd53..8e7ccb80784 100644
--- a/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue
+++ b/app/assets/javascripts/artifacts_settings/keep_latest_artifact_checkbox.vue
@@ -75,7 +75,7 @@ export default {
},
});
- if (data.ciCdSettingsUpdate.errors.length) {
+ if (data.projectCiCdSettingsUpdate.errors.length) {
this.reportError(this.$options.errors.updateError);
}
} catch (error) {
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/password/components/password_input.vue b/app/assets/javascripts/authentication/password/components/password_input.vue
new file mode 100644
index 00000000000..fa9a7782b74
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/components/password_input.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlFormInput, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { SHOW_PASSWORD, HIDE_PASSWORD } from '../constants';
+
+export default {
+ name: 'PasswordInput',
+ components: {
+ GlFormInput,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ id: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ minimumPasswordLength: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ qaSelector: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ testid: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ autocomplete: {
+ type: String,
+ required: false,
+ default: 'current-password',
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isMasked: true,
+ };
+ },
+ computed: {
+ type() {
+ return this.isMasked ? 'password' : 'text';
+ },
+ toggleVisibilityLabel() {
+ return this.isMasked ? SHOW_PASSWORD : HIDE_PASSWORD;
+ },
+ toggleVisibilityIcon() {
+ return this.isMasked ? 'eye' : 'eye-slash';
+ },
+ },
+ methods: {
+ handleToggleVisibilityButtonClick() {
+ this.isMasked = !this.isMasked;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-field-error-anchor input-icon-wrapper">
+ <gl-form-input
+ :id="id"
+ class="js-password-complexity-validation gl-pr-8!"
+ required
+ :autocomplete="autocomplete"
+ :name="name"
+ :minlength="minimumPasswordLength"
+ :data-qa-selector="qaSelector"
+ :data-testid="testid"
+ :title="title"
+ :type="type"
+ />
+ <gl-button
+ v-gl-tooltip="toggleVisibilityLabel"
+ class="input-icon-right gl-right-0!"
+ category="tertiary"
+ :aria-label="toggleVisibilityLabel"
+ :icon="toggleVisibilityIcon"
+ @click="handleToggleVisibilityButtonClick"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/authentication/password/constants.js b/app/assets/javascripts/authentication/password/constants.js
new file mode 100644
index 00000000000..da617877aec
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/constants.js
@@ -0,0 +1,4 @@
+import { __ } from '~/locale';
+
+export const SHOW_PASSWORD = __('Show password');
+export const HIDE_PASSWORD = __('Hide password');
diff --git a/app/assets/javascripts/authentication/password/index.js b/app/assets/javascripts/authentication/password/index.js
new file mode 100644
index 00000000000..a4f2d038cf7
--- /dev/null
+++ b/app/assets/javascripts/authentication/password/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import GlFieldErrors from '~/gl_field_errors';
+import PasswordInput from './components/password_input.vue';
+
+export const initPasswordInput = () => {
+ document.querySelectorAll('.js-password').forEach((el) => {
+ if (!el) {
+ return null;
+ }
+
+ const { form } = el;
+ const { title, id, minimumPasswordLength, qaSelector, testid, autocomplete, name } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'PasswordInputRoot',
+ render(createElement) {
+ return createElement(PasswordInput, {
+ props: {
+ title,
+ id,
+ minimumPasswordLength,
+ qaSelector,
+ testid,
+ autocomplete,
+ name,
+ },
+ });
+ },
+ });
+
+ // Since we replaced password input, we need to re-initialize the field errors handler
+ return new GlFieldErrors(form);
+ });
+};
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..907b68e6ffc 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,7 @@ 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.'),
disableTwoFactor: __('Disable two-factor authentication'),
disable: __('Disable'),
cancel: __('Cancel'),
@@ -43,7 +40,6 @@ export default {
GlModal,
},
inject: [
- 'webauthnEnabled',
'isCurrentPasswordRequired',
'profileTwoFactorAuthPath',
'profileTwoFactorAuthMethod',
@@ -61,11 +57,7 @@ export default {
},
computed: {
confirmText() {
- if (this.webauthnEnabled) {
- return i18n.confirmWebAuthn;
- }
-
- return i18n.confirm;
+ return i18n.confirmWebAuthn;
},
},
methods: {
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
index 9cf41750efe..f23a6fbcaa0 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -1,6 +1,6 @@
<script>
import { GlSprintf, GlButton, GlAlert, GlCard } from '@gitlab/ui';
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js
index 7d21c19ac4c..cec80335ba0 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/index.js
+++ b/app/assets/javascripts/authentication/two_factor_auth/index.js
@@ -13,7 +13,6 @@ export const initManageTwoFactorForm = () => {
}
const {
- webauthnEnabled = false,
currentPasswordRequired,
profileTwoFactorAuthPath = '',
profileTwoFactorAuthMethod = '',
@@ -26,7 +25,6 @@ export const initManageTwoFactorForm = () => {
return new Vue({
el,
provide: {
- webauthnEnabled,
isCurrentPasswordRequired,
profileTwoFactorAuthPath,
profileTwoFactorAuthMethod,
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..d2db61e096a 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,19 +1,10 @@
<script>
-import {
- GlDropdown,
- GlButton,
- GlIcon,
- GlForm,
- GlFormGroup,
- GlLink,
- GlFormCheckbox,
-} from '@gitlab/ui';
-import { mapGetters, mapActions } from 'vuex';
-import { createAlert } from '~/flash';
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
+import { mapGetters, mapActions, mapState } from 'vuex';
+import { createAlert } from '~/alert';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import Autosave from '~/autosave';
-import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
@@ -22,7 +13,6 @@ export default {
GlIcon,
GlForm,
GlFormGroup,
- GlLink,
GlFormCheckbox,
MarkdownField,
ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'),
@@ -41,6 +31,7 @@ export default {
},
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
+ ...mapState('batchComments', ['shouldAnimateReviewButton']),
},
watch: {
'noteData.approve': function noteDataApproveWatch() {
@@ -102,9 +93,6 @@ export default {
},
},
restrictedToolbarItems: ['full-screen'],
- helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', {
- anchor: 'submit-a-review',
- }),
};
</script>
@@ -114,6 +102,7 @@ export default {
right
dropup
class="submit-review-dropdown"
+ :class="{ 'submit-review-dropdown-animated': shouldAnimateReviewButton }"
data-qa-selector="submit_review_dropdown"
variant="info"
category="primary"
@@ -126,19 +115,9 @@ export default {
<gl-form-group label-for="review-note-body" label-class="gl-mb-2">
<template #label>
{{ __('Summary comment (optional)') }}
- <gl-link
- :href="$options.helpPagePath"
- :aria-label="__('More information')"
- target="_blank"
- class="gl-ml-2"
- >
- <gl-icon name="question-o" />
- </gl-link>
</template>
<div class="common-note-form gfm-form">
- <div
- class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
- >
+ <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white gl-overflow-hidden">
<markdown-field
:is-submitting="isSubmitting"
:add-spacing-classes="false"
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
index 65fd34dcb00..bf9769ff359 100644
--- a/app/assets/javascripts/batch_comments/index.js
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -1,17 +1,28 @@
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 = () => {
+export const initReviewBar = ({ editorAiActions = [] } = {}) => {
const el = document.getElementById('js-review-bar');
+ if (!el) return;
+
+ Vue.use(VueApollo);
+
// eslint-disable-next-line no-new
new Vue({
el,
store,
+ apolloProvider,
components: {
ReviewBar: () => import('./components/review_bar.vue'),
},
+ provide: {
+ newCommentTemplatePath: el.dataset.newCommentTemplatePath,
+ editorAiActions,
+ },
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..7961cf134be 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
@@ -8,6 +8,9 @@ const processDraft = (draft) => ({
export default {
[types.ADD_NEW_DRAFT](state, draft) {
state.drafts.push(processDraft(draft));
+ if (state.drafts.length === 1) {
+ state.shouldAnimateReviewButton = true;
+ }
},
[types.DELETE_DRAFT](state, draftId) {
@@ -62,4 +65,7 @@ export default {
return draft;
});
},
+ [types.CLEAR_DRAFTS](state) {
+ state.drafts = [];
+ },
};
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
index 6b97fc242c8..10033ba17f9 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js
@@ -4,4 +4,5 @@ export default () => ({
drafts: [],
isPublishing: false,
currentlyPublishingDrafts: [],
+ shouldAnimateReviewButton: false,
});
diff --git a/app/assets/javascripts/behaviors/components/json_table.vue b/app/assets/javascripts/behaviors/components/json_table.vue
index bb38d80c1b5..9cbaa02f270 100644
--- a/app/assets/javascripts/behaviors/components/json_table.vue
+++ b/app/assets/javascripts/behaviors/components/json_table.vue
@@ -42,6 +42,7 @@ export default {
key: field.key,
label: field.label,
sortable: field.sortable || false,
+ class: field.class || [],
};
});
},
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..89f1ad9c89e 100644
--- a/app/assets/javascripts/behaviors/date_picker.js
+++ b/app/assets/javascripts/behaviors/date_picker.js
@@ -9,7 +9,7 @@ export default function initDatePickers() {
const calendar = new Pikaday({
field: $datePicker.get(0),
- theme: 'gitlab-theme animate-picker',
+ theme: 'gl-datepicker-theme animate-picker',
format: 'yyyy-mm-dd',
container: $datePicker.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
@@ -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_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 04b3599ea8c..39a7a76e91f 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -39,7 +39,7 @@ export function renderGFM(element) {
'.js-render-mermaid',
'[lang="json"][data-lang-params="table"]',
'.gfm-project_member',
- '.gfm-issue, .gfm-merge_request',
+ '.gfm-issue, .gfm-work_item, .gfm-merge_request',
'.js-render-metrics',
'.js-render-observability',
].map((selector) => Array.from(element.querySelectorAll(selector)));
@@ -50,7 +50,9 @@ export function renderGFM(element) {
renderSandboxedMermaid(mermaidEls);
renderJSONTable(tableEls.map((e) => e.parentNode));
highlightCurrentUser(userEls);
- renderMetrics(metricsEls);
+ if (!window.gon?.features?.removeMonitorMetrics) {
+ renderMetrics(metricsEls);
+ }
renderObservability(observabilityEls);
initPopovers(popoverEls);
}
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_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index b1bf6ebcb13..b2348cf0bad 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -97,7 +97,7 @@ class SafeMathRenderer {
<button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button>
</div>
</div>
- <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ <button type="button" class="close js-close" aria-label="Close">
${spriteIcon('close', 's16')}
</button>
</div>
@@ -184,17 +184,24 @@ class SafeMathRenderer {
attachEvents() {
document.body.addEventListener('click', (event) => {
- if (!event.target.classList.contains('js-lazy-render-math')) {
+ const alert = event.target.closest('.js-lazy-render-math-container');
+
+ if (!alert) {
return;
}
- const parent = event.target.closest('.js-lazy-render-math-container');
-
- const pre = parent.nextElementSibling;
-
- parent.remove();
+ // Handle alert close
+ if (event.target.closest('.js-close')) {
+ alert.remove();
+ return;
+ }
- this.renderElement(pre);
+ // Handle "render anyway"
+ if (event.target.classList.contains('js-lazy-render-math')) {
+ const pre = alert.nextElementSibling;
+ alert.remove();
+ this.renderElement(pre);
+ }
});
}
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_observability.js b/app/assets/javascripts/behaviors/markdown/render_observability.js
index d5d46c10efd..6346fb8ab48 100644
--- a/app/assets/javascripts/behaviors/markdown/render_observability.js
+++ b/app/assets/javascripts/behaviors/markdown/render_observability.js
@@ -1,46 +1,25 @@
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 { frameUrl, observabilityUrl } = element.dataset;
-
- try {
- if (
- !observabilityUrl ||
- !frameUrl ||
- new URL(frameUrl)?.host !== new URL(observabilityUrl).host
- )
- return;
-
- // eslint-disable-next-line no-new
- new Vue({
- el: element,
- render(h) {
- return h('iframe', {
- style: {
- height: '366px',
- width: '768px',
- },
- attrs: {
- src: getFrameSrc(frameUrl),
- frameBorder: '0',
- },
- });
- },
- });
- } catch (e) {
- // eslint-disable-next-line no-console
- console.error(e);
- }
+ const url = element.dataset.frameUrl;
+ return new Vue({
+ el: element,
+ render(h) {
+ return h(ObservabilityApp, {
+ props: {
+ observabilityIframeSrc: url,
+ inlineEmbed: true,
+ skeletonVariant: SKELETON_VARIANT_EMBED,
+ height: INLINE_EMBED_DIMENSIONS.HEIGHT,
+ width: INLINE_EMBED_DIMENSIONS.WIDTH,
+ },
+ });
+ },
+ });
};
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..bcd92d09033 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';
@@ -121,9 +121,7 @@ MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form)
const markdownPreview = new MarkdownPreview();
const previewButtonSelector = '.js-md-preview-button';
-const writeButtonSelector = '.js-md-write-button';
lastTextareaPreviewed = null;
-const markdownToolbar = $('.md-header-toolbar');
$(document).on('markdown-preview:show', (e, $form) => {
if (!$form) {
@@ -134,13 +132,15 @@ $(document).on('markdown-preview:show', (e, $form) => {
lastTextareaHeight = lastTextareaPreviewed.height();
// toggle tabs
- $form.find(writeButtonSelector).parent().removeClass('active');
- $form.find(previewButtonSelector).parent().addClass('active');
+ $form.find(previewButtonSelector).val('edit');
+ $form.find(previewButtonSelector).children('span.gl-button-text').text(__('Continue editing'));
+ $form.find(previewButtonSelector).addClass('gl-shadow-none! gl-bg-transparent!');
// toggle content
$form.find('.md-write-holder').hide();
$form.find('.md-preview-holder').show();
- markdownToolbar.removeClass('active');
+ $form.find('.md-header-toolbar, .js-zen-enter').addClass('gl-display-none!');
+
markdownPreview.showPreview($form);
});
@@ -155,14 +155,14 @@ $(document).on('markdown-preview:hide', (e, $form) => {
}
// toggle tabs
- $form.find(writeButtonSelector).parent().addClass('active');
- $form.find(previewButtonSelector).parent().removeClass('active');
+ $form.find(previewButtonSelector).val('preview');
+ $form.find(previewButtonSelector).children('span.gl-button-text').text(__('Preview'));
// toggle content
$form.find('.md-write-holder').show();
$form.find('textarea.markdown-area').focus();
$form.find('.md-preview-holder').hide();
- markdownToolbar.addClass('active');
+ $form.find('.md-header-toolbar, .js-zen-enter').removeClass('gl-display-none!');
markdownPreview.hideReferencedCommands($form);
});
@@ -183,13 +183,26 @@ $(document).on('markdown-preview:toggle', (e, keyboardEvent) => {
$(document).on('click', previewButtonSelector, function (e) {
e.preventDefault();
const $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:show', [$form]);
+ const eventName = e.currentTarget.getAttribute('value') === 'preview' ? 'show' : 'hide';
+ $(document).triggerHandler(`markdown-preview:${eventName}`, [$form]);
+});
+
+$(document).on('mousedown', previewButtonSelector, function (e) {
+ e.preventDefault();
+ const $form = $(this).closest('form');
+ $form.find(previewButtonSelector).removeClass('gl-shadow-none! gl-bg-transparent!');
+});
+
+$(document).on('mouseenter', previewButtonSelector, function (e) {
+ e.preventDefault();
+ const $form = $(this).closest('form');
+ $form.find(previewButtonSelector).removeClass('gl-bg-transparent!');
});
-$(document).on('click', writeButtonSelector, function (e) {
+$(document).on('mouseleave', previewButtonSelector, function (e) {
e.preventDefault();
const $form = $(this).closest('form');
- $(document).triggerHandler('markdown-preview:hide', [$form]);
+ $form.find(previewButtonSelector).addClass('gl-bg-transparent!');
});
export default MarkdownPreview;
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/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 38ee02938cc..a88cc1834ac 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -2,8 +2,11 @@ import { memoize } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { __ } from '~/locale';
-const isCustomizable = (command) =>
- 'customizable' in command ? Boolean(command.customizable) : true;
+/**
+ * @param {object} command
+ * @param {boolean} [command.customizable]
+ */
+const isCustomizable = ({ customizable }) => Boolean(customizable ?? true);
export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
@@ -117,6 +120,12 @@ export const HIDE_APPEARING_CONTENT = {
defaultKeys: ['esc'],
};
+export const TOGGLE_SUPER_SIDEBAR = {
+ id: 'globalShortcuts.toggleSuperSidebar',
+ description: __('Toggle the navigation sidebar'),
+ defaultKeys: ['mod+\\'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
export const TOGGLE_CANARY = {
id: 'globalShortcuts.toggleCanary',
description: __('Toggle GitLab Next'),
@@ -177,7 +186,10 @@ export const TOGGLE_MARKDOWN_PREVIEW = {
defaultKeys: ['ctrl+shift+p', 'command+shift+p'],
};
-export const EDIT_RECENT_COMMENT = {
+/**
+ * @keydown.up event is handled here: https://gitlab.com/gitlab-org/gitlab/-/blob/f3e807cdff5cf25765894163b4e92f8b2bcf8a68/app/assets/javascripts/notes/components/comment_form.vue#L379
+ */
+const EDIT_RECENT_COMMENT = {
id: 'editing.editRecentComment',
description: __('Edit your most recent comment in a thread (from an empty textarea)'),
defaultKeys: ['up'],
@@ -228,7 +240,7 @@ export const REPO_GRAPH_SCROLL_BOTTOM = {
export const GO_TO_PROJECT_OVERVIEW = {
id: 'project.goToOverview',
description: __("Go to the project's overview page"),
- defaultKeys: ['g p'], // eslint-disable-line @gitlab/require-i18n-strings
+ defaultKeys: ['g o'], // eslint-disable-line @gitlab/require-i18n-strings
};
export const GO_TO_PROJECT_ACTIVITY_FEED = {
@@ -297,6 +309,12 @@ export const GO_TO_PROJECT_MERGE_REQUESTS = {
defaultKeys: ['g m'], // eslint-disable-line @gitlab/require-i18n-strings
};
+export const GO_TO_PROJECT_PIPELINES = {
+ id: 'project.goToPipelines',
+ description: __('Go to pipelines'),
+ defaultKeys: ['g p'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
export const GO_TO_PROJECT_JOBS = {
id: 'project.goToJobs',
description: __('Go to jobs'),
@@ -466,13 +484,22 @@ export const ISSUE_CLOSE_DESIGN = {
defaultKeys: ['esc'],
};
-export const WEB_IDE_GO_TO_FILE = {
+/**
+ * Legacy Web IDE uses the same shortcuts as MR_GO_TO_FILE, from this shared component:
+ * https://gitlab.com/gitlab-org/gitlab/-/blob/f3e807cdff5cf25765894163b4e92f8b2bcf8a68/app/assets/javascripts/vue_shared/components/file_finder/index.vue#L6
+ */
+const WEB_IDE_GO_TO_FILE = {
id: 'webIDE.goToFile',
description: __('Go to file'),
- defaultKeys: ['mod+p'],
+ defaultKeys: ['mod+p', 't'],
+ customizable: false /* customize MR_GO_TO_FILE instead */,
};
-export const WEB_IDE_COMMIT = {
+/**
+ * Legacy Web IDE uses @keydown.ctrl.enter and @keydown.meta.enter events here:
+ * https://gitlab.com/gitlab-org/gitlab/-/blob/f3e807cdff5cf25765894163b4e92f8b2bcf8a68/app/assets/javascripts/ide/components/shared/commit_message_field.vue#L131-132
+ */
+const WEB_IDE_COMMIT = {
id: 'webIDE.commit',
description: __('Commit (when editing commit message)'),
defaultKeys: ['mod+enter'],
@@ -483,39 +510,28 @@ export const METRICS_EXPAND_PANEL = {
id: 'metrics.expandPanel',
description: __('Expand panel'),
defaultKeys: ['e'],
- customizable: false,
-};
-
-export const METRICS_VIEW_LOGS = {
- id: 'metrics.viewLogs',
- description: __('View logs'),
- defaultKeys: ['l'],
- customizable: false,
};
export const METRICS_DOWNLOAD_CSV = {
id: 'metrics.downloadCSV',
description: __('Download CSV'),
defaultKeys: ['d'],
- customizable: false,
};
export const METRICS_COPY_LINK_TO_CHART = {
id: 'metrics.copyLinkToChart',
description: __('Copy link to chart'),
defaultKeys: ['c'],
- customizable: false,
};
export const METRICS_SHOW_ALERTS = {
id: 'metrics.showAlerts',
description: __('Alerts'),
defaultKeys: ['a'],
- customizable: false,
};
// All keybinding groups
-export const GLOBAL_SHORTCUTS_GROUP = {
+const GLOBAL_SHORTCUTS_GROUP = {
id: 'globalShortcuts',
name: __('Global Shortcuts'),
keybindings: [
@@ -536,6 +552,10 @@ export const GLOBAL_SHORTCUTS_GROUP = {
],
};
+if (gon.use_new_navigation) {
+ GLOBAL_SHORTCUTS_GROUP.keybindings.push(TOGGLE_SUPER_SIDEBAR);
+}
+
export const EDITING_SHORTCUTS_GROUP = {
id: 'editing',
name: __('Editing'),
@@ -549,13 +569,13 @@ export const EDITING_SHORTCUTS_GROUP = {
],
};
-export const WIKI_SHORTCUTS_GROUP = {
+const WIKI_SHORTCUTS_GROUP = {
id: 'wiki',
name: __('Wiki'),
keybindings: [EDIT_WIKI_PAGE],
};
-export const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
+const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
id: 'repositoryGraph',
name: __('Repository Graph'),
keybindings: [
@@ -568,7 +588,7 @@ export const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
],
};
-export const PROJECT_SHORTCUTS_GROUP = {
+const PROJECT_SHORTCUTS_GROUP = {
id: 'project',
name: __('Project'),
keybindings: [
@@ -584,8 +604,9 @@ export const PROJECT_SHORTCUTS_GROUP = {
NEW_ISSUE,
GO_TO_PROJECT_ISSUE_BOARDS,
GO_TO_PROJECT_MERGE_REQUESTS,
+ GO_TO_PROJECT_PIPELINES,
GO_TO_PROJECT_JOBS,
- GO_TO_PROJECT_METRICS,
+ ...(gon.features?.removeMonitorMetrics ? [] : [GO_TO_PROJECT_METRICS]),
GO_TO_PROJECT_ENVIRONMENTS,
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_SNIPPETS,
@@ -594,7 +615,7 @@ export const PROJECT_SHORTCUTS_GROUP = {
],
};
-export const PROJECT_FILES_SHORTCUTS_GROUP = {
+const PROJECT_FILES_SHORTCUTS_GROUP = {
id: 'projectFiles',
name: __('Project Files'),
keybindings: [
@@ -606,19 +627,19 @@ export const PROJECT_FILES_SHORTCUTS_GROUP = {
],
};
-export const ISSUABLE_SHORTCUTS_GROUP = {
+const ISSUABLE_SHORTCUTS_GROUP = {
id: 'issuables',
name: __('Epics, issues, and merge requests'),
keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL],
};
-export const ISSUE_MR_SHORTCUTS_GROUP = {
+const ISSUE_MR_SHORTCUTS_GROUP = {
id: 'issuesMRs',
name: __('Issues and merge requests'),
keybindings: [ISSUE_MR_CHANGE_ASSIGNEE, ISSUE_MR_CHANGE_MILESTONE],
};
-export const MR_SHORTCUTS_GROUP = {
+const MR_SHORTCUTS_GROUP = {
id: 'mergeRequests',
name: __('Merge requests'),
keybindings: [
@@ -631,30 +652,29 @@ export const MR_SHORTCUTS_GROUP = {
],
};
-export const MR_COMMITS_SHORTCUTS_GROUP = {
+const MR_COMMITS_SHORTCUTS_GROUP = {
id: 'mergeRequestCommits',
name: __('Merge request commits'),
keybindings: [MR_COMMITS_NEXT_COMMIT, MR_COMMITS_PREVIOUS_COMMIT],
};
-export const ISSUES_SHORTCUTS_GROUP = {
+const ISSUES_SHORTCUTS_GROUP = {
id: 'issues',
name: __('Issues'),
keybindings: [ISSUE_NEXT_DESIGN, ISSUE_PREVIOUS_DESIGN, ISSUE_CLOSE_DESIGN],
};
-export const WEB_IDE_SHORTCUTS_GROUP = {
+const WEB_IDE_SHORTCUTS_GROUP = {
id: 'webIDE',
- name: __('Web IDE'),
+ name: __('Legacy Web IDE'),
keybindings: [WEB_IDE_GO_TO_FILE, WEB_IDE_COMMIT],
};
-export const METRICS_SHORTCUTS_GROUP = {
+const METRICS_SHORTCUTS_GROUP = {
id: 'metrics',
name: __('Metrics'),
keybindings: [
METRICS_EXPAND_PANEL,
- METRICS_VIEW_LOGS,
METRICS_DOWNLOAD_CSV,
METRICS_COPY_LINK_TO_CHART,
METRICS_SHOW_ALERTS,
@@ -681,15 +701,18 @@ export const keybindingGroups = [
MR_COMMITS_SHORTCUTS_GROUP,
ISSUES_SHORTCUTS_GROUP,
WEB_IDE_SHORTCUTS_GROUP,
- METRICS_SHORTCUTS_GROUP,
+ ...(gon.features?.removeMonitorMetrics ? [] : [METRICS_SHORTCUTS_GROUP]),
MISC_SHORTCUTS_GROUP,
];
/**
* Gets keyboard shortcuts associated with a command
*
- * @param {string} command The command object. All command
- * objects are available as imports from this file.
+ * @param {Object} command The command object. All command objects are
+ * available as imports from this file.
+ * @param {string} command.id
+ * @param {string[]} command.defaultKeys
+ * @param {boolean} [command.customizable]
*
* @returns {string[]} An array of keyboard shortcut strings bound to the command
*
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 7a1577e97d5..9514ad853b0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import { flatten } from 'lodash';
-import Mousetrap from 'mousetrap';
import Vue from 'vue';
+import { Mousetrap, addStopCallback } from '~/lib/mousetrap';
import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils';
import findAndFollowLink from '~/lib/utils/navigation_utility';
@@ -28,20 +28,11 @@ import {
} from './keybindings';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
-const defaultStopCallback = Mousetrap.prototype.stopCallback;
-Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
- if (keysFor(TOGGLE_MARKDOWN_PREVIEW).indexOf(combo) !== -1) {
- return false;
- }
-
- return defaultStopCallback.call(this, e, element, combo);
-};
-
/**
* The key used to save and fetch the local Mousetrap instance
* attached to a `<textarea>` element using `jQuery.data`
*/
-const LOCAL_MOUSETRAP_DATA_KEY = 'local-mousetrap-instance';
+export const LOCAL_MOUSETRAP_DATA_KEY = 'local-mousetrap-instance';
/**
* Gets a mapping of toolbar button => keyboard shortcuts
@@ -76,62 +67,75 @@ export default class Shortcuts {
this.helpModalElement = null;
this.helpModalVueInstance = null;
- Mousetrap.bind(keysFor(TOGGLE_KEYBOARD_SHORTCUTS_DIALOG), this.onToggleHelp);
- Mousetrap.bind(keysFor(START_SEARCH), Shortcuts.focusSearch);
- Mousetrap.bind(keysFor(FOCUS_FILTER_BAR), this.focusFilter.bind(this));
- Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar);
- Mousetrap.bind(keysFor(HIDE_APPEARING_CONTENT), Shortcuts.hideAppearingContent);
- Mousetrap.bind(keysFor(TOGGLE_CANARY), Shortcuts.onToggleCanary);
-
- const findFileURL = document.body.dataset.findFile;
-
- Mousetrap.bind(keysFor(GO_TO_YOUR_TODO_LIST), () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind(keysFor(GO_TO_ACTIVITY_FEED), () =>
- findAndFollowLink('.dashboard-shortcuts-activity'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_ISSUES), () =>
- findAndFollowLink('.dashboard-shortcuts-issues'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_MERGE_REQUESTS), () =>
- findAndFollowLink('.dashboard-shortcuts-merge_requests'),
+ this.bindCommands([
+ [TOGGLE_KEYBOARD_SHORTCUTS_DIALOG, this.onToggleHelp],
+ [START_SEARCH, Shortcuts.focusSearch],
+ [FOCUS_FILTER_BAR, this.focusFilter.bind(this)],
+ [TOGGLE_PERFORMANCE_BAR, Shortcuts.onTogglePerfBar],
+ [HIDE_APPEARING_CONTENT, Shortcuts.hideAppearingContent],
+ [TOGGLE_CANARY, Shortcuts.onToggleCanary],
+
+ [GO_TO_YOUR_TODO_LIST, () => findAndFollowLink('.shortcuts-todos')],
+ [GO_TO_ACTIVITY_FEED, () => findAndFollowLink('.dashboard-shortcuts-activity')],
+ [GO_TO_YOUR_ISSUES, () => findAndFollowLink('.dashboard-shortcuts-issues')],
+ [GO_TO_YOUR_MERGE_REQUESTS, () => findAndFollowLink('.dashboard-shortcuts-merge_requests')],
+ [GO_TO_YOUR_REVIEW_REQUESTS, () => findAndFollowLink('.dashboard-shortcuts-review_requests')],
+ [GO_TO_YOUR_PROJECTS, () => findAndFollowLink('.dashboard-shortcuts-projects')],
+ [GO_TO_YOUR_GROUPS, () => findAndFollowLink('.dashboard-shortcuts-groups')],
+ [GO_TO_MILESTONE_LIST, () => findAndFollowLink('.dashboard-shortcuts-milestones')],
+ [GO_TO_YOUR_SNIPPETS, () => findAndFollowLink('.dashboard-shortcuts-snippets')],
+
+ [TOGGLE_MARKDOWN_PREVIEW, Shortcuts.toggleMarkdownPreview],
+ ]);
+
+ addStopCallback((e, element, combo) =>
+ keysFor(TOGGLE_MARKDOWN_PREVIEW).includes(combo) ? false : undefined,
);
- Mousetrap.bind(keysFor(GO_TO_YOUR_REVIEW_REQUESTS), () =>
- findAndFollowLink('.dashboard-shortcuts-review_requests'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_PROJECTS), () =>
- findAndFollowLink('.dashboard-shortcuts-projects'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_GROUPS), () =>
- findAndFollowLink('.dashboard-shortcuts-groups'),
- );
- Mousetrap.bind(keysFor(GO_TO_MILESTONE_LIST), () =>
- findAndFollowLink('.dashboard-shortcuts-milestones'),
- );
- Mousetrap.bind(keysFor(GO_TO_YOUR_SNIPPETS), () =>
- findAndFollowLink('.dashboard-shortcuts-snippets'),
- );
-
- Mousetrap.bind(keysFor(TOGGLE_MARKDOWN_PREVIEW), Shortcuts.toggleMarkdownPreview);
+ const findFileURL = document.body.dataset.findFile;
if (typeof findFileURL !== 'undefined' && findFileURL !== null) {
- Mousetrap.bind(keysFor(GO_TO_PROJECT_FIND_FILE), () => {
+ this.bindCommand(GO_TO_PROJECT_FIND_FILE, () => {
visitUrl(findFileURL);
});
}
- $(document).on('click.more_help', '.js-more-help-button', function clickMoreHelp(e) {
- $(this).remove();
- 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();
}
}
+ /**
+ * Bind the keyboard shortcut(s) defined by the given command to the given
+ * callback.
+ *
+ * @param {Object} command A command object.
+ * @param {Function} callback The callback to call when the command's key
+ * combo has been pressed.
+ * @returns {void}
+ */
+ // eslint-disable-next-line class-methods-use-this
+ bindCommand(command, callback) {
+ Mousetrap.bind(keysFor(command), callback);
+ }
+
+ /**
+ * Bind the keyboard shortcut(s) defined by the given commands to the given
+ * callbacks.
+ *
+ * @param {Array<[Object, Function]>} commandsAndCallbacks An array of
+ * command/callback pairs.
+ * @returns {void}
+ */
+ bindCommands(commandsAndCallbacks) {
+ commandsAndCallbacks.forEach((commandAndCallback) => this.bindCommand(...commandAndCallback));
+ }
+
onToggleHelp(e) {
if (e?.preventDefault) {
e.preventDefault();
@@ -182,13 +186,6 @@ export default class Shortcuts {
}
static toggleMarkdownPreview(e) {
- // Check if short-cut was triggered while in Write Mode
- const $target = $(e.target);
- const $form = $target.closest('form');
-
- if ($target.hasClass('js-note-text')) {
- $('.js-md-preview-button', $form).focus();
- }
$(document).triggerHandler('markdown-preview:toggle', [e]);
}
@@ -201,7 +198,11 @@ export default class Shortcuts {
}
static focusSearch(e) {
- $('#search').focus();
+ if (gon.use_new_navigation) {
+ document.querySelector('#super-sidebar-search')?.click();
+ } else {
+ document.querySelector('#search')?.focus();
+ }
if (e.preventDefault) {
e.preventDefault();
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index ab7fcbb35f1..65ae67d156f 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -1,5 +1,4 @@
-import Mousetrap from 'mousetrap';
-import { keysFor, PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
+import { PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import {
getLocationHash,
updateHistory,
@@ -11,7 +10,6 @@ import { updateRefPortionOfTitle } from '~/repository/utils/title';
import Shortcuts from './shortcuts';
const defaults = {
- skipResetBindings: false,
fileBlobPermalinkUrl: null,
fileBlobPermalinkUrlElement: null,
};
@@ -24,12 +22,12 @@ function eventHasModifierKeys(event) {
export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = { ...defaults, ...opts };
- super(options.skipResetBindings);
+ super();
this.options = options;
this.shortcircuitPermalinkButton();
- Mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.moveToFilePermalink.bind(this));
+ this.bindCommand(PROJECT_FILES_GO_TO_PERMALINK, this.moveToFilePermalink.bind(this));
}
moveToFilePermalink() {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
index 992e571e596..f26878cf161 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
@@ -1,4 +1,3 @@
-import Mousetrap from 'mousetrap';
import {
keysFor,
PROJECT_FILES_MOVE_SELECTION_UP,
@@ -6,15 +5,14 @@ import {
PROJECT_FILES_OPEN_SELECTION,
PROJECT_FILES_GO_BACK,
} from '~/behaviors/shortcuts/keybindings';
+import { addStopCallback } from '~/lib/mousetrap';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsFindFile extends ShortcutsNavigation {
constructor(projectFindFile) {
super();
- const oldStopCallback = Mousetrap.prototype.stopCallback;
-
- Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
+ addStopCallback((e, element, combo) => {
if (
element === projectFindFile.inputElement[0] &&
(keysFor(PROJECT_FILES_MOVE_SELECTION_UP).includes(combo) ||
@@ -27,12 +25,14 @@ export default class ShortcutsFindFile extends ShortcutsNavigation {
return false;
}
- return oldStopCallback.call(this, e, element, combo);
- };
+ return undefined;
+ });
- Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_UP), projectFindFile.selectRowUp);
- Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN), projectFindFile.selectRowDown);
- Mousetrap.bind(keysFor(PROJECT_FILES_GO_BACK), projectFindFile.goToTree);
- Mousetrap.bind(keysFor(PROJECT_FILES_OPEN_SELECTION), projectFindFile.goToBlob);
+ this.bindCommands([
+ [PROJECT_FILES_MOVE_SELECTION_UP, projectFindFile.selectRowUp],
+ [PROJECT_FILES_MOVE_SELECTION_DOWN, projectFindFile.selectRowDown],
+ [PROJECT_FILES_GO_BACK, projectFindFile.goToTree],
+ [PROJECT_FILES_OPEN_SELECTION, projectFindFile.goToBlob],
+ ]);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 64297da39cd..0c882ff9ea2 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import ClipboardJS from 'clipboard';
-import Mousetrap from 'mousetrap';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import { DEBOUNCE_DROPDOWN_DELAY } from '~/sidebar/components/labels/labels_select_widget/constants';
@@ -9,7 +8,6 @@ import { s__ } from '~/locale';
import Sidebar from '~/right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import {
- keysFor,
ISSUE_MR_CHANGE_ASSIGNEE,
ISSUE_MR_CHANGE_MILESTONE,
ISSUABLE_CHANGE_LABEL,
@@ -32,18 +30,14 @@ export default class ShortcutsIssuable extends Shortcuts {
toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.'));
});
- Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () =>
- ShortcutsIssuable.openSidebarDropdown('assignee'),
- );
- Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_MILESTONE), () =>
- ShortcutsIssuable.openSidebarDropdown('milestone'),
- );
- Mousetrap.bind(keysFor(ISSUABLE_CHANGE_LABEL), () =>
- ShortcutsIssuable.openSidebarDropdown('labels'),
- );
- Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText);
- Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue);
- Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), () => this.copyBranchName());
+ this.bindCommands([
+ [ISSUE_MR_CHANGE_ASSIGNEE, () => ShortcutsIssuable.openSidebarDropdown('assignee')],
+ [ISSUE_MR_CHANGE_MILESTONE, () => ShortcutsIssuable.openSidebarDropdown('milestone')],
+ [ISSUABLE_CHANGE_LABEL, () => ShortcutsIssuable.openSidebarDropdown('labels')],
+ [ISSUABLE_COMMENT_OR_REPLY, ShortcutsIssuable.replyWithSelectedText],
+ [ISSUABLE_EDIT_DESCRIPTION, ShortcutsIssuable.editIssue],
+ [MR_COPY_SOURCE_BRANCH_NAME, () => this.copyBranchName()],
+ ]);
/**
* We're attaching a global focus event listener on document for
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index 7bb6bc7e9bc..9e6c9c2e08e 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -1,13 +1,12 @@
-import Mousetrap from 'mousetrap';
import { visitUrl, constructWebIDEPath } from '~/lib/utils/url_utility';
import findAndFollowLink from '~/lib/utils/navigation_utility';
import {
- keysFor,
GO_TO_PROJECT_OVERVIEW,
GO_TO_PROJECT_ACTIVITY_FEED,
GO_TO_PROJECT_RELEASES,
GO_TO_PROJECT_FILES,
GO_TO_PROJECT_COMMITS,
+ GO_TO_PROJECT_PIPELINES,
GO_TO_PROJECT_JOBS,
GO_TO_PROJECT_REPO_GRAPH,
GO_TO_PROJECT_REPO_CHARTS,
@@ -28,40 +27,27 @@ export default class ShortcutsNavigation extends Shortcuts {
constructor() {
super();
- Mousetrap.bind(keysFor(GO_TO_PROJECT_OVERVIEW), () => findAndFollowLink('.shortcuts-project'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ACTIVITY_FEED), () =>
- findAndFollowLink('.shortcuts-project-activity'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_RELEASES), () =>
- findAndFollowLink('.shortcuts-project-releases'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_FILES), () => findAndFollowLink('.shortcuts-tree'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_COMMITS), () => findAndFollowLink('.shortcuts-commits'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_JOBS), () => findAndFollowLink('.shortcuts-builds'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_GRAPH), () =>
- findAndFollowLink('.shortcuts-network'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_CHARTS), () =>
- findAndFollowLink('.shortcuts-repository-charts'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUES), () => findAndFollowLink('.shortcuts-issues'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUE_BOARDS), () =>
- findAndFollowLink('.shortcuts-issue-boards'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_MERGE_REQUESTS), () =>
- findAndFollowLink('.shortcuts-merge_requests'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_WIKI), () => findAndFollowLink('.shortcuts-wiki'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_SNIPPETS), () => findAndFollowLink('.shortcuts-snippets'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_KUBERNETES), () =>
- findAndFollowLink('.shortcuts-kubernetes'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_ENVIRONMENTS), () =>
- findAndFollowLink('.shortcuts-environments'),
- );
- Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics'));
- Mousetrap.bind(keysFor(GO_TO_PROJECT_WEBIDE), ShortcutsNavigation.navigateToWebIDE);
- Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue'));
+ this.bindCommands([
+ [GO_TO_PROJECT_OVERVIEW, () => findAndFollowLink('.shortcuts-project')],
+ [GO_TO_PROJECT_ACTIVITY_FEED, () => findAndFollowLink('.shortcuts-project-activity')],
+ [GO_TO_PROJECT_RELEASES, () => findAndFollowLink('.shortcuts-deployments-releases')],
+ [GO_TO_PROJECT_FILES, () => findAndFollowLink('.shortcuts-tree')],
+ [GO_TO_PROJECT_COMMITS, () => findAndFollowLink('.shortcuts-commits')],
+ [GO_TO_PROJECT_PIPELINES, () => findAndFollowLink('.shortcuts-pipelines')],
+ [GO_TO_PROJECT_JOBS, () => findAndFollowLink('.shortcuts-builds')],
+ [GO_TO_PROJECT_REPO_GRAPH, () => findAndFollowLink('.shortcuts-network')],
+ [GO_TO_PROJECT_REPO_CHARTS, () => findAndFollowLink('.shortcuts-repository-charts')],
+ [GO_TO_PROJECT_ISSUES, () => findAndFollowLink('.shortcuts-issues')],
+ [GO_TO_PROJECT_ISSUE_BOARDS, () => findAndFollowLink('.shortcuts-issue-boards')],
+ [GO_TO_PROJECT_MERGE_REQUESTS, () => findAndFollowLink('.shortcuts-merge_requests')],
+ [GO_TO_PROJECT_WIKI, () => findAndFollowLink('.shortcuts-wiki')],
+ [GO_TO_PROJECT_SNIPPETS, () => findAndFollowLink('.shortcuts-snippets')],
+ [GO_TO_PROJECT_KUBERNETES, () => findAndFollowLink('.shortcuts-kubernetes')],
+ [GO_TO_PROJECT_ENVIRONMENTS, () => findAndFollowLink('.shortcuts-environments')],
+ [GO_TO_PROJECT_METRICS, () => findAndFollowLink('.shortcuts-metrics')],
+ [GO_TO_PROJECT_WEBIDE, ShortcutsNavigation.navigateToWebIDE],
+ [NEW_ISSUE, () => findAndFollowLink('.shortcuts-new-issue')],
+ ]);
}
static navigateToWebIDE() {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
index c33c092b009..02c6af53fc2 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
@@ -1,6 +1,4 @@
-import Mousetrap from 'mousetrap';
import {
- keysFor,
REPO_GRAPH_SCROLL_BOTTOM,
REPO_GRAPH_SCROLL_DOWN,
REPO_GRAPH_SCROLL_LEFT,
@@ -14,11 +12,13 @@ export default class ShortcutsNetwork extends ShortcutsNavigation {
constructor(graph) {
super();
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_LEFT), graph.scrollLeft);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_RIGHT), graph.scrollRight);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_UP), graph.scrollUp);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_DOWN), graph.scrollDown);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_TOP), graph.scrollTop);
- Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_BOTTOM), graph.scrollBottom);
+ this.bindCommands([
+ [REPO_GRAPH_SCROLL_LEFT, graph.scrollLeft],
+ [REPO_GRAPH_SCROLL_RIGHT, graph.scrollRight],
+ [REPO_GRAPH_SCROLL_UP, graph.scrollUp],
+ [REPO_GRAPH_SCROLL_DOWN, graph.scrollDown],
+ [REPO_GRAPH_SCROLL_TOP, graph.scrollTop],
+ [REPO_GRAPH_SCROLL_BOTTOM, graph.scrollBottom],
+ ]);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
index 66aa1b752ae..3f3e0c51de5 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
@@ -1,4 +1,4 @@
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
const shorcutsDisabledKey = 'shortcutsDisabled';
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
index b2801f9118d..62d612cfa6d 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
@@ -1,12 +1,12 @@
-import Mousetrap from 'mousetrap';
import findAndFollowLink from '~/lib/utils/navigation_utility';
-import { keysFor, EDIT_WIKI_PAGE } from './keybindings';
+import { EDIT_WIKI_PAGE } from './keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsWiki extends ShortcutsNavigation {
constructor() {
super();
- Mousetrap.bind(keysFor(EDIT_WIKI_PAGE), ShortcutsWiki.editWiki);
+
+ this.bindCommand(EDIT_WIKI_PAGE, ShortcutsWiki.editWiki);
}
static editWiki() {
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/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue
index 5715635fd13..8fd3f03ff71 100644
--- a/app/assets/javascripts/blob/components/blob_edit_header.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_header.vue
@@ -35,9 +35,7 @@ export default {
<div class="gl-display-flex gl-align-items-center gl-w-full">
<gl-form-input
v-model="name"
- :placeholder="
- s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby')
- "
+ :placeholder="s__('Snippets|File name (e.g. test.rb)')"
name="snippet_file_name"
class="form-control js-snippet-file-name"
type="text"
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index fb99392ff48..95b88937c32 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -59,7 +59,7 @@ export default {
:gfm="gfmCopyText"
:title="__('Copy file path')"
category="tertiary"
- css-class="btn-clipboard btn-transparent lh-100 position-static"
+ css-class="gl-mr-2"
/>
<small class="gl-mr-3">{{ blobSize }}</small>
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/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js
index d81aa05c44e..94ae281cada 100644
--- a/app/assets/javascripts/blob/openapi/index.js
+++ b/app/assets/javascripts/blob/openapi/index.js
@@ -1,12 +1,21 @@
import { setAttributes } from '~/lib/utils/dom_utils';
import axios from '~/lib/utils/axios_utils';
-import { getBaseURL, relativePathToAbsolute, joinPaths } from '~/lib/utils/url_utility';
+import {
+ getBaseURL,
+ relativePathToAbsolute,
+ joinPaths,
+ setUrlParams,
+} from '~/lib/utils/url_utility';
const SANDBOX_FRAME_PATH = '/-/sandbox/swagger';
const getSandboxFrameSrc = () => {
const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH);
- return relativePathToAbsolute(path, getBaseURL());
+ const absoluteUrl = relativePathToAbsolute(path, getBaseURL());
+ if (window.gon?.relative_url_root) {
+ return setUrlParams({ relativeRootPath: window.gon.relative_url_root }, absoluteUrl);
+ }
+ return absoluteUrl;
};
const createSandbox = () => {
diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js
index 2c1c6339fdb..834aa3e5354 100644
--- a/app/assets/javascripts/blob/sketch_viewer.js
+++ b/app/assets/javascripts/blob/sketch_viewer.js
@@ -1,8 +1,7 @@
-/* eslint-disable no-new */
import SketchLoader from './sketch';
export default () => {
const el = document.getElementById('js-sketch-viewer');
- new SketchLoader(el);
+ new SketchLoader(el); // eslint-disable-line no-new
};
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 7eb699eacbe..59b7f82c10e 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -1,5 +1,3 @@
-/* eslint-disable class-methods-use-this */
-
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
@@ -70,6 +68,7 @@ export default class TemplateSelector {
return this.requestFile(item);
}
+ // eslint-disable-next-line class-methods-use-this
requestFile() {
// This `requestFile` method is an abstract method that should
// be added by all subclasses.
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..7e667409556 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -1,8 +1,6 @@
-/* eslint-disable no-new */
-
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';
@@ -54,6 +52,7 @@ export default () => {
import('./edit_blob')
.then(({ default: EditBlob } = {}) => {
+ // eslint-disable-next-line no-new
new EditBlob({
assetsPath: `${urlRoot}${assetsPath}`,
filePath,
@@ -80,7 +79,7 @@ export default () => {
window.onbeforeunload = null;
});
- new NewCommitForm(editBlobForm);
+ new NewCommitForm(editBlobForm); // eslint-disable-line no-new
// returning here blocks page navigation
window.onbeforeunload = () => '';
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..3a247819850 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -1,24 +1,106 @@
<script>
import { mapGetters } from 'vuex';
-import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import { refreshCurrentPage, queryToObject } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import BoardTopBar from '~/boards/components/board_top_bar.vue';
+import { listsQuery } from 'ee_else_ce/boards/constants';
+import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export default {
+ i18n: {
+ fetchError: s__(
+ 'Boards|An error occurred while fetching the board lists. Please reload the page.',
+ ),
+ },
components: {
BoardContent,
BoardSettingsSidebar,
BoardTopBar,
},
- inject: ['initialBoardId'],
+ inject: [
+ 'fullPath',
+ 'initialBoardId',
+ 'initialFilterParams',
+ 'isIssueBoard',
+ 'isGroupBoard',
+ 'issuableType',
+ 'boardType',
+ 'isApolloBoard',
+ ],
data() {
return {
+ activeListId: '',
boardId: this.initialBoardId,
+ filterParams: { ...this.initialFilterParams },
+ isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by),
+ apolloError: null,
};
},
+ apollo: {
+ activeBoardItem: {
+ query: activeBoardItemQuery,
+ variables() {
+ return {
+ isIssue: this.isIssueBoard,
+ };
+ },
+ result({ data: { activeBoardItem } }) {
+ if (activeBoardItem) {
+ this.setActiveId('');
+ }
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ boardListsApollo: {
+ query() {
+ return listsQuery[this.issuableType].query;
+ },
+ variables() {
+ return this.listQueryVariables;
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ update(data) {
+ const { lists } = data[this.boardType].board;
+ return formatBoardLists(lists);
+ },
+ error() {
+ this.apolloError = this.$options.i18n.fetchError;
+ },
+ },
+ },
+
computed: {
...mapGetters(['isSidebarOpen']),
+ listQueryVariables() {
+ return {
+ ...(this.isIssueBoard && {
+ isGroup: this.isGroupBoard,
+ isProject: !this.isGroupBoard,
+ }),
+ fullPath: this.fullPath,
+ boardId: this.boardId,
+ filters: this.filterParams,
+ };
+ },
+ isSwimlanesOn() {
+ return (gon?.licensed_features?.swimlanes && this.isShowingEpicsSwimlanes) ?? false;
+ },
+ isAnySidebarOpen() {
+ if (this.isApolloBoard) {
+ return this.activeBoardItem?.id || this.activeListId;
+ }
+ return this.isSidebarOpen;
+ },
+ activeList() {
+ return this.activeListId ? this.boardListsApollo[this.activeListId] : undefined;
+ },
},
created() {
window.addEventListener('popstate', refreshCurrentPage);
@@ -27,17 +109,47 @@ export default {
window.removeEventListener('popstate', refreshCurrentPage);
},
methods: {
+ setActiveId(id) {
+ this.activeListId = id;
+ },
switchBoard(id) {
this.boardId = id;
+ this.setActiveId('');
+ },
+ 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-settings-sidebar />
+ <div class="boards-app gl-relative" :class="{ 'is-compact': isAnySidebarOpen }">
+ <board-top-bar
+ :board-id="boardId"
+ :is-swimlanes-on="isSwimlanesOn"
+ @switchBoard="switchBoard"
+ @setFilters="setFilters"
+ @toggleSwimlanes="isShowingEpicsSwimlanes = $event"
+ />
+ <board-content
+ v-if="!isApolloBoard || boardListsApollo"
+ :board-id="boardId"
+ :is-swimlanes-on="isSwimlanesOn"
+ :filter-params="filterParams"
+ :board-lists-apollo="boardListsApollo"
+ :apollo-error="apolloError"
+ @setActiveList="setActiveId"
+ />
+ <board-settings-sidebar
+ v-if="!isApolloBoard || activeList"
+ :list="activeList"
+ :list-id="activeListId"
+ :board-id="boardId"
+ :query-variables="listQueryVariables"
+ @unsetActiveId="setActiveId('')"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 3071c1f334e..18495f285da 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,6 +1,8 @@
<script>
import { mapActions, mapState } from 'vuex';
import Tracking from '~/tracking';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
import BoardCardInner from './board_card_inner.vue';
export default {
@@ -9,7 +11,7 @@ export default {
BoardCardInner,
},
mixins: [Tracking.mixin()],
- inject: ['disabled', 'isApolloBoard'],
+ inject: ['disabled', 'isIssueBoard', 'isApolloBoard'],
props: {
list: {
type: Object,
@@ -37,14 +39,30 @@ export default {
default: true,
},
},
+ apollo: {
+ activeBoardItem: {
+ query: activeBoardItemQuery,
+ variables() {
+ return {
+ isIssue: this.isIssueBoard,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
computed: {
...mapState(['selectedBoardItems', 'activeId']),
+ activeItemId() {
+ return this.isApolloBoard ? this.activeBoardItem?.id : this.activeId;
+ },
isActive() {
- return this.item.id === this.activeId;
+ return this.item.id === this.activeItemId;
},
multiSelectVisible() {
return (
- !this.activeId &&
+ !this.activeItemId &&
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1
);
},
@@ -83,10 +101,23 @@ export default {
if (isMultiSelect && gon?.features?.boardMultiSelect) {
this.toggleBoardItemMultiSelection(this.item);
} else {
- this.toggleBoardItem({ boardItem: this.item });
+ if (this.isApolloBoard) {
+ this.toggleItem();
+ } else {
+ this.toggleBoardItem({ boardItem: this.item });
+ }
this.track('click_card', { label: 'right_sidebar' });
}
},
+ toggleItem() {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: {
+ boardItem: this.item,
+ isIssue: this.isIssueBoard,
+ },
+ });
+ },
},
};
</script>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 88f51c71e06..befd04c29ae 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -275,7 +275,7 @@ export default {
<gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
<span
v-if="item.referencePath"
- class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary"
+ class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
<work-item-type-icon
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 708e1539c6e..b2054d76e95 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,18 @@ 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-id="boardId"
+ @setActiveList="$emit('setActiveList', $event)"
+ />
<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..8304dfef527 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -1,23 +1,15 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { breakpoints } from '@gitlab/ui/dist/utils';
-import { sortBy, throttle } from 'lodash';
+import { sortBy } from 'lodash';
import Draggable from 'vuedraggable';
-import { mapState, mapGetters, mapActions } from 'vuex';
-import { contentTop } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
-import { formatBoardLists } from 'ee_else_ce/boards/boards_util';
+import { mapState, mapActions } from 'vuex';
+import eventHub from '~/boards/eventhub';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import { defaultSortableOptions } from '~/sortable/constants';
-import { DraggableItemTypes, listsQuery } from 'ee_else_ce/boards/constants';
+import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
import BoardColumn from './board_column.vue';
export default {
- i18n: {
- fetchError: s__(
- 'Boards|An error occurred while fetching the board lists. Please reload the page.',
- ),
- },
draggableItemTypes: DraggableItemTypes,
components: {
BoardAddNewColumn,
@@ -28,73 +20,41 @@ export default {
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
- inject: [
- 'canAdminList',
- 'boardType',
- 'fullPath',
- 'issuableType',
- 'isIssueBoard',
- 'isEpicBoard',
- 'isGroupBoard',
- 'disabled',
- 'isApolloBoard',
- ],
+ inject: ['canAdminList', 'isIssueBoard', 'isEpicBoard', 'disabled', 'isApolloBoard'],
props: {
boardId: {
type: String,
required: true,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
+ isSwimlanesOn: {
+ type: Boolean,
+ required: true,
+ },
+ boardListsApollo: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ apolloError: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
boardHeight: null,
- boardListsApollo: {},
- apolloError: null,
- updatedBoardId: this.boardId,
};
},
- apollo: {
- boardListsApollo: {
- query() {
- return listsQuery[this.issuableType].query;
- },
- variables() {
- return this.queryVariables;
- },
- skip() {
- return !this.isApolloBoard;
- },
- update(data) {
- const { lists } = data[this.boardType].board;
- return formatBoardLists(lists);
- },
- result() {
- // this allows us to delay fetching lists when we switch a board to fetch the actual board lists
- // instead of fetching lists for the "previous" board
- this.updatedBoardId = this.boardId;
- },
- error() {
- this.apolloError = this.$options.i18n.fetchError;
- },
- },
- },
computed: {
...mapState(['boardLists', 'error', 'addColumnForm']),
- ...mapGetters(['isSwimlanesOn']),
addColumnFormVisible() {
return this.addColumnForm?.visible;
},
- queryVariables() {
- return {
- ...(this.isIssueBoard && {
- isGroup: this.isGroupBoard,
- isProject: !this.isGroupBoard,
- }),
- fullPath: this.fullPath,
- boardId: this.boardId,
- filterParams: this.filterParams,
- };
- },
boardListsToUse() {
const lists = this.isApolloBoard ? this.boardListsApollo : this.boardLists;
return sortBy([...Object.values(lists)], 'position');
@@ -126,18 +86,11 @@ export default {
return this.isApolloBoard ? this.apolloError : this.error;
},
},
- mounted() {
- this.setBoardHeight();
-
- this.resizeObserver = new ResizeObserver(
- throttle(() => {
- this.setBoardHeight();
- }, 150),
- );
- this.resizeObserver.observe(document.body);
+ created() {
+ eventHub.$on('updateBoard', this.refetchLists);
},
- unmounted() {
- this.resizeObserver.disconnect();
+ beforeDestroy() {
+ eventHub.$off('updateBoard', this.refetchLists);
},
methods: {
...mapActions(['moveList', 'unsetError']),
@@ -145,19 +98,19 @@ export default {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
},
- setBoardHeight() {
- if (window.innerWidth < breakpoints.md) {
- this.boardHeight = `${window.innerHeight - contentTop()}px`;
- } else {
- this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`;
- }
+ refetchLists() {
+ this.$apollo.queries.boardListsApollo.refetch();
},
},
};
</script>
<template>
- <div v-cloak data-qa-selector="boards_list">
+ <div
+ v-cloak
+ data-qa-selector="boards_list"
+ class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-min-h-0"
+ >
<gl-alert v-if="errorToDisplay" variant="danger" :dismissible="true" @dismiss="unsetError">
{{ errorToDisplay }}
</gl-alert>
@@ -166,8 +119,7 @@ export default {
v-if="!isSwimlanesOn"
ref="list"
v-bind="draggableOptions"
- class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-scroll"
- :style="{ height: boardHeight }"
+ class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-auto"
@end="moveList"
>
<board-column
@@ -176,8 +128,10 @@ export default {
ref="board"
:board-id="boardId"
:list="list"
+ :filters="filterParams"
:data-draggable-item-type="$options.draggableItemTypes.list"
:class="{ 'gl-xs-display-none!': addColumnFormVisible }"
+ @setActiveList="$emit('setActiveList', $event)"
/>
<transition name="slide" @after-enter="afterFormEnters">
@@ -188,9 +142,11 @@ export default {
<epics-swimlanes
v-else-if="boardListsToUse.length"
ref="swimlanes"
+ :board-id="boardId"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
- :style="{ height: boardHeight }"
+ :filters="filterParams"
+ @setActiveList="$emit('setActiveList', $event)"
/>
<board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" />
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 6227f185eda..1b97214ff8b 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -3,12 +3,14 @@ import { GlDrawer } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
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 { 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,8 +18,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 {
components: {
@@ -40,7 +40,6 @@ export default {
SidebarWeightWidget: () =>
import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'),
},
- mixins: [glFeatureFlagMixin()],
inject: {
multipleAssigneesFeatureAvailable: {
default: false,
@@ -72,33 +71,48 @@ export default {
isGroupBoard: {
default: false,
},
+ isApolloBoard: {
+ default: false,
+ },
},
inheritAttrs: false,
+ apollo: {
+ activeBoardCard: {
+ query: activeBoardItemQuery,
+ variables: {
+ isIssue: true,
+ },
+ update(data) {
+ if (!data.activeBoardItem?.id) {
+ return { id: '', iid: '' };
+ }
+ return {
+ ...data.activeBoardItem,
+ assignees: data.activeBoardItem.assignees?.nodes || [],
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ },
+ },
computed: {
- ...mapGetters([
- 'isSidebarOpen',
- 'activeBoardItem',
- 'groupPathForActiveIssue',
- 'projectPathForActiveIssue',
- ]),
+ ...mapGetters(['activeBoardItem']),
...mapState(['sidebarType']),
- isIssuableSidebar() {
- return this.sidebarType === ISSUABLE;
+ activeBoardIssuable() {
+ return this.isApolloBoard ? this.activeBoardCard : this.activeBoardItem;
},
- isIncidentSidebar() {
- return this.activeBoardItem.type === INCIDENT;
+ isSidebarOpen() {
+ return Boolean(this.activeBoardIssuable?.id);
},
- showSidebar() {
- return this.isIssuableSidebar && this.isSidebarOpen;
+ isIncidentSidebar() {
+ return this.activeBoardIssuable?.type === INCIDENT;
},
sidebarTitle() {
return this.isIncidentSidebar ? __('Incident details') : __('Issue details');
},
- fullPath() {
- 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,13 +128,21 @@ 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
? this.labelsFilterBasePath.replace(':project_path', this.projectPathForActiveIssue)
: this.labelsFilterBasePath;
},
+ groupPathForActiveIssue() {
+ const { referencePath = '' } = this.activeBoardIssuable;
+ return referencePath.slice(0, referencePath.lastIndexOf('/'));
+ },
+ projectPathForActiveIssue() {
+ const { referencePath = '' } = this.activeBoardIssuable;
+ return referencePath.slice(0, referencePath.indexOf('#'));
+ },
},
methods: {
...mapActions([
@@ -132,7 +154,19 @@ export default {
'setActiveItemHealthStatus',
]),
handleClose() {
- this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
+ if (this.isApolloBoard) {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: {
+ boardItem: null,
+ },
+ });
+ } else {
+ this.toggleBoardItem({
+ boardItem: this.activeBoardIssuable,
+ sidebarType: this.sidebarType,
+ });
+ }
},
handleUpdateSelectedLabels({ labels, id }) {
this.setActiveBoardItemLabels({
@@ -144,7 +178,7 @@ export default {
},
handleLabelRemove(removeLabelId) {
this.setActiveBoardItemLabels({
- iid: this.activeBoardItem.iid,
+ iid: this.activeBoardIssuable.iid,
projectPath: this.projectPathForActiveIssue,
removeLabelIds: [removeLabelId],
});
@@ -157,7 +191,7 @@ export default {
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append>
<gl-drawer
v-bind="$attrs"
- :open="showSidebar"
+ :open="isSidebarOpen"
class="boards-sidebar"
variant="sidebar"
@close="handleClose"
@@ -168,25 +202,27 @@ export default {
<template #header>
<sidebar-todo-widget
class="gl-mt-3"
- :issuable-id="activeBoardItem.id"
- :issuable-iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :issuable-id="activeBoardIssuable.id"
+ :issuable-iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
/>
</template>
<template #default>
- <board-sidebar-title data-testid="sidebar-title" />
+ <board-sidebar-title :active-item="activeBoardIssuable" data-testid="sidebar-title" />
<sidebar-assignees-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
- :initial-assignees="activeBoardItem.assignees"
+ v-if="activeBoardItem.assignees"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
+ :initial-assignees="activeBoardIssuable.assignees"
:allow-multiple-assignees="multipleAssigneesFeatureAvailable"
:editable="canUpdate"
- @assignees-updated="setAssignees"
+ @assignees-updated="!isApolloBoard && setAssignees($event)"
/>
<sidebar-dropdown-widget
v-if="epicFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
+ :key="`epic-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
issuable-attribute="epic"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
@@ -195,7 +231,8 @@ export default {
/>
<div>
<sidebar-dropdown-widget
- :iid="activeBoardItem.iid"
+ :key="`milestone-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
issuable-attribute="milestone"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="projectPathForActiveIssue"
@@ -204,7 +241,8 @@ export default {
/>
<sidebar-iteration-widget
v-if="iterationFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
+ :key="`iteration-${activeBoardItem.iid}`"
+ :iid="activeBoardIssuable.iid"
:workspace-path="projectPathForActiveIssue"
:attr-workspace-path="groupPathForActiveIssue"
:issuable-type="issuableType"
@@ -214,14 +252,14 @@ export default {
</div>
<board-sidebar-time-tracker />
<sidebar-date-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-due-date"
/>
<sidebar-labels-widget
class="block labels"
- :iid="activeBoardItem.iid"
+ :iid="activeBoardIssuable.iid"
:full-path="projectPathForActiveIssue"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
@@ -233,40 +271,40 @@ export default {
workspace-type="project"
:issuable-type="issuableType"
:label-create-type="labelType"
- @onLabelRemove="handleLabelRemove"
- @updateSelectedLabels="handleUpdateSelectedLabels"
+ @onLabelRemove="!isApolloBoard && handleLabelRemove($event)"
+ @updateSelectedLabels="!isApolloBoard && handleUpdateSelectedLabels($event)"
>
{{ __('None') }}
</sidebar-labels-widget>
<sidebar-severity-widget
v-if="isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :project-path="fullPath"
- :initial-severity="activeBoardItem.severity"
+ :iid="activeBoardIssuable.iid"
+ :project-path="projectPathForActiveIssue"
+ :initial-severity="activeBoardIssuable.severity"
/>
<sidebar-weight-widget
v-if="weightFeatureAvailable && !isIncidentSidebar"
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @weightUpdated="setActiveItemWeight($event)"
+ @weightUpdated="!isApolloBoard && setActiveItemWeight($event)"
/>
<sidebar-health-status-widget
v-if="healthStatusFeatureAvailable"
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @statusUpdated="setActiveItemHealthStatus($event)"
+ @statusUpdated="!isApolloBoard && setActiveItemHealthStatus($event)"
/>
<sidebar-confidentiality-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
- @confidentialityUpdated="setActiveItemConfidential($event)"
+ @confidentialityUpdated="!isApolloBoard && setActiveItemConfidential($event)"
/>
<sidebar-subscriptions-widget
- :iid="activeBoardItem.iid"
- :full-path="fullPath"
+ :iid="activeBoardIssuable.iid"
+ :full-path="projectPathForActiveIssue"
:issuable-type="issuableType"
data-testid="sidebar-notifications"
/>
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index 1bc5d910561..b5d3613ca27 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';
@@ -22,7 +22,8 @@ import {
TOKEN_TYPE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { AssigneeFilterType } from '~/boards/constants';
+import { AssigneeFilterType, GroupByParamType } from 'ee_else_ce/boards/constants';
+import { TYPENAME_ITERATION } from '~/graphql_shared/constants';
import eventHub from '../eventhub';
export default {
@@ -30,8 +31,13 @@ export default {
search: __('Search'),
},
components: { FilteredSearch },
- inject: ['initialFilterParams'],
+ inject: ['initialFilterParams', 'isApolloBoard'],
props: {
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
tokens: {
type: Array,
required: true,
@@ -320,6 +326,7 @@ export default {
release_tag: releaseTag,
confidential,
health_status: healthStatus,
+ group_by: this.isSwimlanesOn ? GroupByParamType.epic : undefined,
},
(value) => {
if (value || value === false) {
@@ -334,11 +341,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 +368,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 +380,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 === '!=');
@@ -373,7 +397,6 @@ export default {
generateParams(filters = []) {
const filterParams = {};
const labels = [];
- const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
@@ -415,7 +438,9 @@ export default {
filterParams.confidential = filter.value.data;
break;
case FILTERED_SEARCH_TERM:
- if (filter.value.data) plainText.push(filter.value.data);
+ if (filter.value.data) {
+ filterParams.search = filter.value.data;
+ }
break;
case TOKEN_TYPE_HEALTH:
filterParams.healthStatus = filter.value.data;
@@ -429,10 +454,6 @@ export default {
filterParams.labelName = labels;
}
- if (plainText.length) {
- filterParams.search = plainText.join(' ');
- }
-
return filterParams;
},
},
@@ -444,6 +465,7 @@ export default {
:key="filteredSearchKey"
class="gl-w-full"
namespace=""
+ terms-as-tokens
:tokens="tokens"
:search-input-placeholder="$options.i18n.search"
:initial-filter-value="getFilteredSearchValue"
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index a71bde54a8f..604e71f5993 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,7 +215,15 @@ 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')
@@ -278,7 +288,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..5f082066ad4 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapActions, mapState } from 'vuex';
+import { STATUS_CLOSED } from '~/issues/constants';
import { sprintf, __ } from '~/locale';
import { defaultSortableOptions } from '~/sortable/constants';
import { sortableStart, sortableEnd } from '~/sortable/utils';
@@ -59,6 +60,10 @@ export default {
type: Array,
required: true,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -108,7 +113,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 +130,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}'), {
@@ -154,10 +159,10 @@ export default {
return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore;
},
epicCreateFormVisible() {
- return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm;
+ return this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showEpicForm;
},
issueCreateFormVisible() {
- return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm;
+ return !this.isEpicBoard && this.list.listType !== STATUS_CLOSED && this.showIssueForm;
},
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
@@ -260,6 +265,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 +402,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"
@@ -404,12 +419,11 @@ export default {
v-if="loadingMore"
size="sm"
:label="$options.i18n.loadingMoreboardItems"
- data-testid="count-loading-icon"
/>
<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..1b711feb686 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -1,38 +1,48 @@
<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 { TYPE_ISSUE } from '~/issues/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql';
import AccessorUtilities from '~/lib/utils/accessor';
-import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
+import {
+ inactiveId,
+ LIST,
+ ListType,
+ toggleFormEventPrefix,
+ updateListQueries,
+ toggleCollapsedMutations,
+} from 'ee_else_ce/boards/constants';
import eventHub from '../eventhub';
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,
@@ -63,6 +73,12 @@ export default {
disabled: {
default: true,
},
+ issuableType: {
+ default: TYPE_ISSUE,
+ },
+ isApolloBoard: {
+ default: false,
+ },
},
props: {
list: {
@@ -75,16 +91,26 @@ export default {
required: false,
default: false,
},
+ filterParams: {
+ type: Object,
+ required: true,
+ },
+ boardId: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['activeId', 'filterParams', 'boardId']),
- ...mapGetters(['isSwimlanesOn']),
+ ...mapState(['activeId']),
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 +137,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 +191,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: {
@@ -175,34 +248,43 @@ export default {
context: {
isSingleRequest: true,
},
- skip() {
- return this.isEpicBoard;
- },
},
},
created() {
const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`));
if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) {
- this.toggleListCollapsed({ listId: this.list.id, collapsed: true });
+ this.updateLocalCollapsedStatus(true);
}
},
methods: {
...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']),
+ closeListActions() {
+ this.$refs.headerListActions?.close();
+ },
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
}
- this.setActiveId({ id: this.list.id, sidebarType: LIST });
+ if (this.isApolloBoard) {
+ this.$apollo.mutate({
+ mutation: setActiveBoardItemMutation,
+ variables: { boardItem: null },
+ });
+ this.$emit('setActiveList', this.list.id);
+ } else {
+ 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,18 +292,22 @@ 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;
- this.toggleListCollapsed({ listId: this.list.id, collapsed });
+ this.updateLocalCollapsedStatus(collapsed);
if (!this.isLoggedIn) {
- this.addToLocalStorage();
+ this.addToLocalStorage(collapsed);
} else {
- this.updateListFunction();
+ this.updateListFunction(collapsed);
}
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
@@ -233,13 +319,37 @@ export default {
property: collapsed ? 'closed' : 'open',
});
},
- addToLocalStorage() {
+ addToLocalStorage(collapsed) {
if (AccessorUtilities.canUseLocalStorage()) {
- localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed);
+ localStorage.setItem(`${this.uniqueKey}.collapsed`, collapsed);
}
},
- updateListFunction() {
- this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
+ async updateListFunction(collapsed) {
+ if (this.isApolloBoard) {
+ try {
+ await this.$apollo.mutate({
+ mutation: updateListQueries[this.issuableType].mutation,
+ variables: {
+ listId: this.list.id,
+ collapsed,
+ },
+ optimisticResponse: {
+ updateBoardList: {
+ __typename: 'UpdateBoardListPayload',
+ errors: [],
+ list: {
+ ...this.list,
+ collapsed,
+ },
+ },
+ },
+ });
+ } catch {
+ this.$emit('error');
+ }
+ } else {
+ this.updateList({ listId: this.list.id, collapsed });
+ }
},
/**
* TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619
@@ -251,6 +361,19 @@ export default {
const due = formatDate(dueDate, 'mmm d, yyyy', true);
return `${start} - ${due}`;
},
+ updateLocalCollapsedStatus(collapsed) {
+ if (this.isApolloBoard) {
+ this.$apollo.mutate({
+ mutation: toggleCollapsedMutations[this.issuableType].mutation,
+ variables: {
+ list: this.list,
+ collapsed,
+ },
+ });
+ } else {
+ this.toggleListCollapsed({ listId: this.list.id, collapsed });
+ }
+ },
},
};
</script>
@@ -364,7 +487,7 @@ export default {
<div v-if="list.maxIssueCount !== 0">
•
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
- <template #issuesSize>{{ itemsTooltipLabel }}</template>
+ <template #issuesSize>{{ itemsCount }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
@@ -392,7 +515,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 +530,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..23e0f2510a7 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -1,8 +1,15 @@
<script>
+import produce from 'immer';
import { GlButton, GlDrawer, GlLabel, GlModal, GlModalDirective } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { LIST, ListType, ListTypeTitles } from '~/boards/constants';
+import {
+ LIST,
+ ListType,
+ ListTypeTitles,
+ listsQuery,
+ deleteListQueries,
+} from 'ee_else_ce/boards/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
@@ -31,8 +38,34 @@ export default {
GlModal: GlModalDirective,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
- inject: ['canAdminList', 'scopedLabelsAvailable', 'isIssueBoard'],
+ inject: [
+ 'boardType',
+ 'canAdminList',
+ 'issuableType',
+ 'scopedLabelsAvailable',
+ 'isIssueBoard',
+ 'isApolloBoard',
+ ],
inheritAttrs: false,
+ props: {
+ listId: {
+ type: String,
+ required: true,
+ },
+ boardId: {
+ type: String,
+ required: true,
+ },
+ list: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ },
data() {
return {
ListType,
@@ -45,8 +78,11 @@ export default {
isWipLimitsOn() {
return this.glFeatures.wipLimits && this.isIssueBoard;
},
+ activeListId() {
+ return this.isApolloBoard ? this.listId : this.activeId;
+ },
activeList() {
- return this.boardLists[this.activeId] || {};
+ return (this.isApolloBoard ? this.list : this.boardLists[this.activeId]) || {};
},
activeListLabel() {
return this.activeList.label;
@@ -58,27 +94,60 @@ export default {
return ListTypeTitles[ListType.label];
},
showSidebar() {
+ if (this.isApolloBoard) {
+ return Boolean(this.listId);
+ }
return this.sidebarType === LIST && this.isSidebarOpen;
},
},
created() {
- eventHub.$on('sidebar.closeAll', this.unsetActiveId);
+ eventHub.$on('sidebar.closeAll', this.unsetActiveListId);
},
beforeDestroy() {
- eventHub.$off('sidebar.closeAll', this.unsetActiveId);
+ eventHub.$off('sidebar.closeAll', this.unsetActiveListId);
},
methods: {
...mapActions(['unsetActiveId', 'removeList']),
handleModalPrimary() {
- this.deleteBoard();
+ this.deleteBoardList();
},
showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label);
},
- deleteBoard() {
+ async deleteBoardList() {
this.track('click_button', { label: 'remove_list' });
- this.removeList(this.activeId);
- this.unsetActiveId();
+ if (this.isApolloBoard) {
+ await this.deleteList(this.activeListId);
+ } else {
+ this.removeList(this.activeId);
+ }
+ this.unsetActiveListId();
+ },
+ unsetActiveListId() {
+ if (this.isApolloBoard) {
+ this.$emit('unsetActiveId');
+ } else {
+ this.unsetActiveId();
+ }
+ },
+ async deleteList(listId) {
+ await this.$apollo.mutate({
+ mutation: deleteListQueries[this.issuableType].mutation,
+ variables: {
+ listId,
+ },
+ update: (store) => {
+ store.updateQuery(
+ { query: listsQuery[this.issuableType].query, variables: this.queryVariables },
+ (sourceData) =>
+ produce(sourceData, (draftData) => {
+ draftData[this.boardType].board.lists.nodes = draftData[
+ this.boardType
+ ].board.lists.nodes.filter((list) => list.id !== listId);
+ }),
+ );
+ },
+ });
},
},
};
@@ -91,7 +160,7 @@ export default {
class="js-board-settings-sidebar gl-absolute"
:open="showSidebar"
variant="sidebar"
- @close="unsetActiveId"
+ @close="unsetActiveListId"
>
<template #title>
<h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">
@@ -109,7 +178,7 @@ export default {
</gl-button>
</div>
</template>
- <template v-if="isSidebarOpen">
+ <template v-if="showSidebar">
<div v-if="boardListType === ListType.label">
<label class="js-list-label gl-display-block">{{ listTypeTitle }}</label>
<gl-label
@@ -127,6 +196,7 @@ export default {
<board-settings-sidebar-wip-limit
v-if="isWipLimitsOn"
:max-issue-count="activeList.maxIssueCount"
+ :active-list-id="activeListId"
/>
</template>
</gl-drawer>
@@ -136,11 +206,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..c186346b2ac 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,28 @@ 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"
+ :is-swimlanes-on="isSwimlanesOn"
+ @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..fddb58c45fe 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;
},
@@ -140,6 +144,9 @@ export default {
},
methods: {
...mapActions(['setError', 'fetchBoard', 'unsetActiveId']),
+ fullBoardId(boardId) {
+ return fullBoardId(boardId);
+ },
showPage(page) {
this.currentPage = page;
},
@@ -155,9 +162,6 @@ export default {
name: node.name,
}));
},
- boardQuery() {
- return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
- },
recentBoardsQuery() {
return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery;
},
@@ -191,6 +195,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 +253,13 @@ 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) {
+ // Epic board ID is supported in EE version of this file
this.$emit('switchBoard', this.fullBoardId(boardId));
+ updateHistory({ url: `${this.boardBaseUrl}/${boardId}` });
} else {
this.unsetActiveId();
this.fetchCurrentBoard(boardId);
@@ -357,6 +383,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..3c056f296e1 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,23 @@ export default {
},
components: { BoardFilteredSearch },
inject: ['isSignedIn', 'releasesFetchPath', 'fullPath', 'isGroupBoard'],
+ props: {
+ board: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ isSwimlanesOn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
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 +146,7 @@ export default {
token: MilestoneToken,
unique: true,
shouldSkipSort: true,
- fetchMilestones: this.fetchMilestones,
+ fetchMilestones,
},
{
icon: 'issues',
@@ -176,7 +187,6 @@ export default {
},
},
methods: {
- ...mapActions(['fetchMilestones']),
preloadedUsers() {
return gon?.current_user_id
? [
@@ -194,5 +204,11 @@ 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"
+ :is-swimlanes-on="isSwimlanesOn"
+ @setFilters="$emit('setFilters', $event)"
+ />
</template>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index c3f7c7d3ca2..1f28974afd1 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -95,9 +95,12 @@ export default {
class="board-card-info-icon gl-mr-2"
name="calendar"
/>
- <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
- body
- }}</time>
+ <time
+ :class="{ 'text-danger': isPastDue }"
+ datetime="date"
+ class="gl-font-sm board-card-info-text"
+ >{{ body }}</time
+ >
</span>
<gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index bc12717a92d..611e875fa40 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -38,7 +38,7 @@ export default {
<span>
<span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help">
<gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" />
- <time class="board-card-info-text">{{ timeEstimate }}</time>
+ <time class="gl-font-sm board-card-info-text">{{ timeEstimate }}</time>
</span>
<gl-tooltip
:target="() => $refs.issueTimeEstimate"
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
index 43a2b13b81c..020edcb01b8 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
@@ -5,6 +5,7 @@ import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.v
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+import { titleQueries } from 'ee_else_ce/boards/constants';
export default {
components: {
@@ -19,6 +20,13 @@ export default {
directives: {
autofocusonshow,
},
+ inject: ['fullPath', 'issuableType', 'isEpicBoard', 'isApolloBoard'],
+ props: {
+ activeItem: {
+ type: Object,
+ required: true,
+ },
+ },
data() {
return {
title: '',
@@ -27,7 +35,10 @@ export default {
};
},
computed: {
- ...mapGetters({ item: 'activeBoardItem' }),
+ ...mapGetters(['activeBoardItem']),
+ item() {
+ return this.isApolloBoard ? this.activeItem : this.activeBoardItem;
+ },
pendingChangesStorageKey() {
return this.getPendingChangesKey(this.item);
},
@@ -67,8 +78,9 @@ export default {
},
async setPendingState() {
const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey);
+ const shouldOpen = pendingChanges !== this.title;
- if (pendingChanges) {
+ if (pendingChanges && shouldOpen) {
this.title = pendingChanges;
this.showChangesAlert = true;
await this.$nextTick();
@@ -83,6 +95,26 @@ export default {
this.showChangesAlert = false;
localStorage.removeItem(this.pendingChangesStorageKey);
},
+ async setActiveBoardItemTitle() {
+ if (!this.isApolloBoard) {
+ await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
+ return;
+ }
+ const { fullPath, issuableType, isEpicBoard, title } = this;
+ const workspacePath = isEpicBoard
+ ? { groupPath: fullPath }
+ : { projectPath: this.projectPath };
+ await this.$apollo.mutate({
+ mutation: titleQueries[issuableType].mutation,
+ variables: {
+ input: {
+ ...workspacePath,
+ iid: String(this.item.iid),
+ title,
+ },
+ },
+ });
+ },
async setTitle() {
this.$refs.sidebarItem.collapse();
@@ -92,7 +124,7 @@ export default {
try {
this.loading = true;
- await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
+ await this.setActiveBoardItemTitle();
localStorage.removeItem(this.pendingChangesStorageKey);
this.showChangesAlert = false;
} catch (e) {
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 712e3e1ac4a..7fe89ffbb52 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,25 +1,18 @@
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';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
+import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed.mutation.graphql';
import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
-/* 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',
@@ -64,10 +57,10 @@ export const INCIDENT = 'INCIDENT';
export const flashAnimationDuration = 2000;
export const boardQuery = {
- [BoardType.group]: {
+ [WORKSPACE_GROUP]: {
query: groupBoardQuery,
},
- [BoardType.project]: {
+ [WORKSPACE_PROJECT]: {
query: projectBoardQuery,
},
};
@@ -84,6 +77,12 @@ export const updateListQueries = {
},
};
+export const toggleCollapsedMutations = {
+ [TYPE_ISSUE]: {
+ mutation: toggleListCollapsedMutation,
+ },
+};
+
export const deleteListQueries = {
[TYPE_ISSUE]: {
mutation: destroyBoardListMutation,
@@ -94,7 +93,7 @@ export const titleQueries = {
[TYPE_ISSUE]: {
mutation: issueSetTitleMutation,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
mutation: updateEpicTitleMutation,
},
};
@@ -103,7 +102,7 @@ export const subscriptionQueries = {
[TYPE_ISSUE]: {
mutation: issueSetSubscriptionMutation,
},
- [issuableTypes.epic]: {
+ [TYPE_EPIC]: {
mutation: updateEpicSubscriptionMutation,
},
};
@@ -143,6 +142,7 @@ export const MilestoneFilterType = {
started: 'Started',
upcoming: 'Upcoming',
};
+/* eslint-enable @gitlab/require-i18n-strings */
export const DraggableItemTypes = {
card: 'card',
@@ -155,7 +155,6 @@ export const MilestoneIDs = {
};
export default {
- BoardType,
ListType,
};
@@ -178,3 +177,5 @@ export const BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS = [
action: () => {},
},
];
+
+export const GroupByParamType = {};
diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
index 06e8c8783de..e987ee08df2 100644
--- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
@@ -3,6 +3,7 @@
query BoardLists(
$fullPath: ID!
$boardId: BoardID!
+ $listId: ListID
$filters: BoardIssueInput
$isGroup: Boolean = false
$isProject: Boolean = false
@@ -12,7 +13,7 @@ query BoardLists(
board(id: $boardId) {
id
hideBacklogList
- lists(issueFilters: $filters) {
+ lists(issueFilters: $filters, id: $listId) {
nodes {
...BoardListFragment
}
@@ -24,7 +25,7 @@ query BoardLists(
board(id: $boardId) {
id
hideBacklogList
- lists(issueFilters: $filters) {
+ lists(issueFilters: $filters, id: $listId) {
nodes {
...BoardListFragment
}
diff --git a/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql
new file mode 100644
index 00000000000..81b1b68a038
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/active_board_item.query.graphql
@@ -0,0 +1,7 @@
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
+
+query activeBoardItem {
+ activeBoardItem @client {
+ ...Issue
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql b/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql
new file mode 100644
index 00000000000..890152989eb
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/board_toggle_collapsed.mutation.graphql
@@ -0,0 +1,9 @@
+#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
+
+mutation toggleListCollapsed($list: BoardList!, $collapsed: Boolean!) {
+ clientToggleListCollapsed(list: $list, collapsed: $collapsed) @client {
+ list {
+ ...BoardListFragment
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql
new file mode 100644
index 00000000000..cce558c649e
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/client/set_active_board_item.mutation.graphql
@@ -0,0 +1,7 @@
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
+
+mutation setActiveBoardItem($boardItem: Issue) {
+ setActiveBoardItem(boardItem: $boardItem) @client {
+ ...Issue
+ }
+}
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/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue
index 70974f2e725..d9d8f1d742d 100644
--- a/app/assets/javascripts/branches/components/delete_merged_branches.vue
+++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue
@@ -1,11 +1,10 @@
<script>
-import { GlButton, GlFormInput, GlModal, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlButton, GlFormInput, GlModal, GlSprintf } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { sprintf, s__, __ } from '~/locale';
export const i18n = {
deleteButtonText: s__('Branches|Delete merged branches'),
- buttonTooltipText: s__("Branches|Delete all branches that are merged into '%{defaultBranch}'"),
modalTitle: s__('Branches|Delete all merged branches?'),
modalMessage: s__(
'Branches|You are about to %{strongStart}delete all branches%{strongEnd} that were merged into %{codeStart}%{defaultBranch}%{codeEnd}.',
@@ -28,14 +27,12 @@ export const i18n = {
export default {
csrf,
components: {
- GlModal,
+ GlDisclosureDropdown,
GlButton,
+ GlModal,
GlFormInput,
GlSprintf,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
props: {
formPath: {
type: String,
@@ -53,9 +50,6 @@ export default {
};
},
computed: {
- buttonTooltipText() {
- return sprintf(this.$options.i18n.buttonTooltipText, { defaultBranch: this.defaultBranch });
- },
modalMessage() {
return sprintf(this.$options.i18n.modalMessage, {
defaultBranch: this.defaultBranch,
@@ -67,6 +61,20 @@ export default {
isDeleteButtonDisabled() {
return !this.isDeletingConfirmed;
},
+ dropdownItems() {
+ return [
+ {
+ text: this.$options.i18n.deleteButtonText,
+ action: () => {
+ this.openModal();
+ },
+ extraAttrs: {
+ 'data-qa-selector': 'delete_merged_branches_button',
+ class: 'gl-text-red-500!',
+ },
+ },
+ ];
+ },
},
methods: {
openModal() {
@@ -87,15 +95,16 @@ export default {
<template>
<div>
- <gl-button
- v-gl-tooltip="buttonTooltipText"
- class="gl-mr-3"
- data-qa-selector="delete_merged_branches_button"
- category="secondary"
- variant="danger"
- @click="openModal"
- >{{ $options.i18n.deleteButtonText }}
- </gl-button>
+ <gl-disclosure-dropdown
+ :toggle-text="$options.i18n.actionsToggleText"
+ text-sr-only
+ icon="ellipsis_v"
+ category="tertiary"
+ no-caret
+ placement="right"
+ data-qa-selector="delete_merged_branches_dropdown_button"
+ :items="dropdownItems"
+ />
<gl-modal
ref="modal"
size="sm"
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/branches/init_new_branch_ref_selector.js b/app/assets/javascripts/branches/init_new_branch_ref_selector.js
index aad3fbb9982..532242ac12d 100644
--- a/app/assets/javascripts/branches/init_new_branch_ref_selector.js
+++ b/app/assets/javascripts/branches/init_new_branch_ref_selector.js
@@ -17,6 +17,7 @@ export default function initNewBranchRefSelector() {
props: {
value: defaultBranchName,
name: hiddenInputName,
+ queryParams: { sort: 'updated_desc' },
projectId,
},
});
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index e895df01f2c..4d1c4be73a3 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,5 +1,3 @@
-/* eslint-disable func-names */
-
import $ from 'jquery';
import { hide, initTooltips, show } from '~/tooltips';
import { parseBoolean } from './lib/utils/common_utils';
@@ -24,6 +22,7 @@ export default class BuildArtifacts {
// eslint-disable-next-line class-methods-use-this
setupEntryClick() {
+ // eslint-disable-next-line func-names
return $('.tree-holder').on('click', 'tr[data-link]', function () {
visitUrl(this.dataset.link, parseBoolean(this.dataset.externalLink));
});
diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/ci/artifacts/components/app.vue
index 3a07be65341..c89df2610eb 100644
--- a/app/assets/javascripts/artifacts/components/app.vue
+++ b/app/assets/javascripts/ci/artifacts/components/app.vue
@@ -18,12 +18,8 @@ export default {
variables() {
return { projectPath: this.projectPath };
},
- update({
- project: {
- statistics: { buildArtifactsSize },
- },
- }) {
- return buildArtifactsSize;
+ update({ project: { statistics } }) {
+ return statistics?.buildArtifactsSize ?? null;
},
},
},
diff --git a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue
index 14edd73824e..14edd73824e 100644
--- a/app/assets/javascripts/artifacts/components/artifact_delete_modal.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_delete_modal.vue
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
index fffdfce60a7..5b1c322f07a 100644
--- a/app/assets/javascripts/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/ci/artifacts/components/artifact_row.vue
@@ -1,7 +1,21 @@
<script>
-import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap } from '@gitlab/ui';
+import {
+ GlButtonGroup,
+ GlButton,
+ GlBadge,
+ GlFriendlyWrap,
+ GlFormCheckbox,
+ GlTooltipDirective,
+} 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,
+ I18N_BULK_DELETE_MAX_SELECTED,
+} from '../constants';
export default {
name: 'ArtifactRow',
@@ -10,17 +24,30 @@ export default {
GlButton,
GlBadge,
GlFriendlyWrap,
+ GlFormCheckbox,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
inject: ['canDestroyArtifacts'],
props: {
artifact: {
type: Object,
required: true,
},
+ isSelected: {
+ type: Boolean,
+ required: true,
+ },
isLastRow: {
type: Boolean,
required: true,
},
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
isExpired() {
@@ -29,9 +56,25 @@ export default {
}
return Date.now() > new Date(this.artifact.expireAt).getTime();
},
+ isCheckboxDisabled() {
+ return this.isSelectedArtifactsLimitReached && !this.isSelected;
+ },
+ checkboxTooltip() {
+ return this.isCheckboxDisabled ? I18N_BULK_DELETE_MAX_SELECTED : '';
+ },
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 +89,15 @@ 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
+ v-gl-tooltip.right
+ :title="checkboxTooltip"
+ :checked="isSelected"
+ :disabled="isCheckboxDisabled"
+ @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/ci/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue
new file mode 100644
index 00000000000..c176532482f
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/artifacts_bulk_delete.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlButton, GlSprintf, GlAlert } from '@gitlab/ui';
+import {
+ I18N_BULK_DELETE_BANNER,
+ I18N_BULK_DELETE_CLEAR_SELECTION,
+ I18N_BULK_DELETE_DELETE_SELECTED,
+ I18N_BULK_DELETE_MAX_SELECTED,
+} from '../constants';
+
+export default {
+ name: 'ArtifactsBulkDelete',
+ components: {
+ GlButton,
+ GlSprintf,
+ GlAlert,
+ },
+ props: {
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.selectedArtifacts.length || 0;
+ },
+ },
+ i18n: {
+ banner: I18N_BULK_DELETE_BANNER,
+ maxSelected: I18N_BULK_DELETE_MAX_SELECTED,
+ clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
+ deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="isSelectedArtifactsLimitReached" variant="warning" :dismissible="false">
+ {{ $options.i18n.maxSelected }}
+ </gl-alert>
+
+ <div
+ v-if="selectedArtifacts.length > 0"
+ class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"
+ data-testid="bulk-delete-container"
+ >
+ <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="$emit('clearSelectedArtifacts')"
+ >
+ {{ $options.i18n.clearSelection }}
+ </gl-button>
+ <gl-button
+ variant="danger"
+ data-testid="bulk-delete-delete-button"
+ @click="$emit('showBulkDeleteModal')"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/ci/artifacts/components/artifacts_table_row_details.vue
index 4a826d0d462..e893040edd8 100644
--- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
+++ b/app/assets/javascripts/ci/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,10 +25,18 @@ export default {
type: Object,
required: true,
},
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
queryVariables: {
type: Object,
required: true,
},
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -52,6 +60,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;
@@ -92,13 +103,16 @@ export default {
};
</script>
<template>
- <div :style="scrollContainerStyle">
+ <div :style="scrollContainerStyle" class="gl-overflow-auto">
<dynamic-scroller :items="artifacts.nodes" :min-item-size="$options.ARTIFACT_ROW_HEIGHT">
<template #default="{ item, index, active }">
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
<artifact-row
:artifact="item"
+ :is-selected="isSelected(item)"
:is-last-row="isLastRow(index)"
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
+ v-on="$listeners"
@delete="showModal(item)"
/>
</dynamic-scroller-item>
diff --git a/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue
new file mode 100644
index 00000000000..00f5b2eab7d
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/bulk_delete_modal.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlModal, GlSprintf } from '@gitlab/ui';
+import {
+ I18N_BULK_DELETE_MODAL_TITLE,
+ I18N_BULK_DELETE_BODY,
+ I18N_BULK_DELETE_ACTION,
+ I18N_MODAL_CANCEL,
+ BULK_DELETE_MODAL_ID,
+} from '../constants';
+
+export default {
+ name: 'BulkDeleteModal',
+ components: {
+ GlModal,
+ GlSprintf,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ artifactsToDelete: {
+ type: Array,
+ required: true,
+ },
+ isDeleting: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ checkedCount() {
+ return this.artifactsToDelete.length || 0;
+ },
+ modalActionPrimary() {
+ return {
+ text: I18N_BULK_DELETE_ACTION(this.checkedCount),
+ attributes: {
+ loading: this.isDeleting,
+ variant: 'danger',
+ },
+ };
+ },
+ modalActionCancel() {
+ return {
+ text: I18N_MODAL_CANCEL,
+ attributes: {
+ disabled: this.isDeleting,
+ },
+ };
+ },
+ },
+ BULK_DELETE_MODAL_ID,
+ i18n: {
+ modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
+ modalBody: I18N_BULK_DELETE_BODY,
+ },
+};
+</script>
+<template>
+ <gl-modal
+ size="sm"
+ :modal-id="$options.BULK_DELETE_MODAL_ID"
+ :visible="visible"
+ :title="$options.i18n.modalTitle(checkedCount)"
+ :action-primary="modalActionPrimary"
+ :action-cancel="modalActionCancel"
+ v-bind="$attrs"
+ v-on="$listeners"
+ >
+ <gl-sprintf :message="$options.i18n.modalBody(checkedCount)" />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/feedback_banner.vue b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue
index d2c96b1a201..d2c96b1a201 100644
--- a/app/assets/javascripts/artifacts/components/feedback_banner.vue
+++ b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue
diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
index 5743ff3ec9e..3f6ea56382f 100644
--- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue
@@ -8,13 +8,18 @@ import {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
} from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/alert';
+import { getIdFromGraphQLId, convertToGraphQLId } 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 { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils';
+import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
+import { removeArtifactFromStore } from '../graphql/cache_update';
import {
STATUS_BADGE_VARIANTS,
I18N_DOWNLOAD,
@@ -33,7 +38,15 @@ import {
INITIAL_NEXT_PAGE_CURSOR,
JOBS_PER_PAGE,
INITIAL_LAST_PAGE_SIZE,
+ BULK_DELETE_FEATURE_FLAG,
+ I18N_BULK_DELETE_ERROR,
+ I18N_BULK_DELETE_PARTIAL_ERROR,
+ I18N_BULK_DELETE_CONFIRMATION_TOAST,
+ SELECTED_ARTIFACTS_MAX_COUNT,
} from '../constants';
+import JobCheckbox from './job_checkbox.vue';
+import ArtifactsBulkDelete from './artifacts_bulk_delete.vue';
+import BulkDeleteModal from './bulk_delete_modal.vue';
import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
import FeedbackBanner from './feedback_banner.vue';
@@ -56,21 +69,25 @@ export default {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
CiIcon,
TimeAgo,
+ JobCheckbox,
+ ArtifactsBulkDelete,
+ BulkDeleteModal,
ArtifactsTableRowDetails,
FeedbackBanner,
},
- inject: ['projectPath', 'canDestroyArtifacts'],
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['projectId', 'projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
query: getJobArtifactsQuery,
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,10 +110,13 @@ export default {
data() {
return {
jobArtifacts: [],
- count: 0,
pageInfo: {},
expandedJobs: [],
+ selectedArtifacts: [],
pagination: INITIAL_PAGINATION_STATE,
+ isBulkDeleteModalVisible: false,
+ jobArtifactsToDelete: [],
+ isBulkDeleting: false,
};
},
computed: {
@@ -110,7 +130,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 +140,30 @@ export default {
nextPage() {
return Number(this.pageInfo.hasNextPage);
},
+ fields() {
+ return [
+ this.canBulkDestroyArtifacts && {
+ key: 'checkbox',
+ label: '',
+ },
+ ...this.$options.fields,
+ ];
+ },
+ anyArtifactsSelected() {
+ return Boolean(this.selectedArtifacts.length);
+ },
+ isSelectedArtifactsLimitReached() {
+ return this.selectedArtifacts.length >= SELECTED_ARTIFACTS_MAX_COUNT;
+ },
+ canBulkDestroyArtifacts() {
+ return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ },
+ isDeletingArtifactsForJob() {
+ return this.jobArtifactsToDelete.length > 0;
+ },
+ artifactsToDelete() {
+ return this.isDeletingArtifactsForJob ? this.jobArtifactsToDelete : this.selectedArtifacts;
+ },
},
methods: {
refetchArtifacts() {
@@ -158,6 +204,79 @@ export default {
this.expandedJobs.splice(this.expandedJobs.indexOf(id), 1);
}
},
+ selectArtifact(artifactNode, checked) {
+ if (checked) {
+ if (!this.isSelectedArtifactsLimitReached) {
+ this.selectedArtifacts.push(artifactNode.id);
+ }
+ } else {
+ this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1);
+ }
+ },
+ onConfirmBulkDelete(e) {
+ // don't close modal until deletion is complete
+ if (e) {
+ e.preventDefault();
+ }
+ this.isBulkDeleting = true;
+
+ this.$apollo
+ .mutate({
+ mutation: bulkDestroyJobArtifactsMutation,
+ variables: {
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
+ ids: this.artifactsToDelete,
+ },
+ 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.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(destroyedCount));
+
+ // Remove deleted artifacts from the cache
+ destroyedIds.forEach((id) => {
+ removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
+ });
+ store.gc();
+
+ if (!this.isDeletingArtifactsForJob) {
+ this.clearSelectedArtifacts();
+ }
+ }
+ },
+ })
+ .catch((error) => {
+ this.onError(error);
+ })
+ .finally(() => {
+ this.isBulkDeleting = false;
+ this.isBulkDeleteModalVisible = false;
+ this.jobArtifactsToDelete = [];
+ });
+ },
+ onError(error) {
+ createAlert({
+ message: I18N_BULK_DELETE_ERROR,
+ captureError: true,
+ error,
+ });
+ },
+ handleBulkDeleteModalShow() {
+ this.isBulkDeleteModalVisible = true;
+ },
+ handleBulkDeleteModalHidden() {
+ this.isBulkDeleteModalVisible = false;
+ this.jobArtifactsToDelete = [];
+ },
+ clearSelectedArtifacts() {
+ this.selectedArtifacts = [];
+ },
downloadPath(job) {
return job.archive?.downloadPath;
},
@@ -167,6 +286,13 @@ export default {
browseButtonDisabled(job) {
return !job.browseArtifactsPath;
},
+ deleteButtonDisabled(job) {
+ return !job.hasArtifacts || !this.canBulkDestroyArtifacts;
+ },
+ deleteArtifactsForJob(job) {
+ this.jobArtifactsToDelete = job.artifacts.nodes.map((node) => node.id);
+ this.handleBulkDeleteModalShow();
+ },
},
fields: [
{
@@ -217,9 +343,23 @@ export default {
<template>
<div>
<feedback-banner />
+ <artifacts-bulk-delete
+ v-if="canBulkDestroyArtifacts"
+ :selected-artifacts="selectedArtifacts"
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
+ @clearSelectedArtifacts="clearSelectedArtifacts"
+ @showBulkDeleteModal="handleBulkDeleteModalShow"
+ />
+ <bulk-delete-modal
+ :visible="isBulkDeleteModalVisible"
+ :artifacts-to-delete="artifactsToDelete"
+ :is-deleting="isBulkDeleting"
+ @primary="onConfirmBulkDelete"
+ @hidden="handleBulkDeleteModalHidden"
+ />
<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 +367,30 @@ 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))
+ "
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
+ @selectArtifact="selectArtifact"
+ />
+ </template>
<template
#cell(artifacts)="{ item: { id, artifacts, hasArtifacts }, toggleDetails, detailsShowing }"
>
@@ -313,18 +477,22 @@ export default {
<gl-button
v-if="canDestroyArtifacts"
icon="remove"
+ :disabled="deleteButtonDisabled(item)"
:title="$options.i18n.delete"
:aria-label="$options.i18n.delete"
data-testid="job-artifacts-delete-button"
- disabled
+ @click="deleteArtifactsForJob(item)"
/>
</gl-button-group>
</template>
<template #row-details="{ item: { artifacts } }">
<artifacts-table-row-details
:artifacts="artifacts"
+ :selected-artifacts="selectedArtifacts"
:query-variables="queryVariables"
+ :is-selected-artifacts-limit-reached="isSelectedArtifactsLimitReached"
@refetch="refetchArtifacts"
+ @selectArtifact="selectArtifact"
/>
</template>
</gl-table>
diff --git a/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
new file mode 100644
index 00000000000..91296bd507e
--- /dev/null
+++ b/app/assets/javascripts/ci/artifacts/components/job_checkbox.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants';
+
+export default {
+ name: 'JobCheckbox',
+ components: {
+ GlFormCheckbox,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ hasArtifacts: {
+ type: Boolean,
+ required: true,
+ },
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ unselectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ isSelectedArtifactsLimitReached: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ disabled() {
+ return (
+ !this.hasArtifacts ||
+ (this.isSelectedArtifactsLimitReached && !(this.checked || this.indeterminate))
+ );
+ },
+ checked() {
+ return this.hasArtifacts && this.unselectedArtifacts.length === 0;
+ },
+ indeterminate() {
+ return this.selectedArtifacts.length > 0 && this.unselectedArtifacts.length > 0;
+ },
+ tooltipText() {
+ return this.isSelectedArtifactsLimitReached && this.disabled
+ ? I18N_BULK_DELETE_MAX_SELECTED
+ : '';
+ },
+ },
+ 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
+ v-gl-tooltip.right
+ :title="tooltipText"
+ :disabled="disabled"
+ :checked="checked"
+ :indeterminate="indeterminate"
+ @input="handleInput"
+ />
+</template>
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js
index da562b03bf8..7ba65e0f98f 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/ci/artifacts/constants.js
@@ -54,6 +54,49 @@ 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 SELECTED_ARTIFACTS_MAX_COUNT = 50;
+export const I18N_BULK_DELETE_MAX_SELECTED = s__(
+ 'Artifacts|Maximum selected artifacts limit reached',
+);
+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/cache_update.js b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js
index 9fa6114c7d4..9fa6114c7d4 100644
--- a/app/assets/javascripts/artifacts/graphql/cache_update.js
+++ b/app/assets/javascripts/ci/artifacts/graphql/cache_update.js
diff --git a/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
new file mode 100644
index 00000000000..421b9258ca0
--- /dev/null
+++ b/app/assets/javascripts/ci/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/graphql/mutations/destroy_artifact.mutation.graphql b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
index 529224b47e6..529224b47e6 100644
--- a/app/assets/javascripts/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/mutations/destroy_artifact.mutation.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
index 23da65ad0bb..23da65ad0bb 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql
index 5737f9f8e8d..5737f9f8e8d 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
+++ b/app/assets/javascripts/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js
index a62b3daa961..6e795fd9bd7 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/ci/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/artifacts/utils.js b/app/assets/javascripts/ci/artifacts/utils.js
index ebcf0af8d2a..ebcf0af8d2a 100644
--- a/app/assets/javascripts/artifacts/utils.js
+++ b/app/assets/javascripts/ci/artifacts/utils.js
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
index 7387a490177..09b02068388 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_environments_dropdown.vue
@@ -1,16 +1,25 @@
<script>
-import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { debounce, uniq } from 'lodash';
+import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { __, s__, sprintf } from '~/locale';
import { convertEnvironmentScope } from '../utils';
+import { ENVIRONMENT_QUERY_LIMIT } from '../constants';
export default {
name: 'CiEnvironmentsDropdown',
components: {
+ GlCollapsibleListbox,
GlDropdownDivider,
GlDropdownItem,
- GlCollapsibleListbox,
+ GlSprintf,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
environments: {
type: Array,
required: true,
@@ -33,24 +42,52 @@ export default {
},
filteredEnvironments() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.environments.filter((environment) => {
+ return environment.toLowerCase().includes(lowerCasedSearchTerm);
+ });
+ },
+ isEnvScopeLimited() {
+ return this.glFeatures?.ciLimitEnvironmentScope;
+ },
+ searchedEnvironments() {
+ // If FF is enabled, search query will be fired so this component will already
+ // receive filtered environments during the refetch.
+ // If FF is disabled, search the existing list of environments in the frontend
+ let filtered = this.isEnvScopeLimited ? this.environments : this.filteredEnvironments;
+
+ // If there is no search term, make sure to include *
+ if (this.isEnvScopeLimited && !this.searchTerm) {
+ filtered = uniq([...filtered, '*']);
+ }
- return this.environments
- .filter((environment) => {
- return environment.toLowerCase().includes(lowerCasedSearchTerm);
- })
- .map((environment) => ({
- value: environment,
- text: environment,
- }));
+ return filtered.sort().map((environment) => ({
+ value: environment,
+ text: environment,
+ }));
+ },
+ shouldShowSearchLoading() {
+ return this.areEnvironmentsLoading && this.isEnvScopeLimited;
},
shouldRenderCreateButton() {
return this.searchTerm && !this.environments.includes(this.searchTerm);
},
+ shouldRenderDivider() {
+ return (
+ (this.isEnvScopeLimited || this.shouldRenderCreateButton) && !this.shouldShowSearchLoading
+ );
+ },
environmentScopeLabel() {
return convertEnvironmentScope(this.selectedEnvironmentScope);
},
},
methods: {
+ debouncedSearch: debounce(function debouncedSearch(searchTerm) {
+ const newSearchTerm = searchTerm.trim();
+ this.searchTerm = newSearchTerm;
+ if (this.isEnvScopeLimited) {
+ this.$emit('search-environment-scope', newSearchTerm);
+ }
+ }, 500),
selectEnvironment(selected) {
this.$emit('select-environment', selected);
this.selectedEnvironment = selected;
@@ -60,22 +97,46 @@ export default {
this.selectEnvironment(this.searchTerm);
},
},
+ ENVIRONMENT_QUERY_LIMIT,
+ i18n: {
+ maxEnvsNote: s__(
+ 'CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query.',
+ ),
+ },
};
</script>
<template>
<gl-collapsible-listbox
v-model="selectedEnvironment"
+ block
searchable
- :items="filteredEnvironments"
+ :items="searchedEnvironments"
+ :searching="shouldShowSearchLoading"
:toggle-text="environmentScopeLabel"
- @search="searchTerm = $event.trim()"
+ @search="debouncedSearch"
@select="selectEnvironment"
>
- <template v-if="shouldRenderCreateButton" #footer>
- <gl-dropdown-divider />
- <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope">
- {{ composedCreateButtonLabel }}
- </gl-dropdown-item>
+ <template #footer>
+ <gl-dropdown-divider v-if="shouldRenderDivider" />
+ <div v-if="isEnvScopeLimited" data-testid="max-envs-notice">
+ <gl-dropdown-item class="gl-list-style-none" disabled>
+ <gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-font-sm">
+ <template #limit>
+ {{ $options.ENVIRONMENT_QUERY_LIMIT }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </div>
+ <div v-if="shouldRenderCreateButton">
+ <!-- TODO: Rethink create wildcard button. https://gitlab.com/gitlab-org/gitlab/-/issues/396928 -->
+ <gl-dropdown-item
+ class="gl-list-style-none"
+ data-testid="create-wildcard-button"
+ @click="createEnvironmentScope"
+ >
+ {{ composedCreateButtonLabel }}
+ </gl-dropdown-item>
+ </div>
</template>
</gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index 16034cce381..b3ecaceba69 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -74,6 +74,10 @@ export default {
'maskableRegex',
],
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
areScopedVariablesAvailable: {
type: Boolean,
required: false,
@@ -142,7 +146,11 @@ export default {
isTipVisible() {
return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
},
- joinedEnvironments() {
+ environmentsList() {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ return this.environments;
+ }
+
return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments);
},
maskedFeedback() {
@@ -368,10 +376,12 @@ export default {
</template>
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
+ :are-environments-loading="areEnvironmentsLoading"
:selected-environment-scope="variable.environmentScope"
- :environments="joinedEnvironments"
+ :environments="environmentsList"
@select-environment="setEnvironmentScope"
@create-environment-scope="createEnvironmentScope"
+ @search-environment-scope="$emit('search-environment-scope', $event)"
/>
<gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly />
@@ -450,7 +460,7 @@ export default {
data-testid="aws-guidance-tip"
@dismiss="dismissTip"
>
- <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3">
+ <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap gl-md-flex-nowrap gl-gap-3">
<div>
<p>
<gl-sprintf :message="$options.i18n.awsTipMessage">
@@ -505,7 +515,6 @@ export default {
ref="deleteCiVariable"
variant="danger"
category="secondary"
- data-qa-selector="ci_variable_delete_button"
@click="deleteVarAndClose"
>{{ __('Delete variable') }}</gl-button
>
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..26e20c690bc 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
@@ -9,6 +9,10 @@ export default {
CiVariableModal,
},
props: {
+ areEnvironmentsLoading: {
+ type: Boolean,
+ required: true,
+ },
areScopedVariablesAvailable: {
type: Boolean,
required: false,
@@ -38,6 +42,10 @@ export default {
required: false,
default: 0,
},
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
variables: {
type: Array,
required: true,
@@ -87,11 +95,16 @@ 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"
+ :are-environments-loading="areEnvironmentsLoading"
:are-scoped-variables-available="areScopedVariablesAvailable"
:environments="environments"
:hide-environment-scope="hideEnvironmentScope"
@@ -102,6 +115,7 @@ export default {
@delete-variable="deleteVariable"
@hideModal="hideModal"
@update-variable="updateVariable"
+ @search-environment-scope="$emit('search-environment-scope', $event)"
/>
</div>
</div>
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..ee2c0a771cf 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,11 +1,15 @@
<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,
+ ENVIRONMENT_QUERY_LIMIT,
+ SORT_DIRECTIONS,
UPDATE_MUTATION_ACTION,
+ mapMutationActionToToast,
environmentFetchErrorText,
genericMutationErrorText,
variableFetchErrorText,
@@ -16,6 +20,7 @@ export default {
components: {
CiVariableSettings,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['endpoint'],
props: {
areScopedVariablesAvailable: {
@@ -97,6 +102,7 @@ export default {
loadingCounter: 0,
maxVariableLimit: 0,
pageInfo: {},
+ sortDirection: SORT_DIRECTIONS.ASC,
};
},
apollo: {
@@ -107,6 +113,8 @@ export default {
variables() {
return {
fullPath: this.fullPath || undefined,
+ first: this.pageSize,
+ sort: this.sortDirection,
};
},
update(data) {
@@ -116,21 +124,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() {
@@ -154,6 +164,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
+ ...this.environmentQueryVariables,
};
},
update(data) {
@@ -165,13 +176,32 @@ export default {
},
},
computed: {
+ areEnvironmentsLoading() {
+ return this.$apollo.queries.environments.loading;
+ },
+ environmentQueryVariables() {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ return {
+ first: ENVIRONMENT_QUERY_LIMIT,
+ search: '',
+ };
+ }
+
+ return {};
+ },
isLoading() {
+ // TODO: Remove areEnvironmentsLoading and show loading icon in dropdown when
+ // environment query is loading and FF is enabled
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/396990
return (
(this.$apollo.queries.ciVariables.loading && this.isInitialLoading) ||
- this.$apollo.queries.environments.loading ||
+ this.areEnvironmentsLoading ||
this.isLoadingMoreItems
);
},
+ pageSize() {
+ return this.glFeatures?.ciVariablesPages ? 20 : 100;
+ },
},
methods: {
addVariable(variable) {
@@ -189,9 +219,39 @@ 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);
},
+ async searchEnvironmentScope(searchTerm) {
+ if (this.glFeatures?.ciLimitEnvironmentScope) {
+ this.$apollo.queries.environments.refetch({ search: searchTerm });
+ }
+ },
async variableMutation(mutationAction, variable) {
try {
const currentMutation = this.mutationData[mutationAction];
@@ -209,11 +269,15 @@ export default {
if (data.ciVariableMutation?.errors?.length) {
const { errors } = data.ciVariableMutation;
createAlert({ message: errors[0] });
- } else if (this.refetchAfterMutation) {
- // The writing to cache for admin variable is not working
- // because there is no ID in the cache at the top level.
- // We therefore need to manually refetch.
- this.$apollo.queries.ciVariables.refetch();
+ } else {
+ this.$toast.show(mapMutationActionToToast[mutationAction](variable.key));
+
+ if (this.refetchAfterMutation) {
+ // The writing to cache for admin variable is not working
+ // because there is no ID in the cache at the top level.
+ // We therefore need to manually refetch.
+ this.$apollo.queries.ciVariables.refetch();
+ }
}
} catch (e) {
createAlert({ message: genericMutationErrorText });
@@ -228,15 +292,21 @@ export default {
<template>
<ci-variable-settings
+ :are-environments-loading="areEnvironmentsLoading"
: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"
+ @search-environment-scope="searchEnvironmentScope"
@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..6f6c55e07c7 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,23 @@ 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" @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 +197,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 +300,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"
@@ -287,12 +312,16 @@ export default {
@click="setSelectedVariable()"
>{{ __('Add variable') }}</gl-button
>
- <gl-button
- v-if="!isTableEmpty"
- data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleHiddenState"
- >{{ valuesButtonText }}</gl-button
- >
+ <gl-button v-if="!isTableEmpty" @click="toggleHiddenState">{{ 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..c8f67bd3436 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -1,6 +1,12 @@
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
+export const ENVIRONMENT_QUERY_LIMIT = 30;
+
+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 = {
@@ -92,6 +98,19 @@ export const ADD_MUTATION_ACTION = 'add';
export const UPDATE_MUTATION_ACTION = 'update';
export const DELETE_MUTATION_ACTION = 'delete';
+export const ADD_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been successfully added.'), { key });
+export const UPDATE_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been updated.'), { key });
+export const DELETE_VARIABLE_TOAST = (key) =>
+ sprintf(s__('CiVariable|Variable %{key} has been deleted.'), { key });
+
+export const mapMutationActionToToast = {
+ [ADD_MUTATION_ACTION]: ADD_VARIABLE_TOAST,
+ [UPDATE_MUTATION_ACTION]: UPDATE_VARIABLE_TOAST,
+ [DELETE_MUTATION_ACTION]: DELETE_VARIABLE_TOAST,
+};
+
export const EXPANDED_VARIABLES_NOTE = __(
'%{codeStart}$%{codeEnd} will be treated as the start of a reference to another 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_environments.query.graphql b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
index 921e0ca25b9..26d1b6a3aaa 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/queries/project_environments.query.graphql
@@ -1,7 +1,7 @@
-query getProjectEnvironments($fullPath: ID!) {
+query getProjectEnvironments($fullPath: ID!, $first: Int, $search: String) {
project(fullPath: $fullPath) {
id
- environments {
+ environments(first: $first, search: $search) {
nodes {
id
name
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..033cdbe864e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -1,11 +1,12 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
import createDefaultClient from '~/lib/graphql';
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 {
@@ -40,10 +41,16 @@ const mountCiVariableListApp = (containerEl) => {
component = CiProjectVariables;
}
+ Vue.use(GlToast);
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/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
index 4775836fcc6..3fe9103c2b3 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue
@@ -146,7 +146,7 @@ export default {
</gl-sprintf>
</gl-form-checkbox>
</gl-form-group>
- <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1">
+ <div class="gl-display-flex gl-py-5">
<gl-button
type="submit"
class="js-no-auto-disable gl-mr-3"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
index 9cbf60b1c8f..b7616c02601 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue
@@ -1,11 +1,13 @@
<script>
import { __, s__, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import {
COMMIT_ACTION_CREATE,
COMMIT_ACTION_UPDATE,
COMMIT_FAILURE,
COMMIT_SUCCESS,
COMMIT_SUCCESS_WITH_REDIRECT,
+ pipelineEditorTrackingOptions,
} from '../../constants';
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql';
@@ -26,6 +28,7 @@ export default {
components: {
CommitForm,
},
+ mixins: [Tracking.mixin()],
inject: ['projectFullPath', 'ciConfigPath'],
props: {
ciFileContent: {
@@ -78,6 +81,8 @@ export default {
async onCommitSubmit({ message, sourceBranch, openMergeRequest }) {
this.isSaving = true;
+ this.trackCommitEvent();
+
try {
const {
data: {
@@ -131,6 +136,10 @@ export default {
this.isSaving = false;
}
},
+ trackCommitEvent() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.commitCiConfig, { label, property: this.action });
+ },
updateCurrentBranch(currentBranch) {
this.$apollo.mutate({
mutation: updateCurrentBranchMutation,
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
index 42e2d34fa3a..9179fe9d075 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -6,7 +6,7 @@ import SourceEditor from '~/vue_shared/components/source_editor.vue';
export default {
i18n: {
- viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
+ viewOnlyMessage: s__('Pipelines|Full configuration is view only'),
},
components: {
SourceEditor,
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
index b78224e93b0..eabf4749e9c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
@@ -10,12 +10,14 @@ export default {
browseTemplates: __('Browse templates'),
help: __('Help'),
jobAssistant: s__('JobAssistant|Job assistant'),
+ aiAssistant: s__('PipelinesAiAssistant|Ai assistant'),
},
TEMPLATE_REPOSITORY_URL,
components: {
GlButton,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ inject: ['aiChatAvailable'],
props: {
showDrawer: {
type: Boolean,
@@ -25,6 +27,15 @@ export default {
type: Boolean,
required: true,
},
+ showAiAssistantDrawer: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isAiConfigChatAvailable() {
+ return this.glFeatures.aiCiConfigGenerator && this.aiChatAvailable;
+ },
},
methods: {
toggleDrawer() {
@@ -40,6 +51,11 @@ export default {
this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer',
);
},
+ toggleAiAssistantDrawer() {
+ this.$emit(
+ this.showAiAssistantDrawer ? 'close-ai-assistant-drawer' : 'open-ai-assistant-drawer',
+ );
+ },
trackHelpDrawerClick() {
const { label, actions } = pipelineEditorTrackingOptions;
this.track(actions.openHelpDrawer, { label });
@@ -85,5 +101,15 @@ export default {
>
{{ $options.i18n.jobAssistant }}
</gl-button>
+ <gl-button
+ v-if="isAiConfigChatAvailable"
+ icon="bulb"
+ size="small"
+ data-testid="ai-assistant-drawer-toggle"
+ data-qa-selector="ai_assistant_drawer_toggle"
+ @click="toggleAiAssistantDrawer"
+ >
+ {{ $options.i18n.aiAssistant }}
+ </gl-button>
</div>
</template>
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/artifacts_and_cache_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
new file mode 100644
index 00000000000..25bbd6b3180
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlAccordionItem, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
+import { get, toPath } from 'lodash';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ components: {
+ GlFormGroup,
+ GlAccordionItem,
+ GlFormInput,
+ GlButton,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ formOptions() {
+ return [
+ {
+ key: 'artifacts.paths',
+ title: i18n.ARTIFACTS_AND_CACHE,
+ paths: this.job.artifacts.paths,
+ generateInputDataTestId: (index) => `artifacts-paths-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-artifacts-paths-button-${index}`,
+ addButtonDataTestId: 'add-artifacts-paths-button',
+ },
+ {
+ key: 'artifacts.exclude',
+ title: i18n.ARTIFACTS_EXCLUDE_PATHS,
+ paths: this.job.artifacts.exclude,
+ generateInputDataTestId: (index) => `artifacts-exclude-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-artifacts-exclude-button-${index}`,
+ addButtonDataTestId: 'add-artifacts-exclude-button',
+ },
+ {
+ key: 'cache.paths',
+ title: i18n.CACHE_PATHS,
+ paths: this.job.cache.paths,
+ generateInputDataTestId: (index) => `cache-paths-input-${index}`,
+ generateDeleteButtonDataTestId: (index) => `delete-cache-paths-button-${index}`,
+ addButtonDataTestId: 'add-cache-paths-button',
+ },
+ ];
+ },
+ },
+ methods: {
+ deleteStringArrayItem(path) {
+ const parentPath = toPath(path).slice(0, -1);
+ const array = get(this.job, parentPath);
+ if (array.length <= 1) {
+ return;
+ }
+ this.$emit('update-job', path);
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.ARTIFACTS_AND_CACHE">
+ <div v-for="entry in formOptions" :key="entry.key" class="form-group">
+ <div class="gl-display-flex">
+ <label class="gl-font-weight-bold gl-mb-3">{{ entry.title }}</label>
+ </div>
+ <div
+ v-for="(path, index) in entry.paths"
+ :key="index"
+ class="gl-display-flex gl-align-items-center gl-mb-3"
+ >
+ <div class="gl-flex-grow-1 gl-flex-basis-0 gl-mr-3">
+ <gl-form-input
+ class="gl-w-full!"
+ :value="path"
+ :data-testid="entry.generateInputDataTestId(index)"
+ @input="$emit('update-job', `${entry.key}[${index}]`, $event)"
+ />
+ </div>
+ <gl-button
+ category="tertiary"
+ icon="remove"
+ :data-testid="entry.generateDeleteButtonDataTestId(index)"
+ @click="deleteStringArrayItem(`${entry.key}[${index}]`)"
+ />
+ </div>
+ <gl-button
+ category="secondary"
+ variant="confirm"
+ :data-testid="entry.addButtonDataTestId"
+ @click="$emit('update-job', `${entry.key}[${entry.paths.length}]`, '')"
+ >{{ $options.i18n.ADD_PATH }}</gl-button
+ >
+ </div>
+ <gl-form-group :label="$options.i18n.CACHE_KEY">
+ <gl-form-input
+ :value="job.cache.key"
+ data-testid="cache-key-input"
+ @input="$emit('update-job', 'cache.key', $event)"
+ />
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
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..b4b468987d8
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlFormGroup, GlAccordionItem, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT,
+ components: {
+ GlAccordionItem,
+ GlFormInput,
+ GlFormTextarea,
+ GlFormGroup,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ imageEntryPoint() {
+ return this.job.image.entrypoint.join('\n');
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.IMAGE">
+ <gl-form-group :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
+ :label="$options.i18n.IMAGE_ENTRYPOINT"
+ :description="$options.i18n.ARRAY_FIELD_DESCRIPTION"
+ class="gl-mb-0"
+ >
+ <gl-form-textarea
+ :no-resize="false"
+ :placeholder="$options.placeholderText"
+ data-testid="image-entrypoint-input"
+ :value="imageEntryPoint"
+ @input="$emit('update-job', 'image.entrypoint', $event.split('\n'))"
+ />
+ </gl-form-group>
+ </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..511003d3ad4
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue
@@ -0,0 +1,90 @@
+<script>
+import {
+ GlAccordionItem,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlTokenSelector,
+ GlFormCombobox,
+} from '@gitlab/ui';
+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,
+ },
+ availableStages: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ },
+};
+</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/accordion_items/rules_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
new file mode 100644
index 00000000000..d068b370852
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue
@@ -0,0 +1,105 @@
+<script>
+import {
+ GlFormGroup,
+ GlAccordionItem,
+ GlFormInput,
+ GlFormSelect,
+ GlFormCheckbox,
+} from '@gitlab/ui';
+import { i18n, JOB_RULES_WHEN, JOB_RULES_START_IN } from '../constants';
+
+export default {
+ i18n,
+ whenOptions: Object.values(JOB_RULES_WHEN),
+ unitOptions: Object.values(JOB_RULES_START_IN),
+ components: {
+ GlAccordionItem,
+ GlFormInput,
+ GlFormSelect,
+ GlFormCheckbox,
+ GlFormGroup,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ isStartValid: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ startInNumber: 1,
+ startInUnit: JOB_RULES_START_IN.second.value,
+ };
+ },
+ computed: {
+ isDelayed() {
+ return this.job.rules[0].when === JOB_RULES_WHEN.delayed.value;
+ },
+ },
+ methods: {
+ updateStartIn() {
+ const plural = this.startInNumber > 1 ? 's' : '';
+ this.$emit(
+ 'update-job',
+ 'rules[0].start_in',
+ `${this.startInNumber} ${this.startInUnit}${plural}`,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.RULES">
+ <div class="gl-display-flex">
+ <gl-form-group class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3" :label="$options.i18n.WHEN">
+ <gl-form-select
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ :options="$options.whenOptions"
+ data-testid="rules-when-select"
+ :value="job.rules[0].when"
+ @input="$emit('update-job', 'rules[0].when', $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ :invalid-feedback="$options.i18n.INVALID_START_IN"
+ :state="isStartValid"
+ >
+ <div class="gl-display-flex gl-mt-5">
+ <gl-form-input
+ v-model="startInNumber"
+ class="gl-flex-grow-1 gl-flex-basis-half gl-mr-3"
+ data-testid="rules-start-in-number-input"
+ type="number"
+ :state="isStartValid"
+ :class="{ 'gl-visibility-hidden': !isDelayed }"
+ number
+ @input="updateStartIn"
+ />
+ <gl-form-select
+ v-model="startInUnit"
+ class="gl-flex-grow-1 gl-flex-basis-half"
+ data-testid="rules-start-in-unit-select"
+ :state="isStartValid"
+ :class="{ 'gl-visibility-hidden': !isDelayed }"
+ :options="$options.unitOptions"
+ @input="updateStartIn"
+ />
+ </div>
+ </gl-form-group>
+ </div>
+ <gl-form-group>
+ <gl-form-checkbox
+ :checked="job.rules[0].allow_failure"
+ data-testid="rules-allow-failure-checkbox"
+ @input="$emit('update-job', 'rules[0].allow_failure', $event)"
+ >
+ {{ $options.i18n.ALLOW_FAILURE }}
+ </gl-form-checkbox>
+ </gl-form-group>
+ </gl-accordion-item>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue
new file mode 100644
index 00000000000..9bada3ef110
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlAccordionItem, GlFormInput, GlButton, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ placeholderText: i18n.ENTRYPOINT_PLACEHOLDER_TEXT,
+ components: {
+ GlAccordionItem,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlButton,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ canDeleteServices() {
+ return this.job.services.length > 1;
+ },
+ },
+ methods: {
+ deleteService(index) {
+ if (!this.canDeleteServices) {
+ return;
+ }
+ this.$emit('update-job', `services[${index}]`);
+ },
+ addService() {
+ this.$emit('update-job', `services[${this.job.services.length}]`, {
+ name: '',
+ entrypoint: [''],
+ });
+ },
+ serviceEntryPoint(service) {
+ const { entrypoint = [''] } = service;
+ return entrypoint.join('\n');
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.SERVICE">
+ <div
+ v-for="(service, index) in job.services"
+ :key="index"
+ class="gl-relative gl-bg-gray-10 gl-mb-5 gl-p-5"
+ >
+ <gl-button
+ v-if="canDeleteServices"
+ class="gl-absolute gl-right-3 gl-top-3"
+ category="tertiary"
+ icon="remove"
+ :data-testid="`delete-job-service-button-${index}`"
+ @click="deleteService(index)"
+ />
+ <gl-form-group :label="$options.i18n.SERVICE_NAME">
+ <gl-form-input
+ :data-testid="`service-name-input-${index}`"
+ :value="service.name"
+ @input="$emit('update-job', `services[${index}].name`, $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.SERVICE_ENTRYPOINT"
+ :description="$options.i18n.ARRAY_FIELD_DESCRIPTION"
+ class="gl-mb-0"
+ >
+ <gl-form-textarea
+ :no-resize="false"
+ :placeholder="$options.placeholderText"
+ :data-testid="`service-entrypoint-input-${index}`"
+ :value="serviceEntryPoint(service)"
+ @input="$emit('update-job', `services[${index}].entrypoint`, $event.split('\n'))"
+ />
+ </gl-form-group>
+ </div>
+ <gl-button
+ category="secondary"
+ variant="confirm"
+ data-testid="add-job-service-button"
+ @click="addService"
+ >{{ $options.i18n.ADD_SERVICE }}</gl-button
+ >
+ </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..e93a9e84302 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,118 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const DRAWER_CONTAINER_CLASS = '.content-wrapper';
+export const JOB_RULES_WHEN = {
+ onSuccess: {
+ value: 'on_success',
+ text: s__('JobAssistant|on_success'),
+ },
+ onFailure: {
+ value: 'on_failure',
+ text: s__('JobAssistant|on_failure'),
+ },
+ manual: {
+ value: 'manual',
+ text: s__('JobAssistant|manual'),
+ },
+ always: {
+ value: 'always',
+ text: s__('JobAssistant|always'),
+ },
+ delayed: {
+ value: 'delayed',
+ text: s__('JobAssistant|delayed'),
+ },
+ never: {
+ value: 'never',
+ text: s__('JobAssistant|never'),
+ },
+};
+
+export const JOB_RULES_START_IN = {
+ second: {
+ value: 'second',
+ text: s__('JobAssistant|second(s)'),
+ },
+ minute: {
+ value: 'minute',
+ text: s__('JobAssistant|minute(s)'),
+ },
+ day: {
+ value: 'day',
+ text: s__('JobAssistant|day(s)'),
+ },
+ week: {
+ value: 'week',
+ text: s__('JobAssistant|week(s)'),
+ },
+};
+
+export const SECONDS_MULTIPLE_MAP = {
+ second: 1,
+ minute: 60,
+ day: 3600 * 24,
+ week: 3600 * 24 * 7,
+};
+
+export const JOB_TEMPLATE = {
+ name: '',
+ stage: '',
+ script: '',
+ tags: [],
+ image: {
+ name: '',
+ entrypoint: [''],
+ },
+ services: [
+ {
+ name: '',
+ entrypoint: [''],
+ },
+ ],
+ artifacts: {
+ paths: [''],
+ exclude: [''],
+ },
+ cache: {
+ paths: [''],
+ key: '',
+ },
+ rules: [
+ {
+ allow_failure: false,
+ when: 'on_success',
+ start_in: '',
+ },
+ ],
+};
+
export const i18n = {
+ ARRAY_FIELD_DESCRIPTION: s__('JobAssistant|Please separate array type fields with new lines'),
+ INPUT_FORMAT: s__('JobAssistant|Input format'),
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'),
+ CACHE_PATHS: s__('JobAssistant|Cache paths (optional)'),
+ CACHE_KEY: s__('JobAssistant|Cache key (optional)'),
+ ARTIFACTS_EXCLUDE_PATHS: s__('JobAssistant|Artifacts exclude paths (optional)'),
+ ARTIFACTS_PATHS: s__('JobAssistant|Artifacts paths (optional)'),
+ ARTIFACTS_AND_CACHE: s__('JobAssistant|Artifacts and cache'),
+ ADD_PATH: s__('JobAssistant|Add path'),
+ RULES: s__('JobAssistant|Rules'),
+ WHEN: s__('JobAssistant|When'),
+ ALLOW_FAILURE: s__('JobAssistant|Allow failure'),
+ INVALID_START_IN: s__('JobAssistant|Error - Valid value is between 1 second and 1 week'),
+ ADD_SERVICE: s__('JobAssistant|Add service'),
+ SERVICE: s__('JobAssistant|Services'),
+ SERVICE_NAME: s__('JobAssistant|Service name (optional)'),
+ SERVICE_ENTRYPOINT: s__('JobAssistant|Service entrypoint (optional)'),
+ ENTRYPOINT_PLACEHOLDER_TEXT: s__('JobAssistant|Please enter the parameters.'),
};
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..30746065732 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,29 @@
<script>
-import { GlDrawer, GlButton } from '@gitlab/ui';
+import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui';
+import { stringify, parse } from 'yaml';
+import { get, omit, toPath } 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 getRunnerTags from '../../graphql/queries/runner_tags.query.graphql';
+import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, JOB_RULES_WHEN, i18n } from './constants';
+import { removeEmptyObj, trimFields, validateEmptyValue, validateStartIn } from './utils';
+import JobSetupItem from './accordion_items/job_setup_item.vue';
+import ImageItem from './accordion_items/image_item.vue';
+import ServicesItem from './accordion_items/services_item.vue';
+import ArtifactsAndCacheItem from './accordion_items/artifacts_and_cache_item.vue';
+import RulesItem from './accordion_items/rules_item.vue';
export default {
i18n,
components: {
GlDrawer,
+ GlAccordion,
GlButton,
+ JobSetupItem,
+ ImageItem,
+ ServicesItem,
+ ArtifactsAndCacheItem,
+ RulesItem,
},
props: {
isVisible: {
@@ -20,16 +36,136 @@ export default {
required: false,
default: 200,
},
+ ciConfigData: {
+ type: Object,
+ required: true,
+ },
+ ciFileContent: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isNameValid: true,
+ isScriptValid: true,
+ isStartValid: true,
+ job: JSON.parse(JSON.stringify(JOB_TEMPLATE)),
+ };
+ },
+ apollo: {
+ runners: {
+ query: getRunnerTags,
+ update(data) {
+ return data?.runners?.nodes || [];
+ },
+ },
},
computed: {
+ availableStages() {
+ if (this.ciConfigData?.mergedYaml) {
+ return parse(this.ciConfigData.mergedYaml).stages;
+ }
+ return [];
+ },
+ 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);
},
+ isJobValid() {
+ return this.isNameValid && this.isScriptValid && this.isStartValid;
+ },
+ },
+
+ watch: {
+ 'job.name': function jobNameWatch(name) {
+ this.isNameValid = validateEmptyValue(name);
+ },
+ 'job.script': function jobScriptWatch(script) {
+ this.isScriptValid = validateEmptyValue(script);
+ },
+ 'job.rules.0.start_in': function JobRulesStartInWatch(startIn) {
+ this.isStartValid = validateStartIn(this.job.rules[0].when, startIn);
+ },
},
methods: {
closeDrawer() {
+ this.clearJob();
this.$emit('close-job-assistant-drawer');
},
+ addCiConfig() {
+ this.validateJob();
+
+ if (!this.isJobValid) {
+ return;
+ }
+
+ const newJobString = this.generateYmlString();
+ this.$emit('updateCiConfig', `${this.ciFileContent}\n${newJobString}`);
+ eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
+
+ this.closeDrawer();
+ },
+ generateYmlString() {
+ let job = JSON.parse(JSON.stringify(this.job));
+ const jobName = job.name;
+ job = this.removeUnnecessaryKeys(job);
+ 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 });
+ },
+ removeUnnecessaryKeys(job) {
+ const keys = ['name'];
+
+ // rules[0].allow_failure value should not be passed down
+ // if it equals the default value
+ if (this.job.rules[0].allow_failure === false) {
+ keys.push('rules[0].allow_failure');
+ }
+ // rules[0].when value should not be passed down
+ // if it equals the default value
+ if (this.job.rules[0].when === JOB_RULES_WHEN.onSuccess.value) {
+ keys.push('rules[0].when');
+ }
+ // rules[0].start_in value should not be passed down
+ // if rules[0].start_in doesn't equal 'delayed'
+ if (this.job.rules[0].when !== JOB_RULES_WHEN.delayed.value) {
+ keys.push('rules[0].start_in');
+ }
+ return omit(job, keys);
+ },
+ clearJob() {
+ this.job = JSON.parse(JSON.stringify(JOB_TEMPLATE));
+ this.$nextTick(() => {
+ this.isNameValid = true;
+ this.isScriptValid = true;
+ this.isStartValid = true;
+ });
+ },
+ updateJob(key, value) {
+ const path = toPath(key);
+ const targetObj = path.length === 1 ? this.job : get(this.job, path.slice(0, -1));
+ const lastKey = path[path.length - 1];
+ if (value !== undefined) {
+ this.$set(targetObj, lastKey, value);
+ } else {
+ this.$delete(targetObj, lastKey);
+ }
+ },
+ validateJob() {
+ this.isNameValid = validateEmptyValue(this.job.name);
+ this.isScriptValid = validateEmptyValue(this.job.script);
+ this.isStartValid = validateStartIn(this.job.rules[0].when, this.job.rules[0].start_in);
+ },
},
};
</script>
@@ -44,6 +180,20 @@ 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"
+ :available-stages="availableStages"
+ @update-job="updateJob"
+ />
+ <image-item :job="job" @update-job="updateJob" />
+ <services-item :job="job" @update-job="updateJob" />
+ <artifacts-and-cache-item :job="job" @update-job="updateJob" />
+ <rules-item :job="job" :is-start-valid="isStartValid" @update-job="updateJob" />
+ </gl-accordion>
<template #footer>
<div class="gl-display-flex gl-justify-content-end">
<gl-button
@@ -51,11 +201,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..a604d79259d
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js
@@ -0,0 +1,53 @@
+import { isEmpty, isObject, isArray, isString, reject, omitBy, mapValues, map, trim } from 'lodash';
+import {
+ JOB_RULES_WHEN,
+ SECONDS_MULTIPLE_MAP,
+} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+
+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);
+};
+
+export const validateEmptyValue = (value) => {
+ return trim(value) !== '';
+};
+
+export const validateStartIn = (when, startIn) => {
+ const hasNoValue = when !== JOB_RULES_WHEN.delayed.value;
+ if (hasNoValue) {
+ return true;
+ }
+
+ let [startInNumber, startInUnit] = startIn.split(' ');
+
+ startInNumber = Number(startInNumber);
+ if (!Number.isInteger(startInNumber)) {
+ return false;
+ }
+
+ const isPlural = startInUnit.slice(-1) === 's';
+ if (isPlural) {
+ startInUnit = startInUnit.slice(0, -1);
+ }
+
+ const multiple = SECONDS_MULTIPLE_MAP[startInUnit];
+
+ return startInNumber * multiple >= 1 && startInNumber * multiple <= SECONDS_MULTIPLE_MAP.week;
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
index fd6547468d9..403793a255a 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -31,7 +31,7 @@ export default {
tabEdit: s__('Pipelines|Edit'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
- tabMergedYaml: s__('Pipelines|View merged YAML'),
+ tabMergedYaml: s__('Pipelines|Full configuration'),
tabValidate: s__('Pipelines|Validate'),
empty: {
visualization: s__(
@@ -41,12 +41,12 @@ export default {
'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.',
),
merge: s__(
- 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.',
+ 'PipelineEditor|The full configuration view is displayed when the CI/CD configuration file has valid syntax.',
),
},
},
errorTexts: {
- loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
+ loadMergedYaml: s__('Pipelines|Could not load full configuration content'),
},
query: {
TAB_QUERY_PARAM,
@@ -99,6 +99,10 @@ export default {
type: Boolean,
required: true,
},
+ showAiAssistantDrawer: {
+ type: Boolean,
+ required: true,
+ },
},
apollo: {
appStatus: {
@@ -194,6 +198,7 @@ export default {
<ci-editor-header
:show-drawer="showDrawer"
:show-job-assistant-drawer="showJobAssistantDrawer"
+ :show-ai-assistant-drawer="showAiAssistantDrawer"
v-on="$listeners"
/>
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
index 83fcab4b343..ba33888e2fb 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue
@@ -2,7 +2,7 @@
import {
GlAlert,
GlButton,
- GlDropdown,
+ GlDisclosureDropdown,
GlIcon,
GlLoadingIcon,
GlLink,
@@ -61,7 +61,7 @@ export default {
CiLintResults,
GlAlert,
GlButton,
- GlDropdown,
+ GlDisclosureDropdown,
GlIcon,
GlLoadingIcon,
GlLink,
@@ -195,11 +195,11 @@ export default {
<div class="gl-display-flex gl-justify-content-space-between gl-mt-3">
<div>
<label>{{ $options.i18n.pipelineSource }}</label>
- <gl-dropdown
+ <gl-disclosure-dropdown
v-gl-tooltip.hover
class="gl-ml-3"
:title="$options.i18n.pipelineSourceTooltip"
- :text="$options.i18n.pipelineSourceDefault"
+ :toggle-text="$options.i18n.pipelineSourceDefault"
disabled
data-testid="pipeline-source"
/>
diff --git a/app/assets/javascripts/ci/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js
index dd25c4d433b..912e0fcbff9 100644
--- a/app/assets/javascripts/ci/pipeline_editor/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/constants.js
@@ -67,6 +67,7 @@ export const pipelineEditorTrackingOptions = {
actions: {
browseTemplates: 'browse_templates',
closeHelpDrawer: 'close_help_drawer',
+ commitCiConfig: 'commit_ci_config',
helpDrawerLinks: {
[CI_EXAMPLES_LINK]: 'visit_help_drawer_link_ci_examples',
[CI_HELP_LINK]: 'visit_help_drawer_link_ci_help',
@@ -86,25 +87,8 @@ export const VALIDATE_TAB_FEEDBACK_URL = 'https://gitlab.com/gitlab-org/gitlab/-
export const COMMIT_SHA_POLL_INTERVAL = 1000;
-export const RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME = 'runners_availability_section';
-export const RUNNERS_SETTINGS_LINK_CLICKED_EVENT = 'runners_settings_link_clicked';
-export const RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT = 'runners_documentation_link_clicked';
-export const RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT = 'runners_settings_button_clicked';
export const I18N = {
title: s__('Pipelines|Get started with GitLab CI/CD'),
- runners: {
- title: s__('Pipelines|Runners are available to run your jobs now'),
- subtitle: s__(
- 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. There are active runners available to run your jobs right now. If you prefer, you can %{settingsLinkStart}configure your runners%{settingsLinkEnd} or %{docsLinkStart}learn more%{docsLinkEnd} about runners.',
- ),
- },
- noRunners: {
- title: s__('Pipelines|No runners detected'),
- subtitle: s__(
- 'Pipelines|A GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline. Install GitLab Runner and register your own runners to get started with CI/CD.',
- ),
- cta: s__('Pipelines|Install GitLab Runner'),
- },
learnBasics: {
title: s__('Pipelines|Learn the basics of pipelines and .yml files'),
subtitle: s__(
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/graphql/queries/runner_tags.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
new file mode 100644
index 00000000000..aab30257d13
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
@@ -0,0 +1,8 @@
+query getRunnerTags {
+ runners {
+ nodes {
+ id
+ tagList
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js
index 6d91c339833..b8d6c27435d 100644
--- a/app/assets/javascripts/ci/pipeline_editor/index.js
+++ b/app/assets/javascripts/ci/pipeline_editor/index.js
@@ -29,12 +29,12 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ciExamplesHelpPagePath,
ciHelpPagePath,
ciLintPath,
+ ciTroubleshootingPath,
defaultBranch,
emptyStateIllustrationPath,
helpPaths,
includesHelpPagePath,
lintHelpPagePath,
- lintUnavailableHelpPagePath,
needsHelpPagePath,
newMergeRequestPath,
pipelinePagePath,
@@ -46,6 +46,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
usesExternalConfig,
validateTabIllustrationPath,
ymlHelpPagePath,
+ aiChatAvailable,
} = el.dataset;
const configurationPaths = Object.fromEntries(
@@ -115,10 +116,12 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
el,
apolloProvider,
provide: {
+ aiChatAvailable: parseBoolean(aiChatAvailable),
ciConfigPath,
ciExamplesHelpPagePath,
ciHelpPagePath,
ciLintPath,
+ ciTroubleshootingPath,
configurationPaths,
dataMethod: 'graphql',
defaultBranch,
@@ -126,7 +129,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..de8e5a1a284 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
-import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility';
+import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
@@ -325,7 +325,7 @@ export default {
},
this.newMergeRequestPath,
);
- redirectTo(url);
+ redirectTo(url); // eslint-disable-line import/no-deprecated
},
async refetchContent() {
this.$apollo.queries.initialCiFileContent.skip = false;
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
index 59863edbe0b..647e33333ce 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -1,6 +1,7 @@
<script>
import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
import JobAssistantDrawer from './components/job_assistant_drawer/job_assistant_drawer.vue';
@@ -10,6 +11,9 @@ import PipelineEditorHeader from './components/header/pipeline_editor_header.vue
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
import { CREATE_TAB, FILE_TREE_DISPLAY_KEY } from './constants';
+const AiAssistantDrawer = () =>
+ import('ee_component/ci/pipeline_editor/components/ai_assistant_drawer.vue');
+
export default {
commitSectionRef: 'commitSectionRef',
modal: {
@@ -30,11 +34,13 @@ export default {
GlModal,
PipelineEditorDrawer,
JobAssistantDrawer,
+ AiAssistantDrawer,
PipelineEditorFileNav,
PipelineEditorFileTree,
PipelineEditorHeader,
PipelineEditorTabs,
},
+ mixins: [glFeatureFlagMixin()],
props: {
ciConfigData: {
type: Object,
@@ -66,8 +72,10 @@ export default {
shouldLoadNewBranch: false,
showDrawer: false,
showJobAssistantDrawer: false,
+ showAiAssistantDrawer: false,
drawerIndex: 200,
jobAssistantIndex: 200,
+ aiAssistantIndex: 200,
showFileTree: false,
showSwitchBranchModal: false,
};
@@ -93,6 +101,13 @@ export default {
closeJobAssistantDrawer() {
this.showJobAssistantDrawer = false;
},
+ closeAiAssistantDrawer() {
+ this.showAiAssistantDrawer = false;
+ },
+ openAiAssistantDrawer() {
+ this.showAiAssistantDrawer = true;
+ this.aiAssistantIndex = this.drawerIndex + 1;
+ },
handleConfirmSwitchBranch() {
this.showSwitchBranchModal = true;
},
@@ -167,11 +182,14 @@ export default {
:is-new-ci-config-file="isNewCiConfigFile"
:show-drawer="showDrawer"
:show-job-assistant-drawer="showJobAssistantDrawer"
+ :show-ai-assistant-drawer="showAiAssistantDrawer"
v-on="$listeners"
@open-drawer="openDrawer"
@close-drawer="closeDrawer"
@open-job-assistant-drawer="openJobAssistantDrawer"
@close-job-assistant-drawer="closeJobAssistantDrawer"
+ @open-ai-assistant-drawer="openAiAssistantDrawer"
+ @close-ai-assistant-drawer="closeAiAssistantDrawer"
@set-current-tab="setCurrentTab"
@walkthrough-popover-cta-clicked="setScrollToCommitForm"
/>
@@ -195,10 +213,18 @@ export default {
@close-drawer="closeDrawer"
/>
<job-assistant-drawer
+ :ci-config-data="ciConfigData"
+ :ci-file-content="ciFileContent"
:is-visible="showJobAssistantDrawer"
:z-index="jobAssistantIndex"
v-on="$listeners"
@close-job-assistant-drawer="closeJobAssistantDrawer"
/>
+ <ai-assistant-drawer
+ v-if="glFeatures.aiCiConfigGenerator"
+ :is-visible="showAiAssistantDrawer"
+ :z-index="aiAssistantIndex"
+ @close-ai-assistant-drawer="closeAiAssistantDrawer"
+ />
</div>
</template>
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..6fd5c8130ad 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
@@ -18,7 +18,7 @@ import { uniqueId } from 'lodash';
import Vue from 'vue';
import { fetchPolicies } from '~/lib/graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__, __, n__ } from '~/locale';
import {
CC_VALIDATION_REQUIRED_ERROR,
@@ -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,
@@ -330,7 +339,7 @@ export default {
const { id, errors, totalWarnings, warnings } = data.createPipeline;
if (id) {
- redirectTo(`${this.pipelinesPath}/${id}`);
+ redirectTo(`${this.pipelinesPath}/${id}`); // eslint-disable-line import/no-deprecated
return;
}
@@ -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"
@@ -406,7 +424,11 @@ export default {
</details>
</gl-alert>
<gl-form-group :label="s__('Pipeline|Run for branch name or tag')">
- <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" />
+ <refs-dropdown
+ v-model="refValue"
+ :project-id="projectId"
+ @loadingError="onRefsLoadingError"
+ />
</gl-form-group>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
diff --git a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
index 060527f2662..429f8e78dbe 100644
--- a/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/refs_dropdown.vue
@@ -1,86 +1,57 @@
<script>
-import { GlCollapsibleListbox } from '@gitlab/ui';
-import { debounce } from 'lodash';
-import axios from '~/lib/utils/axios_utils';
-import { DEBOUNCE_REFS_SEARCH_MS } from '../constants';
-import { formatListBoxItems, searchByFullNameInListboxOptions } from '../utils/format_refs';
+import { __ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
+import { formatToShortName } from '../utils/format_refs';
export default {
+ ENABLED_TYPE_REFS: [REF_TYPE_BRANCHES, REF_TYPE_TAGS],
+ i18n: {
+ /**
+ * In order to hide ListBox header
+ * we need to explicitly provide
+ * empty string for translations
+ */
+ dropdownHeader: '',
+ searchPlaceholder: __('Search refs'),
+ },
components: {
- GlCollapsibleListbox,
+ RefSelector,
},
- inject: ['projectRefsEndpoint'],
props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
value: {
type: Object,
required: false,
default: () => ({}),
},
},
- data() {
- return {
- isLoading: false,
- searchTerm: '',
- listBoxItems: [],
- };
- },
computed: {
- lowerCasedSearchTerm() {
- return this.searchTerm.toLowerCase();
- },
refShortName() {
return this.value.shortName;
},
},
methods: {
- loadRefs() {
- this.isLoading = true;
-
- axios
- .get(this.projectRefsEndpoint, {
- params: {
- search: this.lowerCasedSearchTerm,
- },
- })
- .then(({ data }) => {
- // Note: These keys are uppercase in API
- const { Branches = [], Tags = [] } = data;
-
- this.listBoxItems = formatListBoxItems(Branches, Tags);
- })
- .catch((e) => {
- this.$emit('loadingError', e);
- })
- .finally(() => {
- this.isLoading = false;
- });
- },
- debouncedLoadRefs: debounce(function debouncedLoadRefs() {
- this.loadRefs();
- }, DEBOUNCE_REFS_SEARCH_MS),
- setRefSelected(refFullName) {
- const ref = searchByFullNameInListboxOptions(refFullName, this.listBoxItems);
- this.$emit('input', ref);
- },
- setSearchTerm(searchQuery) {
- this.searchTerm = searchQuery?.trim();
- this.debouncedLoadRefs();
+ setRefSelected(fullName) {
+ this.$emit('input', {
+ shortName: formatToShortName(fullName),
+ fullName,
+ });
},
},
};
</script>
<template>
- <gl-collapsible-listbox
- class="gl-w-full gl-font-monospace"
- :items="listBoxItems"
- :searchable="true"
- :searching="isLoading"
- :search-placeholder="__('Search refs')"
- :selected="value.fullName"
- toggle-class="gl-flex-direction-column gl-align-items-stretch!"
- :toggle-text="refShortName"
- @search="setSearchTerm"
- @select="setRefSelected"
- @shown.once="loadRefs"
+ <ref-selector
+ :value="refShortName"
+ :enabled-ref-types="$options.ENABLED_TYPE_REFS"
+ :project-id="projectId"
+ :translations="$options.i18n"
+ :use-symbolic-ref-names="true"
+ toggle-button-class="gl-w-auto! gl-mb-0!"
+ @input="setRefSelected"
/>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
index 648cd8b66b5..f93f5ad4f11 100644
--- a/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
+++ b/app/assets/javascripts/ci/pipeline_new/graphql/queries/ci_config_variables.graphql
@@ -1,7 +1,7 @@
query ciConfigVariables($fullPath: ID!, $ref: String!) {
project(fullPath: $fullPath) {
id
- ciConfigVariables(sha: $ref) {
+ ciConfigVariables(ref: $ref) {
description
key
value
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_new/utils/format_refs.js b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
index e6d26b32d47..228702fbc71 100644
--- a/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
+++ b/app/assets/javascripts/ci/pipeline_new/utils/format_refs.js
@@ -5,6 +5,10 @@ function convertToListBoxItems(items) {
return items.map(({ shortName, fullName }) => ({ text: shortName, value: fullName }));
}
+export function formatToShortName(ref) {
+ return ref.replace(/^refs\/(tags|heads)\//, '');
+}
+
export function formatRefs(refs, type) {
let fullName;
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/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
index 56461165588..92f461c72d7 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue
@@ -23,7 +23,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="last-pipeline-status">
<ci-badge-link
v-if="hasPipeline"
:status="lastPipelineStatus"
diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
index 48d59bf6e7c..9c0fc148dac 100644
--- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
+++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_next_run.vue
@@ -23,7 +23,7 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="next-run-cell">
<time-ago-tooltip v-if="showTimeAgo" :time="realNextRunTime" />
<span v-else data-testid="pipeline-schedule-inactive">
{{ s__('PipelineSchedules|Inactive') }}
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..b97914f8c26 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,12 +59,21 @@ export default {
type: Array,
required: true,
},
+ currentUser: {
+ type: Object,
+ required: true,
+ },
},
};
</script>
<template>
- <gl-table-lite :fields="$options.fields" :items="schedules" stacked="md">
+ <gl-table-lite
+ :fields="$options.fields"
+ :items="schedules"
+ :tbody-tr-attr="{ 'data-testid': 'pipeline-schedule-table-row' }"
+ stacked="md"
+ >
<template #table-colgroup="{ fields }">
<col v-for="field in fields" :key="field.key" :class="field.columnClass" />
</template>
@@ -94,6 +103,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/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js
index e1486649dbb..d8af2a11e0d 100644
--- a/app/assets/javascripts/ci/reports/codequality_report/constants.js
+++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js
@@ -1,6 +1,6 @@
export const SEVERITY_CLASSES = {
info: 'gl-text-blue-400',
- minor: 'gl-text-orange-200',
+ minor: 'gl-text-orange-300',
major: 'gl-text-orange-400',
critical: 'gl-text-red-600',
blocker: 'gl-text-red-800',
diff --git a/app/assets/javascripts/ci/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue
index 97d4ac7bf6f..ca1f3301691 100644
--- a/app/assets/javascripts/ci/reports/components/report_item.vue
+++ b/app/assets/javascripts/ci/reports/components/report_item.vue
@@ -53,7 +53,7 @@ export default {
};
</script>
<template>
- <li class="report-block-list-issue align-items-center" data-qa-selector="report_item_row">
+ <li class="report-block-list-issue gl-p-3!" data-qa-selector="report_item_row">
<component
:is="iconComponent"
v-if="showReportSectionStatusIcon"
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..e4d47fba464 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,67 +1,61 @@
<script>
-import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
-import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { s__ } from '~/locale';
+
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.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, INSTANCE_TYPE } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
export default {
name: 'AdminNewRunnerApp',
components: {
- GlLink,
- GlSprintf,
- RunnerInstructionsModal,
+ RegistrationCompatibilityAlert,
+ RegistrationFeedbackBanner,
RunnerPlatformsRadioGroup,
- RunnerFormFields,
- },
- directives: {
- GlModal: GlModalDirective,
- },
- props: {
- legacyRegistrationToken: {
- type: String,
- required: true,
- },
+ RunnerCreateForm,
},
data() {
return {
platform: DEFAULT_PLATFORM,
- runner: {
- description: '',
- maintenanceNote: '',
- paused: false,
- accessLevel: DEFAULT_ACCESS_LEVEL,
- runUntagged: false,
- tagList: '',
- maximumTimeout: ' ',
- },
};
},
- modalId: 'runners-legacy-registration-instructions-modal',
+ methods: {
+ onSaved(runner) {
+ const params = { [PARAM_KEY_PLATFORM]: this.platform };
+ const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl);
+
+ saveAlertToLocalStorage({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ INSTANCE_TYPE,
};
</script>
<template>
<div>
+ <registration-feedback-banner />
+
<h1 class="gl-font-size-h2">{{ s__('Runners|New instance runner') }}</h1>
+
+ <registration-compatibility-alert :alert-key="$options.INSTANCE_TYPE" />
+
<p>
- <gl-sprintf
- :message="
- s__(
- 'Runners|Create an instance runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{
- content
- }}</gl-link>
- <runner-instructions-modal
- :modal-id="$options.modalId"
- :registration-token="legacyRegistrationToken"
- />
- </template>
- </gl-sprintf>
+ {{
+ s__(
+ 'Runners|Create an instance runner to generate a command that registers the runner with all its configurations.',
+ )
+ }}
</p>
<hr aria-hidden="true" />
@@ -73,6 +67,6 @@ export default {
<hr aria-hidden="true" />
- <runner-form-fields v-model="runner" />
+ <runner-create-form :runner-type="$options.INSTANCE_TYPE" @saved="onSaved" @error="onError" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/index.js b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
index 502d9d33b4d..434c1197f71 100644
--- a/app/assets/javascripts/ci/runner/admin_new_runner/index.js
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
@@ -12,8 +12,6 @@ export const initAdminNewRunner = (selector = '#js-admin-new-runner') => {
return null;
}
- const { legacyRegistrationToken } = el.dataset;
-
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
@@ -22,11 +20,7 @@ export const initAdminNewRunner = (selector = '#js-admin-new-runner') => {
el,
apolloProvider,
render(h) {
- return h(AdminNewRunnerApp, {
- props: {
- legacyRegistrationToken,
- },
- });
+ return h(AdminNewRunnerApp);
},
});
};
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..668a55d2437 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,8 +1,8 @@
<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';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
@@ -71,7 +71,7 @@ export default {
},
onDeleted({ message }) {
saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
- redirectTo(this.runnersPath);
+ redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated
},
},
};
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..4d88feebe53 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';
@@ -54,7 +54,6 @@ export default {
RunnerJobStatusBadge,
},
mixins: [glFeatureFlagMixin()],
- inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
newRunnerPath: {
type: String,
@@ -128,8 +127,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: {
@@ -193,16 +192,17 @@ export default {
nav-class="gl-border-none!"
/>
- <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm">
- {{ s__('Runners|New instance runner') }}
- </gl-button>
- <registration-dropdown
- v-else
- class="gl-w-full gl-sm-w-auto gl-mr-auto"
- :registration-token="registrationToken"
- :type="$options.INSTANCE_TYPE"
- right
- />
+ <div class="gl-w-full gl-md-w-auto gl-display-flex">
+ <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm">
+ {{ s__('Runners|New instance runner') }}
+ </gl-button>
+ <registration-dropdown
+ class="gl-ml-3"
+ :registration-token="registrationToken"
+ :type="$options.INSTANCE_TYPE"
+ right
+ />
+ </div>
</div>
<runner-filtered-search-bar
@@ -219,8 +219,6 @@ export default {
:registration-token="registrationToken"
:is-search-filtered="isSearchFiltered"
:new-runner-path="newRunnerPath"
- :svg-path="emptyStateSvgPath"
- :filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
<runner-list
diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js
index 881dc3613e9..54eb37f8c90 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/index.js
+++ b/app/assets/javascripts/ci/runner/admin_runners/index.js
@@ -35,8 +35,6 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
registrationToken,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
} = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
@@ -53,8 +51,6 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
},
render(h) {
return h(AdminRunnersApp, {
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
index 97dfbe1a051..f24fb5575ae 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
@@ -1,6 +1,8 @@
<script>
import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, __ } from '~/locale';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerName from '../runner_name.vue';
@@ -14,6 +16,7 @@ import {
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
} from '../../constants';
import RunnerSummaryField from './runner_summary_field.vue';
@@ -28,6 +31,7 @@ export default {
RunnerTypeBadge,
RunnerUpgradeStatusIcon: () =>
import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
+ UserAvatarLink,
TooltipOnTruncate,
},
directives: {
@@ -43,6 +47,16 @@ export default {
jobCount() {
return formatJobCount(this.runner.jobCount);
},
+ createdBy() {
+ return this.runner?.createdBy;
+ },
+ createdByImgAlt() {
+ const name = this.createdBy?.name;
+ if (name) {
+ return sprintf(__("%{name}'s avatar"), { name });
+ }
+ return null;
+ },
},
i18n: {
I18N_NO_DESCRIPTION,
@@ -50,13 +64,14 @@ export default {
I18N_VERSION_LABEL,
I18N_LAST_CONTACT_LABEL,
I18N_CREATED_AT_LABEL,
+ I18N_CREATED_AT_BY_LABEL,
},
};
</script>
<template>
<div>
- <div>
+ <div class="gl-mb-3">
<slot :runner="runner" name="runner-name">
<runner-name :runner="runner" />
</slot>
@@ -69,22 +84,23 @@ export default {
<runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" />
</div>
- <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2">
- <div class="gl-flex-shrink-0">
- <runner-upgrade-status-icon :runner="runner" />
- <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL">
- <template #version>{{ runner.version }}</template>
- </gl-sprintf>
- </div>
- <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
+ <div class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full">
+ <template v-if="runner.version">
+ <div class="gl-flex-shrink-0">
+ <runner-upgrade-status-icon :runner="runner" />
+ <gl-sprintf :message="$options.i18n.I18N_VERSION_LABEL">
+ <template #version>{{ runner.version }}</template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
+ </template>
<tooltip-on-truncate
- v-if="runner.description"
class="gl-text-truncate gl-display-block"
+ :class="{ 'gl-text-secondary': !runner.description }"
:title="runner.description"
>
- {{ runner.description }}
+ {{ runner.description || $options.i18n.I18N_NO_DESCRIPTION }}
</tooltip-on-truncate>
- <span v-else class="gl-text-secondary">{{ $options.i18n.I18N_NO_DESCRIPTION }}</span>
</div>
<div>
@@ -106,14 +122,33 @@ export default {
</runner-summary-field>
<runner-summary-field icon="calendar">
- <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
- <template #timeAgo>
- <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
- </template>
- </gl-sprintf>
+ <template v-if="createdBy">
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_BY_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ <template #avatar>
+ <user-avatar-link
+ :link-href="createdBy.webUrl"
+ :img-src="createdBy.avatarUrl"
+ img-css-classes="gl-vertical-align-top"
+ :img-size="16"
+ :img-alt="createdByImgAlt"
+ :tooltip-text="createdBy.username"
+ />
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-else>
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </template>
</runner-summary-field>
</div>
- <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" />
+ <runner-tags class="gl-display-block" :tag-list="runner.tagList" size="sm" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
index 1bbbd55089a..742259ee491 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue
@@ -24,7 +24,7 @@ export default {
</script>
<template>
- <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2">
+ <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-mb-3 gl-mr-4">
<gl-icon v-if="icon" :name="icon" />
<!-- display tooltip as a label for screen readers -->
<span class="gl-sr-only">{{ tooltip }}</span>
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_compatibility_alert.vue b/app/assets/javascripts/ci/runner/components/registration/registration_compatibility_alert.vue
new file mode 100644
index 00000000000..4ebb5754e61
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_compatibility_alert.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import DismissibleFeedbackAlert from '~/vue_shared/components/dismissible_feedback_alert.vue';
+import { s__ } from '~/locale';
+import { CHANGELOG_URL } from '../../constants';
+
+export default {
+ name: 'RegistrationCompatibilityAlert',
+ components: {
+ GlLink,
+ GlSprintf,
+ DismissibleFeedbackAlert,
+ },
+ props: {
+ alertKey: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ alertFeatureName() {
+ return `new_runner_compatibility_${this.alertKey}`;
+ },
+ },
+ CHANGELOG_URL,
+ i18n: {
+ title: s__(
+ 'Runners|This registration process is only supported in GitLab Runner 15.10 or later',
+ ),
+ message: s__(
+ 'Runners|This registration process is not supported in GitLab Runner 15.9 or earlier and only available as an experimental feature in GitLab Runner 15.10 and 15.11. You should upgrade to %{linkStart}GitLab Runner 16.0%{linkEnd} or later to use a stable version of this registration process.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <dismissible-feedback-alert
+ :feature-name="alertFeatureName"
+ class="gl-mb-4"
+ variant="warning"
+ :title="$options.i18n.title"
+ >
+ <gl-sprintf :message="$options.i18n.message">
+ <template #link="{ content }">
+ <gl-link :href="$options.CHANGELOG_URL" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </dismissible-feedback-alert>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
index 212ad5fa5a0..2fdf8456615 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue
@@ -1,8 +1,17 @@
<script>
-import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_REGISTER_INSTANCE_TYPE,
+ I18N_REGISTER_GROUP_TYPE,
+ I18N_REGISTER_PROJECT_TYPE,
+ I18N_REGISTER_RUNNER,
+} from '../../constants';
import RegistrationToken from './registration_token.vue';
import RegistrationTokenResetDropdownItem from './registration_token_reset_dropdown_item.vue';
@@ -17,10 +26,12 @@ export default {
GlDropdownForm,
GlDropdownItem,
GlDropdownDivider,
+ GlIcon,
RegistrationToken,
RunnerInstructionsModal,
RegistrationTokenResetDropdownItem,
},
+ mixins: [glFeatureFlagMixin()],
props: {
registrationToken: {
type: String,
@@ -40,17 +51,49 @@ export default {
};
},
computed: {
- dropdownText() {
+ isDeprecated() {
+ // Show a compact version when used as secondary option
+ // create_runner_workflow_for_admin or create_runner_workflow_for_namespace
+ return (
+ this.glFeatures?.createRunnerWorkflowForAdmin ||
+ this.glFeatures?.createRunnerWorkflowForNamespace
+ );
+ },
+ actionText() {
switch (this.type) {
case INSTANCE_TYPE:
- return s__('Runners|Register an instance runner');
+ return I18N_REGISTER_INSTANCE_TYPE;
case GROUP_TYPE:
- return s__('Runners|Register a group runner');
+ return I18N_REGISTER_GROUP_TYPE;
case PROJECT_TYPE:
- return s__('Runners|Register a project runner');
+ return I18N_REGISTER_PROJECT_TYPE;
default:
- return s__('Runners|Register a runner');
+ return I18N_REGISTER_RUNNER;
+ }
+ },
+ dropdownText() {
+ if (this.isDeprecated) {
+ return '';
+ }
+ return this.actionText;
+ },
+ dropdownToggleClass() {
+ if (this.isDeprecated) {
+ return ['gl-px-3!'];
+ }
+ return [];
+ },
+ dropdownCategory() {
+ if (this.isDeprecated) {
+ return 'tertiary';
}
+ return 'primary';
+ },
+ dropdownVariant() {
+ if (this.isDeprecated) {
+ return 'default';
+ }
+ return 'confirm';
},
},
methods: {
@@ -71,9 +114,26 @@ export default {
ref="runnerRegistrationDropdown"
menu-class="gl-w-auto!"
:text="dropdownText"
- variant="confirm"
+ :toggle-class="dropdownToggleClass"
+ :variant="dropdownVariant"
+ :category="dropdownCategory"
v-bind="$attrs"
>
+ <template v-if="isDeprecated" #button-content>
+ <span class="gl-sr-only">{{ actionText }}</span>
+ <gl-icon name="ellipsis_v" />
+ </template>
+ <gl-dropdown-form class="gl-p-4!">
+ <registration-token input-id="token-value" :value="currentRegistrationToken">
+ <template v-if="isDeprecated" #label-description>
+ <gl-icon name="warning" class="gl-text-orange-500" />
+ <span class="gl-text-secondary">
+ {{ s__('Runners|Support for registration tokens is deprecated') }}
+ </span>
+ </template>
+ </registration-token>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
<gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick">
{{ $options.i18n.showInstallationInstructions }}
<runner-instructions-modal
@@ -83,10 +143,6 @@ export default {
/>
</gl-dropdown-item>
<gl-dropdown-divider />
- <gl-dropdown-form class="gl-p-4!">
- <registration-token input-id="token-value" :value="currentRegistrationToken" />
- </gl-dropdown-form>
- <gl-dropdown-divider />
<registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" />
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue
new file mode 100644
index 00000000000..fe19977f783
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue
@@ -0,0 +1,41 @@
+<script>
+import ILLUSTRATION_URL from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg?url';
+import { GlBanner } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+
+const FEEDBACK_ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/387993';
+
+export default {
+ components: {
+ GlBanner,
+ UserCalloutDismisser,
+ },
+ i18n: {
+ title: s__("Runners|We've made some changes and want your feedback"),
+ body: s__(
+ "Runners|We've been making improvements to how you register runners so that it's more secure and efficient. Tell us how we're doing.",
+ ),
+ button: s__('Runners|Add your feedback to this issue'),
+ },
+ ILLUSTRATION_URL,
+ FEEDBACK_ISSUE_URL,
+};
+</script>
+<template>
+ <user-callout-dismisser feature-name="create_runner_workflow_banner">
+ <template #default="{ dismiss, shouldShowCallout }">
+ <gl-banner
+ v-if="shouldShowCallout"
+ class="gl-my-6"
+ :title="$options.i18n.title"
+ :svg-path="$options.ILLUSTRATION_URL"
+ :button-text="$options.i18n.button"
+ :button-link="$options.FEEDBACK_ISSUE_URL"
+ @close="dismiss"
+ >
+ <p>{{ $options.i18n.body }}</p>
+ </gl-banner>
+ </template>
+ </user-callout-dismisser>
+</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..69021dde0e9
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue
@@ -0,0 +1,256 @@
+<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.isRunnerOnline) {
+ // 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');
+ },
+ 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,
+ token: this.token,
+ });
+ },
+ runCommand() {
+ return runCommand({ platform: this.platform });
+ },
+ isRunnerOnline() {
+ return this.runner?.status === STATUS_ONLINE;
+ },
+ },
+ created() {
+ window.addEventListener('beforeunload', this.onBeforeunload);
+ },
+ destroyed() {
+ window.removeEventListener('beforeunload', this.onBeforeunload);
+ },
+ methods: {
+ toggleDrawer() {
+ this.$emit('toggleDrawer');
+ },
+ onBeforeunload(event) {
+ if (this.isRunnerOnline) {
+ return undefined;
+ }
+
+ const str = s__('Runners|You may lose access to the runner token if you leave this page.');
+ event.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ event.returnValue = str; // Chrome requires returnValue to be set
+ return str;
+ },
+ },
+ EXECUTORS_HELP_URL,
+ SERVICE_COMMANDS_HELP_URL,
+ 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="isRunnerOnline">
+ <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.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
index 6b4e6a929b7..b196bccf66f 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue
@@ -45,5 +45,9 @@ export default {
:copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
:form-input-group-props="formInputGroupProps"
@copy="onCopy"
- />
+ >
+ <template v-for="slot in Object.keys($scopedSlots)" #[slot]>
+ <slot :name="slot"></slot>
+ </template>
+ </input-copy-toggle-visibility>
</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..c8a75506c9c
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/registration/utils.js
@@ -0,0 +1,81 @@
+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',
+ },
+};
+
+export const commandPrompt = ({ platform }) => {
+ return (OS[platform] || OS[DEFAULT_PLATFORM]).commandPrompt;
+};
+
+export const executable = ({ platform }) => {
+ return (OS[platform] || OS[DEFAULT_PLATFORM]).executable;
+};
+
+export const registerCommand = ({ platform, url = gon.gitlab_url, token }) => {
+ const lines = [`${executable({ platform })} register`]; // eslint-disable-line @gitlab/require-i18n-strings
+ if (url) {
+ lines.push(` --url ${url}`);
+ }
+ if (token) {
+ lines.push(` --token ${token}`);
+ }
+ return lines;
+};
+
+export const runCommand = ({ platform }) => {
+ return `${executable({ platform })} run`; // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+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..6107b4dd3ea
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue
@@ -0,0 +1,120 @@
+<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 {
+ RUNNER_TYPES,
+ DEFAULT_ACCESS_LEVEL,
+ PROJECT_TYPE,
+ GROUP_TYPE,
+ INSTANCE_TYPE,
+} from '../constants';
+
+export default {
+ name: 'RunnerCreateForm',
+ components: {
+ GlForm,
+ GlButton,
+ RunnerFormFields,
+ },
+ props: {
+ runnerType: {
+ type: String,
+ required: true,
+ validator: (t) => RUNNER_TYPES.includes(t),
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ projectId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ saving: false,
+ runner: {
+ description: '',
+ maintenanceNote: '',
+ paused: false,
+ accessLevel: DEFAULT_ACCESS_LEVEL,
+ runUntagged: false,
+ tagList: '',
+ maximumTimeout: '',
+ },
+ };
+ },
+ computed: {
+ mutationInput() {
+ const { input } = modelToUpdateMutationVariables(this.runner);
+
+ if (this.runnerType === GROUP_TYPE) {
+ return {
+ ...input,
+ runnerType: GROUP_TYPE,
+ groupId: this.groupId,
+ };
+ }
+ if (this.runnerType === PROJECT_TYPE) {
+ return {
+ ...input,
+ runnerType: PROJECT_TYPE,
+ projectId: this.projectId,
+ };
+ }
+ return {
+ ...input,
+ runnerType: INSTANCE_TYPE,
+ };
+ },
+ },
+ methods: {
+ async onSubmit() {
+ this.saving = true;
+ try {
+ const {
+ data: {
+ runnerCreate: { errors, runner },
+ },
+ } = await this.$apollo.mutate({
+ mutation: runnerCreateMutation,
+ variables: {
+ input: this.mutationInput,
+ },
+ });
+
+ 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..62e09346c2c 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs.vue
@@ -1,12 +1,13 @@
<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';
import { getPaginationVariables } from '../utils';
import RunnerJobsTable from './runner_jobs_table.vue';
import RunnerPagination from './runner_pagination.vue';
+import RunnerJobsEmptyState from './runner_jobs_empty_state.vue';
export default {
name: 'RunnerJobs',
@@ -14,7 +15,9 @@ export default {
GlSkeletonLoader,
RunnerJobsTable,
RunnerPagination,
+ RunnerJobsEmptyState,
},
+
props: {
runner: {
type: Object,
@@ -75,7 +78,7 @@ export default {
<gl-skeleton-loader />
</div>
<runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
- <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
+ <runner-jobs-empty-state v-else />
<runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" />
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue
new file mode 100644
index 00000000000..c30a824120d
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_empty_state.vue
@@ -0,0 +1,27 @@
+<script>
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+
+import { GlEmptyState } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('Runners|This runner has not run any jobs'),
+ description: s__(
+ 'Runners|Make sure the runner is online and available to run jobs (not paused). Jobs display here when the runner picks them up.',
+ ),
+ },
+ components: {
+ GlEmptyState,
+ },
+ EMPTY_STATE_SVG_URL,
+};
+</script>
+
+<template>
+ <gl-empty-state :svg-path="$options.EMPTY_STATE_SVG_URL" :title="$options.i18n.title">
+ <template #description>
+ <p>{{ $options.i18n.description }}</p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
index ebcda4f0ac3..5d8e9dcdee2 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
@@ -2,7 +2,7 @@
import { GlTableLite } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { formatTime } from '~/lib/utils/datetime_utility';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RunnerTags from '~/ci/runner/components/runner_tags.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -50,11 +50,11 @@ export default {
},
duration(job) {
const { duration } = job;
- return duration ? durationTimeFormatted(duration) : '';
+ return duration ? formatTime(duration * 1000) : '';
},
queued(job) {
const { queuedDuration } = job;
- return queuedDuration ? durationTimeFormatted(queuedDuration) : '';
+ return queuedDuration ? formatTime(queuedDuration * 1000) : '';
},
},
fields: [
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..8606c22db34 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
@@ -1,4 +1,7 @@
<script>
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/magnifying-glass.svg?url';
+
import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
@@ -20,16 +23,6 @@ export default {
required: false,
default: false,
},
- svgPath: {
- type: String,
- required: false,
- default: '',
- },
- filteredSvgPath: {
- type: String,
- required: false,
- default: '',
- },
registrationToken: {
type: String,
required: false,
@@ -43,12 +36,18 @@ export default {
},
computed: {
shouldShowCreateRunnerWorkflow() {
- // create_runner_workflow feature flag
- return this.newRunnerPath && this.glFeatures?.createRunnerWorkflow;
+ // create_runner_workflow_for_admin or create_runner_workflow_for_namespace
+ return (
+ this.newRunnerPath &&
+ (this.glFeatures?.createRunnerWorkflowForAdmin ||
+ this.glFeatures?.createRunnerWorkflowForNamespace)
+ );
},
},
modalId: 'runners-empty-state-instructions-modal',
svgHeight: 145,
+ EMPTY_STATE_SVG_URL,
+ FILTERED_SVG_URL,
};
</script>
@@ -56,14 +55,14 @@ export default {
<gl-empty-state
v-if="isSearchFiltered"
:title="s__('Runners|No results found')"
- :svg-path="filteredSvgPath"
+ :svg-path="$options.FILTERED_SVG_URL"
:svg-height="$options.svgHeight"
:description="s__('Runners|Edit your search and try again')"
/>
<gl-empty-state
v-else
:title="s__('Runners|Get started with runners')"
- :svg-path="svgPath"
+ :svg-path="$options.EMPTY_STATE_SVG_URL"
:svg-height="$options.svgHeight"
>
<template v-if="registrationToken" #description>
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_platforms_radio.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
index d70c51e83f9..48c5d2f9721 100644
--- a/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
@@ -30,6 +30,9 @@ export default {
isChecked() {
return this.value && this.value === this.checked;
},
+ imgClass() {
+ return 'gl-h-6 gl-mt-n2 gl-mr-2';
+ },
},
methods: {
onInput($event) {
@@ -47,23 +50,22 @@ export default {
<template>
<div
- class="runner-platforms-radio gl-display-flex gl-border gl-rounded-base gl-px-5 gl-py-6"
+ class="runner-platforms-radio gl-border gl-rounded-base gl-px-5 gl-pt-6 gl-pb-5"
:class="{ 'gl-bg-blue-50 gl-border-blue-500': isChecked, 'gl-cursor-pointer': value }"
@click="onInput(value)"
>
<gl-form-radio
v-if="value"
- class="gl-min-h-5"
:checked="checked"
:value="value"
@input="onInput($event)"
@change="onChange($event)"
>
- <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <img v-if="image" :src="image" :class="imgClass" aria-hidden="true" />
<span class="gl-font-weight-bold"><slot></slot></span>
</gl-form-radio>
- <div v-else class="gl-h-5">
- <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <div v-else class="gl-mb-3">
+ <img v-if="image" :src="image" :class="imgClass" aria-hidden="true" />
<span class="gl-font-weight-bold"><slot></slot></span>
</div>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
index 273226141d2..a49641194a7 100644
--- a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
@@ -1,6 +1,6 @@
<script>
-import AWS_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/aws.svg?url';
import DOCKER_LOGO_URL from '@gitlab/svgs/dist/illustrations/third-party-logos/ci_cd-template-logos/docker.png';
+import LINUX_LOGO_URL from '@gitlab/svgs/dist/illustrations/third-party-logos/linux.svg?url';
import KUBERNETES_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/kubernetes.svg?url';
import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui';
@@ -8,7 +8,6 @@ import {
LINUX_PLATFORM,
MACOS_PLATFORM,
WINDOWS_PLATFORM,
- AWS_PLATFORM,
DOCKER_HELP_URL,
KUBERNETES_HELP_URL,
} from '../constants';
@@ -40,11 +39,10 @@ export default {
},
},
LINUX_PLATFORM,
+ LINUX_LOGO_URL,
MACOS_PLATFORM,
WINDOWS_PLATFORM,
- AWS_PLATFORM,
- AWS_LOGO_URL,
DOCKER_HELP_URL,
DOCKER_LOGO_URL,
KUBERNETES_HELP_URL,
@@ -59,7 +57,11 @@ export default {
<div class="gl-display-flex gl-flex-wrap gl-gap-5">
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <runner-platforms-radio v-model="model" :value="$options.LINUX_PLATFORM">
+ <runner-platforms-radio
+ v-model="model"
+ :image="$options.LINUX_LOGO_URL"
+ :value="$options.LINUX_PLATFORM"
+ >
Linux
</runner-platforms-radio>
<runner-platforms-radio v-model="model" :value="$options.MACOS_PLATFORM">
@@ -72,20 +74,6 @@ export default {
</div>
<div class="gl-mt-3 gl-mb-6">
- <label>{{ s__('Runners|Cloud templates') }}</label>
- <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <div class="gl-display-flex gl-flex-wrap gl-gap-5">
- <runner-platforms-radio
- v-model="model"
- :image="$options.AWS_LOGO_URL"
- :value="$options.AWS_PLATFORM"
- >
- AWS
- </runner-platforms-radio>
- </div>
- </div>
-
- <div class="gl-mt-3 gl-mb-6">
<label>{{ s__('Runners|Containers') }}</label>
<div class="gl-display-flex gl-flex-wrap gl-gap-5">
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..2d34c551d6d 100644
--- a/app/assets/javascripts/ci/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue
@@ -13,8 +13,8 @@ import {
modelToUpdateMutationVariables,
runnerToModel,
} from 'ee_else_ce/ci/runner/runner_update_form_utils';
-import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __ } from '~/locale';
import { captureException } from '~/ci/runner/sentry_utils';
import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants';
@@ -101,7 +101,7 @@ export default {
},
onSuccess() {
saveAlertToLocalStorage({ message: __('Changes saved.'), variant: VARIANT_SUCCESS });
- redirectTo(this.runnerPath);
+ redirectTo(this.runnerPath); // eslint-disable-line import/no-deprecated
},
onError(message) {
this.saving = false;
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..4e36a410a66 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -71,6 +71,12 @@ export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__(
'Runners|Runner is stale; it has never contacted this instance',
);
+// Registration dropdown
+export const I18N_REGISTER_INSTANCE_TYPE = s__('Runners|Register an instance runner');
+export const I18N_REGISTER_GROUP_TYPE = s__('Runners|Register a group runner');
+export const I18N_REGISTER_PROJECT_TYPE = s__('Runners|Register a project runner');
+export const I18N_REGISTER_RUNNER = s__('Runners|Register a runner');
+
// Actions
export const I18N_EDIT = __('Edit');
@@ -93,6 +99,7 @@ export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
+export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{avatar}');
export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited');
export const I18N_ADMIN = s__('Runners|Administrator');
@@ -105,10 +112,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,11 +141,14 @@ 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';
export const GROUP_TYPE = 'GROUP_TYPE';
export const PROJECT_TYPE = 'PROJECT_TYPE';
+export const RUNNER_TYPES = [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE];
// CiRunnerStatus
@@ -180,11 +195,64 @@ export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners';
export const LINUX_PLATFORM = 'linux';
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 CHANGELOG_URL = 'https://gitlab.com/gitlab-org/gitlab-runner/blob/main/CHANGELOG.md';
export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html';
export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html';
diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
index 29abddf84f5..d18b80511fb 100644
--- a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql
@@ -10,5 +10,5 @@ fragment RunnerFieldsShared on CiRunner {
maximumTimeout
tagList
createdAt
- status(legacyMode: null)
+ status
}
diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
index 6f72509f599..4eebcd01be6 100644
--- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
fragment ListItemShared on CiRunner {
id
description
@@ -10,8 +12,11 @@ fragment ListItemShared on CiRunner {
jobCount
tagList
createdAt
+ createdBy {
+ ...User
+ }
contactedAt
- status(legacyMode: null)
+ status
jobExecutionStatus
userPermissions {
updateRunner
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..07236808dca
--- /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
+ ephemeralRegisterUrl
+ }
+ 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/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
index b5689ff7687..bd53fb29bd0 100644
--- a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql
@@ -15,7 +15,7 @@ fragment RunnerDetailsShared on CiRunner {
jobCount
tagList
createdAt
- status(legacyMode: null)
+ status
contactedAt
tokenExpiresAt
version
diff --git a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
new file mode 100644
index 00000000000..67d29daf66f
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue
@@ -0,0 +1,83 @@
+<script>
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { s__ } from '~/locale';
+
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { DEFAULT_PLATFORM, GROUP_TYPE, PARAM_KEY_PLATFORM } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
+
+export default {
+ name: 'GroupNewRunnerApp',
+ components: {
+ RegistrationCompatibilityAlert,
+ RegistrationFeedbackBanner,
+ RunnerPlatformsRadioGroup,
+ RunnerCreateForm,
+ },
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: DEFAULT_PLATFORM,
+ };
+ },
+ methods: {
+ onSaved(runner) {
+ const params = { [PARAM_KEY_PLATFORM]: this.platform };
+ const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl);
+
+ saveAlertToLocalStorage({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ GROUP_TYPE,
+};
+</script>
+
+<template>
+ <div>
+ <registration-feedback-banner />
+
+ <h1 class="gl-font-size-h2">{{ s__('Runners|New group runner') }}</h1>
+
+ <registration-compatibility-alert :alert-key="groupId" />
+
+ <p>
+ {{
+ s__(
+ 'Runners|Create a group runner to generate a command that registers the runner with all its configurations.',
+ )
+ }}
+ </p>
+
+ <hr aria-hidden="true" />
+
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Platform') }}
+ </h2>
+ <runner-platforms-radio-group v-model="platform" />
+
+ <hr aria-hidden="true" />
+
+ <runner-create-form
+ :runner-type="$options.GROUP_TYPE"
+ :group-id="groupId"
+ @saved="onSaved"
+ @error="onError"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/group_new_runner/index.js b/app/assets/javascripts/ci/runner/group_new_runner/index.js
new file mode 100644
index 00000000000..9e056081e03
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_new_runner/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import GroupNewRunnerApp from './group_new_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupNewRunner = (selector = '#js-group-new-runner') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { groupId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(GroupNewRunnerApp, {
+ props: {
+ groupId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue b/app/assets/javascripts/ci/runner/group_register_runner/group_register_runner_app.vue
new file mode 100644
index 00000000000..533d31b70a3
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_register_runner/group_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: 'GroupRegisterRunnerApp',
+ 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|Group 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/group_register_runner/index.js b/app/assets/javascripts/ci/runner/group_register_runner/index.js
new file mode 100644
index 00000000000..a00db8853a2
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/group_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 GroupRegisterRunnerApp from './group_register_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupRegisterRunner = (selector = '#js-group-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(GroupRegisterRunnerApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
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..1318bf5a2e6 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,8 +1,8 @@
<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';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
@@ -76,7 +76,7 @@ export default {
},
onDeleted({ message }) {
saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
- redirectTo(this.runnersPath);
+ redirectTo(this.runnersPath); // eslint-disable-line import/no-deprecated
},
},
};
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..74523bc335f 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 { GlButton, GlLink } from '@gitlab/ui';
+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';
@@ -42,6 +42,7 @@ import { captureException } from '../sentry_utils';
export default {
name: 'GroupRunnersApp',
components: {
+ GlButton,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -56,8 +57,12 @@ export default {
RunnerJobStatusBadge,
},
mixins: [glFeatureFlagMixin()],
- inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
+ newRunnerPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
registrationToken: {
type: String,
required: false,
@@ -150,6 +155,10 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
+ shouldShowCreateRunnerWorkflow() {
+ // create_runner_workflow_for_namespace feature flag
+ return this.glFeatures.createRunnerWorkflowForNamespace;
+ },
},
watch: {
search: {
@@ -207,7 +216,9 @@ export default {
<template>
<div>
- <div class="gl-display-flex gl-align-items-center">
+ <div
+ class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
+ >
<runner-type-tabs
ref="runner-type-tabs"
v-model="search"
@@ -219,15 +230,23 @@ export default {
nav-class="gl-border-none!"
/>
- <registration-dropdown
- v-if="registrationToken"
- class="gl-ml-auto"
- :registration-token="registrationToken"
- :type="$options.GROUP_TYPE"
- right
- />
+ <div class="gl-w-full gl-md-w-auto gl-display-flex">
+ <gl-button
+ v-if="shouldShowCreateRunnerWorkflow && newRunnerPath"
+ :href="newRunnerPath"
+ variant="confirm"
+ >
+ {{ s__('Runners|New group runner') }}
+ </gl-button>
+ <registration-dropdown
+ v-if="registrationToken"
+ class="gl-ml-3"
+ :registration-token="registrationToken"
+ :type="$options.GROUP_TYPE"
+ right
+ />
+ </div>
</div>
-
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3"
:class="$options.FILTER_CSS_CLASSES"
@@ -250,8 +269,7 @@ export default {
v-if="noRunnersFound"
:registration-token="registrationToken"
:is-search-filtered="isSearchFiltered"
- :svg-path="emptyStateSvgPath"
- :filtered-svg-path="emptyStateFilteredSvgPath"
+ :new-runner-path="newRunnerPath"
/>
<template v-else>
<runner-list
diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js
index 46514d5afe8..a5e2521ede5 100644
--- a/app/assets/javascripts/ci/runner/group_runners/index.js
+++ b/app/assets/javascripts/ci/runner/group_runners/index.js
@@ -18,12 +18,11 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
const {
registrationToken,
runnerInstallHelpPage,
+ newRunnerPath,
groupId,
groupFullPath,
onlineContactTimeoutSecs,
staleTimeoutSecs,
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
} = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
@@ -41,14 +40,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
groupId,
onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10),
staleTimeoutSecs: parseInt(staleTimeoutSecs, 10),
- emptyStateSvgPath,
- emptyStateFilteredSvgPath,
},
render(h) {
return h(GroupRunnersApp, {
props: {
registrationToken,
groupFullPath,
+ newRunnerPath,
},
});
},
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/project_new_runner/index.js b/app/assets/javascripts/ci/runner/project_new_runner/index.js
new file mode 100644
index 00000000000..44f1a0ffdab
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_new_runner/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import ProjectNewRunnerApp from './project_new_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initProjectNewRunner = (selector = '#js-project-new-runner') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { projectId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(ProjectNewRunnerApp, {
+ props: {
+ projectId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue
new file mode 100644
index 00000000000..f0ae54c0232
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue
@@ -0,0 +1,83 @@
+<script>
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { redirectTo, setUrlParams } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { s__ } from '~/locale';
+
+import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue';
+import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
+import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, PROJECT_TYPE } from '../constants';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
+
+export default {
+ name: 'ProjectNewRunnerApp',
+ components: {
+ RegistrationCompatibilityAlert,
+ RegistrationFeedbackBanner,
+ RunnerPlatformsRadioGroup,
+ RunnerCreateForm,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: DEFAULT_PLATFORM,
+ };
+ },
+ methods: {
+ onSaved(runner) {
+ const params = { [PARAM_KEY_PLATFORM]: this.platform };
+ const ephemeralRegisterUrl = setUrlParams(params, runner.ephemeralRegisterUrl);
+
+ saveAlertToLocalStorage({
+ message: s__('Runners|Runner created.'),
+ variant: VARIANT_SUCCESS,
+ });
+ redirectTo(ephemeralRegisterUrl); // eslint-disable-line import/no-deprecated
+ },
+ onError(error) {
+ createAlert({ message: error.message });
+ },
+ },
+ PROJECT_TYPE,
+};
+</script>
+
+<template>
+ <div>
+ <registration-feedback-banner />
+
+ <h1 class="gl-font-size-h2">{{ s__('Runners|New project runner') }}</h1>
+
+ <registration-compatibility-alert :alert-key="projectId" />
+
+ <p>
+ {{
+ s__(
+ 'Runners|Create a project runner to generate a command that registers the runner with all its configurations.',
+ )
+ }}
+ </p>
+
+ <hr aria-hidden="true" />
+
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Platform') }}
+ </h2>
+ <runner-platforms-radio-group v-model="platform" />
+
+ <hr aria-hidden="true" />
+
+ <runner-create-form
+ :runner-type="$options.PROJECT_TYPE"
+ :project-id="projectId"
+ @saved="onSaved"
+ @error="onError"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/project_register_runner/index.js b/app/assets/javascripts/ci/runner/project_register_runner/index.js
new file mode 100644
index 00000000000..63e88359ee7
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_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 ProjectRegisterRunnerApp from './project_register_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initProjectRegisterRunner = (selector = '#js-project-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(ProjectRegisterRunnerApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/project_register_runner/project_register_runner_app.vue b/app/assets/javascripts/ci/runner/project_register_runner/project_register_runner_app.vue
new file mode 100644
index 00000000000..b3fad595c7e
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_register_runner/project_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: 'ProjectRegisterRunnerApp',
+ 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|Project › CI/CD Settings › 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/project_runners/register/index.js b/app/assets/javascripts/ci/runner/project_runners/register/index.js
new file mode 100644
index 00000000000..9986c93c918
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/project_runners/register/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue';
+import { PROJECT_TYPE } from '~/ci/runner/constants';
+
+Vue.use(VueApollo);
+
+export const initProjectRunnersRegistrationDropdown = (
+ selector = '#js-project-runner-registration-dropdown',
+) => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { registrationToken, projectId } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ projectId,
+ },
+ render(h) {
+ return h(RegistrationDropdown, {
+ props: {
+ registrationToken,
+ type: PROJECT_TYPE,
+ },
+ });
+ },
+ });
+};
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/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
index ca65665b9ed..24a776e1a29 100644
--- a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
+++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue
@@ -164,7 +164,7 @@ export default {
:href="$options.emptyHelpLink"
:title="$options.i18n.emptyTooltip"
:aria-label="$options.i18n.emptyTooltip"
- ><gl-icon name="question" :size="14"
+ ><gl-icon name="question-o" :size="14"
/></gl-link>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/clusters/agents/components/create_token_modal.vue b/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
index 451e1ee1d67..d2e5e484502 100644
--- a/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
+++ b/app/assets/javascripts/clusters/agents/components/create_token_modal.vue
@@ -9,7 +9,6 @@ import {
EVENT_ACTIONS_OPEN,
EVENT_ACTIONS_CLICK,
TOKEN_NAME_LIMIT,
- TOKEN_STATUS_ACTIVE,
} from '../constants';
import createNewAgentToken from '../graphql/mutations/create_new_agent_token.mutation.graphql';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
@@ -66,7 +65,6 @@ export default {
variables: {
agentName: this.agentName,
projectPath: this.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
},
};
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..c2c7beffae7 100644
--- a/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
+++ b/app/assets/javascripts/clusters/agents/components/revoke_token_button.vue
@@ -9,7 +9,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
-import { REVOKE_TOKEN_MODAL_ID, TOKEN_STATUS_ACTIVE } from '../constants';
+import { REVOKE_TOKEN_MODAL_ID } from '../constants';
import revokeAgentToken from '../graphql/mutations/revoke_token.mutation.graphql';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import { removeTokenFromStore } from '../graphql/cache_update';
@@ -61,7 +61,6 @@ export default {
variables: {
agentName: this.agentName,
projectPath: this.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
},
};
@@ -78,16 +77,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/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index f1bd36b4a63..d740d1c8865 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -10,7 +10,7 @@ import {
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants';
+import { MAX_LIST_COUNT } from '../constants';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
import ActivityEvents from './activity_events_list.vue';
@@ -31,7 +31,6 @@ export default {
return {
agentName: this.agentName,
projectPath: this.projectPath,
- tokenStatus: TOKEN_STATUS_ACTIVE,
...this.cursor,
};
},
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index e97d6500260..8eff9a152b2 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -37,7 +37,6 @@ export const EVENT_DETAILS = {
};
export const DEFAULT_ICON = 'token';
-export const TOKEN_STATUS_ACTIVE = 'ACTIVE';
export const CREATE_TOKEN_MODAL = 'create-token';
export const EVENT_LABEL_MODAL = 'agent_token_creation_modal';
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
index d7a8e447071..7be524f92c4 100644
--- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -4,7 +4,6 @@
query getClusterAgent(
$projectPath: ID!
$agentName: String!
- $tokenStatus: AgentTokenStatus!
$first: Int
$last: Int
$afterToken: String
@@ -21,13 +20,7 @@ query getClusterAgent(
name
}
- tokens(
- status: $tokenStatus
- first: $first
- last: $last
- before: $beforeToken
- after: $afterToken
- ) {
+ tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
count
nodes {
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/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index e0e3b961c51..d7e98638a11 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -8,9 +8,13 @@ import {
GlTooltipDirective,
GlPopover,
} from '@gitlab/ui';
+import semverLt from 'semver/functions/lt';
+import semverInc from 'semver/functions/inc';
+import semverPrerelease from 'semver/functions/prerelease';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { AGENT_STATUSES, I18N_AGENT_TABLE } from '../constants';
import { getAgentConfigPath } from '../clusters_util';
import DeleteAgentButton from './delete_agent_button.vue';
@@ -81,6 +85,11 @@ export default {
tdClass,
},
{
+ key: 'agentID',
+ label: this.$options.i18n.agentIdLabel,
+ tdClass,
+ },
+ {
key: 'configuration',
label: this.$options.i18n.configurationLabel,
tdClass,
@@ -116,6 +125,9 @@ export default {
getPopoverTestId(item) {
return `popover-${item.name}`;
},
+ getAgentId(item) {
+ return getIdFromGraphQLId(item.id);
+ },
getAgentConfigPath,
getAgentVersions(agent) {
const agentConnections = agent.connections?.nodes || [];
@@ -134,18 +146,26 @@ export default {
isVersionMismatch(agent) {
return agent.versions.length > 1;
},
+ // isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version
+ // using the following heuristics:
+ // - KAS Version is used as *server* version if available, otherwise the GitLab version is used.
+ // - returns `outdated` if the agent has a different major version than the server
+ // - returns `outdated` if the agents minor version is at least two proper versions older than the server
+ // - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version
+ //
+ // Note that it does NOT support if the agent is newer than the server version.
isVersionOutdated(agent) {
if (!agent.versions.length) return false;
- const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.');
- const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.');
-
- const majorVersionMismatch = agentMajorVersion !== serverMajorVersion;
+ const agentVersion = this.getAgentVersionString(agent);
+ let allowableAgentVersion = semverInc(agentVersion, 'minor');
- // We should warn user if their current GitLab and agent versions are more than 1 minor version apart:
- const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1;
+ const isServerPrerelease = Boolean(semverPrerelease(this.serverVersion));
+ if (isServerPrerelease) {
+ allowableAgentVersion = semverInc(allowableAgentVersion, 'minor');
+ }
- return majorVersionMismatch || minorVersionMismatch;
+ return semverLt(allowableAgentVersion, this.serverVersion);
},
getVersionPopoverTitle(agent) {
@@ -265,6 +285,12 @@ export default {
</gl-popover>
</template>
+ <template #cell(agentID)="{ item }">
+ <span data-testid="cluster-agent-id">
+ {{ getAgentId(item) }}
+ </span>
+ </template>
+
<template #cell(configuration)="{ item }">
<span data-testid="cluster-agent-configuration-link">
<gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
@@ -279,7 +305,7 @@ export default {
:title="$options.i18n.defaultConfigTooltip"
:aria-label="$options.i18n.defaultConfigTooltip"
class="gl-vertical-align-middle"
- ><gl-icon name="question" :size="14" /></gl-link
+ ><gl-icon name="question-o" :size="14" /></gl-link
></span>
</span>
</template>
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/code_review/signals.js b/app/assets/javascripts/code_review/signals.js
new file mode 100644
index 00000000000..101b7996bb5
--- /dev/null
+++ b/app/assets/javascripts/code_review/signals.js
@@ -0,0 +1,51 @@
+import createApolloClient from '../lib/graphql';
+
+import { getDerivedMergeRequestInformation } from '../diffs/utils/merge_request';
+import { EVT_MR_PREPARED } from '../diffs/constants';
+
+import getMr from '../graphql_shared/queries/merge_request.query.graphql';
+import mrPreparation from '../graphql_shared/subscriptions/merge_request_prepared.subscription.graphql';
+
+function required(name) {
+ throw new Error(`${name} is a required argument`);
+}
+
+async function observeMergeRequestFinishingPreparation({ apollo, signaler }) {
+ const { namespace, project, id: iid } = getDerivedMergeRequestInformation({
+ endpoint: document.location.pathname,
+ });
+ const projectPath = `${namespace}/${project}`;
+
+ if (projectPath && iid) {
+ const currentStatus = await apollo.query({
+ query: getMr,
+ variables: { projectPath, iid },
+ });
+ const { id: gqlMrId, preparedAt } = currentStatus.data.project.mergeRequest;
+ let preparationObservable;
+ let preparationSubscriber;
+
+ if (!preparedAt) {
+ preparationObservable = apollo.subscribe({
+ query: mrPreparation,
+ variables: {
+ issuableId: gqlMrId,
+ },
+ });
+
+ preparationSubscriber = preparationObservable.subscribe((preparationUpdate) => {
+ if (preparationUpdate.data.mergeRequestMergeStatusUpdated?.preparedAt) {
+ signaler.$emit(EVT_MR_PREPARED);
+ preparationSubscriber.unsubscribe();
+ }
+ });
+ }
+ }
+}
+
+export async function start({
+ signalBus = required('signalBus'),
+ apolloClient = createApolloClient(),
+} = {}) {
+ await observeMergeRequestFinishingPreparation({ signaler: signalBus, apollo: apolloClient });
+}
diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/comment_templates/components/app.vue
index db8476c44f3..9e0d2cc73ec 100644
--- a/app/assets/javascripts/saved_replies/components/app.vue
+++ b/app/assets/javascripts/comment_templates/components/app.vue
@@ -6,18 +6,18 @@ export default {};
<div class="row gl-mt-5">
<div class="col-lg-4">
<h4 class="gl-mt-0">
- {{ __('Saved Replies') }}
+ {{ __('Comment templates') }}
</h4>
<p>
{{
__(
- 'Saved replies can be used when creating comments inside issues, merge requests, and epics.',
+ 'Comment templates can be used when creating comments inside issues, merge requests, and epics.',
)
}}
</p>
</div>
<div class="col-lg-8">
- <router-view />
+ <keep-alive><router-view /></keep-alive>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/comment_templates/components/form.vue b/app/assets/javascripts/comment_templates/components/form.vue
new file mode 100644
index 00000000000..47efccc3d0c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/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,
+ updateCommentTemplate: {
+ name: this.name,
+ content: this.content,
+ },
+ };
+ },
+ computed: {
+ isNameValid() {
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.name);
+
+ return true;
+ },
+ isContentValid() {
+ if (this.showValidation) return Boolean(this.updateCommentTemplate.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.updateCommentTemplate.name,
+ content: this.updateCommentTemplate.content,
+ },
+ update: (store, { data: { savedReplyMutation } }) => {
+ if (savedReplyMutation.errors.length) {
+ this.errors = savedReplyMutation.errors.map((e) => e);
+ } else {
+ this.$emit('saved');
+ this.updateCommentTemplate = { 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 gl-mb-6"
+ data-testid="comment-template-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 comment template.')"
+ data-testid="comment-template-name-form-group"
+ >
+ <gl-form-input
+ v-model="updateCommentTemplate.name"
+ :placeholder="__('Enter a name for your comment template')"
+ data-testid="comment-template-name-input"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="__('Content')"
+ :state="isContentValid"
+ :invalid-feedback="__('Please enter the comment template content.')"
+ data-testid="comment-template-content-form-group"
+ >
+ <markdown-field
+ :enable-preview="false"
+ :is-submitting="saving"
+ :add-spacing-classes="false"
+ :textarea-value="updateCommentTemplate.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="updateCommentTemplate.content"
+ dir="auto"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-supports-quick-actions="false"
+ :aria-label="__('Content')"
+ :placeholder="__('Write comment template content here…')"
+ data-testid="comment-template-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="comment-template-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/comment_templates/components/list.vue b/app/assets/javascripts/comment_templates/components/list.vue
new file mode 100644
index 00000000000..52bebfd050c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/components/list.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import ListItem from './list_item.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlSprintf,
+ ListItem,
+ },
+ 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 class="gl-border-t gl-pt-4">
+ <gl-loading-icon v-if="loading" size="lg" />
+ <template v-else>
+ <h5 class="gl-font-lg" data-testid="title">
+ <gl-sprintf :message="__('My comment templates (%{count})')">
+ <template #count>{{ count }}</template>
+ </gl-sprintf>
+ </h5>
+ <ul class="gl-list-style-none gl-p-0 gl-m-0">
+ <list-item v-for="template in savedReplies" :key="template.id" :template="template" />
+ </ul>
+ <gl-keyset-pagination
+ v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
+ v-bind="pageInfo"
+ class="gl-mt-4"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/comment_templates/components/list_item.vue b/app/assets/javascripts/comment_templates/components/list_item.vue
new file mode 100644
index 00000000000..d763700db42
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/components/list_item.vue
@@ -0,0 +1,116 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlDisclosureDropdown, GlTooltip, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import deleteSavedReplyMutation from '../queries/delete_saved_reply.mutation.graphql';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ GlTooltip,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ template: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleting: false,
+ modalId: uniqueId('delete-comment-template-'),
+ toggleId: uniqueId('actions-toggle-'),
+ };
+ },
+ computed: {
+ id() {
+ return getIdFromGraphQLId(this.template.id);
+ },
+ dropdownItems() {
+ return [
+ {
+ text: __('Edit'),
+ action: () => this.$router.push({ name: 'edit', params: { id: this.id } }),
+ extraAttrs: {
+ 'data-testid': 'comment-template-edit-btn',
+ },
+ },
+ {
+ text: __('Delete'),
+ action: () => this.$refs['delete-modal'].show(),
+ extraAttrs: {
+ 'data-testid': 'comment-template-delete-btn',
+ class: 'gl-text-red-500!',
+ },
+ },
+ ];
+ },
+ },
+ methods: {
+ onDelete() {
+ this.isDeleting = true;
+
+ this.$apollo.mutate({
+ mutation: deleteSavedReplyMutation,
+ variables: {
+ id: this.template.id,
+ },
+ update: (cache) => {
+ const cacheId = cache.identify(this.template);
+ cache.evict({ id: cacheId });
+ },
+ });
+ },
+ },
+ actionPrimary: { text: __('Delete'), attributes: { variant: 'danger' } },
+ actionSecondary: { text: __('Cancel'), attributes: { variant: 'default' } },
+};
+</script>
+
+<template>
+ <li class="gl-pt-4 gl-pb-5 gl-border-b">
+ <div class="gl-display-flex gl-align-items-center">
+ <h6 class="gl-mr-3 gl-my-0" data-testid="comment-template-name">{{ template.name }}</h6>
+ <div class="gl-ml-auto">
+ <gl-disclosure-dropdown
+ :items="dropdownItems"
+ :toggle-id="toggleId"
+ icon="ellipsis_v"
+ no-caret
+ text-sr-only
+ placement="right"
+ :toggle-text="__('Comment template actions')"
+ :loading="isDeleting"
+ category="tertiary"
+ />
+ <gl-tooltip :target="toggleId">
+ {{ __('Comment template actions') }}
+ </gl-tooltip>
+ </div>
+ </div>
+ <div class="gl-mt-3 gl-font-monospace">{{ template.content }}</div>
+ <gl-modal
+ ref="delete-modal"
+ :title="__('Delete comment template')"
+ :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>{{ template.name }}</strong></template
+ >
+ </gl-sprintf>
+ </gl-modal>
+ </li>
+</template>
diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/comment_templates/index.js
index 5022ff62b10..8cd763e7a9e 100644
--- a/app/assets/javascripts/saved_replies/index.js
+++ b/app/assets/javascripts/comment_templates/index.js
@@ -5,11 +5,11 @@ import createDefaultClient from '~/lib/graphql';
import routes from './routes';
import App from './components/app.vue';
-export const initSavedReplies = () => {
+export const initCommentTemplates = () => {
Vue.use(VueApollo);
Vue.use(VueRouter);
- const el = document.getElementById('js-saved-replies-root');
+ const el = document.getElementById('js-comment-templates-root');
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/comment_templates/pages/edit.vue b/app/assets/javascripts/comment_templates/pages/edit.vue
new file mode 100644
index 00000000000..343efdccefa
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/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 comment template') });
+ this.redirectToRoot();
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ savedReply: null,
+ };
+ },
+ methods: {
+ redirectToRoot() {
+ this.$router.push({ path: '/' });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Edit comment template') }}
+ </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/comment_templates/pages/index.vue b/app/assets/javascripts/comment_templates/pages/index.vue
new file mode 100644
index 00000000000..72a94dafc58
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/pages/index.vue
@@ -0,0 +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>
+ <h5 class="gl-mt-0 gl-font-lg">
+ {{ __('Add new comment template') }}
+ </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/comment_templates/queries/create_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/create_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..c4e632d0f16
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/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/comment_templates/queries/delete_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/delete_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..76571ba628c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/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/comment_templates/queries/get_saved_reply.query.graphql b/app/assets/javascripts/comment_templates/queries/get_saved_reply.query.graphql
new file mode 100644
index 00000000000..66f5f43af49
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/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/comment_templates/queries/saved_replies.query.graphql
index af1f12f3ceb..d8e76b5e2a8 100644
--- a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
+++ b/app/assets/javascripts/comment_templates/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/comment_templates/queries/update_saved_reply.mutation.graphql b/app/assets/javascripts/comment_templates/queries/update_saved_reply.mutation.graphql
new file mode 100644
index 00000000000..14a47d7bc9c
--- /dev/null
+++ b/app/assets/javascripts/comment_templates/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/comment_templates/routes.js
index bd582a5ed86..7687c6f335a 100644
--- a/app/assets/javascripts/saved_replies/routes.js
+++ b/app/assets/javascripts/comment_templates/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/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/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 2109aecdf03..96c274225d8 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -1,6 +1,14 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { initPipelineCountListener } from './utils';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
/**
* Used in:
* - Project Pipelines List (projects:pipelines:index)
@@ -20,9 +28,12 @@ export default () => {
components: {
CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
},
+ apolloProvider,
provide: {
artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
+ fullPath: pipelineTableViewEl.dataset.fullPath,
+ manualActionsLimit: 50,
},
render(createElement) {
return createElement('commit-pipelines-table', {
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index b0a1c46e619..f2dac15a99e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -234,6 +234,7 @@ export default {
<template v-else-if="shouldRenderEmptyState">
<gl-empty-state
:svg-path="emptyStateSvgPath"
+ :svg-height="150"
:title="$options.i18n.emptyStateTitle"
data-testid="pipeline-empty-state"
>
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/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index e5e23f2fb5e..17c9f55a8a0 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -1,10 +1,6 @@
import $ from 'jquery';
// bootstrap jQuery plugins
-import 'bootstrap/js/dist/alert';
-import 'bootstrap/js/dist/button';
-import 'bootstrap/js/dist/collapse';
-import 'bootstrap/js/dist/modal';
import 'bootstrap/js/dist/dropdown';
import 'bootstrap/js/dist/tab';
diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js
index b105273ece7..90dca0310f3 100644
--- a/app/assets/javascripts/commons/nav/user_merge_requests.js
+++ b/app/assets/javascripts/commons/nav/user_merge_requests.js
@@ -30,6 +30,12 @@ function updateMergeRequestCounts(newCount) {
* Refresh user counts (and broadcast if open)
*/
export function refreshUserMergeRequestCounts() {
+ if (gon?.use_new_navigation) {
+ // The new sidebar manages _all_ the counts in
+ // ~/super_sidebar/user_counts_manager.js
+ document.dispatchEvent(new CustomEvent('userCounts:fetch'));
+ return Promise.resolve();
+ }
return getUserCounts()
.then(({ data }) => {
const assignedMergeRequests = data.assigned_merge_requests;
@@ -67,6 +73,12 @@ export function closeUserCountsBroadcast() {
* no special functionality lost except cross tab notifications
*/
export function openUserCountsBroadcast() {
+ if (gon?.use_new_navigation) {
+ // The new sidebar broadcasts _all counts_ and updates
+ // them accordingly. Therefore we do not need this manager
+ // ~/super_sidebar/user_counts_manager.js
+ return;
+ }
closeUserCountsBroadcast();
if (window.BroadcastChannel) {
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index cd24a503631..09d2e065e58 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -9,4 +9,4 @@ if (process.env.NODE_ENV !== 'production') {
Vue.use(GlFeatureFlagsPlugin);
Vue.use(Translate);
-Vue.config.ignoredElements = ['gl-emoji'];
+Vue.config.ignoredElements = ['gl-emoji', 'copy-code'];
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/constants.js b/app/assets/javascripts/constants.js
index defc2cbe276..f43a2d5d8ff 100644
--- a/app/assets/javascripts/constants.js
+++ b/app/assets/javascripts/constants.js
@@ -1,6 +1,5 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
export const getModifierKey = (removeSuffix = false) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
const winKey = `Ctrl${removeSuffix ? '' : '+'}`;
return window.gl?.client?.isMac ? '⌘' : winKey;
};
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
index 3891274e35e..7c06417e6b3 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
@@ -43,6 +43,7 @@ export default {
this.$emit('hidden', ...args);
this.menuVisible = false;
},
+ appendTo: () => document.body,
},
}),
);
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
index 98b7203778f..caac61fe9a6 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
@@ -250,7 +250,7 @@ export default {
variant="default"
category="tertiary"
size="medium"
- :class="{ active: showPreview }"
+ :class="{ 'gl-bg-gray-100!': showPreview }"
data-testid="preview-diagram"
:aria-label="__('Preview diagram')"
:title="__('Preview diagram')"
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
deleted file mode 100644
index 354db88f11c..00000000000
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
+++ /dev/null
@@ -1,133 +0,0 @@
-<script>
-import { GlButtonGroup } from '@gitlab/ui';
-import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants';
-import trackUIControl from '../../services/track_ui_control';
-import Paragraph from '../../extensions/paragraph';
-import Heading from '../../extensions/heading';
-import Audio from '../../extensions/audio';
-import Video from '../../extensions/video';
-import Image from '../../extensions/image';
-import ToolbarButton from '../toolbar_button.vue';
-import BubbleMenu from './bubble_menu.vue';
-
-export default {
- components: {
- BubbleMenu,
- GlButtonGroup,
- ToolbarButton,
- },
- inject: ['tiptapEditor'],
- methods: {
- trackToolbarControlExecution({ contentType, value }) {
- trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value });
- },
-
- shouldShow: ({ editor, from, to }) => {
- if (from === to) return false;
-
- const includes = [Paragraph.name, Heading.name];
- const excludes = [Image.name, Audio.name, Video.name];
-
- return (
- includes.some((type) => editor.isActive(type)) &&
- !excludes.some((type) => editor.isActive(type))
- );
- },
- },
- toggleLinkCommandParams: {
- href: '',
- },
-};
-</script>
-<template>
- <bubble-menu
- data-testid="formatting-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- :should-show="shouldShow"
- :plugin-key="'formatting'"
- >
- <gl-button-group>
- <toolbar-button
- data-testid="bold"
- content-type="bold"
- icon-name="bold"
- editor-command="toggleBold"
- category="tertiary"
- size="medium"
- :label="__('Bold text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="italic"
- content-type="italic"
- icon-name="italic"
- editor-command="toggleItalic"
- category="tertiary"
- size="medium"
- :label="__('Italic text')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="strike"
- content-type="strike"
- icon-name="strikethrough"
- editor-command="toggleStrike"
- category="tertiary"
- size="medium"
- :label="__('Strikethrough')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code"
- content-type="code"
- icon-name="code"
- editor-command="toggleCode"
- category="tertiary"
- size="medium"
- :label="__('Code')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="superscript"
- content-type="superscript"
- icon-name="superscript"
- editor-command="toggleSuperscript"
- category="tertiary"
- size="medium"
- :label="__('Superscript')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="subscript"
- content-type="subscript"
- icon-name="subscript"
- editor-command="toggleSubscript"
- category="tertiary"
- size="medium"
- :label="__('Subscript')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="highlight"
- content-type="highlight"
- icon-name="highlight"
- editor-command="toggleHighlight"
- category="tertiary"
- size="medium"
- :label="__('Highlight')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="link"
- content-type="link"
- icon-name="link"
- editor-command="toggleLink"
- :editor-command-params="$options.toggleLinkCommandParams"
- category="tertiary"
- size="medium"
- :label="__('Insert link')"
- @execute="trackToolbarControlExecution"
- />
- </gl-button-group>
- </bubble-menu>
-</template>
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
index a4713eb3275..91009fad3f4 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
@@ -1,13 +1,16 @@
<script>
import {
GlLink,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
GlButton,
GlButtonGroup,
+ GlLoadingIcon,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
+import { getMarkType, getMarkRange } from '@tiptap/core';
import Link from '../../extensions/link';
import EditorStateObserver from '../editor_state_observer.vue';
import BubbleMenu from './bubble_menu.vue';
@@ -15,12 +18,14 @@ import BubbleMenu from './bubble_menu.vue';
export default {
components: {
BubbleMenu,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
GlLink,
GlButton,
GlButtonGroup,
+ GlLoadingIcon,
EditorStateObserver,
},
directives: {
@@ -31,12 +36,39 @@ export default {
return {
linkHref: undefined,
linkCanonicalSrc: undefined,
- linkTitle: undefined,
+ linkText: undefined,
isEditing: false,
+
+ uploading: false,
+ uploadProgress: 0,
};
},
methods: {
+ linkIsEmpty() {
+ return (
+ !this.linkCanonicalSrc &&
+ !this.linkHref &&
+ (!this.linkText || this.linkText === this.linkTextInDoc())
+ );
+ },
+
+ linkTextInDoc() {
+ const { state } = this.tiptapEditor;
+ const type = getMarkType(Link.name, state.schema);
+ let { selection: range } = state;
+ if (range.from === range.to) {
+ range =
+ getMarkRange(state.selection.$from, type) ||
+ getMarkRange(state.selection.$to, type) ||
+ {};
+ }
+
+ if (!range.from || !range.to) return '';
+
+ return state.doc.textBetween(range.from, range.to, ' ');
+ },
+
shouldShow() {
return this.tiptapEditor.isActive(Link.name);
},
@@ -52,31 +84,51 @@ export default {
this.isEditing = false;
this.linkHref = await this.contentEditor.resolveUrl(this.linkCanonicalSrc);
-
- if (!this.linkCanonicalSrc && !this.linkHref) {
- this.removeLink();
- }
},
cancelEditingLink() {
this.endEditingLink();
- this.updateLinkToState();
+
+ if (this.linkIsEmpty()) {
+ this.removeLink();
+ } else {
+ this.updateLinkToState();
+ }
},
async saveEditedLink() {
- if (!this.linkCanonicalSrc) {
+ const chain = this.tiptapEditor.chain().focus();
+
+ const attrs = {
+ href: this.linkCanonicalSrc,
+ canonicalSrc: this.linkCanonicalSrc,
+ };
+
+ // if nothing was entered by the user and the link is empty, remove it
+ // since we don't want to insert an empty link
+ if (this.linkIsEmpty()) {
this.removeLink();
- } else {
- this.tiptapEditor
- .chain()
- .focus()
+ return;
+ }
+
+ if (!this.linkText) {
+ this.linkText = this.linkCanonicalSrc;
+ }
+
+ // if link text was updated, insert a new link in the doc with the new text
+ if (this.linkTextInDoc() !== this.linkText) {
+ chain
.extendMarkRange(Link.name)
- .updateAttributes(Link.name, {
- href: this.linkCanonicalSrc,
- canonicalSrc: this.linkCanonicalSrc,
- title: this.linkTitle,
+ .setMeta('preventAutolink', true)
+ .insertContent({
+ marks: [{ type: Link.name, attrs }],
+ type: 'text',
+ text: this.linkText,
})
.run();
+ } else {
+ // if link text was not updated, just update the attributes
+ chain.updateAttributes(Link.name, attrs).run();
}
this.endEditingLink();
@@ -84,22 +136,34 @@ export default {
updateLinkToState() {
const editor = this.tiptapEditor;
+ const { href, canonicalSrc, uploading } = editor.getAttributes(Link.name);
+ const text = this.linkTextInDoc();
- const { href, title, canonicalSrc } = editor.getAttributes(Link.name);
+ this.uploading = uploading;
if (
canonicalSrc === this.linkCanonicalSrc &&
href === this.linkHref &&
- title === this.linkTitle
+ text === this.linkText
) {
return;
}
- this.linkTitle = title;
+ this.linkText = text;
this.linkHref = href;
this.linkCanonicalSrc = canonicalSrc || href;
+ },
+
+ onTransaction({ transaction }) {
+ this.linkText = this.linkTextInDoc();
+ if (transaction.getMeta('creatingLink')) {
+ this.isEditing = true;
+ }
- this.isEditing = !this.linkCanonicalSrc;
+ const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {};
+ if (this.uploading === filename) {
+ this.uploadProgress = Math.round(progress * 100);
+ }
},
copyLinkHref() {
@@ -107,13 +171,28 @@ export default {
},
removeLink() {
- this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run();
+ const chain = this.tiptapEditor.chain().focus();
+ if (this.linkTextInDoc()) {
+ chain.unsetLink().run();
+ } else {
+ chain
+ .insertContent({
+ type: 'text',
+ text: ' ',
+ })
+ .extendMarkRange(Link.name)
+ .unsetLink()
+ .deleteSelection()
+ .run();
+ }
},
resetBubbleMenuState() {
- this.linkTitle = undefined;
+ this.linkText = undefined;
this.linkHref = undefined;
this.linkCanonicalSrc = undefined;
+
+ this.isEditing = false;
},
},
tippyOptions: {
@@ -122,18 +201,29 @@ export default {
};
</script>
<template>
- <bubble-menu
- data-testid="link-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- plugin-key="bubbleMenuLink"
- :should-show="shouldShow"
- :tippy-options="$options.tippyOptions"
- @show="updateLinkToState"
- @hidden="resetBubbleMenuState"
+ <editor-state-observer
+ :debounce="0"
+ @transaction="onTransaction"
+ @selectionUpdate="updateLinkToState"
>
- <editor-state-observer @selectionUpdate="updateLinkToState">
+ <bubble-menu
+ data-testid="link-bubble-menu"
+ class="gl-shadow gl-rounded-base gl-bg-white"
+ plugin-key="bubbleMenuLink"
+ :should-show="shouldShow"
+ :tippy-options="$options.tippyOptions"
+ @show="updateLinkToState"
+ @hidden="resetBubbleMenuState"
+ >
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
+ <gl-loading-icon v-if="uploading" class="gl-pl-4 gl-pr-3" />
+ <span v-if="uploading" class="gl-text-secondary gl-pr-3">
+ <gl-sprintf :message="__('Uploading: %{progress}')">
+ <template #progress>{{ uploadProgress }}&percnt;</template>
+ </gl-sprintf>
+ </span>
<gl-link
+ v-else
v-gl-tooltip
:href="linkHref"
:aria-label="linkCanonicalSrc"
@@ -178,11 +268,16 @@ export default {
/>
</gl-button-group>
<gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink">
- <gl-form-group :label="__('URL')" label-for="link-href">
- <gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" />
+ <gl-form-group :label="__('Text')" label-for="link-text">
+ <gl-form-input id="link-text" v-model="linkText" data-testid="link-text" />
</gl-form-group>
- <gl-form-group :label="__('Title')" label-for="link-title">
- <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" />
+ <gl-form-group :label="__('URL')" label-for="link-href">
+ <gl-form-input
+ id="link-href"
+ v-model="linkCanonicalSrc"
+ autofocus
+ data-testid="link-href"
+ />
</gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
<gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink">
@@ -193,6 +288,6 @@ export default {
</gl-button>
</div>
</gl-form>
- </editor-state-observer>
- </bubble-menu>
+ </bubble-menu>
+ </editor-state-observer>
</template>
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..6bb6bdc4e65 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
@@ -1,6 +1,7 @@
<script>
import {
GlLink,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
@@ -11,23 +12,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,12 +42,14 @@ export default {
},
deleteLabels: {
[Audio.name]: __('Delete audio'),
+ [DrawioDiagram.name]: __('Delete diagram'),
[Image.name]: __('Delete image'),
[Video.name]: __('Delete video'),
},
},
components: {
BubbleMenu,
+ GlSprintf,
GlForm,
GlFormGroup,
GlFormInput,
@@ -67,7 +73,10 @@ export default {
isEditing: false,
isUpdating: false,
- isUploading: false,
+
+ uploading: false,
+
+ uploadProgress: 0,
};
},
computed: {
@@ -84,7 +93,10 @@ export default {
return this.$options.i18n.deleteLabels[this.mediaType];
},
showProgressIndicator() {
- return this.isUploading || this.isUpdating;
+ return this.uploading || this.isUpdating;
+ },
+ isDrawioDiagram() {
+ return this.mediaType === DrawioDiagram.name;
},
},
methods: {
@@ -150,16 +162,37 @@ export default {
this.mediaTitle = title;
this.mediaAlt = alt;
this.mediaCanonicalSrc = canonicalSrc || src;
- this.isUploading = uploading;
+ this.uploading = uploading;
+
this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc);
this.isUpdating = false;
},
+ onTransaction({ transaction }) {
+ const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {};
+ if (this.uploading === filename) {
+ this.uploadProgress = Math.round(progress * 100);
+ }
+ },
+
+ resetMediaInfo() {
+ this.mediaTitle = null;
+ this.mediaAlt = null;
+ this.mediaCanonicalSrc = null;
+ this.uploading = false;
+
+ this.uploadProgress = 0;
+ },
+
replaceMedia() {
this.$refs.fileSelector.click();
},
+ editDiagram() {
+ this.tiptapEditor.chain().focus().createOrEditDiagram().run();
+ },
+
onFileSelect(e) {
this.tiptapEditor
.chain()
@@ -186,15 +219,26 @@ export default {
};
</script>
<template>
- <bubble-menu
- data-testid="media-bubble-menu"
- class="gl-shadow gl-rounded-base gl-bg-white"
- plugin-key="bubbleMenuMedia"
- :should-show="shouldShow"
+ <editor-state-observer
+ :debounce="0"
+ @selectionUpdate="updateMediaInfoToState"
+ @transaction="onTransaction"
>
- <editor-state-observer @transaction="updateMediaInfoToState">
+ <bubble-menu
+ data-testid="media-bubble-menu"
+ class="gl-shadow gl-rounded-base gl-bg-white"
+ plugin-key="bubbleMenuMedia"
+ :should-show="shouldShow"
+ @show="updateMediaInfoToState"
+ @hidden="resetMediaInfo"
+ >
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
<gl-loading-icon v-if="showProgressIndicator" class="gl-pl-4 gl-pr-3" />
+ <span v-if="uploading" class="gl-text-secondary gl-pr-3">
+ <gl-sprintf :message="__('Uploading: %{progress}')">
+ <template #progress>{{ uploadProgress }}&percnt;</template>
+ </gl-sprintf>
+ </span>
<input
ref="fileSelector"
type="file"
@@ -240,6 +284,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"
@@ -247,7 +304,7 @@ export default {
data-testid="replace-media"
:aria-label="replaceLabel"
:title="replaceLabel"
- icon="upload"
+ icon="retry"
@click="replaceMedia"
/>
<gl-button
@@ -282,6 +339,6 @@ export default {
<gl-button variant="confirm" type="submit">{{ __('Apply') }}</gl-button>
</div>
</gl-form>
- </editor-state-observer>
- </bubble-menu>
+ </bubble-menu>
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 237808983ee..4c5bbca4110 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,13 +1,13 @@
<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 { createContentEditor } from '../services/create_content_editor';
import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
-import FormattingBubbleMenu from './bubble_menus/formatting_bubble_menu.vue';
import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue';
import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue';
import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue';
@@ -16,12 +16,13 @@ import LoadingIndicator from './loading_indicator.vue';
export default {
components: {
+ GlSprintf,
+ GlLink,
LoadingIndicator,
ContentEditorAlert,
ContentEditorProvider,
TiptapEditorContent,
FormattingToolbar,
- FormattingBubbleMenu,
CodeBlockBubbleMenu,
LinkBubbleMenu,
MediaBubbleMenu,
@@ -51,17 +52,42 @@ 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,
+ },
+ enableAutocomplete: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -70,15 +96,33 @@ export default {
latestMarkdown: null,
};
},
+ computed: {
+ showPlaceholder() {
+ return this.placeholder && !this.markdown && !this.focused;
+ },
+ },
watch: {
markdown(markdown) {
if (markdown !== this.latestMarkdown) {
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,
+ enableAutocomplete,
+ autocompleteDataSources,
+ } = this;
// This is a non-reactive attribute intentionally since this is a complex object.
this.contentEditor = createContentEditor({
@@ -86,8 +130,12 @@ export default {
uploadsPath,
extensions,
serializerConfig,
+ drawioEnabled,
+ enableAutocomplete,
+ autocompleteDataSources,
tiptapOptions: {
autofocus,
+ editable,
},
});
},
@@ -104,10 +152,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 +163,10 @@ export default {
variant: VARIANT_DANGER,
actionLabel: __('Retry'),
action: () => {
+ this.contentEditor.setEditable(true);
this.setSerializedContent(markdown);
},
});
- this.contentEditor.setEditable(false);
this.notifyLoadingError();
}
},
@@ -150,6 +198,11 @@ export default {
});
},
},
+ i18n: {
+ quickActionsText: s__(
+ 'ContentEditor|For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.',
+ ),
+ },
};
</script>
<template>
@@ -165,33 +218,41 @@ export default {
<div
data-testid="content-editor"
data-qa-selector="content_editor_container"
- class="md-area"
+ class="md-area gl-border-none! gl-shadow-none!"
:class="{ 'is-focused': focused }"
>
- <formatting-toolbar
- v-if="!useBottomToolbar"
- ref="toolbar"
- class="gl-border-b"
- @enableMarkdownEditor="$emit('enableMarkdownEditor')"
- />
- <div class="gl-relative gl-mt-4">
- <formatting-bubble-menu />
+ <formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" />
+ <div class="gl-relative">
<code-block-bubble-menu />
<link-bubble-menu />
<media-bubble-menu />
+ <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4">
+ {{ placeholder }}
+ </div>
<tiptap-editor-content
- class="md"
+ class="md gl-px-5"
data-testid="content_editor_editablebox"
:editor="contentEditor.tiptapEditor"
/>
<loading-indicator v-if="isLoading" />
+ <div
+ v-if="quickActionsDocsPath"
+ class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary"
+ >
+ <div class="gl-w-full gl-line-height-32 gl-font-sm">
+ <gl-sprintf :message="$options.i18n.quickActionsText">
+ <template #keyboard="{ content }">
+ <kbd>{{ content }}</kbd>
+ </template>
+ <template #quickActionsDocsLink="{ content }">
+ <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </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/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
index 87eff2451ec..59d71169dd3 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
@@ -34,7 +34,6 @@ export default {
<editor-state-observer @alert="displayAlert">
<gl-alert
v-if="message"
- class="gl-mb-6"
:variant="variant"
:primary-button-text="actionLabel"
@dismiss="dismissAlert"
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
index 5dcff1f6295..fa842f23cc3 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -4,7 +4,10 @@ export default {
// We can't use this.contentEditor due to bug in vue-apollo when
// provide is called in beforeCreate
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
- const { contentEditor } = this.$options.propsData;
+
+ // @vue-compat does not care to normalize propsData fields
+ const contentEditor =
+ this.$options.propsData.contentEditor || this.$options.propsData['content-editor'];
return {
contentEditor,
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index ccb46e3b593..62f2113a8f4 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -16,14 +16,21 @@ const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEv
export default {
inject: ['tiptapEditor', 'eventHub'],
+ props: {
+ debounce: {
+ type: Number,
+ required: false,
+ default: 100,
+ },
+ },
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
- const eventHandler = debounce(
- (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
- 100,
- );
+ let eventHandler = (params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params);
+ if (this.debounce) {
+ eventHandler = debounce(eventHandler, this.debounce);
+ }
this.tiptapEditor?.on(tiptapEvent, eventHandler);
diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
index 36ca3b8cfb6..fac259cf6a1 100644
--- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue
@@ -1,115 +1,128 @@
<script>
-import EditorModeDropdown from '~/vue_shared/components/markdown/editor_mode_dropdown.vue';
+import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
import trackUIControl from '../services/track_ui_control';
import ToolbarButton from './toolbar_button.vue';
-import ToolbarImageButton from './toolbar_image_button.vue';
-import ToolbarLinkButton from './toolbar_link_button.vue';
+import ToolbarAttachmentButton from './toolbar_attachment_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
import ToolbarMoreDropdown from './toolbar_more_dropdown.vue';
export default {
components: {
- EditorModeDropdown,
ToolbarButton,
ToolbarTextStyleDropdown,
- ToolbarLinkButton,
ToolbarTableButton,
- ToolbarImageButton,
+ ToolbarAttachmentButton,
ToolbarMoreDropdown,
+ EditorModeSwitcher,
},
methods: {
trackToolbarControlExecution({ contentType, value }) {
trackUIControl({ property: contentType, value });
},
- handleEditorModeChanged(mode) {
- if (mode === 'markdown') {
- this.$emit('enableMarkdownEditor');
- }
+ handleEditorModeChanged() {
+ 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 class="gl-mx-2 gl-mt-2">
+ <div
+ class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-bg-gray-50 gl-px-2 gl-rounded-base gl-justify-content-space-between"
+ data-testid="formatting-toolbar"
+ >
+ <div class="gl-py-2 gl-display-flex gl-flex-wrap">
+ <toolbar-text-style-dropdown
+ data-testid="text-styles"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bold"
+ content-type="bold"
+ icon-name="bold"
+ editor-command="toggleBold"
+ :label="__('Bold text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="italic"
+ content-type="italic"
+ icon-name="italic"
+ editor-command="toggleItalic"
+ :label="__('Italic text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="strike"
+ content-type="strike"
+ icon-name="strikethrough"
+ editor-command="toggleStrike"
+ :label="__('Strikethrough')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="code"
+ content-type="code"
+ icon-name="code"
+ editor-command="toggleCode"
+ :label="__('Code')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="link"
+ content-type="link"
+ icon-name="link"
+ editor-command="editLink"
+ :label="__('Insert link')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bullet-list"
+ content-type="bulletList"
+ icon-name="list-bulleted"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleBulletList"
+ :label="__('Add a bullet list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="ordered-list"
+ content-type="orderedList"
+ icon-name="list-numbered"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleOrderedList"
+ :label="__('Add a numbered list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="task-list"
+ content-type="taskList"
+ icon-name="list-task"
+ class="gl-display-none gl-sm-display-inline"
+ editor-command="toggleTaskList"
+ :label="__('Add a checklist')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
+ <toolbar-attachment-button
+ data-testid="attachment"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
+ </div>
+ <div class="content-editor-switcher gl-display-flex gl-align-items-center gl-ml-auto">
+ <editor-mode-switcher size="small" value="richText" @input="handleEditorModeChanged" />
+ </div>
+ </div>
</div>
</template>
<style>
diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
index 37e6ef61d50..4074e50a706 100644
--- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
@@ -1,10 +1,11 @@
<script>
-import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui';
+import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
GlAvatarLabeled,
+ GlLoadingIcon,
},
props: {
@@ -32,6 +33,12 @@ export default {
type: Function,
required: true,
},
+
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
@@ -208,65 +215,75 @@ export default {
</script>
<template>
- <ul
- :class="{ show: items.length > 0 }"
- class="gl-dropdown dropdown-menu gl-relative"
- data-testid="content-editor-suggestions-dropdown"
- >
- <div class="gl-dropdown-inner gl-overflow-y-auto">
- <gl-dropdown-item
- v-for="(item, index) in items"
- ref="dropdownItems"
- :key="index"
- :class="{ 'gl-bg-gray-50': index === selectedIndex }"
- @click="selectItem(index)"
- >
- <gl-avatar-labeled
- v-if="isUser"
- :label="item.username"
- :sub-label="avatarSubLabel(item)"
- :src="item.avatar_url"
- :entity-name="item.username"
- :shape="item.type === 'Group' ? 'rect' : 'circle'"
- :size="32"
- />
- <span v-if="isIssue || isMergeRequest">
- <small>{{ item.iid }}</small>
- {{ item.title }}
- </span>
- <span v-if="isVulnerability || isSnippet">
- <small>{{ item.id }}</small>
- {{ item.title }}
- </span>
- <span v-if="isEpic">
- <small>{{ item.reference }}</small>
- {{ item.title }}
- </span>
- <span v-if="isMilestone">
- {{ item.title }}
- </span>
- <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
- <span
- data-testid="label-color-box"
- class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
- :style="{ backgroundColor: item.color }"
- ></span>
- {{ item.title }}
- </span>
- <span v-if="isCommand">
- /{{ item.name }} <small> {{ item.params[0] }} </small><br />
- <em>
- <small> {{ item.description }} </small>
- </em>
- </span>
- <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
- <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
- <div class="gl-flex-grow-1">
- {{ item.name }}<br />
- <small>{{ item.d }}</small>
+ <div>
+ <ul
+ v-if="!loading"
+ :class="{ show: items.length > 0 }"
+ class="gl-dropdown dropdown-menu gl-relative gl-m-0!"
+ data-testid="content-editor-suggestions-dropdown"
+ >
+ <div class="gl-dropdown-inner gl-overflow-y-auto">
+ <gl-dropdown-item
+ v-for="(item, index) in items"
+ ref="dropdownItems"
+ :key="index"
+ :class="{ 'gl-bg-gray-50': index === selectedIndex }"
+ @click="selectItem(index)"
+ >
+ <gl-avatar-labeled
+ v-if="isUser"
+ :label="item.username"
+ :sub-label="avatarSubLabel(item)"
+ :src="item.avatar_url"
+ :entity-name="item.username"
+ :shape="item.type === 'Group' ? 'rect' : 'circle'"
+ :size="32"
+ />
+ <span v-if="isIssue || isMergeRequest">
+ <small>{{ item.iid }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isVulnerability || isSnippet">
+ <small>{{ item.id }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isEpic">
+ <small>{{ item.reference }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isMilestone">
+ {{ item.title }}
+ </span>
+ <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
+ <span
+ data-testid="label-color-box"
+ class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
+ :style="{ backgroundColor: item.color }"
+ ></span>
+ {{ item.title }}
+ </span>
+ <span v-if="isCommand">
+ /{{ item.name }} <small> {{ item.params[0] }} </small><br />
+ <em>
+ <small> {{ item.description }} </small>
+ </em>
+ </span>
+ <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
+ <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
+ <div class="gl-flex-grow-1">
+ {{ item.name }}<br />
+ <small>{{ item.d }}</small>
+ </div>
</div>
+ </gl-dropdown-item>
+ </div>
+ </ul>
+ <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!">
+ <div class="gl-dropdown-inner gl-overflow-y-auto">
+ <div class="gl-px-5">
+ <gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }}
</div>
- </gl-dropdown-item>
+ </div>
</div>
- </ul>
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
new file mode 100644
index 00000000000..1e13c17bc38
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Link from '../extensions/link';
+
+export default {
+ i18n: {
+ inputLabel: __('Attach a file or image'),
+ },
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor'],
+ data() {
+ return {
+ linkHref: '',
+ };
+ },
+ methods: {
+ emitExecute(source = 'url') {
+ this.$emit('execute', { contentType: Link.name, value: source });
+ },
+ openFileUpload() {
+ this.$refs.fileSelector.click();
+ },
+ onFileSelect(e) {
+ for (const file of e.target.files) {
+ this.tiptapEditor.chain().focus().uploadAttachment({ file }).run();
+ }
+
+ // Reset the file input so that the same file can be uploaded again
+ this.$refs.fileSelector.value = '';
+ this.emitExecute('upload');
+ },
+ },
+};
+</script>
+<template>
+ <span class="gl-display-inline-flex">
+ <gl-button
+ v-gl-tooltip
+ :aria-label="$options.i18n.inputLabel"
+ :title="$options.i18n.inputLabel"
+ category="tertiary"
+ icon="paperclip"
+ size="small"
+ lazy
+ @click="openFileUpload"
+ />
+ <input
+ ref="fileSelector"
+ type="file"
+ multiple
+ name="content_editor_image"
+ class="gl-display-none"
+ :aria-label="$options.i18n.inputLabel"
+ data-qa-selector="file_upload_field"
+ @change="onFileSelect"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue
index cef026c5bc6..a62f66d8557 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue
@@ -47,7 +47,7 @@ export default {
size: {
type: String,
required: false,
- default: 'medium',
+ default: 'small',
},
},
data() {
@@ -78,10 +78,11 @@ export default {
:variant="variant"
:category="category"
:size="size"
- :class="{ active: isActive }"
+ :class="{ 'gl-bg-gray-100!': isActive }"
:aria-label="label"
:title="label"
:icon="iconName"
+ class="gl-mr-3"
@click="execute"
/>
</editor-state-observer>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
deleted file mode 100644
index 8ed4dfce6de..00000000000
--- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownForm,
- GlButton,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
-import { acceptedMimes } from '../services/upload_helpers';
-import { extractFilename } from '../services/utils';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownForm,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlButton,
- },
- directives: {
- GlTooltip,
- },
- inject: ['tiptapEditor'],
- data() {
- return {
- imgSrc: '',
- };
- },
- methods: {
- resetFields() {
- this.imgSrc = '';
- this.$refs.fileSelector.value = '';
- },
- insertImage() {
- this.tiptapEditor
- .chain()
- .focus()
- .setImage({
- src: this.imgSrc,
- canonicalSrc: this.imgSrc,
- alt: extractFilename(this.imgSrc),
- })
- .run();
-
- this.resetFields();
- this.emitExecute();
- },
- emitExecute(source = 'url') {
- this.$emit('execute', { contentType: 'image', value: source });
- },
- openFileUpload() {
- this.$refs.fileSelector.click();
- },
- onFileSelect(e) {
- this.tiptapEditor
- .chain()
- .focus()
- .uploadAttachment({
- file: e.target.files[0],
- })
- .run();
-
- this.resetFields();
- this.emitExecute('upload');
- },
- },
- acceptedMimes: acceptedMimes.image,
-};
-</script>
-<template>
- <span class="gl-display-inline-flex">
- <gl-dropdown
- v-gl-tooltip
- :text="__('Insert image')"
- :title="__('Insert image')"
- size="small"
- category="tertiary"
- icon="media"
- lazy
- text-sr-only
- data-testid="insert-image-toolbar-button"
- @hidden="resetFields()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
- <template #append>
- <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item @click="openFileUpload">
- {{ __('Upload image') }}
- </gl-dropdown-item>
- </gl-dropdown>
- <input
- ref="fileSelector"
- type="file"
- name="content_editor_image"
- :accept="$options.acceptedMimes"
- class="gl-display-none"
- data-qa-selector="file_upload_field"
- @change="onFileSelect"
- />
- </span>
-</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
deleted file mode 100644
index 4fb1e8ce16f..00000000000
--- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownForm,
- GlButton,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
-import Link from '../extensions/link';
-import { hasSelection } from '../services/utils';
-import EditorStateObserver from './editor_state_observer.vue';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownForm,
- GlFormInputGroup,
- GlDropdownDivider,
- GlDropdownItem,
- GlButton,
- EditorStateObserver,
- },
- directives: {
- GlTooltip,
- },
- inject: ['tiptapEditor'],
- data() {
- return {
- linkHref: '',
- isActive: false,
- };
- },
- methods: {
- resetFields() {
- this.imgSrc = '';
- this.$refs.fileSelector.value = '';
- },
- openFileUpload() {
- this.$refs.fileSelector.click();
- },
- updateLinkState({ editor }) {
- const { canonicalSrc, href } = editor.getAttributes(Link.name);
-
- this.isActive = editor.isActive(Link.name);
- this.linkHref = canonicalSrc || href;
- },
- updateLink() {
- this.tiptapEditor
- .chain()
- .focus()
- .unsetLink()
- .setLink({
- href: this.linkHref,
- canonicalSrc: this.linkHref,
- })
- .run();
-
- this.$emit('execute', { contentType: Link.name });
- },
- selectLink() {
- const { tiptapEditor } = this;
-
- // a selection has already been made by the user, so do nothing
- if (!hasSelection(tiptapEditor)) {
- tiptapEditor.chain().focus().extendMarkRange(Link.name).run();
- }
- },
- removeLink() {
- this.tiptapEditor.chain().focus().unsetLink().run();
-
- this.$emit('execute', { contentType: Link.name });
- },
- onFileSelect(e) {
- this.tiptapEditor
- .chain()
- .focus()
- .uploadAttachment({
- file: e.target.files[0],
- })
- .run();
-
- this.resetFields();
- this.$emit('execute', { contentType: Link.name });
- },
- },
-};
-</script>
-<template>
- <editor-state-observer @transaction="updateLinkState">
- <span class="gl-display-inline-flex">
- <gl-dropdown
- v-gl-tooltip
- :title="__('Insert link')"
- :text="__('Insert link')"
- :toggle-class="{ active: isActive }"
- size="small"
- category="tertiary"
- icon="link"
- text-sr-only
- lazy
- @show="selectLink()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
- <template #append>
- <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item v-if="isActive" @click="removeLink">
- {{ __('Remove link') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-else @click="openFileUpload">
- {{ __('Upload file') }}
- </gl-dropdown-item>
- </gl-dropdown>
- <input
- ref="fileSelector"
- type="file"
- name="content_editor_attachment"
- class="gl-display-none"
- @change="onFileSelect"
- />
- </span>
- </editor-state-observer>
-</template>
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_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index 4b1929e1a20..bf2740f9864 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -83,7 +83,7 @@ export default {
text-sr-only
lazy
>
- <gl-dropdown-form class="gl-px-3!">
+ <gl-dropdown-form class="gl-px-3! gl-pb-2!">
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
<gl-button
v-for="c of list(maxCols)"
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/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
index 81f9b1f0af5..4a3dfe3656c 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
@@ -80,8 +80,9 @@ export default {
<template>
<editor-state-observer @transaction="updateDiagramPreview">
<node-view-wrapper
- :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`"
+ :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`"
as="pre"
+ dir="auto"
>
<div
v-if="node.attrs.showPreview"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/details.vue b/app/assets/javascripts/content_editor/components/wrappers/details.vue
index aff15ac3e53..e09f2fd1456 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/details.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/details.vue
@@ -28,6 +28,6 @@ export default {
:class="{ 'is-open': open }"
@click="open = !open"
></div>
- <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" />
+ <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" dir="auto" />
</node-view-wrapper>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
index 8b7b02605f7..b96b7400d85 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
@@ -21,6 +21,7 @@ export default {
data-testid="footnote-label"
contenteditable="false"
class="gl-display-inline-flex gl-mr-2"
+ dir="auto"
>{{ node.attrs.label }}:</span
>
<node-view-content />
diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
new file mode 100644
index 00000000000..4126c65d87f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue
@@ -0,0 +1,45 @@
+<script>
+import { NodeViewWrapper } from '@tiptap/vue-2';
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ name: 'DetailsWrapper',
+ components: {
+ NodeViewWrapper,
+ GlLink,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return this.node.attrs.text;
+ },
+ isCommand() {
+ return this.node.attrs.referenceType === 'command';
+ },
+ isMember() {
+ return this.node.attrs.referenceType === 'user';
+ },
+ isCurrentUser() {
+ return gon.current_username === this.text.substring(1);
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-inline-block">
+ <span v-if="isCommand">{{ text }}</span>
+ <gl-link
+ v-else
+ href="#"
+ class="gfm"
+ :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }"
+ @click.prevent.stop
+ >{{ text }}</gl-link
+ >
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
index 4206c866032..4206c866032 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/label.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue
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..5624bae34c2 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';
@@ -110,6 +110,7 @@ export default {
<node-view-wrapper
class="gl-relative gl-padding-5 gl-min-w-10"
:as="cellType"
+ dir="auto"
@click="hideDropdown"
>
<span
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
index edd75d232e8..9f0709ca83a 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
@@ -10,11 +10,11 @@ export default {
};
</script>
<template>
- <li>
+ <li dir="auto">
<a v-if="heading.text" href="#" @click.prevent>
{{ heading.text }}
</a>
- <ul v-if="heading.subHeadings.length">
+ <ul v-if="heading.subHeadings.length" dir="auto">
<table-of-contents-heading
v-for="(child, index) in heading.subHeadings"
:key="index"
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index 14862727811..490025a9ac6 100644
--- a/app/assets/javascripts/content_editor/constants/index.js
+++ b/app/assets/javascripts/content_editor/constants/index.js
@@ -12,6 +12,11 @@ export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
export const TEXT_STYLE_DROPDOWN_ITEMS = [
{
+ contentType: 'paragraph',
+ editorCommand: 'setParagraph',
+ label: __('Normal text'),
+ },
+ {
contentType: 'heading',
commandParams: { level: 1 },
editorCommand: 'setHeading',
@@ -35,11 +40,6 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
commandParams: { level: 4 },
label: __('Heading 4'),
},
- {
- contentType: 'paragraph',
- editorCommand: 'setParagraph',
- label: __('Normal text'),
- },
];
export const ALERT_EVENT = 'alert';
@@ -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..e7a6af30266 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -1,7 +1,21 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { handleFileEvent } from '../services/upload_helpers';
+const processFiles = ({ files, uploadsPath, renderMarkdown, eventHub, editor }) => {
+ if (!files.length) {
+ return false;
+ }
+
+ let handled = true;
+
+ for (const file of files) {
+ handled = handled && handleFileEvent({ editor, file, uploadsPath, renderMarkdown, eventHub });
+ }
+
+ return handled;
+};
+
export default Extension.create({
name: 'attachment',
@@ -36,25 +50,17 @@ export default Extension.create({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
- const { uploadsPath, renderMarkdown, eventHub } = this.options;
-
- return handleFileEvent({
+ return processFiles({
+ files: event.clipboardData.files,
editor,
- file: event.clipboardData.files[0],
- uploadsPath,
- renderMarkdown,
- eventHub,
+ ...this.options,
});
},
handleDrop: (_, event) => {
- const { uploadsPath, renderMarkdown, eventHub } = this.options;
-
- return handleFileEvent({
+ return processFiles({
+ files: event.dataTransfer.files,
editor,
- file: event.dataTransfer.files[0],
- uploadsPath,
- renderMarkdown,
- eventHub,
+ ...this.options,
});
},
},
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
index 9b424ac8367..f5ffc990061 100644
--- a/app/assets/javascripts/content_editor/extensions/blockquote.js
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -4,6 +4,15 @@ import { getParents } from '~/lib/utils/dom_utils';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default Blockquote.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js
index 8d0faf7a9fe..dfd9cac4c66 100644
--- a/app/assets/javascripts/content_editor/extensions/bullet_list.js
+++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js
@@ -2,6 +2,15 @@ import { BulletList } from '@tiptap/extension-bullet-list';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default BulletList.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 1d85bfcc965..8917417e55e 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,6 +1,6 @@
import { lowlight } from 'lowlight/lib/core';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
-import { textblockTypeInputRule } from '@tiptap/core';
+import { mergeAttributes, textblockTypeInputRule } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import languageLoader from '../services/code_block_language_loader';
import CodeBlockWrapper from '../components/wrappers/code_block.vue';
@@ -13,6 +13,16 @@ export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export default CodeBlockLowlight.extend({
isolating: true,
exitOnArrowDown: false,
+
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
language: {
@@ -61,7 +71,7 @@ export default CodeBlockLowlight.extend({
return [
'pre',
{
- ...HTMLAttributes,
+ ...mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
class: `content-editor-code-block ${gon.user_color_scheme} ${HTMLAttributes.class}`,
},
['code', {}, 0],
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/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js
index 957fdede27b..06fecf8196d 100644
--- a/app/assets/javascripts/content_editor/extensions/description_item.js
+++ b/app/assets/javascripts/content_editor/extensions/description_item.js
@@ -5,6 +5,14 @@ export default Node.create({
content: 'block+',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
isTerm: {
@@ -21,7 +29,9 @@ export default Node.create({
renderHTML({ HTMLAttributes: { isTerm, ...HTMLAttributes } }) {
return [
'li',
- mergeAttributes(HTMLAttributes, { class: isTerm ? 'dl-term' : 'dl-description' }),
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ class: isTerm ? 'dl-term' : 'dl-description',
+ }),
0,
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js
index 8f5b145cfa3..72c191757d0 100644
--- a/app/assets/javascripts/content_editor/extensions/description_list.js
+++ b/app/assets/javascripts/content_editor/extensions/description_list.js
@@ -6,12 +6,24 @@ export default Node.create({
group: 'block list',
content: 'descriptionItem+',
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [{ tag: 'dl' }];
},
renderHTML({ HTMLAttributes }) {
- return ['ul', mergeAttributes(HTMLAttributes, { class: 'dl-content' }), 0];
+ return [
+ 'ul',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: 'dl-content' }),
+ 0,
+ ];
},
addInputRules() {
diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js
index fb6c49d91aa..fbe58664a10 100644
--- a/app/assets/javascripts/content_editor/extensions/details_content.js
+++ b/app/assets/javascripts/content_editor/extensions/details_content.js
@@ -1,4 +1,4 @@
-import { Node } from '@tiptap/core';
+import { Node, mergeAttributes } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default Node.create({
@@ -6,6 +6,14 @@ export default Node.create({
content: 'block+',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [
{ tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST },
@@ -13,7 +21,7 @@ export default Node.create({
},
renderHTML({ HTMLAttributes }) {
- return ['li', HTMLAttributes, 0];
+ return ['li', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addKeyboardShortcuts() {
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/figure.js b/app/assets/javascripts/content_editor/extensions/figure.js
index b2076894412..e82c2cb9813 100644
--- a/app/assets/javascripts/content_editor/extensions/figure.js
+++ b/app/assets/javascripts/content_editor/extensions/figure.js
@@ -1,4 +1,4 @@
-import { Node } from '@tiptap/core';
+import { Node, mergeAttributes } from '@tiptap/core';
export default Node.create({
name: 'figure',
@@ -6,11 +6,19 @@ export default Node.create({
group: 'block',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [{ tag: 'figure' }];
},
renderHTML({ HTMLAttributes }) {
- return ['figure', HTMLAttributes, 0];
+ return ['figure', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/figure_caption.js b/app/assets/javascripts/content_editor/extensions/figure_caption.js
index ffd1b474f03..c08db6d9f4d 100644
--- a/app/assets/javascripts/content_editor/extensions/figure_caption.js
+++ b/app/assets/javascripts/content_editor/extensions/figure_caption.js
@@ -1,4 +1,4 @@
-import { Node } from '@tiptap/core';
+import { Node, mergeAttributes } from '@tiptap/core';
export default Node.create({
name: 'figureCaption',
@@ -6,11 +6,19 @@ export default Node.create({
group: 'block',
defining: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [{ tag: 'figcaption' }];
},
renderHTML({ HTMLAttributes }) {
- return ['figcaption', HTMLAttributes, 0];
+ return ['figcaption', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
index ae5b8edc7af..270f0977a7a 100644
--- a/app/assets/javascripts/content_editor/extensions/footnote_reference.js
+++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
@@ -17,6 +17,14 @@ export default Node.create({
selectable: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
identifier: {
@@ -35,6 +43,6 @@ export default Node.create({
},
renderHTML({ HTMLAttributes: { label, ...HTMLAttributes } }) {
- return ['sup', mergeAttributes(HTMLAttributes), label];
+ return ['sup', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), label];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
index 2b2c4177e1d..2fdad39635e 100644
--- a/app/assets/javascripts/content_editor/extensions/footnotes_section.js
+++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
@@ -9,6 +9,14 @@ export default Node.create({
isolating: true,
+ addOptions() {
+ return {
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
parseHTML() {
return [
{ tag: 'section.footnotes', skip: true },
@@ -17,6 +25,12 @@ export default Node.create({
},
renderHTML({ HTMLAttributes }) {
- return ['ol', mergeAttributes(HTMLAttributes, { class: 'footnotes gl-font-sm' }), 0];
+ return [
+ 'ol',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ class: 'footnotes gl-font-sm',
+ }),
+ 0,
+ ];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js
index 41903162ba5..8927d7b9c1e 100644
--- a/app/assets/javascripts/content_editor/extensions/heading.js
+++ b/app/assets/javascripts/content_editor/extensions/heading.js
@@ -2,6 +2,15 @@ import { Heading } from '@tiptap/extension-heading';
import { textblockTypeInputRule } from '@tiptap/core';
export default Heading.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addInputRules() {
return this.options.levels.map((level) => {
return textblockTypeInputRule({
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/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index e985e561fda..b83814103d1 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -18,6 +18,8 @@ export const extractHrefFromMarkdownLink = (match) => {
};
export default Link.extend({
+ inclusive: false,
+
addOptions() {
return {
...this.parent?.(),
@@ -27,7 +29,6 @@ export default Link.extend({
addInputRules() {
const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
- const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
return [
markInputRule({
@@ -35,16 +36,15 @@ export default Link.extend({
type: this.type,
getAttributes: extractHrefFromMarkdownLink,
}),
- markInputRule({
- find: urlSyntaxRegExp,
- type: this.type,
- getAttributes: extractHrefFromMatch,
- }),
];
},
addAttributes() {
return {
...this.parent?.(),
+ uploading: {
+ default: false,
+ renderHTML: ({ uploading }) => (uploading ? { class: 'with-attachment-icon' } : {}),
+ },
href: {
default: null,
parseHTML: (element) => element.getAttribute('href'),
@@ -64,4 +64,18 @@ export default Link.extend({
},
};
},
+ addCommands() {
+ return {
+ ...this.parent?.(),
+ editLink: (attrs) => ({ chain }) => {
+ chain().setMeta('creatingLink', true).setLink(attrs).run();
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ 'Mod-k': () => this.editor.commands.editLink(),
+ };
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/list_item.js b/app/assets/javascripts/content_editor/extensions/list_item.js
index 72454b0905d..7b9ca4e14c8 100644
--- a/app/assets/javascripts/content_editor/extensions/list_item.js
+++ b/app/assets/javascripts/content_editor/extensions/list_item.js
@@ -1 +1,12 @@
-export { ListItem as default } from '@tiptap/extension-list-item';
+import ListItem from '@tiptap/extension-list-item';
+
+export default ListItem.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js
deleted file mode 100644
index 2324e9b132d..00000000000
--- a/app/assets/javascripts/content_editor/extensions/loading.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Node } from '@tiptap/core';
-
-export default Node.create({
- name: 'loading',
- inline: true,
- group: 'inline',
-
- addAttributes() {
- return {
- label: {
- default: null,
- },
- };
- },
-
- renderHTML({ node }) {
- return [
- 'span',
- { class: 'gl-display-inline-flex gl-align-items-center' },
- ['span', { class: 'gl-spinner gl-mx-2' }],
- ['span', { class: 'gl-link' }, node.attrs.label],
- ];
- },
-});
diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js
index 57d5bd6ebf8..d0b760010de 100644
--- a/app/assets/javascripts/content_editor/extensions/ordered_list.js
+++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js
@@ -2,6 +2,15 @@ import { OrderedList } from '@tiptap/extension-ordered-list';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default OrderedList.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js
index 33bf1c94003..c63b64fd784 100644
--- a/app/assets/javascripts/content_editor/extensions/paragraph.js
+++ b/app/assets/javascripts/content_editor/extensions/paragraph.js
@@ -1 +1,12 @@
-export { Paragraph as default } from '@tiptap/extension-paragraph';
+import Paragraph from '@tiptap/extension-paragraph';
+
+export default Paragraph.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ dir: 'auto',
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 848c4c12a9a..82fa5ce6c1d 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';
@@ -37,8 +37,18 @@ export default Extension.create({
const { state, view } = editor;
const { tr, selection } = state;
+ const { firstChild } = document.content;
+ const content =
+ document.content.childCount === 1 && firstChild.type.name === 'paragraph'
+ ? firstChild.content
+ : document.content;
+
+ if (selection.to - selection.from > 0) {
+ tr.replaceWith(selection.from, selection.to, content);
+ } else {
+ tr.insert(selection.from, content);
+ }
- tr.replaceWith(selection.from - 1, selection.to, document.content);
view.dispatch(tr);
})
.catch(() => {
@@ -53,13 +63,29 @@ export default Extension.create({
};
},
addProseMirrorPlugins() {
+ let pasteRaw = false;
+
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
- handlePaste: (_, event) => {
+ handleKeyDown: (_, event) => {
+ pasteRaw = event.key === 'v' && (event.metaKey || event.ctrlKey) && event.shiftKey;
+ },
+
+ handlePaste: (view, event) => {
const { clipboardData } = event;
const content = clipboardData.getData(TEXT_FORMAT);
+ const { state } = view;
+ const { tr, selection } = state;
+ const { from, to } = selection;
+
+ if (pasteRaw) {
+ tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to);
+ view.dispatch(tr);
+ return true;
+ }
+
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
index ed343d8acf8..47766c966a1 100644
--- a/app/assets/javascripts/content_editor/extensions/playable.js
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -1,5 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import { Node } from '@tiptap/core';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
@@ -44,7 +42,7 @@ export default Node.create({
parseHTML() {
return [
{
- tag: `.${this.options.mediaType}-container`,
+ tag: `.${this.options.mediaType}-container`, // eslint-disable-line @gitlab/require-i18n-strings
},
];
},
@@ -63,7 +61,11 @@ export default Node.create({
...this.extraElementAttrs,
},
],
- ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''],
+ [
+ 'a',
+ { href: node.attrs.src, class: 'with-attachment-icon' },
+ node.attrs.title || node.attrs.alt || '',
+ ],
];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index 707beaf1231..b56aa8596a0 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -1,4 +1,6 @@
import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import ReferenceWrapper from '../components/wrappers/reference.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const getAnchor = (element) => {
@@ -49,7 +51,7 @@ export default Node.create({
];
},
- renderHTML({ node }) {
- return ['a', { href: '#' }, node.attrs.text];
+ addNodeView() {
+ return new VueNodeViewRenderer(ReferenceWrapper);
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js
index 9dff0b7a689..0441f8ef8d2 100644
--- a/app/assets/javascripts/content_editor/extensions/reference_label.js
+++ b/app/assets/javascripts/content_editor/extensions/reference_label.js
@@ -1,6 +1,6 @@
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants';
-import LabelWrapper from '../components/wrappers/label.vue';
+import LabelWrapper from '../components/wrappers/reference_label.vue';
import Reference from './reference';
export default Reference.extend({
diff --git a/app/assets/javascripts/content_editor/extensions/selection.js b/app/assets/javascripts/content_editor/extensions/selection.js
new file mode 100644
index 00000000000..2e0bb29e5a1
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/selection.js
@@ -0,0 +1,26 @@
+import { Extension } from '@tiptap/core';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
+
+export default Extension.create({
+ name: 'selection',
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey('selection'),
+ props: {
+ decorations(state) {
+ if (state.selection.empty) return null;
+
+ return DecorationSet.create(state.doc, [
+ Decoration.inline(state.selection.from, state.selection.to, {
+ class: 'content-editor-selection',
+ }),
+ ]);
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 54d69d83188..f02d0c2ca52 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -31,6 +31,8 @@ import TableOfContents from './table_of_contents';
import Video from './video';
export default Extension.create({
+ name: 'sourcemap',
+
addGlobalAttributes() {
return [
{
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index a9628c78add..e72b5c7365c 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';
@@ -57,14 +57,25 @@ function createSuggestionPlugin({
let component;
let popup;
+ const onUpdate = (props) => {
+ component?.updateProps({ ...props, loading: false });
+
+ if (!props.clientRect) return;
+
+ popup?.[0].setProps({
+ getReferenceClientRect: props.clientRect,
+ });
+ };
+
return {
- onStart: (props) => {
+ onBeforeStart: (props) => {
component = new VueRenderer(SuggestionsDropdown, {
propsData: {
...props,
char,
nodeType,
nodeProps,
+ loading: true,
},
editor: props.editor,
});
@@ -84,17 +95,8 @@ function createSuggestionPlugin({
});
},
- onUpdate(props) {
- component?.updateProps(props);
-
- if (!props.clientRect) {
- return;
- }
-
- popup?.[0].setProps({
- getReferenceClientRect: props.clientRect,
- });
- },
+ onStart: onUpdate,
+ onUpdate,
onKeyDown(props) {
if (props.event.key === 'Escape') {
@@ -118,12 +120,18 @@ function createSuggestionPlugin({
export default Node.create({
name: 'suggestions',
+ addOptions() {
+ return {
+ autocompleteDataSources: {},
+ };
+ },
+
addProseMirrorPlugins() {
return [
createSuggestionPlugin({
editor: this.editor,
char: '@',
- dataSource: gl.GfmAutoComplete?.dataSources.members,
+ dataSource: this.options.autocompleteDataSources.members,
nodeType: 'reference',
nodeProps: {
referenceType: 'user',
@@ -133,7 +141,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '#',
- dataSource: gl.GfmAutoComplete?.dataSources.issues,
+ dataSource: this.options.autocompleteDataSources.issues,
nodeType: 'reference',
nodeProps: {
referenceType: 'issue',
@@ -143,7 +151,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '$',
- dataSource: gl.GfmAutoComplete?.dataSources.snippets,
+ dataSource: this.options.autocompleteDataSources.snippets,
nodeType: 'reference',
nodeProps: {
referenceType: 'snippet',
@@ -153,7 +161,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '~',
- dataSource: gl.GfmAutoComplete?.dataSources.labels,
+ dataSource: this.options.autocompleteDataSources.labels,
nodeType: 'reference_label',
nodeProps: {
referenceType: 'label',
@@ -163,7 +171,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '&',
- dataSource: gl.GfmAutoComplete?.dataSources.epics,
+ dataSource: this.options.autocompleteDataSources.epics,
nodeType: 'reference',
nodeProps: {
referenceType: 'epic',
@@ -173,7 +181,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '[vulnerability:',
- dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities,
+ dataSource: this.options.autocompleteDataSources.vulnerabilities,
nodeType: 'reference',
nodeProps: {
referenceType: 'vulnerability',
@@ -183,7 +191,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '!',
- dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests,
+ dataSource: this.options.autocompleteDataSources.mergeRequests,
nodeType: 'reference',
nodeProps: {
referenceType: 'merge_request',
@@ -193,7 +201,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '%',
- dataSource: gl.GfmAutoComplete?.dataSources.milestones,
+ dataSource: this.options.autocompleteDataSources.milestones,
nodeType: 'reference',
nodeProps: {
referenceType: 'milestone',
@@ -203,7 +211,7 @@ export default Node.create({
createSuggestionPlugin({
editor: this.editor,
char: '/',
- dataSource: gl.GfmAutoComplete?.dataSources.commands,
+ dataSource: this.options.autocompleteDataSources.commands,
nodeType: 'reference',
nodeProps: {
referenceType: 'command',
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/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js
index 6efef3f8198..849fd55034e 100644
--- a/app/assets/javascripts/content_editor/extensions/task_item.js
+++ b/app/assets/javascripts/content_editor/extensions/task_item.js
@@ -4,8 +4,9 @@ import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default TaskItem.extend({
addOptions() {
return {
+ ...this.parent?.(),
nested: true,
- HTMLAttributes: {},
+ HTMLAttributes: { dir: 'auto' },
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js
index 72c6e020102..01e5bddb97a 100644
--- a/app/assets/javascripts/content_editor/extensions/task_list.js
+++ b/app/assets/javascripts/content_editor/extensions/task_list.js
@@ -4,6 +4,13 @@ import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default TaskList.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: { dir: 'auto' },
+ };
+ },
+
addAttributes() {
return {
numeric: {
@@ -33,6 +40,10 @@ export default TaskList.extend({
},
renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) {
- return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
+ return [
+ numeric ? 'ol' : 'ul',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': 'taskList' }),
+ 0,
+ ];
},
});
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..3958f77745a 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';
@@ -39,7 +40,6 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
-import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
@@ -47,6 +47,7 @@ import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
import ReferenceLabel from '../extensions/reference_label';
import ReferenceDefinition from '../extensions/reference_definition';
+import Selection from '../extensions/selection';
import Sourcemap from '../extensions/sourcemap';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
@@ -74,7 +75,7 @@ const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
extensions: [...extensions],
editorProps: {
attributes: {
- class: 'gl-outline-0!',
+ class: 'gl-shadow-none!',
},
},
...options,
@@ -86,6 +87,9 @@ export const createContentEditor = ({
extensions = [],
serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
+ drawioEnabled = false,
+ enableAutocomplete,
+ autocompleteDataSources = {},
} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
@@ -131,7 +135,6 @@ export const createContentEditor = ({
ExternalKeydownHandler.configure({ eventHub }),
Link,
ListItem,
- Loading,
MathInline,
OrderedList,
Paragraph,
@@ -139,10 +142,10 @@ export const createContentEditor = ({
Reference,
ReferenceLabel,
ReferenceDefinition,
+ Selection,
Sourcemap,
Strike,
Subscript,
- Suggestions,
Superscript,
TableCell,
TableHeader,
@@ -157,6 +160,10 @@ export const createContentEditor = ({
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
+
+ if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources }));
+ 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 +180,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..3b77064e903 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();
@@ -222,6 +227,7 @@ const defaultSerializerConfig = {
[TableRow.name]: renderTableRow,
[TaskItem.name]: preserveUnchanged((state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
+ if (!node.textContent) state.write('&nbsp;');
state.renderContent(node);
}),
[TaskList.name]: preserveUnchanged((state, node) => {
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
index fe1b32c5b0a..11a11ed43bd 100644
--- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -28,6 +28,8 @@ export const getMarkdownSource = (element) => {
const range = getRangeFromSourcePos(element.dataset.sourcepos);
let elSource = '';
+ if (!source.length) return undefined;
+
for (let i = range.start.row; i <= range.end.row; i += 1) {
if (i === range.start.row) {
elSource += source[i].substring(range.start.col);
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 540815f57c9..478b87372d7 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -309,12 +309,13 @@ export function renderHardBreak(state, node, parent, index) {
export function renderImage(state, node) {
const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs;
+ const realSrc = canonicalSrc || src || '';
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (realSrc.startsWith('data:') || realSrc.startsWith('blob:')) return;
if (isString(src) || isString(canonicalSrc)) {
const quotedTitle = title ? ` ${state.quote(title)}` : '';
- const sourceExpression = isReference
- ? `[${canonicalSrc}]`
- : `(${state.esc(canonicalSrc || src)}${quotedTitle})`;
+ const sourceExpression = isReference ? `[${canonicalSrc}]` : `(${realSrc}${quotedTitle})`;
const sizeAttributes = [];
if (width) {
@@ -335,9 +336,9 @@ export function renderPlayable(state, node) {
}
export function renderComment(state, node) {
- state.text('<!--');
- state.text(node.textContent);
- state.text('-->');
+ state.write('<!--');
+ state.write(node.textContent);
+ state.write('-->');
state.closeBlock(node);
}
@@ -604,7 +605,7 @@ export const link = {
return '[';
}
- const attrs = { href: state.esc(href || canonicalSrc) };
+ const attrs = { href: state.esc(href || canonicalSrc || '') };
if (title) {
attrs.title = title;
@@ -620,14 +621,14 @@ export const link = {
const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs;
if (isReference) {
- return `][${state.esc(canonicalSrc || href)}]`;
+ return `][${state.esc(canonicalSrc || href || '')}]`;
}
if (linkType(sourceMarkdown) === LINK_HTML) {
return closeTag('a');
}
- return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`;
+ return `](${state.esc(canonicalSrc || href || '')}${title ? ` ${state.quote(title)}` : ''})`;
},
};
@@ -638,9 +639,8 @@ const generateStrikeTag = (wrapTagName = openTag) => {
switch (type) {
case '~~':
return type;
- /* eslint-disable @gitlab/require-i18n-strings */
- case '<del':
- case '<strike':
+ case '<del': // eslint-disable-line @gitlab/require-i18n-strings
+ case '<strike': // eslint-disable-line @gitlab/require-i18n-strings
case '<s':
return wrapTagName(type.substring(1));
default:
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
index 09f0738b51b..f5785397bf0 100644
--- a/app/assets/javascripts/content_editor/services/upload_helpers.js
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -1,20 +1,66 @@
-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';
+import { __, sprintf } from '~/locale';
+import { bytesToMiB } from '~/lib/utils/number_utils';
+import TappablePromise from '~/lib/utils/tappable_promise';
+import { ALERT_EVENT } from '../constants';
+
+const chain = (editor) => editor.chain().setMeta('preventAutolink', true);
+
+const findUploadedFilePosition = (editor, filename) => {
+ let position;
+
+ editor.view.state.doc.descendants((descendant, pos) => {
+ if (descendant.attrs.uploading === filename) {
+ position = pos;
+ return false;
+ }
+
+ for (const mark of descendant.marks) {
+ if (mark.type.name === 'link' && mark.attrs.uploading === filename) {
+ position = pos + 1;
+ return false;
+ }
+ }
+
+ return true;
+ });
+
+ return position;
+};
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/svg+xml',
+ 'image/webp',
+ 'image/tiff',
+ 'image/bmp',
+ 'image/vnd.microsoft.icon',
+ 'image/x-icon',
+ ],
+ },
+ 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) => {
@@ -27,6 +73,18 @@ const extractAttachmentLinkUrl = (html) => {
return { src, canonicalSrc };
};
+class UploadError extends Error {}
+
+const notifyUploadError = (eventHub, error) => {
+ eventHub.$emit(ALERT_EVENT, {
+ message:
+ error instanceof UploadError
+ ? error.message
+ : __('An error occurred while uploading the file. Please try again.'),
+ variant: VARIANT_DANGER,
+ });
+};
+
/**
* Uploads a file with a post request to the URL indicated
* in the uploadsPath parameter. The expected response of the
@@ -44,93 +102,155 @@ const extractAttachmentLinkUrl = (html) => {
* and returns a rendered version in HTML format.
* @param {File} params.file The file to upload
*
- * @returns Returns an object with two properties:
+ * @returns {TappablePromise} Returns an object with two properties:
*
* canonicalSrc: The URL as defined in the Markdown
* src: The absolute URL that points to the resource in the server
*/
-export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
- const formData = new FormData();
- formData.append('file', file, file.name);
+export const uploadFile = ({ uploadsPath, renderMarkdown, file }) => {
+ return new TappablePromise(async (tap) => {
+ const maxFileSize = (gon.max_file_size || 10).toFixed(0);
+ const fileSize = bytesToMiB(file.size);
+ if (fileSize > maxFileSize) {
+ throw new UploadError(
+ sprintf(__('File is too big (%{fileSize}MiB). Max filesize: %{maxFileSize}MiB.'), {
+ fileSize: fileSize.toFixed(2),
+ maxFileSize,
+ }),
+ );
+ }
- const { data } = await axios.post(uploadsPath, formData);
- const { markdown } = data.link;
- const rendered = await renderMarkdown(markdown);
+ const formData = new FormData();
+ formData.append('file', file, file.name);
- return extractAttachmentLinkUrl(rendered);
+ const { data } = await axios.post(uploadsPath, formData, {
+ onUploadProgress: (e) => tap(e.loaded / e.total),
+ });
+ const { markdown } = data.link;
+ const rendered = await renderMarkdown(markdown);
+
+ return extractAttachmentLinkUrl(rendered);
+ });
};
-const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
- const encodedSrc = await readFileAsDataURL(file);
- const { view } = editor;
+const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+ // needed to avoid mismatched transaction error
+ await Promise.resolve();
+
+ const objectUrl = URL.createObjectURL(file);
+ const { selection } = editor.view.state;
+ const currentNode = selection.$to.node();
+
+ let position = selection.to;
+ let content = {
+ type,
+ attrs: { uploading: file.name, src: objectUrl, alt: file.name },
+ };
+ let selectionIncrement = 0;
+
+ // if the current node is not empty, we need to wrap the content in a new paragraph
+ if (currentNode.content.size > 0 || currentNode.type.name === 'doc') {
+ content = {
+ type: 'paragraph',
+ content: [content],
+ };
+ selectionIncrement = 1;
+ }
- editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } });
+ chain(editor)
+ .insertContentAt(position, content)
+ .setNodeSelection(position + selectionIncrement)
+ .run();
- const { state } = view;
- const position = state.selection.from - 1;
- const { tr } = state;
+ uploadFile({ file, uploadsPath, renderMarkdown })
+ .tap((progress) => {
+ chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run();
+ })
+ .then(({ canonicalSrc }) => {
+ // the position might have changed while uploading, so we need to find it again
+ position = findUploadedFilePosition(editor, file.name);
- editor.commands.setNodeSelection(position);
+ editor.view.dispatch(
+ editor.state.tr.setMeta('preventAutolink', true).setNodeMarkup(position, undefined, {
+ uploading: false,
+ src: objectUrl,
+ alt: file.name,
+ canonicalSrc,
+ }),
+ );
- try {
- const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
+ chain(editor).setNodeSelection(position).run();
+ })
+ .catch((e) => {
+ position = findUploadedFilePosition(editor, file.name);
- view.dispatch(
- tr.setNodeMarkup(position, undefined, {
- uploading: false,
- src: encodedSrc,
- alt: extractFilename(src),
- canonicalSrc,
- }),
- );
+ chain(editor)
+ .deleteRange({ from: position, to: position + 1 })
+ .run();
- editor.commands.setNodeSelection(position);
- } catch (e) {
- editor.commands.deleteRange({ from: position, to: position + 1 });
- eventHub.$emit('alert', {
- message: __('An error occurred while uploading the file. Please try again.'),
- variant: VARIANT_DANGER,
+ notifyUploadError(eventHub, e);
});
- }
};
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
+ // needed to avoid mismatched transaction error
await Promise.resolve();
- const { view } = editor;
+ const objectUrl = URL.createObjectURL(file);
+ const { selection } = editor.view.state;
+ const currentNode = selection.$to.node();
+
+ let position = selection.to;
+ let content = {
+ type: 'text',
+ text: file.name,
+ marks: [{ type: 'link', attrs: { href: objectUrl, uploading: file.name } }],
+ };
- const text = extractFilename(file.name);
+ // if the current node is not empty, we need to wrap the content in a new paragraph
+ if (currentNode.content.size > 0 || currentNode.type.name === 'doc') {
+ content = {
+ type: 'paragraph',
+ content: [content],
+ };
+ }
- const { state } = view;
- const { from } = state.selection;
+ chain(editor).insertContentAt(position, content).extendMarkRange('link').run();
- editor.commands.insertContent({
- type: 'loading',
- attrs: { label: text },
- });
+ uploadFile({ file, uploadsPath, renderMarkdown })
+ .tap((progress) => {
+ chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run();
+ })
+ .then(({ src, canonicalSrc }) => {
+ // the position might have changed while uploading, so we need to find it again
+ position = findUploadedFilePosition(editor, file.name);
- try {
- const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
-
- editor.commands.insertContentAt(
- { from, to: from + 1 },
- { type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] },
- );
- } catch (e) {
- editor.commands.deleteRange({ from, to: from + 1 });
- eventHub.$emit('alert', {
- message: __('An error occurred while uploading the file. Please try again.'),
- variant: VARIANT_DANGER,
+ chain(editor)
+ .setTextSelection(position)
+ .extendMarkRange('link')
+ .updateAttributes('link', { href: src, canonicalSrc, uploading: false })
+ .run();
+ })
+ .catch((e) => {
+ position = findUploadedFilePosition(editor, file.name);
+
+ chain(editor)
+ .setTextSelection(position)
+ .extendMarkRange('link')
+ .unsetLink()
+ .deleteSelection()
+ .run();
+
+ notifyUploadError(eventHub, e);
});
- }
};
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)) {
- uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
+ for (const [type, { mimes, ext }] of Object.entries(acceptedMimes)) {
+ if (mimes.includes(file?.type) && (!ext || file?.name.endsWith(ext))) {
+ uploadMedia({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
}
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
index e352fa8a9db..1c128b4aa19 100644
--- a/app/assets/javascripts/content_editor/services/utils.js
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -4,26 +4,4 @@ export const hasSelection = (tiptapEditor) => {
return from < to;
};
-/**
- * Extracts filename from a URL
- *
- * @example
- * > extractFilename('https://gitlab.com/images/logo-full.png')
- * < 'logo-full'
- *
- * @param {string} src The URL to extract filename from
- * @returns {string}
- */
-export const extractFilename = (src) => {
- return src.replace(/^.*\/|\.[^.]+?$/g, '');
-};
-
-export const readFileAsDataURL = (file) => {
- return new Promise((resolve) => {
- const reader = new FileReader();
- reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
- reader.readAsDataURL(file);
- });
-};
-
export const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
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_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index c9097b9384f..94f27dbf048 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -131,7 +131,7 @@ export default {
</dl>
</div>
</div>
- <div class="table-section section-30 section-wrap">
+ <div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div>
<div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0">
@@ -168,7 +168,7 @@ export default {
<span v-else class="text-secondary">{{ __('None') }}</span>
</div>
</div>
- <div class="table-section section-15 text-right">
+ <div class="table-section section-15">
<div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div>
<div class="table-mobile-content text-secondary key-created-at">
<span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
@@ -176,7 +176,23 @@ export default {
</span>
</div>
</div>
- <div class="table-section section-15 table-button-footer deploy-key-actions">
+ <div class="table-section section-15">
+ <div role="rowheader" class="table-mobile-header">{{ __('Expires') }}</div>
+ <div class="table-mobile-content text-secondary key-expires-at">
+ <span
+ v-if="deployKey.expires_at"
+ v-gl-tooltip
+ :title="tooltipTitle(deployKey.expires_at)"
+ data-testid="expires-at-tooltip"
+ >
+ <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span>
+ </span>
+ <span v-else>
+ <span data-testid="expires-never">{{ __('Never') }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="table-section section-10 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons">
<action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
{{ __('Enable') }}
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index 77ec1ef590f..e04cbbe72b9 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -34,10 +34,12 @@ export default {
<div role="rowheader" class="table-section section-40">
{{ s__('DeployKeys|Deploy key') }}
</div>
- <div role="rowheader" class="table-section section-30">
+ <div role="rowheader" class="table-section section-20">
{{ s__('DeployKeys|Project usage') }}
</div>
- <div role="rowheader" class="table-section section-15 text-right">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Expires') }}</div>
+ <!-- leave 10% space for actions --->
</div>
<deploy-key
v-for="deployKey in keys"
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_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
index 8ca4dc587a8..2cb9e9a56a3 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
import { debounce } from 'lodash';
@@ -59,6 +57,7 @@ export class GitLabDropdownFilter {
return BLUR_KEYCODES.indexOf(keyCode) !== -1;
}
+ // eslint-disable-next-line consistent-return
filter(searchText) {
let group;
let results;
@@ -114,9 +113,10 @@ export class GitLabDropdownFilter {
const matches = fuzzaldrinPlus.match($el.text().trim(), searchText);
if (!$el.is('.dropdown-header')) {
if (matches.length) {
- return $el.show().removeClass('option-hidden');
+ $el.show().removeClass('option-hidden');
+ } else {
+ $el.hide().addClass('option-hidden');
}
- return $el.hide().addClass('option-hidden');
}
});
} else {
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 7503df9194b..a46a8d4affa 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}"]`);
@@ -602,7 +599,10 @@ export default class Notes {
// remove validation errors
form.find('.js-errors').remove();
// reset text and preview
- form.find('.js-md-write-button').click();
+ if (form.find('.js-md-preview-button').val() === 'edit') {
+ form.find('.js-md-preview-button').click();
+ }
+
form.find('.js-note-text').val('').trigger('input');
form.find('.js-note-text').each(function reset() {
this.$autosave.reset();
@@ -745,7 +745,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}`);
@@ -942,6 +942,7 @@ export default class Notes {
const replyLink = $(target).closest('.js-discussion-reply-button');
// insert the form after the button
replyLink.closest('.discussion-reply-holder').hide().after(form);
+
// show the form
return this.setupDiscussionNoteForm(replyLink, form);
}
@@ -1182,9 +1183,11 @@ export default class Notes {
const form = textarea.parents('form');
const reopenbtn = form.find('.js-note-target-reopen');
const closebtn = form.find('.js-note-target-close');
+ const savebtn = form.find('.js-comment-save-button');
const commentTypeComponent = form.get(0)?.commentTypeComponent;
if (textarea.val().trim().length > 0) {
+ savebtn.enable();
reopentext = reopenbtn.attr('data-alternative-text');
closetext = closebtn.attr('data-alternative-text');
if (reopenbtn.text() !== reopentext) {
@@ -1203,6 +1206,7 @@ export default class Notes {
commentTypeComponent.disabled = false;
}
} else {
+ savebtn.disable();
reopentext = reopenbtn.data('originalText');
closetext = closebtn.data('originalText');
if (reopenbtn.text() !== reopentext) {
@@ -1241,7 +1245,10 @@ export default class Notes {
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
$editForm.find('.js-note-text').focus().val(originalContent);
- $editForm.find('.js-md-write-button').trigger('click');
+ // reset preview
+ if ($editForm.find('.js-md-preview-button').val() === 'edit') {
+ $editForm.find('.js-md-preview-button').click();
+ }
$editForm.find('.referenced-users').hide();
}
@@ -1396,8 +1403,29 @@ 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) {
+ note_ids = Notes.getNotesIds();
+ }
+ const isNewEntry = $.inArray(noteEntity.id, note_ids) === -1;
+ if (isNewEntry) {
+ note_ids.push(noteEntity.id);
+ }
+ return isNewEntry;
+ }
+
+ /**
+ * Get notes ids
+ */
+ static getNotesIds() {
+ /**
+ * The selector covers following notes
+ * - notes and thread below the snippets and commit page
+ * - notes on the file of commit page
+ * - notes on an image file of commit page
+ */
+ const notesList = [...document.querySelectorAll('.notes:not(.notes-form) li[id]')];
+ return notesList.map((noteItem) => parseInt(noteItem.dataset.noteId, 10));
}
/**
@@ -1422,7 +1450,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 +1459,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 +1866,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/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index f52486f0629..9c1bcf5bf90 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -133,7 +133,11 @@ export default {
<div
class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
>
- <div v-if="icon.name" data-testid="design-event" class="gl-top-5 gl-right-5 gl-absolute">
+ <div
+ v-if="icon.name"
+ data-testid="design-event"
+ class="gl-absolute gl-top-3 gl-right-3 gl-mr-1"
+ >
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<gl-icon
:name="icon.name"
@@ -165,11 +169,11 @@ export default {
/>
</gl-intersection-observer>
</div>
- <div class="card-footer gl-display-flex gl-w-full">
+ <div class="card-footer gl-display-flex gl-w-full gl-bg-white gl-py-3 gl-px-4">
<div class="gl-display-flex gl-flex-direction-column str-truncated-100">
<span
v-gl-tooltip
- class="gl-font-weight-bold str-truncated-100"
+ class="gl-font-weight-semibold str-truncated-100"
data-qa-selector="design_file_name"
:data-testid="`design-img-filename-${id}`"
:title="filename"
diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
index 0bbbc795fff..e08eb853ad7 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -1,12 +1,11 @@
<script>
-/* global Mousetrap */
-import 'mousetrap';
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import {
keysFor,
ISSUE_PREVIOUS_DESIGN,
ISSUE_NEXT_DESIGN,
} from '~/behaviors/shortcuts/keybindings';
+import { Mousetrap } from '~/lib/mousetrap';
import { s__, sprintf } from '~/locale';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
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..12bb4b830f8 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,
+ newCommentTemplatePath,
+ } = el.dataset;
const router = createRouter(issuePath);
apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -32,6 +39,7 @@ export default () => {
issueIid,
registerPath,
signInPath,
+ newCommentTemplatePath,
},
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..eeb36e59b89 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -1,10 +1,9 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { isNull } from 'lodash';
-import Mousetrap from 'mousetrap';
-import { ApolloMutation } from 'vue-apollo';
+import { Mousetrap } from '~/lib/mousetrap';
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;
@@ -338,7 +332,7 @@ export default {
<template>
<div
- class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
+ class="design-detail js-design-detail fixed-top gl-w-full gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
>
<div
class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
@@ -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/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index ab003fb2879..dcc65c957fe 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -104,7 +104,7 @@ export default {
return this.permissions.createDesign;
},
showToolbar() {
- return this.canCreateDesign && this.allVersions.length > 0;
+ return this.allVersions.length > 0;
},
hasDesigns() {
return this.designs.length > 0;
@@ -141,6 +141,12 @@ export default {
}
return 'col-12';
},
+ designContentWrapperClass() {
+ if (this.hasDesigns) {
+ return 'gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5';
+ }
+ return null;
+ },
},
mounted() {
if (this.$route.path === '/designs') {
@@ -364,7 +370,7 @@ export default {
</gl-alert>
<header
v-if="showToolbar"
- class="gl-display-flex gl-my-0 gl-text-gray-900"
+ class="gl-border gl-px-5 gl-py-4 gl-display-flex gl-justify-content-space-between gl-bg-white gl-rounded-base gl-rounded-bottom-left-none! gl-rounded-bottom-right-none!"
data-testid="design-toolbar-wrapper"
>
<div
@@ -375,6 +381,7 @@ export default {
<design-version-dropdown />
</div>
<div
+ v-if="canCreateDesign"
v-show="hasDesigns"
class="gl-display-flex gl-align-items-center"
data-testid="design-selector-toolbar"
@@ -417,7 +424,7 @@ export default {
</div>
</div>
</header>
- <div>
+ <div :class="designContentWrapperClass">
<gl-loading-icon v-if="isLoading" size="lg" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
@@ -482,14 +489,18 @@ export default {
v-if="canSelectDesign(design.filename)"
:checked="isDesignSelected(design.filename)"
type="checkbox"
- class="design-checkbox"
+ class="design-checkbox gl-absolute gl-top-4 gl-left-6 gl-ml-2"
data-qa-selector="design_checkbox"
:data-qa-design="design.filename"
@change="changeSelectedDesigns(design.filename)"
/>
</li>
<template #header>
- <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper">
+ <li
+ v-if="canCreateDesign"
+ :class="designDropzoneWrapperClass"
+ data-testid="design-dropzone-wrapper"
+ >
<design-dropzone
:enable-drag-behavior="isDraggingDesign"
:class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }"
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index cfec5828c85..1ae7b6a2110 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -1,8 +1,7 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
import produce from 'immer';
import { differenceBy } from 'lodash';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { TYPENAME_DISCUSSION, TYPENAME_TODO, TYPENAME_USER } from '~/graphql_shared/constants';
import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils';
import {
ADD_IMAGE_DIFF_NOTE_ERROR,
@@ -60,7 +59,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
});
const newDiscussion = {
- __typename: 'Discussion',
+ __typename: TYPENAME_DISCUSSION,
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
resolvable: true,
@@ -86,7 +85,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) =
design.issue.participants.nodes = [
...design.issue.participants.nodes,
{
- __typename: 'User',
+ __typename: TYPENAME_USER,
...createImageDiffNote.note.author,
},
];
@@ -199,7 +198,7 @@ export const addPendingTodoToStore = (store, pendingTodo, query, queryVariables)
const data = produce(sourceData, (draftData) => {
const design = extractDesign(draftData);
const existingTodos = design.currentUserTodos?.nodes || [];
- const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: 'Todo' }];
+ const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: TYPENAME_TODO }];
if (!design.currentUserTodos) {
design.currentUserTodos = {
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..02307150e2f 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,8 +1,8 @@
<script>
import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import Mousetrap from 'mousetrap';
import { mapState, mapGetters, mapActions } from 'vuex';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import api from '~/api';
import {
keysFor,
@@ -11,16 +11,18 @@ 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';
+import { Mousetrap } from '~/lib/mousetrap';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
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,
@@ -39,12 +41,15 @@ import {
TRACKING_WHITESPACE_HIDE,
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
+ EVT_MR_PREPARED,
} from '../constants';
import diffsEventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
+import { updateChangesTabCount } from '../utils/merge_request';
import { queueRedisHllEvents } from '../utils/queue_events';
+import FindingsDrawer from './shared/findings_drawer.vue';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
import CommitWidget from './commit_widget.vue';
import CompareVersions from './compare_versions.vue';
@@ -53,15 +58,15 @@ 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),
+ FindingsDrawer,
+ DynamicScroller,
+ DynamicScrollerItem,
+ PreRenderer,
VirtualScrollerScrollSync,
CompareVersions,
DiffFile,
@@ -75,6 +80,8 @@ export default {
GlPagination,
GlSprintf,
GlAlert,
+ GenerateTestFileDrawer: () =>
+ import('ee_component/ai/components/generate_test_file_drawer.vue'),
},
mixins: [glFeatureFlagsMixin()],
alerts: {
@@ -95,6 +102,10 @@ export default {
type: String,
required: true,
},
+ endpointDiffForPath: {
+ type: String,
+ required: true,
+ },
endpointCoverage: {
type: String,
required: false,
@@ -195,6 +206,7 @@ export default {
numTotalFiles: 'realSize',
numVisibleFiles: 'size',
}),
+ ...mapState('findingsDrawer', ['activeDrawer']),
...mapState('diffs', [
'showTreeList',
'isLoading',
@@ -218,6 +230,7 @@ export default {
'showWhitespace',
'targetBranchName',
'branchName',
+ 'generateTestFilePath',
]),
...mapGetters('diffs', [
'whichCollapsedTypes',
@@ -226,8 +239,10 @@ export default {
'isVirtualScrollingEnabled',
'isBatchLoading',
'isBatchLoadingError',
+ 'flatBlobsList',
]),
...mapGetters(['isNotesFetched', 'getNoteableData']),
+ ...mapGetters('findingsDrawer', ['activeDrawer']),
diffs() {
if (!this.viewDiffsFileByFile) {
return this.diffFiles;
@@ -241,7 +256,10 @@ 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;
+ },
+ diffsIncomplete() {
+ return this.flatBlobsList.length !== this.diffFiles.length;
},
renderFileTree() {
return this.renderDiffFiles && this.showTreeList;
@@ -253,7 +271,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 +282,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;
@@ -284,6 +302,9 @@ export default {
fileReviews() {
return reviewStatuses(this.diffFiles, this.mrReviews);
},
+ resourceId() {
+ return convertToGraphQLId('MergeRequest', this.getNoteableData.id);
+ },
},
watch: {
commit(newCommit, oldCommit) {
@@ -303,6 +324,11 @@ export default {
diffViewType() {
this.adjustView();
},
+ viewDiffsFileByFile(newViewFileByFile) {
+ if (!newViewFileByFile && this.diffsIncomplete && this.glFeatures.singleFileFileByFile) {
+ this.refetchDiffData({ refetchMeta: false });
+ }
+ },
shouldShow() {
// When the shouldShow property changed to true, the route is rendered for the first time
// and if we have the isLoading as true this means we didn't fetch the data
@@ -321,6 +347,7 @@ export default {
endpoint: this.endpoint,
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
+ endpointDiffForPath: this.endpointDiffForPath,
endpointCoverage: this.endpointCoverage,
endpointUpdateUser: this.endpointUpdateUser,
projectPath: this.projectPath,
@@ -331,8 +358,6 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
- this.interfaceWithDOM();
-
if (this.endpointCodequality) {
this.setCodequalityEndpoint(this.endpointCodequality);
}
@@ -385,7 +410,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();
@@ -420,41 +445,51 @@ export default {
'setCodequalityEndpoint',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
+ 'fetchFileByFile',
'fetchCoverageFiles',
'fetchCodequality',
+ 'rereadNoteHash',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
'cacheTreeListWidth',
- 'scrollToFile',
+ 'goToFile',
'setShowTreeList',
'navigateToDiffFileIndex',
'setFileByFile',
'disableVirtualScroller',
+ 'setGenerateTestFilePath',
]),
+ ...mapActions('findingsDrawer', ['setDrawer']),
+ closeDrawer() {
+ this.setDrawer({});
+ },
subscribeToEvents() {
notesEventHub.$once('fetchDiffData', this.fetchData);
notesEventHub.$on('refetchDiffData', this.refetchDiffData);
+ if (this.glFeatures.singleFileFileByFile) {
+ diffsEventHub.$on('diffFilesModified', this.setDiscussions);
+ notesEventHub.$on('fetchedNotesData', this.rereadNoteHash);
+ }
+ diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData);
},
unsubscribeFromEvents() {
+ diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData);
+ if (this.glFeatures.singleFileFileByFile) {
+ notesEventHub.$off('fetchedNotesData', this.rereadNoteHash);
+ diffsEventHub.$off('diffFilesModified', this.setDiscussions);
+ }
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
- interfaceWithDOM() {
- this.diffsTab = document.querySelector('.js-diffs-tab');
- },
- updateChangesTabCount() {
- const badge = this.diffsTab.querySelector('.gl-badge');
-
- if (this.diffsTab && badge) {
- badge.textContent = this.diffFilesLength;
- }
- },
navigateToDiffFileNumber(number) {
- this.navigateToDiffFileIndex(number - 1);
+ this.navigateToDiffFileIndex({
+ index: number - 1,
+ singleFile: this.glFeatures.singleFileFileByFile,
+ });
},
- refetchDiffData() {
- this.fetchData(false);
+ refetchDiffData({ refetchMeta = true } = {}) {
+ this.fetchData({ toggleTree: false, fetchMeta: refetchMeta });
},
needsReload() {
return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]);
@@ -462,42 +497,52 @@ export default {
needsFirstLoad() {
return !this.diffFiles.length;
},
- fetchData(toggleTree = true) {
- this.fetchDiffFilesMeta()
- .then((data) => {
- let realSize = 0;
-
- if (data) {
- realSize = data.real_size;
- }
-
- this.diffFilesLength = parseInt(realSize, 10) || 0;
- if (toggleTree) {
- this.setTreeDisplay();
- }
-
- this.updateChangesTabCount();
- })
- .catch(() => {
- createAlert({
- message: __('Something went wrong on our end. Please try again!'),
+ fetchData({ toggleTree = true, fetchMeta = true } = {}) {
+ if (fetchMeta) {
+ this.fetchDiffFilesMeta()
+ .then((data) => {
+ let realSize = 0;
+
+ if (data) {
+ realSize = data.real_size;
+
+ if (this.viewDiffsFileByFile && this.glFeatures.singleFileFileByFile) {
+ this.fetchFileByFile();
+ }
+ }
+
+ this.diffFilesLength = parseInt(realSize, 10) || 0;
+ if (toggleTree) {
+ this.setTreeDisplay();
+ }
+
+ updateChangesTabCount({
+ count: this.diffFilesLength,
+ });
+ })
+ .catch(() => {
+ createAlert({
+ message: __('Something went wrong on our end. Please try again!'),
+ });
});
- });
+ }
- this.fetchDiffFilesBatch()
- .then(() => {
- if (toggleTree) this.setTreeDisplay();
- // Guarantee the discussions are assigned after the batch finishes.
- // Just watching the length of the discussions or the diff files
- // isn't enough, because with split diff loading, neither will
- // change when loading the other half of the diff files.
- this.setDiscussions();
- })
- .catch(() => {
- createAlert({
- message: __('Something went wrong on our end. Please try again!'),
+ if (!this.viewDiffsFileByFile || !this.glFeatures.singleFileFileByFile) {
+ this.fetchDiffFilesBatch()
+ .then(() => {
+ if (toggleTree) this.setTreeDisplay();
+ // Guarantee the discussions are assigned after the batch finishes.
+ // Just watching the length of the discussions or the diff files
+ // isn't enough, because with split diff loading, neither will
+ // change when loading the other half of the diff files.
+ this.setDiscussions();
+ })
+ .catch(() => {
+ createAlert({
+ message: __('Something went wrong on our end. Please try again!'),
+ });
});
- });
+ }
if (this.endpointCoverage) {
this.fetchCoverageFiles();
@@ -572,8 +617,11 @@ 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.goToFile({
+ path: this.flatBlobsList[targetIndex].path,
+ singleFile: this.glFeatures.singleFileFileByFile,
+ });
}
},
setTreeDisplay() {
@@ -582,7 +630,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;
}
@@ -634,6 +682,11 @@ export default {
<template>
<div v-show="shouldShow">
+ <findings-drawer
+ v-if="glFeatures.codeQualityInlineDrawer"
+ :drawer="activeDrawer"
+ @close="closeDrawer"
+ />
<div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div>
<div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane">
<compare-versions :diff-files-count-text="numTotalFiles" />
@@ -753,7 +806,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" />
@@ -765,5 +818,11 @@ export default {
</div>
</div>
</div>
+ <generate-test-file-drawer
+ v-if="getNoteableData.id"
+ :resource-id="resourceId"
+ :file-path="generateTestFilePath"
+ @close="() => setGenerateTestFilePath('')"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 1857ff557e6..d050f2fb9ae 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
@@ -30,6 +30,7 @@ export default {
CommitPipelineStatus,
GlButtonGroup,
GlButton,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -117,12 +118,11 @@ export default {
</div>
<div>
<div class="d-flex float-left align-items-center align-self-start">
- <input
+ <gl-form-checkbox
v-if="isSelectable"
- class="gl-mr-3"
- type="checkbox"
:checked="checked"
- @change="$emit('handleCheckboxChange', $event.target.checked)"
+ class="gl-mt-3"
+ @change="$emit('handleCheckboxChange', !checked)"
/>
<user-avatar-link
:link-href="authorUrl"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
index 11aa856619b..f3f05e3d9d9 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -1,34 +1,26 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
-import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { GlButton } from '@gitlab/ui';
import { NEW_CODE_QUALITY_FINDINGS } from '../i18n';
+import DiffCodeQualityItem from './diff_code_quality_item.vue';
export default {
i18n: {
newFindings: NEW_CODE_QUALITY_FINDINGS,
},
- components: { GlButton, GlIcon },
+ components: { GlButton, DiffCodeQualityItem },
props: {
codeQuality: {
type: Array,
required: true,
},
},
- methods: {
- severityClass(severity) {
- return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
- },
- severityIcon(severity) {
- return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
- },
- },
};
</script>
<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"
@@ -37,23 +29,11 @@ export default {
{{ $options.i18n.newFindings }}
</h4>
<ul class="gl-list-style-none gl-mb-0 gl-p-0">
- <li
+ <diff-code-quality-item
v-for="finding in codeQuality"
:key="finding.description"
- class="gl-pt-1 gl-pb-1 gl-font-regular gl-display-flex"
- >
- <span class="gl-mr-3">
- <gl-icon
- :size="12"
- :name="severityIcon(finding.severity)"
- :class="severityClass(finding.severity)"
- class="codequality-severity-icon"
- />
- </span>
- <span>
- <span class="severity-copy">{{ finding.severity }}</span> - {{ finding.description }}
- </span>
- </li>
+ :finding="finding"
+ />
</ul>
<gl-button
data-testid="diff-codequality-close"
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality_item.vue b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
new file mode 100644
index 00000000000..eede110f46c
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_code_quality_item.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: { GlLink, GlIcon },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ finding: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ toggleDrawer() {
+ this.setDrawer(this.finding);
+ },
+ ...mapActions('findingsDrawer', ['setDrawer']),
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-1 gl-font-regular gl-display-flex">
+ <span class="gl-mr-3">
+ <gl-icon
+ :size="12"
+ :name="severityIcon(finding.severity)"
+ :class="severityClass(finding.severity)"
+ class="codequality-severity-icon"
+ />
+ </span>
+ <span
+ v-if="glFeatures.codeQualityInlineDrawer"
+ data-testid="description-button-section"
+ class="gl-display-flex"
+ >
+ <gl-link category="primary" variant="link" @click="toggleDrawer">
+ {{ finding.severity }} - {{ finding.description }}</gl-link
+ >
+ </span>
+ <span v-else data-testid="description-plain-text" class="gl-display-flex">
+ {{ finding.severity }} - {{ finding.description }}
+ </span>
+ </li>
+</template>
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..4c2cb83ffb3 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';
@@ -209,7 +209,11 @@ export default {
if (this.hasDiff) {
this.postRender();
- } else if (this.viewDiffsFileByFile && !this.isCollapsed) {
+ } else if (
+ this.viewDiffsFileByFile &&
+ !this.isCollapsed &&
+ !this.glFeatures.singleFileFileByFile
+ ) {
this.requestDiff();
}
@@ -246,11 +250,11 @@ export default {
async postRender() {
const eventsForThisFile = [];
- if (this.isFirstFile) {
+ if (this.isFirstFile || this.viewDiffsFileByFile) {
eventsForThisFile.push(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
}
- if (this.isLastFile) {
+ if (this.isLastFile || this.viewDiffsFileByFile) {
eventsForThisFile.push(EVT_PERF_MARK_DIFF_FILES_END);
}
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 16f45c3ad6a..792be3de1e5 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -50,6 +50,12 @@ export default {
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: __('Compare submodule commit revisions'),
+ fileModeTooltip: __('File permissions'),
+ },
+ inject: {
+ showGenerateTestFileButton: {
+ default: false,
+ },
},
props: {
discussionPath: {
@@ -201,6 +207,9 @@ export default {
externalUrlLabel() {
return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url });
},
+ labelToggleFile() {
+ return this.expanded ? __('Hide file contents') : __('Show file contents');
+ },
},
watch: {
'idState.moreActionsShown': {
@@ -223,6 +232,7 @@ export default {
'setCurrentFileHash',
'reviewFile',
'setFileCollapsedByUser',
+ 'setGenerateTestFilePath',
]),
handleToggleFile() {
this.$emit('toggleFile');
@@ -287,12 +297,14 @@ export default {
@click.self="handleToggleFile"
>
<div class="file-header-content">
- <gl-icon
+ <gl-button
v-if="collapsible"
- ref="collapseIcon"
- :name="collapseIcon"
- :size="16"
- class="diff-toggle-caret gl-mr-2"
+ ref="collapseButton"
+ class="gl-mr-2"
+ category="tertiary"
+ size="small"
+ :icon="collapseIcon"
+ :aria-label="labelToggleFile"
@click.stop="handleToggleFile"
/>
<a
@@ -342,7 +354,13 @@ export default {
data-track-property="diff_copy_file"
/>
- <small v-if="isModeChanged" ref="fileMode" class="mr-1">
+ <small
+ v-if="isModeChanged"
+ ref="fileMode"
+ v-gl-tooltip.hover
+ class="mr-1"
+ :title="$options.i18n.fileModeTooltip"
+ >
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -364,7 +382,7 @@ export default {
v-if="isReviewable && showLocalFileReviews"
v-gl-tooltip.hover
data-testid="fileReviewCheckbox"
- class="gl-mr-5 gl-display-flex gl-align-items-center"
+ class="gl-mr-5 gl-mb-n3 gl-display-flex gl-align-items-center"
:title="$options.i18n.fileReviewTooltip"
:checked="reviewed"
@change="toggleReview"
@@ -400,14 +418,6 @@ export default {
<gl-icon name="ellipsis_v" class="mr-0" />
<span class="sr-only">{{ $options.i18n.optionsDropdownTitle }}</span>
</template>
- <gl-dropdown-item
- v-if="diffFile.replaced_view_path"
- ref="replacedFileButton"
- :href="diffFile.replaced_view_path"
- target="_blank"
- >
- {{ viewReplacedFileButtonText }}
- </gl-dropdown-item>
<gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank">
{{ viewFileButtonText }}
</gl-dropdown-item>
@@ -431,6 +441,20 @@ export default {
>
{{ __('Open in Web IDE') }}
</gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showGenerateTestFileButton"
+ @click="setGenerateTestFilePath(diffFile.new_path)"
+ >
+ {{ __('Generate test with AI') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="diffFile.replaced_view_path"
+ ref="replacedFileButton"
+ :href="diffFile.replaced_view_path"
+ target="_blank"
+ >
+ {{ viewReplacedFileButtonText }}
+ </gl-dropdown-item>
</template>
<template v-if="!isCollapsed">
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index f63ab1bb067..43ba527dad8 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -8,7 +8,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils';
import NoteForm from '~/notes/components/note_form.vue';
-import autosave from '~/notes/mixins/autosave';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import {
DIFF_NOTE_TYPE,
INLINE_DIFF_LINES_KEY,
@@ -21,7 +21,7 @@ export default {
NoteForm,
MultilineCommentForm,
},
- mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()],
+ mixins: [diffLineNoteFormMixin, glFeatureFlagsMixin()],
props: {
diffFileHash: {
type: String,
@@ -146,6 +146,27 @@ export default {
return lines;
},
+ autosaveKey() {
+ if (!this.isLoggedIn) return '';
+
+ const {
+ id,
+ noteable_type: noteableTypeUnderscored,
+ noteableType,
+ diff_head_sha: diffHeadSha,
+ source_project_id: sourceProjectId,
+ } = this.noteableData;
+
+ return [
+ s__('Autosave|Note'),
+ capitalizeFirstCharacter(noteableTypeUnderscored || noteableType),
+ id,
+ diffHeadSha,
+ DIFF_NOTE_TYPE,
+ sourceProjectId,
+ this.line.line_code,
+ ].join('/');
+ },
},
created() {
if (this.range) {
@@ -155,17 +176,6 @@ export default {
}
},
mounted() {
- if (this.isLoggedIn) {
- const keys = [
- this.noteableData.diff_head_sha,
- DIFF_NOTE_TYPE,
- this.noteableData.source_project_id,
- this.line.line_code,
- ];
-
- this.initAutoSave(this.noteableData, keys);
- }
-
if (this.selectedCommentPosition) {
this.commentLineStart = this.selectedCommentPosition.start;
}
@@ -196,9 +206,6 @@ export default {
lineCode: this.line.line_code,
fileHash: this.diffFileHash,
});
- this.$nextTick(() => {
- this.resetAutoSave();
- });
}),
handleSaveNote(note) {
return this.saveDiffDiscussion({ note, formData: this.formData }).then(() =>
@@ -232,6 +239,7 @@ export default {
:diff-file="diffFile"
:show-suggest-popover="showSuggestPopover"
:save-button-title="__('Comment')"
+ :autosave-key="autosaveKey"
class="diff-comment-form gl-mt-3"
@handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
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/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
index e8b4ff16aec..7de8eff7863 100644
--- a/app/assets/javascripts/diffs/components/diff_stats.vue
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -76,7 +76,7 @@ export default {
class="diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center"
:class="{ bold: isCompareVersionsHeader }"
>
- <span>-</span>
+ <span>−</span>
<span data-testid="js-file-deletion-line">{{ removedLines }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index a2e052e0f93..348d6d1d78d 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,7 +1,6 @@
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
@@ -21,11 +20,7 @@ export default {
DiffCommentCell,
DraftNote,
},
- mixins: [
- draftCommentsMixin,
- IdState({ idProp: (vm) => vm.diffFile.file_hash }),
- glFeatureFlagsMixin(),
- ],
+ mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
props: {
diffFile: {
type: Object,
@@ -265,10 +260,7 @@ export default {
@stopdragging="onStopDragging"
/>
<diff-line
- v-if="
- glFeatures.refactorCodeQualityInlineFindings &&
- codeQualityExpandedLines.includes(getCodeQualityLine(line))
- "
+ v-if="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
:key="line.line_code"
:line="line"
@hideCodeQualityFindings="hideCodeQualityFindings"
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/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
index f6a8c679f3b..26d37484541 100644
--- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
@@ -1,17 +1,18 @@
<script>
-import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
export const i18n = {
- title: __('Too many changes to show.'),
+ title: __('Some changes are not shown.'),
plainDiff: __('Plain diff'),
- emailPatch: __('Email patch'),
+ emailPatch: __('Patches'),
};
export default {
i18n,
components: {
GlAlert,
+ GlButton,
GlSprintf,
},
props: {
@@ -38,18 +39,15 @@ export default {
<template>
<gl-alert
variant="warning"
+ class="gl-mx-5 gl-mb-4 gl-mt-3"
:title="$options.i18n.title"
- :primary-button-text="$options.i18n.plainDiff"
- :primary-button-link="plainDiffPath"
- :secondary-button-text="$options.i18n.emailPatch"
- :secondary-button-link="emailPatchPath"
:dismissible="false"
>
<gl-sprintf
:message="
sprintf(
__(
- 'To preserve performance only %{strongStart}%{visible} of %{total}%{strongEnd} files are displayed.',
+ 'For a faster browsing experience, only %{strongStart}%{visible} of %{total}%{strongEnd} files are shown. Download one of the files below to see all changes.',
),
{ visible, total } /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */,
)
@@ -59,5 +57,13 @@ export default {
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
+ <template #actions>
+ <gl-button :href="plainDiffPath" class="gl-mr-3 gl-alert-action">
+ {{ $options.i18n.plainDiff }}
+ </gl-button>
+ <gl-button :href="emailPatchPath" class="gl-alert-action">
+ {{ $options.i18n.emailPatch }}
+ </gl-button>
+ </template>
</gl-alert>
</template>
diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
new file mode 100644
index 00000000000..da880c6f3ca
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlDrawer, GlIcon, GlLink } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { s__ } from '~/locale';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+
+export const i18n = {
+ severity: s__('FindingsDrawer|Severity:'),
+ engine: s__('FindingsDrawer|Engine:'),
+ category: s__('FindingsDrawer|Category:'),
+ otherLocations: s__('FindingsDrawer|Other locations:'),
+};
+
+export default {
+ i18n,
+ components: { GlDrawer, GlIcon, GlLink },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ drawer: {
+ type: Object,
+ required: true,
+ },
+ },
+ safeHtmlConfig: {
+ ALLOWED_TAGS: ['a', 'h1', 'h2', 'p'],
+ ALLOWED_ATTR: ['href', 'rel'],
+ },
+ computed: {
+ drawerOffsetTop() {
+ return getContentWrapperHeight('.content-wrapper');
+ },
+ },
+ DRAWER_Z_INDEX,
+ methods: {
+ severityClass(severity) {
+ return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
+ },
+ severityIcon(severity) {
+ return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
+ },
+ },
+};
+</script>
+<template>
+ <gl-drawer
+ :header-height="drawerOffsetTop"
+ :z-index="$options.DRAWER_Z_INDEX"
+ class="findings-drawer"
+ :open="Object.keys(drawer).length !== 0"
+ @close="$emit('close')"
+ >
+ <template #title>
+ <h2 data-testid="findings-drawer-heading" class="gl-font-size-h2 gl-mt-0 gl-mb-0">
+ {{ drawer.description }}
+ </h2>
+ </template>
+
+ <template #default>
+ <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!">
+ <li data-testid="findings-drawer-severity" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.severity }}</span>
+ <gl-icon
+ data-testid="findings-drawer-severity-icon"
+ :size="12"
+ :name="severityIcon(drawer.severity)"
+ :class="severityClass(drawer.severity)"
+ class="codequality-severity-icon"
+ />
+
+ {{ drawer.severity }}
+ </li>
+ <li data-testid="findings-drawer-engine" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.engine }}</span>
+ {{ drawer.engineName }}
+ </li>
+ <li data-testid="findings-drawer-category" class="gl-mb-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.category }}</span>
+ {{ drawer.categories ? drawer.categories[0] : '' }}
+ </li>
+ <li data-testid="findings-drawer-other-locations" class="gl-mb-4">
+ <span class="gl-font-weight-bold gl-mb-3 gl-display-block">{{
+ $options.i18n.otherLocations
+ }}</span>
+ <ul class="gl-pl-6">
+ <li
+ v-for="otherLocation in drawer.otherLocations"
+ :key="otherLocation.path"
+ class="gl-mb-1"
+ >
+ <gl-link
+ data-testid="findings-drawer-other-locations-link"
+ :href="otherLocation.href"
+ >{{ otherLocation.path }}</gl-link
+ >
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <span
+ v-safe-html:[$options.safeHtmlConfig]="drawer.content ? drawer.content.body : ''"
+ data-testid="findings-drawer-body"
+ class="drawer-body gl-display-block gl-px-3 gl-py-0!"
+ ></span>
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 8bb1872567c..4f1875e9175 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -2,9 +2,11 @@
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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { RecycleScroller } from 'vendor/vue-virtual-scroller';
import DiffFileRow from './diff_file_row.vue';
const MODIFIER_KEY = getModifierKey();
@@ -15,8 +17,10 @@ export default {
},
components: {
GlIcon,
- FileTree,
+ DiffFileRow,
+ RecycleScroller,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
hideFileStats: {
type: Boolean,
@@ -26,6 +30,10 @@ export default {
data() {
return {
search: '',
+ scrollerHeight: 0,
+ resizeObserver: null,
+ rowHeight: 0,
+ debouncedHeightCalc: null,
};
},
computed: {
@@ -61,12 +69,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']),
+ ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']),
clearSearch() {
this.search = '';
},
+ calculateScrollerHeight() {
+ this.scrollerHeight = this.$refs.scrollRoot.clientHeight;
+ },
},
searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), {
MODIFIER_KEY,
@@ -76,10 +123,14 @@ 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" />
+ <gl-icon name="search" class="gl-absolute gl-top-5 tree-list-icon" />
<label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
<input
id="diff-tree-search"
@@ -89,6 +140,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 +153,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 mr-tree-list"
+ >
+ <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.level > 0 }"
+ class="gl-relative"
+ @toggleTreeOpen="toggleTreeOpen"
+ @clickFile="(path) => goToFile({ singleFile: glFeatures.singleFileFileByFile, 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..063e36fa7fb 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;
@@ -87,6 +79,7 @@ export const RENAMED_DIFF_TRANSITIONS = {
};
// MR Diffs known events
+export const EVT_MR_PREPARED = 'mr:asyncPreparationFinished';
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
@@ -120,3 +113,6 @@ export const TRACKING_WHITESPACE_HIDE = 'i_code_review_diff_hide_whitespace';
export const TRACKING_CLICK_SINGLE_FILE_SETTING = 'i_code_review_click_single_file_mode_setting';
export const TRACKING_SINGLE_FILE_MODE = 'i_code_review_diff_single_file';
export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files';
+
+// UI
+export const ZERO_CHANGES_ALT_DISPLAY = '-';
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index 0f44eb06cb3..e233a0cef0a 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -1,6 +1,12 @@
import { __, s__ } from '~/locale';
export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!');
+export const LOAD_SINGLE_DIFF_FAILED = s__(
+ "MergeRequest|Can't fetch the diff needed to update this view. Please reload this page.",
+);
+export const DISCUSSION_SINGLE_DIFF_FAILED = s__(
+ "MergeRequest|Can't fetch the single file diff for the discussion. Please reload this page.",
+);
export const DIFF_FILE_HEADER = {
optionsDropdownTitle: __('Options'),
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 7da5ef54b80..53c27632c4f 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,29 @@ 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: {
+ newCommentTemplatePath: dataset.newCommentTemplatePath,
+ showGenerateTestFileButton: parseBoolean(dataset.showGenerateTestFileButton),
+ },
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 +97,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..0668551902a 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,9 @@ 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 { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
@@ -46,13 +49,14 @@ import {
TRACKING_CLICK_SINGLE_FILE_SETTING,
TRACKING_SINGLE_FILE_MODE,
TRACKING_MULTIPLE_FILES_MODE,
+ EVT_MR_PREPARED,
} from '../constants';
+import { DISCUSSION_SINGLE_DIFF_FAILED, LOAD_SINGLE_DIFF_FAILED } from '../i18n';
import eventHub from '../event_hub';
import { isCollapsed } from '../utils/diff_file';
import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews';
import { getDerivedMergeRequestInformation } from '../utils/merge_request';
import { queueRedisHllEvents } from '../utils/queue_events';
-import TreeWorker from '../workers/tree_worker?worker';
import * as types from './mutation_types';
import {
getDiffPositionByLineCode,
@@ -61,6 +65,8 @@ import {
idleCallback,
allDiscussionWrappersExpanded,
prepareLineForRenamedFile,
+ parseUrlHashAsFileHash,
+ isUrlHashNoteLink,
} from './utils';
export const setBaseConfig = ({ commit }, options) => {
@@ -68,6 +74,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -81,6 +88,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpoint,
endpointMetadata,
endpointBatch,
+ endpointDiffForPath,
endpointCoverage,
endpointUpdateUser,
projectPath,
@@ -98,6 +106,58 @@ export const setBaseConfig = ({ commit }, options) => {
});
};
+export const fetchFileByFile = async ({ state, getters, commit }) => {
+ const isNoteLink = isUrlHashNoteLink(window?.location?.hash);
+ const id = parseUrlHashAsFileHash(window?.location?.hash, state.currentDiffFileId);
+ const versionPath = state.mergeRequestDiff?.version_path;
+ const treeEntry = id
+ ? getters.flatBlobsList.find(({ fileHash }) => fileHash === id)
+ : getters.flatBlobsList[0];
+
+ eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START);
+
+ if (treeEntry && !treeEntry.diffLoaded && !getters.getDiffFileByHash(id)) {
+ // Overloading "batch" loading indicators so the UI stays mostly the same
+ commit(types.SET_BATCH_LOADING_STATE, 'loading');
+ commit(types.SET_RETRIEVING_BATCHES, true);
+
+ const urlParams = {
+ old_path: treeEntry.filePaths.old,
+ new_path: treeEntry.filePaths.new,
+ w: state.showWhitespace ? '0' : '1',
+ view: 'inline',
+ commit_id: getters.commitId,
+ };
+
+ if (versionPath) {
+ const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath });
+
+ urlParams.diff_id = diffId;
+ urlParams.start_sha = startSha;
+ }
+
+ axios
+ .get(mergeUrlParams({ ...urlParams }, state.endpointDiffForPath))
+ .then(({ data: diffData }) => {
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffData.diff_files });
+
+ if (!isNoteLink && !state.currentDiffFileId) {
+ commit(types.SET_CURRENT_DIFF_FILE, state.diffFiles[0]?.file_hash || '');
+ }
+
+ commit(types.SET_BATCH_LOADING_STATE, 'loaded');
+
+ eventHub.$emit('diffFilesModified');
+ })
+ .catch(() => {
+ commit(types.SET_BATCH_LOADING_STATE, 'error');
+ })
+ .finally(() => {
+ commit(types.SET_RETRIEVING_BATCHES, false);
+ });
+ }
+};
+
export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
let perPage = state.viewDiffsFileByFile ? 1 : 5;
let increaseAmount = 1.4;
@@ -199,21 +259,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 +276,28 @@ 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.'),
+ const alert = createAlert({
+ message: __(
+ 'Building your merge request… This page will update when the build is complete.',
+ ),
variant: VARIANT_WARNING,
});
+
+ eventHub.$once(EVT_MR_PREPARED, () => alert.dismiss());
+ } else {
+ throw error;
}
});
};
@@ -512,13 +573,20 @@ export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
}
};
-export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
+export const saveDiffDiscussion = async ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({
commit: state.commit,
note,
...formData,
});
+ if (containsSensitiveToken(note)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ return null;
+ }
+ }
+
return dispatch('saveNote', postData, { root: true })
.then((result) => dispatch('updateDiscussion', result.discussion, { root: true }))
.then((discussion) => dispatch('assignDiscussionsToDiff', [discussion]))
@@ -539,6 +607,31 @@ export const setCurrentFileHash = ({ commit }, hash) => {
commit(types.SET_CURRENT_DIFF_FILE, hash);
};
+export const goToFile = ({ state, commit, dispatch, getters }, { path, singleFile }) => {
+ if (!state.viewDiffsFileByFile || !singleFile) {
+ dispatch('scrollToFile', { path });
+ } else {
+ if (!state.treeEntries[path]) return;
+
+ const { fileHash } = state.treeEntries[path];
+
+ commit(types.SET_CURRENT_DIFF_FILE, fileHash);
+ document.location.hash = fileHash;
+
+ if (!getters.isTreePathLoaded(path)) {
+ dispatch('fetchFileByFile')
+ .then(() => {
+ dispatch('scrollToFile', { path });
+ })
+ .catch(() => {
+ createAlert({
+ message: LOAD_SINGLE_DIFF_FAILED,
+ });
+ });
+ }
+ }
+};
+
export const scrollToFile = ({ state, commit, getters }, { path }) => {
if (!state.treeEntries[path]) return;
@@ -779,13 +872,11 @@ export const setSuggestPopoverDismissed = ({ commit, state }) =>
});
export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) {
- /* eslint-disable @gitlab/require-i18n-strings */
if (!commitId) {
return Promise.reject(new Error('`commitId` is a required argument'));
} else if (!state.commit) {
- return Promise.reject(new Error('`state` must already contain a valid `commit`'));
+ return Promise.reject(new Error('`state` must already contain a valid `commit`')); // eslint-disable-line @gitlab/require-i18n-strings
}
- /* eslint-enable @gitlab/require-i18n-strings */
// this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421
const commitRE = new RegExp(state.commit.id, 'g');
@@ -821,23 +912,48 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
}
}
-export const setCurrentDiffFileIdFromNote = ({ commit, state, rootGetters }, noteId) => {
+export const rereadNoteHash = ({ state, dispatch }) => {
+ const urlHash = window?.location?.hash;
+
+ if (isUrlHashNoteLink(urlHash)) {
+ dispatch('setCurrentDiffFileIdFromNote', urlHash.split('_').pop())
+ .then(() => {
+ if (state.viewDiffsFileByFile) {
+ dispatch('fetchFileByFile');
+ }
+ })
+ .catch(() => {
+ createAlert({
+ message: DISCUSSION_SINGLE_DIFF_FAILED,
+ });
+ });
+ }
+};
+
+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 = (
+ { state, getters, commit, dispatch },
+ { index, singleFile },
+) => {
+ const { fileHash } = getters.flatBlobsList[index];
document.location.hash = fileHash;
commit(types.SET_CURRENT_DIFF_FILE, fileHash);
+
+ if (state.viewDiffsFileByFile && singleFile) {
+ dispatch('fetchFileByFile');
+ }
};
export const setFileByFile = ({ state, commit }, { fileByFile }) => {
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..4ca353333b7 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/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js
index bcd9fa01278..e2fb24f7b57 100644
--- a/app/assets/javascripts/diffs/utils/diff_file.js
+++ b/app/assets/javascripts/diffs/utils/diff_file.js
@@ -39,12 +39,12 @@ function collapsed(file) {
}
function identifier(file) {
- const { userOrGroup, project, id } = getDerivedMergeRequestInformation({
+ const { namespace, project, id } = getDerivedMergeRequestInformation({
endpoint: file.load_collapsed_diff_url,
});
return uuids({
- seeds: [userOrGroup, project, id, file.file_identifier_hash, file.blob?.id],
+ seeds: [namespace, project, id, file.file_identifier_hash, file.blob?.id],
})[0];
}
diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js
index 43e04a814c5..bc81c0b0a05 100644
--- a/app/assets/javascripts/diffs/utils/merge_request.js
+++ b/app/assets/javascripts/diffs/utils/merge_request.js
@@ -1,4 +1,6 @@
-const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i;
+import { ZERO_CHANGES_ALT_DISPLAY } from '../constants';
+
+const endpointRE = /^(\/?(.+\/)+(.+)\/-\/merge_requests\/(\d+)).*$/i;
function getVersionInfo({ endpoint } = {}) {
const dummyRoot = 'https://gitlab.com';
@@ -13,9 +15,20 @@ function getVersionInfo({ endpoint } = {}) {
};
}
+export function updateChangesTabCount({
+ count,
+ badge = document.querySelector('.js-diffs-tab .gl-badge'),
+} = {}) {
+ if (badge) {
+ // The purpose of this function is to assign to this parameter
+ /* eslint-disable-next-line no-param-reassign */
+ badge.textContent = count || ZERO_CHANGES_ALT_DISPLAY;
+ }
+}
+
export function getDerivedMergeRequestInformation({ endpoint } = {}) {
let mrPath;
- let userOrGroup;
+ let namespace;
let project;
let id;
let diffId;
@@ -23,13 +36,15 @@ export function getDerivedMergeRequestInformation({ endpoint } = {}) {
const matches = endpointRE.exec(endpoint);
if (matches) {
- [, mrPath, userOrGroup, project, id] = matches;
+ [, mrPath, namespace, project, id] = matches;
({ diffId, startSha } = getVersionInfo({ endpoint }));
+
+ namespace = namespace.replace(/\/$/, '');
}
return {
mrPath,
- userOrGroup,
+ namespace,
project,
id,
diffId,
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/docs/docs_bundle.js b/app/assets/javascripts/docs/docs_bundle.js
index 897439f56b0..32aa4a22cba 100644
--- a/app/assets/javascripts/docs/docs_bundle.js
+++ b/app/assets/javascripts/docs/docs_bundle.js
@@ -1,4 +1,4 @@
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
function addMousetrapClick(el, key) {
el.addEventListener('click', () => Mousetrap.trigger(key));
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..0afee7bebe0 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-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..9ec1a97ba1a 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) => {
@@ -37,8 +37,6 @@ const setupDomElement = ({ injectToEl = null } = {}) => {
return previewEl;
};
-let dimResize = false;
-
export class EditorMarkdownPreviewExtension {
static get extensionName() {
return 'EditorMarkdownPreview';
@@ -53,7 +51,6 @@ export class EditorMarkdownPreviewExtension {
},
shown: false,
modelChangeListener: undefined,
- layoutChangeListener: undefined,
path: setupOptions.previewMarkdownPath,
actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true),
eventEmitter: new Emitter(),
@@ -65,13 +62,17 @@ export class EditorMarkdownPreviewExtension {
this.setupToolbar(instance);
}
- this.preview.layoutChangeListener = instance.onDidLayoutChange(() => {
- if (instance.markdownPreview?.shown && !dimResize) {
- const { width } = instance.getLayoutInfo();
- const newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
- EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
+ const debouncedResizeHandler = debounce((entries) => {
+ for (const entry of entries) {
+ const { width: newInstanceWidth } = entry.contentRect;
+ if (instance.markdownPreview?.shown) {
+ const newWidth = newInstanceWidth * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
+ }
}
- });
+ }, 50);
+
+ this.resizeObserver = new ResizeObserver(debouncedResizeHandler);
this.preview.eventEmitter.event(this.togglePreview.bind(this, instance));
}
@@ -85,9 +86,7 @@ export class EditorMarkdownPreviewExtension {
}
cleanup(instance) {
- if (this.preview.layoutChangeListener) {
- this.preview.layoutChangeListener.dispose();
- }
+ this.resizeObserver.disconnect();
if (this.preview.modelChangeListener) {
this.preview.modelChangeListener.dispose();
}
@@ -102,11 +101,7 @@ export class EditorMarkdownPreviewExtension {
static resizePreviewLayout(instance, width) {
const { height } = instance.getLayoutInfo();
- dimResize = true;
instance.layout({ width, height });
- window.requestAnimationFrame(() => {
- dimResize = false;
- });
}
setupToolbar(instance) {
@@ -116,7 +111,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(),
@@ -130,9 +125,16 @@ export class EditorMarkdownPreviewExtension {
togglePreviewLayout(instance) {
const { width } = instance.getLayoutInfo();
- const newWidth = this.preview.shown
- ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
- : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ let newWidth;
+ if (this.preview.shown) {
+ // This means the preview is to be closed at the next step
+ newWidth = width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ this.resizeObserver.disconnect();
+ } else {
+ // The preview is hidden, but is in the process to be opened
+ newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ this.resizeObserver.observe(instance.getContainerDomNode());
+ }
EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 57477a993c5..d240ad7353a 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)."
}
}
}
@@ -356,6 +360,9 @@
},
"rules": {
"$ref": "#/definitions/rules"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -391,6 +398,9 @@
}
}
]
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -407,6 +417,9 @@
"type": "string",
"format": "uri-reference",
"pattern": "\\.ya?ml$"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -421,6 +434,9 @@
"description": "Local path to component directory or full path to external component directory.",
"type": "string",
"format": "uri-reference"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -436,6 +452,9 @@
"type": "string",
"format": "uri-reference",
"pattern": "^https?://.+\\.ya?ml$"
+ },
+ "inputs": {
+ "$ref": "#/definitions/inputs"
}
},
"required": [
@@ -537,7 +556,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 +591,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 +599,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": [
@@ -751,6 +774,9 @@
},
"allow_failure": {
"$ref": "#/definitions/allow_failure"
+ },
+ "needs": {
+ "$ref": "#/definitions/rulesNeeds"
}
}
},
@@ -913,6 +939,39 @@
"markdownDescription": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job. e.g. '5', 5 seconds, 30 minutes, 1 week, etc. [Learn More](https://docs.gitlab.com/ee/ci/jobs/job_control.html#run-a-job-after-a-delay).",
"minLength": 1
},
+ "rulesNeeds": {
+ "markdownDescription": "Use needs in rules to update job needs for specific conditions. When a condition matches a rule, the job's needs configuration is completely replaced with the needs in the rule. [Learn More](https://docs.gitlab.com/ee/ci/yaml/index.html#rulesneeds).",
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "job": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Name of a job that is defined in the pipeline."
+ },
+ "artifacts": {
+ "type": "boolean",
+ "description": "Download artifacts of the job in needs."
+ },
+ "optional": {
+ "type": "boolean",
+ "description": "Whether the job needs to be present in the pipeline to run ahead of the current job."
+ }
+ },
+ "required": [
+ "job"
+ ]
+ }
+ ]
+ }
+ },
"allow_failure": {
"markdownDescription": "Allow job to fail. A failed job does not cause the pipeline to fail. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#allow_failure).",
"oneOf": [
@@ -1033,6 +1092,14 @@
"on_failure",
"always"
]
+ },
+ "fallback_keys": {
+ "type": "array",
+ "markdownDescription": "List of keys to download cache from if no cache hit occurred for key",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 5
}
}
},
@@ -1244,6 +1311,10 @@
"markdownDescription": "Interruptible is used to indicate that a job should be canceled if made redundant by a newer pipeline run. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#interruptible).",
"default": false
},
+ "inputs": {
+ "markdownDescription": "Used to pass input values to included templates or components. [Learn More](https://docs.gitlab.com/ee/ci/yaml/includes.html#set-input-parameter-values-with-includeinputs).",
+ "type": "object"
+ },
"job": {
"allOf": [
{
@@ -1886,6 +1957,10 @@
}
},
"additionalProperties": false
+ },
+ "publish": {
+ "description": "A path to a directory that contains the files to be published with Pages",
+ "type": "string"
}
},
"oneOf": [
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/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
index 427a504e038..677c11277a3 100644
--- a/app/assets/javascripts/emoji/awards_app/store/actions.js
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -2,8 +2,6 @@ import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import { normalizeHeaders } from '~/lib/utils/common_utils';
import { joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import showToast from '~/vue_shared/plugins/global_toast';
import {
SET_INITIAL_DATA,
FETCH_AWARDS_SUCCESS,
@@ -62,8 +60,6 @@ export const toggleAward = async ({ commit, state }, name) => {
throw err;
});
-
- showToast(__('Award removed'));
} else {
const optimisticAward = newOptimisticAward(name, state);
@@ -78,8 +74,6 @@ export const toggleAward = async ({ commit, state }, name) => {
});
commit(ADD_NEW_AWARD, data);
-
- showToast(__('Award added'));
}
} catch (error) {
Sentry.captureException(error);
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/emoji/index.js b/app/assets/javascripts/emoji/index.js
index b9392fabcbd..4484bc03737 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -232,8 +232,8 @@ export function emojiImageTag(name, src) {
title: `:${name}:`,
alt: `:${name}:`,
src,
- width: '20',
- height: '20',
+ width: '16',
+ height: '16',
align: 'absmiddle',
});
diff --git a/app/assets/javascripts/entrypoints/super_sidebar.js b/app/assets/javascripts/entrypoints/super_sidebar.js
new file mode 100644
index 00000000000..6e88a998096
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/super_sidebar.js
@@ -0,0 +1,6 @@
+import '~/webpack';
+import '~/commons';
+import { initSuperSidebar, initSuperSidebarToggle } from '~/super_sidebar/super_sidebar_bundle';
+
+initSuperSidebar();
+initSuperSidebarToggle();
diff --git a/app/assets/javascripts/entrypoints/tracker.js b/app/assets/javascripts/entrypoints/tracker.js
new file mode 100644
index 00000000000..91d19d249b3
--- /dev/null
+++ b/app/assets/javascripts/entrypoints/tracker.js
@@ -0,0 +1,50 @@
+import {
+ newTracker,
+ enableActivityTracking,
+ trackPageView,
+ setDocumentTitle,
+ trackStructEvent,
+ setCustomUrl,
+ setReferrerUrl,
+} from '@snowplow/browser-tracker';
+import {
+ enableLinkClickTracking,
+ LinkClickTrackingPlugin,
+} from '@snowplow/browser-plugin-link-click-tracking';
+import { enableFormTracking, FormTrackingPlugin } from '@snowplow/browser-plugin-form-tracking';
+import { TimezonePlugin } from '@snowplow/browser-plugin-timezone';
+import { GaCookiesPlugin } from '@snowplow/browser-plugin-ga-cookies';
+import { PerformanceTimingPlugin } from '@snowplow/browser-plugin-performance-timing';
+import { ClientHintsPlugin } from '@snowplow/browser-plugin-client-hints';
+
+const SNOWPLOW_ACTIONS = {
+ newTracker,
+ enableActivityTracking,
+ trackPageView,
+ setDocumentTitle,
+ trackStructEvent,
+ enableLinkClickTracking,
+ enableFormTracking,
+ setCustomUrl,
+ setReferrerUrl,
+};
+
+window.snowplow = (action, ...config) => {
+ if (SNOWPLOW_ACTIONS[action]) {
+ SNOWPLOW_ACTIONS[action](...config);
+ } else {
+ // eslint-disable-next-line no-console, @gitlab/require-i18n-strings
+ console.warn('Unsupported snowplow action:', action);
+ }
+};
+
+window.snowplowPlugins = [
+ LinkClickTrackingPlugin(),
+ FormTrackingPlugin(),
+ TimezonePlugin(),
+ GaCookiesPlugin(),
+ PerformanceTimingPlugin(),
+ ClientHintsPlugin(),
+];
+
+export default {};
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..2cf71de7ea2 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -3,6 +3,7 @@
* Render modal to confirm rollback/redeploy.
*/
import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { escape } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
@@ -75,7 +76,12 @@ export default {
if (this.hasMultipleCommits) {
if (this.graphql) {
const { lastDeployment } = this.environment;
- return this.commitData(lastDeployment, 'commitPath');
+ return (
+ // data shape comming from REST and GraphQL is unfortunately different
+ // once we fully migrate to GraphQL it could be streamlined
+ this.commitData(lastDeployment, 'commitPath') ||
+ this.commitData(lastDeployment, 'webUrl')
+ );
}
const { last_deployment } = this.environment;
@@ -120,10 +126,17 @@ export default {
},
onOk() {
if (this.graphql) {
- this.$apollo.mutate({
- mutation: rollbackEnvironment,
- variables: { environment: this.environment },
- });
+ this.$apollo
+ .mutate({
+ mutation: rollbackEnvironment,
+ variables: { environment: this.environment },
+ })
+ .then(() => {
+ this.$emit('rollback');
+ })
+ .catch((e) => {
+ Sentry.captureException(e);
+ });
} else {
eventHub.$emit('rollbackEnvironment', this.environment);
}
@@ -135,7 +148,6 @@ export default {
csrf,
cancelProps: {
text: __('Cancel'),
- attributes: [{ variant: 'danger' }],
},
docsPath: helpPagePath('ci/environments/index.md', { anchor: 'retry-or-roll-back-a-deployment' }),
};
@@ -157,7 +169,7 @@ export default {
}}</gl-link>
</template>
<template #docs="{ content }">
- <gl-link :href="$options.docsLink" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="$options.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-modal>
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/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 31bc462f0b9..b2843b79ba6 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -158,7 +158,7 @@ export default {
>{{ instanceTitle }} ({{ instanceCount }})</span
>
<span ref="legend-icon" data-testid="legend-tooltip-target">
- <gl-icon class="gl-text-blue-500 gl-ml-2" name="question" />
+ <gl-icon class="gl-text-blue-500 gl-ml-2" name="question-o" />
</span>
<gl-tooltip :target="() => $refs['legend-icon']" boundary="#content-body">
<div class="deploy-board-legend gl-display-flex gl-flex-direction-column">
diff --git a/app/assets/javascripts/environments/components/deploy_freeze_alert.vue b/app/assets/javascripts/environments/components/deploy_freeze_alert.vue
new file mode 100644
index 00000000000..aaa7e71758c
--- /dev/null
+++ b/app/assets/javascripts/environments/components/deploy_freeze_alert.vue
@@ -0,0 +1,79 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { sortBy } from 'lodash';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+import deployFreezesQuery from '../graphql/queries/deploy_freezes.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['projectFullPath'],
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { deployFreezes: [] };
+ },
+
+ apollo: {
+ deployFreezes: {
+ query: deployFreezesQuery,
+ update(data) {
+ const freezes = data?.project?.environment?.deployFreezes;
+ return sortBy(freezes, [(freeze) => freeze.startTime]);
+ },
+ variables() {
+ return {
+ projectFullPath: this.projectFullPath,
+ environmentName: this.name,
+ };
+ },
+ },
+ },
+ computed: {
+ shouldShowDeployFreezeAlert() {
+ return this.deployFreezes.length > 0;
+ },
+ nextDeployFreeze() {
+ return this.deployFreezes[0];
+ },
+ deployFreezeStartTime() {
+ return formatDate(this.nextDeployFreeze.startTime);
+ },
+ deployFreezeEndTime() {
+ return formatDate(this.nextDeployFreeze.endTime);
+ },
+ },
+ i18n: {
+ deployFreezeAlert: s__(
+ 'Environments|A freeze period is in effect from %{startTime} to %{endTime}. Deployments might fail during this time. For more information, see the %{docsLinkStart}deploy freeze documentation%{docsLinkEnd}.',
+ ),
+ },
+ deployFreezeDocsPath: helpPagePath('user/project/releases/index', {
+ anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze',
+ }),
+};
+</script>
+<template>
+ <gl-alert v-if="shouldShowDeployFreezeAlert" :dismissible="false" class="gl-mt-4">
+ <gl-sprintf :message="$options.i18n.deployFreezeAlert">
+ <template #startTime
+ ><span class="gl-font-weight-bold">{{ deployFreezeStartTime }}</span></template
+ >
+ <template #endTime
+ ><span class="gl-font-weight-bold">{{ deployFreezeEndTime }}</span></template
+ >
+ <template #docsLink="{ content }"
+ ><gl-link :href="$options.deployFreezeDocsPath">{{ content }}</gl-link></template
+ >
+ </gl-sprintf>
+ </gl-alert>
+</template>
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/empty_state.vue b/app/assets/javascripts/environments/components/empty_state.vue
index e40c37b5095..3ac32f0d045 100644
--- a/app/assets/javascripts/environments/components/empty_state.vue
+++ b/app/assets/javascripts/environments/components/empty_state.vue
@@ -1,12 +1,13 @@
<script>
-import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { ENVIRONMENTS_SCOPE } from '../constants';
export default {
components: {
+ GlButton,
GlEmptyState,
GlLink,
+ GlSprintf,
},
inject: ['newEnvironmentPath'],
props: {
@@ -14,10 +15,6 @@ export default {
type: String,
required: true,
},
- scope: {
- type: String,
- required: true,
- },
hasTerm: {
type: Boolean,
required: false,
@@ -26,40 +23,40 @@ export default {
},
computed: {
title() {
- return this.hasTerm
- ? this.$options.i18n.searchingTitle
- : this.$options.i18n.title[this.scope];
+ return this.hasTerm ? this.$options.i18n.searchingTitle : this.$options.i18n.title;
},
content() {
return this.hasTerm ? this.$options.i18n.searchingContent : this.$options.i18n.content;
},
- buttonText() {
- return this.hasTerm ? this.$options.i18n.newEnvironmentButtonLabel : '';
- },
},
i18n: {
- title: {
- [ENVIRONMENTS_SCOPE.AVAILABLE]: s__("Environments|You don't have any environments."),
- [ENVIRONMENTS_SCOPE.STOPPED]: s__("Environments|You don't have any stopped environments."),
- },
- content: s__(
- 'Environments|Environments are places where code gets deployed, such as staging or production.',
- ),
searchingTitle: s__('Environments|No results found'),
+ title: s__('Environments|Get started with environments'),
searchingContent: s__('Environments|Edit your search and try again'),
- link: s__('Environments|How do I create an environment?'),
- newEnvironmentButtonLabel: s__('Environments|New environment'),
+ content: s__(
+ 'Environments|Environments are places where code gets deployed, such as staging or production. You can create an environment in the UI or in your .gitlab-ci.yml file. You can also enable review apps, which assist with providing an environment to showcase product changes. %{linkStart}Learn more%{linkEnd} about environments.',
+ ),
+ newEnvironmentButtonLabel: s__('Environments|Create an environment'),
+ enablingReviewButtonLabel: s__('Environments|Enable review apps'),
},
};
</script>
<template>
- <gl-empty-state :primary-button-text="buttonText" :primary-button-link="newEnvironmentPath">
- <template #title>
- <h4>{{ title }}</h4>
- </template>
+ <gl-empty-state class="gl-layout-w-limited" :title="title">
<template #description>
- <p>{{ content }}</p>
- <gl-link v-if="!hasTerm" :href="helpPath">{{ $options.i18n.link }}</gl-link>
+ <gl-sprintf :message="content">
+ <template #link="{ content: contentToDisplay }">
+ <gl-link :href="helpPath">{{ contentToDisplay }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-if="!hasTerm" #actions>
+ <gl-button :href="newEnvironmentPath" variant="confirm">
+ {{ $options.i18n.newEnvironmentButtonLabel }}
+ </gl-button>
+ <gl-button @click="$emit('enable-review')">
+ {{ $options.i18n.enablingReviewButtonLabel }}
+ </gl-button>
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 74eef50ebaf..d49598d2f21 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { formatTime } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
@@ -7,12 +7,9 @@ import eventHub from '../event_hub';
import actionMutation from '../graphql/mutations/action.mutation.graphql';
export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdown,
GlIcon,
},
props: {
@@ -36,6 +33,16 @@ export default {
title() {
return __('Deploy to...');
},
+ actionItems() {
+ return this.actions.map((actionItem) => ({
+ text: actionItem.name,
+ action: () => this.onClickAction(actionItem),
+ extraAttrs: {
+ disabled: this.isActionDisabled(actionItem),
+ },
+ ...actionItem,
+ }));
+ },
},
methods: {
async onClickAction(action) {
@@ -48,7 +55,6 @@ export default {
);
const confirmed = await confirmAction(confirmationMessage);
-
if (!confirmed) {
return;
}
@@ -80,30 +86,31 @@ export default {
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
+ <gl-disclosure-dropdown
:text="title"
:title="title"
:loading="isLoading"
:aria-label="title"
+ :items="actionItems"
icon="play"
text-sr-only
right
data-container="body"
data-testid="environment-actions-button"
>
- <gl-dropdown-item
- v-for="(action, i) in actions"
- :key="i"
- :disabled="isActionDisabled(action)"
+ <gl-disclosure-dropdown-item
+ v-for="item in actionItems"
+ :key="item.name"
+ :item="item"
data-testid="manual-action-link"
- @click="onClickAction(action)"
>
- <span class="gl-flex-grow-1">{{ action.name }}</span>
- <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right">
- <gl-icon name="clock" />
- {{ remainingTime(action) }}
- </span>
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item>
+ <span class="gl-flex-grow-1">{{ item.text }}</span>
+ <span v-if="item.scheduledAt" class="gl-text-gray-500 float-right">
+ <gl-icon name="clock" />
+ {{ remainingTime(item) }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index 04a390fbba7..27f322eaf93 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -1,8 +1,6 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import { s__, __ } from '~/locale';
-import { isSafeURL } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
/**
* Renders the external url link in environments table.
@@ -10,7 +8,6 @@ import { isSafeURL } from '~/lib/utils/url_utility';
export default {
components: {
GlButton,
- ModalCopyButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -24,23 +21,16 @@ export default {
i18n: {
title: s__('Environments|Open live environment'),
open: s__('Environments|Open'),
- copy: __('Copy URL'),
- copyTitle: s__('Environments|Copy live environment URL'),
- },
- computed: {
- isSafeUrl() {
- return isSafeURL(this.externalUrl);
- },
},
};
</script>
<template>
<gl-button
- v-if="isSafeUrl"
v-gl-tooltip
:title="$options.i18n.title"
:aria-label="$options.i18n.title"
:href="externalUrl"
+ is-unsafe-link
class="external-url"
target="_blank"
icon="external-link"
@@ -48,7 +38,4 @@ export default {
>
{{ $options.i18n.open }}
</gl-button>
- <modal-copy-button v-else :title="$options.i18n.copyTitle" :text="externalUrl">
- {{ $options.i18n.copy }}
- </modal-copy-button>
</template>
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/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 1e9924246b9..1486a66fe13 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -16,6 +16,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import DeleteComponent from './environment_delete.vue';
@@ -56,7 +57,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
model: {
@@ -532,6 +533,10 @@ export default {
return this.model.metrics_path || '';
},
+ canShowMetricsLink() {
+ return Boolean(!this.glFeatures.removeMonitorMetrics && this.monitoringUrl);
+ },
+
terminalPath() {
return this.model?.terminal_path ?? '';
},
@@ -544,7 +549,7 @@ export default {
return (
this.actions.length > 0 ||
this.externalURL ||
- this.monitoringUrl ||
+ this.canShowMetricsLink ||
this.canStopEnvironment ||
this.canDeleteEnvironment ||
this.canRetry
@@ -568,7 +573,7 @@ export default {
return Boolean(
this.canRetry ||
this.canShowAutoStopDate ||
- this.monitoringUrl ||
+ this.canShowMetricsLink ||
this.terminalPath ||
this.canDeleteEnvironment,
);
@@ -856,10 +861,11 @@ export default {
/>
<monitoring-button-component
- v-if="monitoringUrl"
+ v-if="canShowMetricsLink"
:monitoring-url="monitoringUrl"
data-track-action="click_button"
data-track-label="environment_monitoring"
+ data-testid="environment-monitoring"
/>
<terminal-button-component
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index b2a69cdb6c6..a95b5b273f7 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -76,7 +76,7 @@ export default {
inject: ['newEnvironmentPath', 'canCreateEnvironment', 'helpPagePath'],
i18n: {
newEnvironmentButtonLabel: s__('Environments|New environment'),
- reviewAppButtonLabel: s__('Environments|Enable review app'),
+ reviewAppButtonLabel: s__('Environments|Enable review apps'),
cleanUpEnvsButtonLabel: s__('Environments|Clean up environments'),
available: __('Available'),
stopped: __('Stopped'),
@@ -124,12 +124,24 @@ export default {
hasEnvironments() {
return this.environments.length > 0 || this.folders.length > 0;
},
+ showEmptyState() {
+ return !this.$apollo.queries.environmentApp.loading && !this.hasEnvironments;
+ },
hasSearch() {
return Boolean(this.search);
},
availableCount() {
return this.environmentApp?.availableCount;
},
+ stoppedCount() {
+ return this.environmentApp?.stoppedCount;
+ },
+ hasAnyEnvironment() {
+ return this.availableCount > 0 || this.stoppedCount > 0;
+ },
+ showContent() {
+ return this.hasAnyEnvironment || this.hasSearch;
+ },
addEnvironment() {
if (!this.canCreateEnvironment) {
return null;
@@ -170,9 +182,6 @@ export default {
},
};
},
- stoppedCount() {
- return this.environmentApp?.stoppedCount;
- },
totalItems() {
return this.pageInfo?.total;
},
@@ -253,45 +262,45 @@ export default {
<stop-environment-modal :environment="environmentToStop" graphql />
<confirm-rollback-modal :environment="environmentToRollback" graphql />
<canary-update-modal :environment="environmentToChangeCanary" :weight="weight" />
- <gl-tabs
- :action-secondary="openReviewAppModal"
- :action-primary="openCleanUpEnvsModal"
- :action-tertiary="addEnvironment"
- sync-active-tab-with-query-params
- query-param-name="scope"
- @secondary="showReviewAppModal"
- @primary="showCleanUpEnvsModal"
- >
- <gl-tab
- :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE"
- @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)"
+ <template v-if="showContent">
+ <gl-tabs
+ :action-secondary="openReviewAppModal"
+ :action-primary="openCleanUpEnvsModal"
+ :action-tertiary="addEnvironment"
+ sync-active-tab-with-query-params
+ query-param-name="scope"
+ @secondary="showReviewAppModal"
+ @primary="showCleanUpEnvsModal"
>
- <template #title>
- <span>{{ $options.i18n.available }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ availableCount }}
- </gl-badge>
- </template>
- </gl-tab>
- <gl-tab
- :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED"
- @click="setScope($options.ENVIRONMENTS_SCOPE.STOPPED)"
- >
- <template #title>
- <span>{{ $options.i18n.stopped }}</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">
- {{ stoppedCount }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
- <gl-search-box-by-type
- class="gl-mb-4"
- :value="search"
- :placeholder="$options.i18n.searchPlaceholder"
- @input="setSearch"
- />
- <template v-if="hasEnvironments">
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)"
+ >
+ <template #title>
+ <span>{{ $options.i18n.available }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ availableCount }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ <gl-tab
+ :query-param-value="$options.ENVIRONMENTS_SCOPE.STOPPED"
+ @click="setScope($options.ENVIRONMENTS_SCOPE.STOPPED)"
+ >
+ <template #title>
+ <span>{{ $options.i18n.stopped }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">
+ {{ stoppedCount }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ <gl-search-box-by-type
+ class="gl-mb-4"
+ :value="search"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @input="setSearch"
+ />
<environment-folder
v-for="folder in folders"
:key="folder.name"
@@ -309,10 +318,10 @@ export default {
/>
</template>
<empty-state
- v-else-if="!$apollo.queries.environmentApp.loading"
+ v-if="showEmptyState"
:help-path="helpPagePath"
- :scope="scope"
:has-term="hasSearch"
+ @enable-review="showReviewAppModal"
/>
<gl-pagination
align="center"
diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue
index bb2f053b3fc..0507abf3eaf 100644
--- a/app/assets/javascripts/environments/components/environments_detail_header.vue
+++ b/app/assets/javascripts/environments/components/environments_detail_header.vue
@@ -4,10 +4,10 @@ import csrf from '~/lib/utils/csrf';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import { isSafeURL } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
+import DeployFreezeAlert from './deploy_freeze_alert.vue';
export default {
name: 'EnvironmentsDetailHeader',
@@ -16,15 +16,15 @@ export default {
GlButton,
GlSprintf,
TimeAgo,
+ DeployFreezeAlert,
DeleteEnvironmentModal,
StopEnvironmentModal,
- ModalCopyButton,
},
directives: {
GlModalDirective,
GlTooltip,
},
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
environment: {
type: Object,
@@ -76,8 +76,6 @@ export default {
deleteButtonText: s__('Environments|Delete'),
externalButtonTitle: s__('Environments|Open live environment'),
externalButtonText: __('View deployment'),
- copyUrlText: __('Copy URL'),
- copyUrlTitle: s__('Environments|Copy live environment URL'),
cancelAutoStopButtonTitle: __('Prevent environment from auto-stopping'),
},
computed: {
@@ -87,102 +85,101 @@ export default {
shouldShowExternalUrlButton() {
return Boolean(this.environment.externalUrl);
},
- isSafeUrl() {
- return isSafeURL(this.environment.externalUrl);
- },
shouldShowStopButton() {
return this.canStopEnvironment && this.environment.isAvailable;
},
shouldShowTerminalButton() {
return this.canAdminEnvironment && this.environment.hasTerminals;
},
+ shouldShowMetricsButton() {
+ return Boolean(!this.glFeatures.removeMonitorMetrics && this.shouldShowExternalUrlButton);
+ },
},
};
</script>
<template>
- <header class="top-area gl-justify-content-between">
- <div class="gl-display-flex gl-flex-grow-1 gl-align-items-center">
- <h1 class="page-title gl-font-size-h-display">
- {{ environment.name }}
- </h1>
- <p v-if="shouldShowCancelAutoStopButton" class="gl-mb-0 gl-ml-3" data-testid="auto-stops-at">
- <gl-sprintf :message="$options.i18n.autoStopAtText">
- <template #autoStopAt>
- <time-ago :time="environment.autoStopAt" />
- </template>
- </gl-sprintf>
- </p>
- </div>
- <div class="nav-controls gl-my-1">
- <form method="POST" :action="cancelAutoStopPath" data-testid="cancel-auto-stop-form">
- <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-button
+ <div>
+ <deploy-freeze-alert :name="environment.name" />
+ <header class="top-area gl-justify-content-between">
+ <div class="gl-display-flex gl-flex-grow-1 gl-align-items-center">
+ <h1 class="page-title gl-font-size-h-display">
+ {{ environment.name }}
+ </h1>
+ <p
v-if="shouldShowCancelAutoStopButton"
- v-gl-tooltip.hover
- data-testid="cancel-auto-stop-button"
- :title="$options.i18n.cancelAutoStopButtonTitle"
- type="submit"
- icon="thumbtack"
+ class="gl-mb-0 gl-ml-3"
+ data-testid="auto-stops-at"
+ >
+ <gl-sprintf :message="$options.i18n.autoStopAtText">
+ <template #autoStopAt>
+ <time-ago :time="environment.autoStopAt" />
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="nav-controls gl-my-1">
+ <form method="POST" :action="cancelAutoStopPath" data-testid="cancel-auto-stop-form">
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-button
+ v-if="shouldShowCancelAutoStopButton"
+ v-gl-tooltip.hover
+ data-testid="cancel-auto-stop-button"
+ :title="$options.i18n.cancelAutoStopButtonTitle"
+ type="submit"
+ icon="thumbtack"
+ />
+ </form>
+ <gl-button
+ v-if="shouldShowTerminalButton"
+ data-testid="terminal-button"
+ :href="terminalPath"
+ icon="terminal"
/>
- </form>
- <gl-button
- v-if="shouldShowTerminalButton"
- data-testid="terminal-button"
- :href="terminalPath"
- icon="terminal"
- />
- <template v-if="shouldShowExternalUrlButton">
<gl-button
- v-if="isSafeUrl"
+ v-if="shouldShowExternalUrlButton"
v-gl-tooltip.hover
data-testid="external-url-button"
:title="$options.i18n.externalButtonTitle"
:href="environment.externalUrl"
+ is-unsafe-link
icon="external-link"
target="_blank"
>{{ $options.i18n.externalButtonText }}</gl-button
>
- <modal-copy-button
- v-else
- :title="$options.i18n.copyUrlTitle"
- :text="environment.externalUrl"
+ <gl-button
+ v-if="shouldShowMetricsButton"
+ v-gl-tooltip.hover
+ data-testid="metrics-button"
+ :href="metricsPath"
+ :title="$options.i18n.metricsButtonTitle"
+ icon="chart"
+ class="gl-mr-2"
+ >
+ {{ $options.i18n.metricsButtonText }}
+ </gl-button>
+ <gl-button v-if="canUpdateEnvironment" data-testid="edit-button" :href="updatePath">
+ {{ $options.i18n.editButtonText }}
+ </gl-button>
+ <gl-button
+ v-if="shouldShowStopButton"
+ v-gl-modal-directive="'stop-environment-modal'"
+ data-testid="stop-button"
+ icon="stop"
+ variant="danger"
+ >
+ {{ $options.i18n.stopButtonText }}
+ </gl-button>
+ <gl-button
+ v-if="canDestroyEnvironment"
+ v-gl-modal-directive="'delete-environment-modal'"
+ data-testid="destroy-button"
+ variant="danger"
>
- {{ $options.i18n.copyUrlText }}
- </modal-copy-button>
- </template>
- <gl-button
- v-if="shouldShowExternalUrlButton"
- v-gl-tooltip.hover
- data-testid="metrics-button"
- :href="metricsPath"
- :title="$options.i18n.metricsButtonTitle"
- icon="chart"
- class="gl-mr-2"
- >
- {{ $options.i18n.metricsButtonText }}
- </gl-button>
- <gl-button v-if="canUpdateEnvironment" data-testid="edit-button" :href="updatePath">
- {{ $options.i18n.editButtonText }}
- </gl-button>
- <gl-button
- v-if="shouldShowStopButton"
- v-gl-modal-directive="'stop-environment-modal'"
- data-testid="stop-button"
- icon="stop"
- variant="danger"
- >
- {{ $options.i18n.stopButtonText }}
- </gl-button>
- <gl-button
- v-if="canDestroyEnvironment"
- v-gl-modal-directive="'delete-environment-modal'"
- data-testid="destroy-button"
- variant="danger"
- >
- {{ $options.i18n.deleteButtonText }}
- </gl-button>
- </div>
- <delete-environment-modal v-if="canDestroyEnvironment" :environment="environment" />
- <stop-environment-modal v-if="shouldShowStopButton" :environment="environment" />
- </header>
+ {{ $options.i18n.deleteButtonText }}
+ </gl-button>
+ </div>
+ <delete-environment-modal v-if="canDestroyEnvironment" :environment="environment" />
+ <stop-environment-modal v-if="shouldShowStopButton" :environment="environment" />
+ </header>
+ </div>
</template>
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..7660912f93a
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_agent_info.vue
@@ -0,0 +1,99 @@
+<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 { 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,
+ };
+ },
+ 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..1f15c4daa2f
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue
@@ -0,0 +1,117 @@
+<script>
+import { GlCollapse, GlButton, GlAlert } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
+import KubernetesAgentInfo from './kubernetes_agent_info.vue';
+import KubernetesPods from './kubernetes_pods.vue';
+import KubernetesTabs from './kubernetes_tabs.vue';
+
+export default {
+ components: {
+ GlCollapse,
+ GlButton,
+ GlAlert,
+ KubernetesAgentInfo,
+ KubernetesPods,
+ KubernetesTabs,
+ },
+ inject: ['kasTunnelUrl'],
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ agentId: {
+ required: true,
+ type: String,
+ },
+ agentProjectPath: {
+ required: true,
+ type: String,
+ },
+ namespace: {
+ required: false,
+ type: String,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isVisible: false,
+ error: '',
+ };
+ },
+ computed: {
+ chevronIcon() {
+ return this.isVisible ? 'chevron-down' : 'chevron-right';
+ },
+ label() {
+ return this.isVisible ? this.$options.i18n.collapse : this.$options.i18n.expand;
+ },
+ gitlabAgentId() {
+ const id = isGid(this.agentId) ? getIdFromGraphQLId(this.agentId) : this.agentId;
+ return id.toString();
+ },
+ k8sAccessConfiguration() {
+ return {
+ basePath: this.kasTunnelUrl,
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': this.gitlabAgentId, ...csrf.headers },
+ },
+ };
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.isVisible = !this.isVisible;
+ },
+ onClusterError(message) {
+ this.error = message;
+ },
+ },
+ 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" />
+
+ <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-5">
+ {{ error }}
+ </gl-alert>
+
+ <kubernetes-pods
+ :configuration="k8sAccessConfiguration"
+ :namespace="namespace"
+ class="gl-mb-5"
+ @cluster-error="onClusterError" />
+ <kubernetes-tabs
+ :configuration="k8sAccessConfiguration"
+ :namespace="namespace"
+ class="gl-mb-5"
+ @cluster-error="onClusterError"
+ /></template>
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_pods.vue b/app/assets/javascripts/environments/components/kubernetes_pods.vue
new file mode 100644
index 00000000000..a153331ee58
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_pods.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { s__ } from '~/locale';
+import k8sPodsQuery from '../graphql/queries/k8s_pods.query.graphql';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlSingleStat,
+ },
+ apollo: {
+ k8sPods: {
+ query: k8sPodsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ update(data) {
+ return data?.k8sPods || [];
+ },
+ error(error) {
+ this.error = error;
+ this.$emit('cluster-error', this.error);
+ },
+ },
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ error: '',
+ };
+ },
+
+ computed: {
+ podStats() {
+ if (!this.k8sPods) return null;
+
+ return [
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Running'),
+ title: this.$options.i18n.runningPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Pending'),
+ title: this.$options.i18n.pendingPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Succeeded'),
+ title: this.$options.i18n.succeededPods,
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ value: this.getPodsByPhase('Failed'),
+ title: this.$options.i18n.failedPods,
+ },
+ ];
+ },
+ loading() {
+ return this.$apollo.queries.k8sPods.loading;
+ },
+ },
+ methods: {
+ getPodsByPhase(phase) {
+ const filteredPods = this.k8sPods.filter((item) => item.status.phase === phase);
+ return filteredPods.length;
+ },
+ },
+ i18n: {
+ podsTitle: s__('Environment|Pods'),
+ runningPods: s__('Environment|Running'),
+ pendingPods: s__('Environment|Pending'),
+ succeededPods: s__('Environment|Succeeded'),
+ failedPods: s__('Environment|Failed'),
+ },
+};
+</script>
+<template>
+ <div>
+ <p class="gl-text-gray-500">{{ $options.i18n.podsTitle }}</p>
+
+ <gl-loading-icon v-if="loading" />
+
+ <div
+ v-else-if="podStats && !error"
+ class="gl-display-flex gl-flex-wrap gl-sm-flex-nowrap gl-mx-n3 gl-mt-n3"
+ >
+ <gl-single-stat
+ v-for="(stat, index) in podStats"
+ :key="index"
+ class="gl-w-full gl-flex-direction-column gl-align-items-center gl-justify-content-center gl-bg-white gl-border gl-border-gray-a-08 gl-mx-3 gl-p-3 gl-mt-3"
+ :value="stat.value"
+ :title="stat.title"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_summary.vue b/app/assets/javascripts/environments/components/kubernetes_summary.vue
new file mode 100644
index 00000000000..85fc1c1a07d
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_summary.vue
@@ -0,0 +1,180 @@
+<script>
+import { GlTab, GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import k8sWorkloadsQuery from '../graphql/queries/k8s_workloads.query.graphql';
+import {
+ getDeploymentsStatuses,
+ getDaemonSetStatuses,
+ getStatefulSetStatuses,
+ getReplicaSetStatuses,
+ getJobsStatuses,
+ getCronJobsStatuses,
+} from '../helpers/k8s_integration_helper';
+
+export default {
+ components: {
+ GlTab,
+ GlBadge,
+ GlLoadingIcon,
+ },
+ apollo: {
+ k8sWorkloads: {
+ query: k8sWorkloadsQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ namespace: this.namespace,
+ };
+ },
+ update(data) {
+ return data?.k8sWorkloads || {};
+ },
+ error(error) {
+ this.$emit('cluster-error', error);
+ },
+ },
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ },
+ computed: {
+ summaryLoading() {
+ return this.$apollo.queries.k8sWorkloads.loading;
+ },
+ summaryCount() {
+ return this.k8sWorkloads ? Object.values(this.k8sWorkloads).flat().length : 0;
+ },
+ summaryObjects() {
+ return [
+ this.deploymentsItems,
+ this.daemonSetsItems,
+ this.statefulSetItems,
+ this.replicaSetItems,
+ this.jobItems,
+ this.cronJobItems,
+ ].filter(Boolean);
+ },
+ deploymentsItems() {
+ const items = this.k8sWorkloads?.DeploymentList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.deployments,
+ items: getDeploymentsStatuses(items),
+ };
+ },
+ daemonSetsItems() {
+ const items = this.k8sWorkloads?.DaemonSetList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.daemonSets,
+ items: getDaemonSetStatuses(items),
+ };
+ },
+ statefulSetItems() {
+ const items = this.k8sWorkloads?.StatefulSetList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.statefulSets,
+ items: getStatefulSetStatuses(items),
+ };
+ },
+ replicaSetItems() {
+ const items = this.k8sWorkloads?.ReplicaSetList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.replicaSets,
+ items: getReplicaSetStatuses(items),
+ };
+ },
+ jobItems() {
+ const items = this.k8sWorkloads?.JobList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.jobs,
+ items: getJobsStatuses(items),
+ };
+ },
+ cronJobItems() {
+ const items = this.k8sWorkloads?.CronJobList;
+ if (!items?.length) {
+ return null;
+ }
+
+ return {
+ name: this.$options.i18n.cronJobs,
+ items: getCronJobsStatuses(items),
+ };
+ },
+ },
+ i18n: {
+ summaryTitle: s__('Environment|Summary'),
+ deployments: s__('Environment|Deployments'),
+ daemonSets: s__('Environment|DaemonSets'),
+ statefulSets: s__('Environment|StatefulSets'),
+ replicaSets: s__('Environment|ReplicaSets'),
+ jobs: s__('Environment|Jobs'),
+ cronJobs: s__('Environment|CronJobs'),
+ },
+ badgeVariants: {
+ ready: 'success',
+ completed: 'success',
+ failed: 'danger',
+ suspended: 'neutral',
+ },
+ icons: {
+ Active: { icon: 'status_success', class: 'gl-text-green-500' },
+ },
+};
+</script>
+<template>
+ <gl-tab>
+ <template #title>
+ {{ $options.i18n.summaryTitle }}
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ summaryCount }}</gl-badge>
+ </template>
+
+ <gl-loading-icon v-if="summaryLoading" />
+
+ <ul v-else class="gl-mt-3 gl-list-style-none gl-bg-white gl-pl-0 gl-mb-0">
+ <li
+ v-for="object in summaryObjects"
+ :key="object.name"
+ class="gl-display-flex gl-align-items-center gl-p-3 gl-border-t gl-text-gray-700"
+ data-testid="summary-list-item"
+ >
+ <div class="gl-flex-grow-1">{{ object.name }}</div>
+
+ <gl-badge
+ v-for="(item, key) in object.items"
+ :key="key"
+ :variant="$options.badgeVariants[key]"
+ size="sm"
+ class="gl-ml-2"
+ >{{ item.length }} {{ key }}</gl-badge
+ >
+ </li>
+ </ul>
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
new file mode 100644
index 00000000000..b900c23b2b7
--- /dev/null
+++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue
@@ -0,0 +1,166 @@
+<script>
+import { GlTabs, GlTab, GlLoadingIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import k8sServicesQuery from '../graphql/queries/k8s_services.query.graphql';
+import { generateServicePortsString, getServiceAge } from '../helpers/k8s_integration_helper';
+import { SERVICES_LIMIT_PER_PAGE } from '../constants';
+import KubernetesSummary from './kubernetes_summary.vue';
+
+const tableHeadingClasses = 'gl-bg-gray-50! gl-font-weight-bold gl-white-space-nowrap';
+
+export default {
+ components: {
+ GlTabs,
+ GlTab,
+ GlBadge,
+ GlTable,
+ GlPagination,
+ GlLoadingIcon,
+ KubernetesSummary,
+ },
+ apollo: {
+ k8sServices: {
+ query: k8sServicesQuery,
+ variables() {
+ return {
+ configuration: this.configuration,
+ };
+ },
+ update(data) {
+ return data?.k8sServices || [];
+ },
+ error(error) {
+ this.$emit('cluster-error', error);
+ },
+ },
+ },
+ props: {
+ configuration: {
+ required: true,
+ type: Object,
+ },
+ namespace: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ currentPage: 1,
+ };
+ },
+ computed: {
+ servicesItems() {
+ if (!this.k8sServices?.length) return [];
+
+ return this.k8sServices.map((service) => {
+ return {
+ name: service?.metadata?.name,
+ namespace: service?.metadata?.namespace,
+ type: service?.spec?.type,
+ clusterIP: service?.spec?.clusterIP,
+ externalIP: service?.spec?.externalIP,
+ ports: generateServicePortsString(service?.spec?.ports),
+ age: getServiceAge(service?.metadata?.creationTimestamp),
+ };
+ });
+ },
+ servicesLoading() {
+ return this.$apollo.queries.k8sServices.loading;
+ },
+ showPagination() {
+ return this.servicesItems.length > SERVICES_LIMIT_PER_PAGE;
+ },
+ prevPage() {
+ return Math.max(this.currentPage - 1, 0);
+ },
+ nextPage() {
+ const nextPage = this.currentPage + 1;
+ return nextPage > Math.ceil(this.servicesItems.length / SERVICES_LIMIT_PER_PAGE)
+ ? null
+ : nextPage;
+ },
+ },
+ i18n: {
+ servicesTitle: s__('Environment|Services'),
+ name: __('Name'),
+ namespace: __('Namespace'),
+ status: __('Status'),
+ type: __('Type'),
+ clusterIP: s__('Environment|Cluster IP'),
+ externalIP: s__('Environment|External IP'),
+ ports: s__('Environment|Ports'),
+ age: s__('Environment|Age'),
+ },
+ servicesFields: [
+ {
+ key: 'name',
+ label: __('Name'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'namespace',
+ label: __('Namespace'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'type',
+ label: __('Type'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'clusterIP',
+ label: s__('Environment|Cluster IP'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'externalIP',
+ label: s__('Environment|External IP'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'ports',
+ label: s__('Environment|Ports'),
+ thClass: tableHeadingClasses,
+ },
+ {
+ key: 'age',
+ label: s__('Environment|Age'),
+ thClass: tableHeadingClasses,
+ },
+ ],
+ SERVICES_LIMIT_PER_PAGE,
+};
+</script>
+<template>
+ <gl-tabs>
+ <kubernetes-summary :namespace="namespace" :configuration="configuration" />
+
+ <gl-tab>
+ <template #title>
+ {{ $options.i18n.servicesTitle }}
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ servicesItems.length }}</gl-badge>
+ </template>
+
+ <gl-loading-icon v-if="servicesLoading" />
+
+ <gl-table
+ v-else
+ :fields="$options.servicesFields"
+ :items="servicesItems"
+ :per-page="$options.SERVICES_LIMIT_PER_PAGE"
+ :current-page="currentPage"
+ stacked="lg"
+ class="gl-bg-white! gl-mt-3"
+ />
+ <gl-pagination
+ v-if="showPagination"
+ v-model="currentPage"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-mt-6"
+ />
+ </gl-tab>
+ </gl-tabs>
+</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..912c558c3ce 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: {
@@ -129,7 +133,7 @@ export default {
return Boolean(
this.retryPath ||
this.canShowAutoStopDate ||
- this.metricsPath ||
+ this.canShowMetricsLink ||
this.terminalPath ||
this.canDeleteEnvironment,
);
@@ -144,12 +148,18 @@ export default {
return now < autoStopDate;
},
+ upcomingDeploymentIid() {
+ return this.environment.upcomingDeployment?.iid.toString() || '';
+ },
autoStopPath() {
return this.environment?.cancelAutoStopPath ?? '';
},
metricsPath() {
return this.environment?.metricsPath ?? '';
},
+ canShowMetricsLink() {
+ return Boolean(!this.glFeatures.removeMonitorMetrics && this.metricsPath);
+ },
terminalPath() {
return this.environment?.terminalPath ?? '';
},
@@ -162,6 +172,19 @@ export default {
rolloutStatus() {
return this.environment?.rolloutStatus;
},
+ agent() {
+ return this.environment?.agent || {};
+ },
+ isKubernetesOverviewAvailable() {
+ return this.glFeatures?.kasUserAccessProject;
+ },
+ hasRequiredAgentData() {
+ const { project, id, name } = this.agent || {};
+ return project && id && name;
+ },
+ showKubernetesOverview() {
+ return this.isKubernetesOverviewAvailable && this.hasRequiredAgentData;
+ },
},
methods: {
toggleCollapse() {
@@ -184,6 +207,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>
@@ -200,7 +230,7 @@ export default {
:icon="icon"
:aria-label="label"
size="small"
- category="tertiary"
+ category="secondary"
@click="toggleCollapse"
/>
<gl-link
@@ -247,7 +277,6 @@ export default {
<stop-component
v-if="canStop"
:environment="environment"
- class="gl-z-index-2"
data-track-action="click_button"
data-track-label="environment_stop"
graphql
@@ -281,10 +310,11 @@ export default {
/>
<monitoring
- v-if="metricsPath"
+ v-if="canShowMetricsLink"
:monitoring-url="metricsPath"
data-track-action="click_button"
data-track-label="environment_monitoring"
+ data-testid="environment-monitoring"
/>
<terminal
@@ -328,7 +358,11 @@ export default {
class="gl-pl-4"
>
<template #approval>
- <environment-approval :environment="environment" @change="$emit('change')" />
+ <environment-approval
+ :deployment-iid="upcomingDeploymentIid"
+ :environment="environment"
+ @change="$emit('change')"
+ />
</template>
</deployment>
</div>
@@ -340,6 +374,14 @@ 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"
+ :namespace="agent.kubernetesNamespace"
+ />
+ </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..95ece2b653e 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() {
@@ -41,6 +41,9 @@ export default {
text: __('Cancel'),
};
},
+ hasStopAction() {
+ return this.graphql ? this.environment.hasStopAction : this.environment.has_stop_action;
+ },
},
methods: {
@@ -81,7 +84,7 @@ export default {
<p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p>
- <div v-if="!environment.has_stop_action" class="warning_message">
+ <div v-if="!hasStopAction" class="warning_message">
<p>
<gl-sprintf
:message="
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index 28424322dd2..448cee530f6 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -51,7 +51,7 @@ export const ENVIRONMENT_COUNT_BY_SCOPE = {
};
export const REVIEW_APP_MODAL_I18N = {
- title: s__('ReviewApp|Enable Review App'),
+ title: s__('Environments|Enable Review Apps'),
intro: s__(
'EnableReviewApp|Review apps are dynamic environments that you can use to provide a live preview of changes made in a feature branch.',
),
@@ -87,3 +87,5 @@ export const ENVIRONMENT_NEW_HELP_TEXT = __(
);
export const ENVIRONMENT_EDIT_HELP_TEXT = ENVIRONMENT_NEW_HELP_TEXT;
+
+export const SERVICES_LIMIT_PER_PAGE = 10;
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
index 77d9311743c..92a0b0e550e 100644
--- a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
@@ -1,9 +1,22 @@
<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { translations } from '~/environments/environment_details/constants';
import ActionsComponent from '~/environments/components/environment_actions.vue';
+import setEnvironmentToRollback from '~/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql';
+
+const EnvironmentApprovalComponent = import(
+ 'ee_component/environments/components/environment_approval.vue'
+);
export default {
components: {
+ GlButton,
ActionsComponent,
+ EnvironmentApproval: () => EnvironmentApprovalComponent,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
props: {
actions: {
@@ -18,14 +31,92 @@ export default {
type: Array,
required: true,
},
+ rollback: {
+ // rollback shape:
+ /*
+ {
+ id: string,
+ name: string,
+ lastDeployment: {
+ commit: Commit,
+ isLast: boolean,
+ },
+ retryUrl: url,
+ };
+ */
+ type: Object,
+ required: false,
+ default: null,
+ },
+ // approvalEnvironment shape:
+ /* {
+ isApprovalActionAvailable: boolean,
+ deploymentIid: string,
+ environment: {
+ name: string,
+ tier: string,
+ requiredApprovalCount: number,
+ },
+ */
+ approvalEnvironment: {
+ type: Object,
+ required: false,
+ default: () => ({
+ isApprovalActionAvailable: false,
+ }),
+ },
},
computed: {
+ isRollbackAvailable() {
+ return Boolean(this.rollback?.lastDeployment);
+ },
+ rollbackIcon() {
+ return this.rollback.lastDeployment.isLast ? 'repeat' : 'redo';
+ },
isActionsShown() {
return this.actions.length > 0;
},
+ deploymentIid() {
+ return this.approvalEnvironment.deploymentIid;
+ },
+ environment() {
+ return this.approvalEnvironment.environment;
+ },
+ rollbackButtonTitle() {
+ return this.rollback.lastDeployment?.isLast
+ ? translations.redeployButtonTitle
+ : translations.rollbackButtonTitle;
+ },
+ },
+ methods: {
+ onRollbackClick() {
+ this.$apollo.mutate({
+ mutation: setEnvironmentToRollback,
+ variables: {
+ environment: this.rollback,
+ },
+ });
+ },
},
};
</script>
<template>
- <actions-component v-if="isActionsShown" :actions="actions" graphql />
+ <div>
+ <actions-component v-if="isActionsShown" :actions="actions" graphql />
+ <gl-button
+ v-if="isRollbackAvailable"
+ v-gl-modal.confirm-rollback-modal
+ v-gl-tooltip
+ data-testid="rollback-button"
+ :title="rollbackButtonTitle"
+ :icon="rollbackIcon"
+ @click="onRollbackClick"
+ />
+ <environment-approval
+ v-if="approvalEnvironment.isApprovalActionAvailable"
+ :environment="environment"
+ :deployment-iid="deploymentIid"
+ :show-text="false"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js
index 3b33d6a676e..e7b10aed20d 100644
--- a/app/assets/javascripts/environments/environment_details/constants.js
+++ b/app/assets/javascripts/environments/environment_details/constants.js
@@ -1,6 +1,7 @@
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
+export const ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL = 3000;
export const ENVIRONMENT_DETAILS_PAGE_SIZE = 20;
export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
@@ -30,7 +31,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
key: 'job',
label: __('Job'),
- columnClass: 'gl-w-20p',
+ columnClass: 'gl-w-15p',
tdClass: 'gl-vertical-align-middle!',
},
{
@@ -48,7 +49,7 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
{
key: 'actions',
label: __('Actions'),
- columnClass: 'gl-w-10p',
+ columnClass: 'gl-w-15p',
tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap',
},
];
@@ -61,6 +62,8 @@ export const translations = {
),
nextPageButtonLabel: __('Next'),
previousPageButtonLabel: __('Prev'),
+ redeployButtonTitle: s__('Environments|Re-deploy to environment'),
+ rollbackButtonTitle: s__('Environments|Rollback environment'),
};
export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] };
diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue
index 10f8c06e581..f37f93798ae 100644
--- a/app/assets/javascripts/environments/environment_details/deployments_table.vue
+++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue
@@ -48,13 +48,21 @@ export default {
<deployment-job :job="item.job" />
</template>
<template #cell(created)="{ item }">
- <time-ago-tooltip :time="item.created" />
+ <time-ago-tooltip :time="item.created" data-testid="deployment-created-at" />
</template>
<template #cell(deployed)="{ item }">
- <time-ago-tooltip :time="item.deployed" />
+ <time-ago-tooltip
+ v-if="item.deployed"
+ :time="item.deployed"
+ data-testid="deployment-deployed-at"
+ />
</template>
<template #cell(actions)="{ item }">
- <deployment-actions :actions="item.actions" />
+ <deployment-actions
+ :actions="item.actions"
+ :rollback="item.rollback"
+ :approval-environment="item.deploymentApproval"
+ />
</template>
</gl-table-lite>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue
index f4657c5100a..ff2dd9935ae 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -1,20 +1,29 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { logError } from '~/lib/logger';
+import { toggleQueryPollingByVisibility, etagQueryHeaders } from '~/graphql_shared/utils';
+import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql';
+import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql';
import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper';
import EmptyState from './empty_state.vue';
import DeploymentsTable from './deployments_table.vue';
import Pagination from './pagination.vue';
-import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants';
+import {
+ ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL,
+ ENVIRONMENT_DETAILS_PAGE_SIZE,
+} from './constants';
export default {
components: {
+ ConfirmRollbackModal,
Pagination,
DeploymentsTable,
EmptyState,
GlLoadingIcon,
},
+ inject: { graphqlEtagKey: { default: '' } },
props: {
projectFullPath: {
type: String,
@@ -48,11 +57,21 @@ export default {
before: this.before,
};
},
+ pollInterval() {
+ return this.graphqlEtagKey ? ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL : null;
+ },
+ context() {
+ return etagQueryHeaders('environment_details', this.graphqlEtagKey);
+ },
+ },
+ environmentToRollback: {
+ query: environmentToRollbackQuery,
},
},
data() {
return {
project: {},
+ environmentToRollback: {},
isInitialPageDataReceived: false,
isPrefetchingPages: false,
};
@@ -129,6 +148,26 @@ export default {
this.isPrefetchingPages = false;
},
},
+ errorCaptured(error) {
+ Sentry.withScope((scope) => {
+ scope.setTag('vue_component', 'EnvironmentDetailsIndex');
+
+ Sentry.captureException(error);
+ });
+ },
+ mounted() {
+ if (this.graphqlEtagKey) {
+ toggleQueryPollingByVisibility(
+ this.$apollo.queries.project,
+ ENVIRONMENT_DETAILS_QUERY_POLLING_INTERVAL,
+ );
+ }
+ },
+ methods: {
+ resetPage() {
+ this.$router.push({ query: {} });
+ },
+ },
};
</script>
<template>
@@ -143,5 +182,6 @@ export default {
<pagination :page-info="pageInfo" :disabled="isPaginationDisabled" />
</div>
<empty-state v-if="!isDeploymentTableShown && !isLoading" />
+ <confirm-rollback-modal :environment="environmentToRollback" graphql @rollback="resetPage" />
</div>
</template>
diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js
index 26514b59995..6d06cff06b9 100644
--- a/app/assets/javascripts/environments/graphql/client.js
+++ b/app/assets/javascripts/environments/graphql/client.js
@@ -5,12 +5,16 @@ import pageInfoQuery from './queries/page_info.query.graphql';
import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql';
import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql';
import environmentToStopQuery from './queries/environment_to_stop.query.graphql';
+import k8sPodsQuery from './queries/k8s_pods.query.graphql';
+import k8sServicesQuery from './queries/k8s_services.query.graphql';
+import k8sWorkloadsQuery from './queries/k8s_workloads.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
export const apolloProvider = (endpoint) => {
const defaultClient = createDefaultClient(resolvers(endpoint), {
typeDefs,
+ useGet: true,
});
const { cache } = defaultClient;
@@ -82,6 +86,81 @@ export const apolloProvider = (endpoint) => {
},
},
});
+ cache.writeQuery({
+ query: k8sPodsQuery,
+ data: {
+ status: {
+ phase: null,
+ },
+ },
+ });
+ cache.writeQuery({
+ query: k8sServicesQuery,
+ data: {
+ metadata: {
+ name: null,
+ namespace: null,
+ creationTimestamp: null,
+ },
+ spec: {
+ type: null,
+ clusterIP: null,
+ externalIP: null,
+ ports: [],
+ },
+ },
+ });
+ cache.writeQuery({
+ query: k8sWorkloadsQuery,
+ data: {
+ DeploymentList: {
+ status: {
+ conditions: [],
+ },
+ },
+ DaemonSetList: {
+ status: {
+ numberMisscheduled: 0,
+ numberReady: 0,
+ desiredNumberScheduled: 0,
+ },
+ },
+ StatefulSetList: {
+ status: {
+ readyReplicas: 0,
+ },
+ spec: {
+ replicas: 0,
+ },
+ },
+ ReplicaSetList: {
+ status: {
+ readyReplicas: 0,
+ },
+ spec: {
+ replicas: 0,
+ },
+ },
+ JobList: {
+ status: {
+ failed: 0,
+ succeeded: 0,
+ },
+ spec: {
+ completions: 0,
+ },
+ },
+ CronJobList: {
+ status: {
+ active: 0,
+ lastScheduleTime: '',
+ },
+ spec: {
+ suspend: false,
+ },
+ },
+ },
+ });
return new VueApollo({
defaultClient,
});
diff --git a/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql
new file mode 100644
index 00000000000..e799623f9bb
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/fragments/deployment_job.fragment.graphql
@@ -0,0 +1,6 @@
+fragment DeploymentJob on CiJob {
+ name
+ id
+ webPath
+ playable
+}
diff --git a/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql
new file mode 100644
index 00000000000..1ff68c56362
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/fragments/environment_protected_data.fragment.graphql
@@ -0,0 +1,3 @@
+fragment ProtectedEnvironment on Environment {
+ id
+}
diff --git a/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
index 7eae0ef4ce4..f530b798253 100644
--- a/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
+++ b/app/assets/javascripts/environments/graphql/mutations/stop_environment.mutation.graphql
@@ -1,5 +1,5 @@
mutation stopEnvironment($environment: LocalEnvironment) {
- stopEnvironment(environment: $environment) @client {
+ stopEnvironmentREST(environment: $environment) @client {
errors
}
}
diff --git a/app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql b/app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql
new file mode 100644
index 00000000000..7d701b95bbf
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/deploy_freezes.query.graphql
@@ -0,0 +1,12 @@
+query getEnvironmentFreezes($projectFullPath: ID!, $environmentName: String) {
+ project(fullPath: $projectFullPath) {
+ id
+ environment(name: $environmentName) {
+ id
+ deployFreezes {
+ startTime
+ endTime
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
index 0182b3a7234..65d36242afe 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
@@ -1,3 +1,7 @@
+#import "ee_else_ce/environments/graphql/fragments/environment_protected_data.fragment.graphql"
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/environments/graphql/fragments/deployment_job.fragment.graphql"
+
query getEnvironmentDetails(
$projectFullPath: ID!
$environmentName: String
@@ -11,8 +15,9 @@ query getEnvironmentDetails(
name
fullPath
environment(name: $environmentName) {
- id
+ ...ProtectedEnvironment
name
+ tier
lastDeployment(status: SUCCESS) {
id
job {
@@ -40,19 +45,13 @@ query getEnvironmentDetails(
ref
tag
job {
- name
- id
- webPath
- playable
+ ...DeploymentJob
deploymentPipeline: pipeline {
id
jobs(whenExecuted: ["manual"], retried: false) {
nodes {
- id
- name
- playable
+ ...DeploymentJob
scheduledAt
- webPath
}
}
}
@@ -66,17 +65,11 @@ query getEnvironmentDetails(
authorName
authorEmail
author {
- id
- name
- avatarUrl
- webUrl
+ ...User
}
}
triggerer {
- id
- webUrl
- name
- avatarUrl
+ ...User
}
createdAt
finishedAt
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..bd45d2dba2f
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_cluster_agent.query.graphql
@@ -0,0 +1,15 @@
+query getK8sClusterAgentQuery($projectPath: ID!, $agentName: String!) {
+ project(fullPath: $projectPath) {
+ id
+ clusterAgent(name: $agentName) {
+ id
+ webPath
+ tokens {
+ nodes {
+ id
+ lastUsedAt
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql
new file mode 100644
index 00000000000..2d57ede8c15
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_pods.query.graphql
@@ -0,0 +1,7 @@
+query getK8sPods($configuration: LocalConfiguration, $namespace: String) {
+ k8sPods(configuration: $configuration, namespace: $namespace) @client {
+ status {
+ phase
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql
new file mode 100644
index 00000000000..d97849eecc1
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_services.query.graphql
@@ -0,0 +1,15 @@
+query getK8sServices($configuration: LocalConfiguration) {
+ k8sServices(configuration: $configuration) @client {
+ metadata {
+ name
+ namespace
+ creationTimestamp
+ }
+ spec {
+ type
+ clusterIP
+ externalIP
+ ports
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql b/app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql
new file mode 100644
index 00000000000..27d39272734
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/k8s_workloads.query.graphql
@@ -0,0 +1,50 @@
+query getK8sWorkloads($configuration: LocalConfiguration, $namespace: String) {
+ k8sWorkloads(configuration: $configuration, namespace: $namespace) @client {
+ DeploymentList {
+ status {
+ conditions
+ }
+ }
+ DaemonSetList {
+ status {
+ numberMisscheduled
+ numberReady
+ desiredNumberScheduled
+ }
+ }
+ StatefulSetList {
+ status {
+ readyReplicas
+ }
+ spec {
+ replicas
+ }
+ }
+ ReplicaSetList {
+ status {
+ readyReplicas
+ }
+ spec {
+ replicas
+ }
+ }
+ JobList {
+ status {
+ failed
+ succeeded
+ }
+ spec {
+ completions
+ }
+ }
+ CronJobList {
+ status {
+ active
+ lastScheduleTime
+ }
+ spec {
+ suspend
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index e21670870b8..044e7927606 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -1,3 +1,4 @@
+import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import {
@@ -28,6 +29,49 @@ const mapEnvironment = (env) => ({
__typename: 'LocalEnvironment',
});
+const mapWorkloadItems = (items, kind) => {
+ return items.map((item) => {
+ const updatedItem = {
+ status: {},
+ spec: {},
+ };
+
+ switch (kind) {
+ case 'DeploymentList':
+ updatedItem.status.conditions = item.status.conditions || [];
+ break;
+ case 'DaemonSetList':
+ updatedItem.status = {
+ numberMisscheduled: item.status.numberMisscheduled || 0,
+ numberReady: item.status.numberReady || 0,
+ desiredNumberScheduled: item.status.desiredNumberScheduled || 0,
+ };
+ break;
+ case 'StatefulSetList':
+ case 'ReplicaSetList':
+ updatedItem.status.readyReplicas = item.status.readyReplicas || 0;
+ updatedItem.spec.replicas = item.spec.replicas || 0;
+ break;
+ case 'JobList':
+ updatedItem.status.failed = item.status.failed || 0;
+ updatedItem.status.succeeded = item.status.succeeded || 0;
+ updatedItem.spec.completions = item.spec.completions || 0;
+ break;
+ case 'CronJobList':
+ updatedItem.status.active = item.status.active || 0;
+ updatedItem.status.lastScheduleTime = item.status.lastScheduleTime || '';
+ updatedItem.spec.suspend = item.spec.suspend || 0;
+ break;
+ default:
+ updatedItem.status = item?.status;
+ updatedItem.spec = item?.spec;
+ break;
+ }
+
+ return updatedItem;
+ });
+};
+
export const resolvers = (endpoint) => ({
Query: {
environmentApp(_context, { page, scope, search }, { cache }) {
@@ -71,9 +115,100 @@ export const resolvers = (endpoint) => ({
isLastDeployment(_, { environment }) {
return environment?.lastDeployment?.isLast;
},
+ k8sPods(_, { configuration, namespace }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ const podsApi = namespace
+ ? coreV1Api.listCoreV1NamespacedPod(namespace)
+ : coreV1Api.listCoreV1PodForAllNamespaces();
+
+ return podsApi
+ .then((res) => res?.data?.items || [])
+ .catch((err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+ });
+ },
+ k8sServices(_, { configuration }) {
+ const coreV1Api = new CoreV1Api(new Configuration(configuration));
+ return coreV1Api
+ .listCoreV1ServiceForAllNamespaces()
+ .then((res) => {
+ const items = res?.data?.items || [];
+ return items.map((item) => {
+ const { type, clusterIP, externalIP, ports } = item.spec;
+ return {
+ metadata: item.metadata,
+ spec: {
+ type,
+ clusterIP: clusterIP || '-',
+ externalIP: externalIP || '-',
+ ports,
+ },
+ };
+ });
+ })
+ .catch((err) => {
+ const error = err?.response?.data?.message ? new Error(err.response.data.message) : err;
+ throw error;
+ });
+ },
+ k8sWorkloads(_, { configuration, namespace }) {
+ const appsV1api = new AppsV1Api(configuration);
+ const batchV1api = new BatchV1Api(configuration);
+
+ let promises;
+
+ if (namespace) {
+ promises = [
+ appsV1api.listAppsV1NamespacedDeployment(namespace),
+ appsV1api.listAppsV1NamespacedDaemonSet(namespace),
+ appsV1api.listAppsV1NamespacedStatefulSet(namespace),
+ appsV1api.listAppsV1NamespacedReplicaSet(namespace),
+ batchV1api.listBatchV1NamespacedJob(namespace),
+ batchV1api.listBatchV1NamespacedCronJob(namespace),
+ ];
+ } else {
+ promises = [
+ appsV1api.listAppsV1DeploymentForAllNamespaces(),
+ appsV1api.listAppsV1DaemonSetForAllNamespaces(),
+ appsV1api.listAppsV1StatefulSetForAllNamespaces(),
+ appsV1api.listAppsV1ReplicaSetForAllNamespaces(),
+ batchV1api.listBatchV1JobForAllNamespaces(),
+ batchV1api.listBatchV1CronJobForAllNamespaces(),
+ ];
+ }
+
+ const summaryList = {
+ DeploymentList: [],
+ DaemonSetList: [],
+ StatefulSetList: [],
+ ReplicaSetList: [],
+ JobList: [],
+ CronJobList: [],
+ };
+
+ return Promise.allSettled(promises).then((results) => {
+ if (results.every((res) => res.status === 'rejected')) {
+ const error = results[0].reason;
+ const errorMessage = error?.response?.data?.message ?? error;
+ throw new Error(errorMessage);
+ }
+ for (const promiseResult of results) {
+ if (promiseResult.status === 'fulfilled' && promiseResult?.value?.data) {
+ const { kind, items } = promiseResult.value.data;
+
+ if (items?.length > 0) {
+ summaryList[kind] = mapWorkloadItems(items, kind);
+ }
+ }
+ }
+
+ return summaryList;
+ });
+ },
},
Mutation: {
- stopEnvironment(_, { environment }, { client }) {
+ stopEnvironmentREST(_, { environment }, { client }) {
client.writeQuery({
query: isEnvironmentStoppingQuery,
variables: { environment },
diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql
index b4d1f7326f6..7e46385946f 100644
--- a/app/assets/javascripts/environments/graphql/typedefs.graphql
+++ b/app/assets/javascripts/environments/graphql/typedefs.graphql
@@ -62,6 +62,105 @@ type LocalPageInfo {
previousPage: Int!
}
+type k8sPodStatus {
+ phase: String
+}
+
+type LocalK8sPods {
+ status: k8sPodStatus
+}
+
+input LocalConfiguration {
+ basePath: String
+ baseOptions: JSON
+}
+
+type k8sServiceMetadata {
+ name: String
+ namespace: String
+ creationTimestamp: String
+}
+
+type k8sServiceSpec {
+ type: String
+ clusterIP: String
+ externalIP: String
+ ports: JSON
+}
+
+type LocalK8sServices {
+ metadata: k8sServiceMetadata
+ spec: k8sServiceSpec
+}
+
+type k8sDeploymentStatus {
+ conditions: JSON
+}
+
+type localK8sDeployment {
+ status: k8sDeploymentStatus
+}
+
+type k8sDaemonSetStatus {
+ IntMisscheduled: Int
+ IntReady: Int
+ desiredIntScheduled: Int
+}
+
+type localK8sDaemonSet {
+ status: k8sDaemonSetStatus
+}
+
+type k8sSetStatus {
+ readyReplicas: Int
+}
+
+type k8sSetSpec {
+ replicas: Int
+}
+
+type localK8sSet {
+ status: k8sSetStatus
+ spec: k8sSetSpec
+}
+
+type k8sJobStatus {
+ failed: Int
+ succeeded: Int
+}
+
+type k8sJobSpec {
+ completions: Int
+}
+
+type localK8sJob {
+ status: k8sJobStatus
+ spec: k8sJobSpec
+}
+
+type k8sCronJobStatus {
+ active: Int
+ lastScheduleTime: String
+}
+
+type k8sCronJobSpec {
+ suspend: Boolean
+}
+
+type localK8sCronJob {
+ status: k8sCronJobStatus
+ spec: k8sCronJobSpec
+}
+
+type LocalK8sWorkloads {
+ DeploymentList: [localK8sDeployment]
+ DaemonSetList: [localK8sDaemonSet]
+ StatefulSetList: [localK8sSet]
+ ReplicaSetList: [localK8sSet]
+ JobList: [localK8sJob]
+ CronJobList: [localK8sCronJob]
+}
+
extend type Query {
environmentApp(page: Int, scope: String): LocalEnvironmentApp
folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder
@@ -71,10 +170,13 @@ extend type Query {
environmentToStop: LocalEnvironment
isEnvironmentStopping(environment: LocalEnvironmentInput): Boolean
isLastDeployment(environment: LocalEnvironmentInput): Boolean
+ k8sPods(configuration: LocalConfiguration, namespace: String): [LocalK8sPods]
+ k8sServices(configuration: LocalConfiguration): [LocalK8sServices]
+ k8sWorkloads(configuration: LocalConfiguration, namespace: String): LocalK8sWorkloads
}
extend type Mutation {
- stopEnvironment(environment: LocalEnvironmentInput): LocalErrors
+ stopEnvironmentREST(environment: LocalEnvironmentInput): LocalErrors
deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors
rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors
cancelAutoStop(autoStopUrl: String!): LocalErrors
diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
index 9802dcbcf78..92efd46df64 100644
--- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
+++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
@@ -62,6 +62,60 @@ export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName)
);
};
+export const getRollbackActionFromDeploymentNode = (deploymentNode, environment) => {
+ const { job, id } = deploymentNode;
+
+ if (!job) {
+ return null;
+ }
+ const isLastDeployment = id === environment.lastDeployment?.id;
+ const { webPath } = job;
+ return {
+ id,
+ name: environment.name,
+ lastDeployment: {
+ commit: deploymentNode.commit,
+ isLast: isLastDeployment,
+ },
+ retryUrl: `${webPath}/retry`,
+ };
+};
+
+const getDeploymentApprovalFromDeploymentNode = (deploymentNode, environment) => {
+ if (!environment.protectedEnvironments || environment.protectedEnvironments.nodes.length === 0) {
+ return {
+ isApprovalActionAvailable: false,
+ };
+ }
+
+ const protectedEnvironmentInfo = environment.protectedEnvironments.nodes[0];
+
+ const hasApprovalRules = protectedEnvironmentInfo.approvalRules.nodes?.length > 0;
+ const hasRequiredApprovals = protectedEnvironmentInfo.requiredApprovalCount > 0;
+
+ const isApprovalActionAvailable = hasRequiredApprovals || hasApprovalRules;
+ const requiredMultipleApprovalRulesApprovals = protectedEnvironmentInfo.approvalRules.nodes.reduce(
+ (requiredApprovals, rule) => {
+ return requiredApprovals + rule.requiredApprovals;
+ },
+ 0,
+ );
+
+ const requiredApprovalCount = hasRequiredApprovals
+ ? protectedEnvironmentInfo.requiredApprovalCount
+ : requiredMultipleApprovalRulesApprovals;
+
+ return {
+ isApprovalActionAvailable,
+ deploymentIid: deploymentNode.iid,
+ environment: {
+ name: environment.name,
+ tier: environment.tier,
+ requiredApprovalCount,
+ },
+ };
+};
+
/**
* This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table
* @param {Object} deploymentNode
@@ -82,5 +136,7 @@ export const convertToDeploymentTableRow = (deploymentNode, environment) => {
created: deploymentNode.createdAt || '',
deployed: deploymentNode.finishedAt || '',
actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name),
+ rollback: getRollbackActionFromDeploymentNode(deploymentNode, environment),
+ deploymentApproval: getDeploymentApprovalFromDeploymentNode(deploymentNode, environment),
};
};
diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
new file mode 100644
index 00000000000..45c65c93a91
--- /dev/null
+++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js
@@ -0,0 +1,141 @@
+import { differenceInSeconds } from '~/lib/utils/datetime_utility';
+
+export function generateServicePortsString(ports) {
+ if (!ports?.length) return '';
+
+ return ports
+ .map((port) => {
+ const nodePort = port.nodePort ? `:${port.nodePort}` : '';
+ return `${port.port}${nodePort}/${port.protocol}`;
+ })
+ .join(', ');
+}
+
+export function getServiceAge(creationTimestamp) {
+ if (!creationTimestamp) return '';
+
+ const timeDifference = differenceInSeconds(new Date(creationTimestamp), new Date());
+
+ const seconds = Math.floor(timeDifference);
+ const minutes = Math.floor(seconds / 60) % 60;
+ const hours = Math.floor(seconds / 60 / 60) % 24;
+ const days = Math.floor(seconds / 60 / 60 / 24);
+
+ let ageString;
+ if (days > 0) {
+ ageString = `${days}d`;
+ } else if (hours > 0) {
+ ageString = `${hours}h`;
+ } else if (minutes > 0) {
+ ageString = `${minutes}m`;
+ } else {
+ ageString = `${seconds}s`;
+ }
+
+ return ageString;
+}
+
+export function getDeploymentsStatuses(items) {
+ const failed = [];
+ const ready = [];
+
+ items.forEach((item) => {
+ const [available, progressing] = item.status?.conditions ?? [];
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (available.status === 'True') {
+ ready.push(item);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ } else if (available.status !== 'True' && progressing.status !== 'True') {
+ failed.push(item);
+ }
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getDaemonSetStatuses(items) {
+ const failed = items.filter((item) => {
+ return (
+ item.status?.numberMisscheduled > 0 ||
+ item.status?.numberReady !== item.status?.desiredNumberScheduled
+ );
+ });
+ const ready = items.filter((item) => {
+ return (
+ item.status?.numberReady === item.status?.desiredNumberScheduled &&
+ !item.status?.numberMisscheduled
+ );
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getStatefulSetStatuses(items) {
+ const failed = items.filter((item) => {
+ return item.status?.readyReplicas < item.spec?.replicas;
+ });
+ const ready = items.filter((item) => {
+ return item.status?.readyReplicas === item.spec?.replicas;
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getReplicaSetStatuses(items) {
+ const failed = items.filter((item) => {
+ return item.status?.readyReplicas < item.spec?.replicas;
+ });
+ const ready = items.filter((item) => {
+ return item.status?.readyReplicas === item.spec?.replicas;
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(ready.length && { ready }),
+ };
+}
+
+export function getJobsStatuses(items) {
+ const failed = items.filter((item) => {
+ return item.status.failed > 0 || item.status?.succeeded !== item.spec?.completions;
+ });
+ const completed = items.filter((item) => {
+ return item.status?.succeeded === item.spec?.completions;
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(completed.length && { completed }),
+ };
+}
+
+export function getCronJobsStatuses(items) {
+ const failed = [];
+ const ready = [];
+ const suspended = [];
+
+ items.forEach((item) => {
+ if (item.status?.active > 0 && !item.status?.lastScheduleTime) {
+ failed.push(item);
+ } else if (item.spec?.suspend) {
+ suspended.push(item);
+ } else if (item.status?.lastScheduleTime) {
+ ready.push(item);
+ }
+ });
+
+ return {
+ ...(failed.length && { failed }),
+ ...(suspended.length && { suspended }),
+ ...(ready.length && { ready }),
+ };
+}
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index d9a523fd806..3f746bc5383 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { removeLastSlashInUrlPath } from '~/lib/utils/url_utility';
import { parseBoolean } from '../lib/utils/common_utils';
import { apolloProvider } from './graphql/client';
import EnvironmentsApp from './components/environments_app.vue';
@@ -16,6 +17,7 @@ export default (el) => {
projectPath,
defaultBranchName,
projectId,
+ kasTunnelUrl,
} = el.dataset;
return new Vue({
@@ -28,6 +30,7 @@ export default (el) => {
newEnvironmentPath,
helpPagePath,
projectId,
+ kasTunnelUrl: removeLastSlashInUrlPath(kasTunnelUrl),
canCreateEnvironment: parseBoolean(canCreateEnvironment),
},
render(h) {
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/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index afce2b7f237..f73cb7fe1bc 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -3,9 +3,13 @@ import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import EnvironmentsDetailHeader from './components/environments_detail_header.vue';
-import { apolloProvider } from './graphql/client';
+import { apolloProvider as createApolloProvider } from './graphql/client';
import environmentsMixin from './mixins/environments_mixin';
+Vue.use(VueApollo);
+
+const apolloProvider = createApolloProvider();
+
export const initHeader = () => {
const el = document.getElementById('environments-detail-view-header');
const container = document.getElementById('environments-detail-view');
@@ -13,7 +17,11 @@ export const initHeader = () => {
return new Vue({
el,
+ apolloProvider,
mixins: [environmentsMixin],
+ provide: {
+ projectFullPath: dataset.projectFullPath,
+ },
data() {
const environment = {
name: dataset.name,
@@ -60,7 +68,6 @@ export const initPage = async () => {
const dataElement = document.getElementById('environments-detail-view');
const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details));
- Vue.use(VueApollo);
Vue.use(VueRouter);
const el = document.getElementById('environment_details_page');
@@ -90,9 +97,12 @@ export const initPage = async () => {
return new Vue({
el,
- apolloProvider: apolloProvider(),
+ apolloProvider,
router,
- provide: {},
+ provide: {
+ projectPath: dataSet.projectFullPath,
+ graphqlEtagKey: dataSet.graphqlEtagKey,
+ },
render(createElement) {
return createElement('router-view');
},
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index b02c3cd2cba..ccadf940fe3 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -2,7 +2,6 @@
import {
GlButton,
GlFormInput,
- GlLink,
GlLoadingIcon,
GlBadge,
GlAlert,
@@ -10,24 +9,22 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
- 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';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import TrackEventDirective from '~/vue_shared/directives/track_event';
import query from '../queries/details.query.graphql';
import {
- trackClickErrorLinkToSentryOptions,
trackErrorDetailsViewsOptions,
trackErrorStatusUpdateOptions,
-} from '../utils';
-
+ trackCreateIssueFromError,
+} from '../events_tracking';
import { severityLevel, severityLevelVariant, errorStatus } from '../constants';
import Stacktrace from './stacktrace.vue';
+import ErrorDetailsInfo from './error_details_info.vue';
const SENTRY_TIMEOUT = 10000;
@@ -35,10 +32,8 @@ export default {
components: {
GlButton,
GlFormInput,
- GlLink,
GlLoadingIcon,
TooltipOnTruncate,
- GlIcon,
Stacktrace,
GlBadge,
GlAlert,
@@ -47,9 +42,7 @@ export default {
GlDropdownItem,
GlDropdownDivider,
TimeAgoTooltip,
- },
- directives: {
- TrackEvent: TrackEventDirective,
+ ErrorDetailsInfo,
},
props: {
issueUpdatePath: {
@@ -122,18 +115,7 @@ export default {
'errorStatus',
]),
...mapGetters('details', ['stacktrace']),
- firstReleaseLink() {
- return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseVersion}`;
- },
- lastReleaseLink() {
- return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
- },
- firstCommitLink() {
- return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`;
- },
- lastCommitLink() {
- return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`;
- },
+
showStacktrace() {
return Boolean(this.stacktrace?.length);
},
@@ -204,9 +186,10 @@ export default {
'updateResolveStatus',
'updateIgnoreStatus',
]),
- trackClickErrorLinkToSentryOptions,
createIssue() {
this.issueCreationInProgress = true;
+ const { category, action } = trackCreateIssueFromError;
+ Tracking.event(category, action);
this.$refs.sentryIssueForm.submit();
},
onIgnoreStatusUpdate() {
@@ -257,6 +240,7 @@ export default {
<div v-if="errorLoading" class="py-3">
<gl-loading-icon size="lg" />
</div>
+
<div v-else-if="error" class="error-details">
<gl-alert v-if="isAlertVisible" @dismiss="isAlertVisible = false">
<gl-sprintf
@@ -386,60 +370,8 @@ export default {
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="muted">{{ error.tags.logger }} </gl-badge>
</template>
- <ul>
- <li v-if="error.gitlabCommit">
- <strong class="bold">{{ __('GitLab commit') }}:</strong>
- <gl-link :href="error.gitlabCommitPath">
- <span>{{ error.gitlabCommit.substr(0, 10) }}</span>
- </gl-link>
- </li>
- <li v-if="error.gitlabIssuePath">
- <strong class="bold">{{ __('GitLab Issue') }}:</strong>
- <gl-link :href="error.gitlabIssuePath">
- <span>{{ error.gitlabIssuePath }}</span>
- </gl-link>
- </li>
- <li v-if="!error.integrated">
- <strong class="bold">{{ __('Sentry event') }}:</strong>
- <gl-link
- v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
- :href="error.externalUrl"
- target="_blank"
- data-testid="external-url-link"
- >
- <span class="text-truncate">{{ error.externalUrl }}</span>
- <gl-icon name="external-link" class="ml-1 flex-shrink-0" />
- </gl-link>
- </li>
- <li v-if="error.firstReleaseVersion">
- <strong class="bold">{{ __('First seen') }}:</strong>
- <time-ago-tooltip :time="error.firstSeen" />
- <gl-link v-if="error.integrated" :href="firstCommitLink">
- {{ __('GitLab commit') }}: {{ error.firstReleaseVersion }}
- </gl-link>
- <gl-link v-else :href="firstReleaseLink" target="_blank">
- {{ __('Release') }}: {{ error.firstReleaseVersion }}
- </gl-link>
- </li>
- <li v-if="error.lastReleaseVersion">
- <strong class="bold">{{ __('Last seen') }}:</strong>
- <time-ago-tooltip :time="error.lastSeen" />
- <gl-link v-if="error.integrated" :href="lastCommitLink">
- {{ __('GitLab commit') }}: {{ error.lastReleaseVersion }}
- </gl-link>
- <gl-link v-else :href="lastReleaseLink" target="_blank">
- {{ __('Release') }}: {{ error.lastReleaseVersion }}
- </gl-link>
- </li>
- <li>
- <strong class="bold">{{ __('Events') }}:</strong>
- <span>{{ error.count }}</span>
- </li>
- <li>
- <strong class="bold">{{ __('Users') }}:</strong>
- <span>{{ error.userCount }}</span>
- </li>
- </ul>
+
+ <error-details-info :error="error" />
<div v-if="loadingStacktrace" class="py-3">
<gl-loading-icon size="lg" />
diff --git a/app/assets/javascripts/error_tracking/components/error_details_info.vue b/app/assets/javascripts/error_tracking/components/error_details_info.vue
new file mode 100644
index 00000000000..f6f39f178fb
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/error_details_info.vue
@@ -0,0 +1,174 @@
+<script>
+import { GlLink, GlIcon, GlCard, GlTooltipDirective } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { trackClickErrorLinkToSentryOptions } from '../events_tracking';
+
+const CARD_CLASS = 'gl-mr-7 gl-w-15p gl-min-w-fit-content';
+const HEADER_CLASS =
+ 'gl-p-2 gl-font-weight-bold gl-display-flex gl-justify-content-center gl-align-items-center';
+const BODY_CLASS =
+ 'gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column gl-my-0 gl-p-4 gl-font-weight-bold gl-text-center gl-flex-grow-1 gl-font-lg';
+
+export default {
+ components: {
+ GlCard,
+ GlLink,
+ TimeAgoTooltip,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
+ },
+ props: {
+ error: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ firstReleaseLink() {
+ return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseVersion}`;
+ },
+ lastReleaseLink() {
+ return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
+ },
+ firstCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`;
+ },
+ lastCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`;
+ },
+ shortFirstReleaseVersion() {
+ return this.error.firstReleaseVersion.substr(0, 10);
+ },
+ shortLastReleaseVersion() {
+ return this.error.lastReleaseVersion.substr(0, 10);
+ },
+ shortGitlabCommit() {
+ return this.error.gitlabCommit.substr(0, 10);
+ },
+ },
+ methods: {
+ trackClickErrorLinkToSentryOptions,
+ },
+ CARD_CLASS,
+ HEADER_CLASS,
+ BODY_CLASS,
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-if="error"
+ class="gl-display-flex gl-flex-wrap gl-justify-content-center gl-my-7 gl-row-gap-6"
+ >
+ <gl-card
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="error-count-card"
+ >
+ <template #header>
+ <span>{{ __('Events') }}</span>
+ </template>
+
+ <template #default>
+ <span>{{ error.count }}</span>
+ </template>
+ </gl-card>
+
+ <gl-card
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="user-count-card"
+ >
+ <template #header>
+ <span>{{ __('Users') }}</span>
+ </template>
+
+ <template #default>
+ <span>{{ error.userCount }}</span>
+ </template>
+ </gl-card>
+
+ <gl-card
+ v-if="error.firstReleaseVersion"
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="first-release-card"
+ >
+ <template #header>
+ <gl-icon v-gl-tooltip :title="shortFirstReleaseVersion" name="commit" class="gl-mr-1" />
+ <span>{{ __('First seen') }}</span>
+ </template>
+
+ <template #default>
+ <gl-link v-if="error.integrated" :href="firstCommitLink" class="gl-font-lg">
+ <time-ago-tooltip :time="error.firstSeen" />
+ </gl-link>
+
+ <gl-link v-else :href="firstReleaseLink" target="_blank" class="gl-font-lg">
+ <time-ago-tooltip :time="error.firstSeen" />
+ </gl-link>
+ </template>
+ </gl-card>
+
+ <gl-card
+ v-if="error.lastReleaseVersion"
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="last-release-card"
+ >
+ <template #header>
+ <gl-icon v-gl-tooltip :title="shortLastReleaseVersion" name="commit" class="gl-mr-1" />
+ {{ __('Last seen') }}
+ </template>
+
+ <template #default>
+ <gl-link v-if="error.integrated" :href="lastCommitLink" class="gl-font-lg">
+ <time-ago-tooltip :time="error.lastSeen" />
+ </gl-link>
+ <gl-link v-else :href="lastReleaseLink" target="_blank" class="gl-font-lg">
+ <time-ago-tooltip :time="error.lastSeen" />
+ </gl-link>
+ </template>
+ </gl-card>
+
+ <gl-card
+ v-if="error.gitlabCommit"
+ :class="$options.CARD_CLASS"
+ :body-class="$options.BODY_CLASS"
+ :header-class="$options.HEADER_CLASS"
+ data-testid="gitlab-commit-card"
+ >
+ <template #header>
+ {{ __('GitLab commit') }}
+ </template>
+
+ <template #default>
+ <gl-link :href="error.gitlabCommitPath" class="gl-font-lg">
+ {{ shortGitlabCommit }}
+ </gl-link>
+ </template>
+ </gl-card>
+ </div>
+ <div v-if="!error.integrated" class="py-3">
+ <span class="gl-font-weight-bold">{{ __('Sentry event') }}:</span>
+ <gl-link
+ v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
+ :href="error.externalUrl"
+ target="_blank"
+ data-testid="external-url-link"
+ >
+ <span class="text-truncate">{{ error.externalUrl }}</span>
+ <gl-icon name="external-link" class="ml-1 flex-shrink-0" />
+ </gl-link>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 2a4bb88b6c2..6750f0f5411 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -23,7 +23,12 @@ import { __ } from '~/locale';
import Tracking from '~/tracking';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { sanitizeUrl } from '~/lib/utils/url_utility';
-import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils';
+import {
+ trackErrorListViewsOptions,
+ trackErrorStatusUpdateOptions,
+ trackErrorStatusFilterOptions,
+ trackErrorSortedByField,
+} from '../events_tracking';
import { I18N_ERROR_TRACKING_LIST } from '../constants';
import ErrorTrackingActions from './error_tracking_actions.vue';
@@ -237,8 +242,15 @@ export default {
},
filterErrors(status, label) {
this.filterValue = label;
+ const { category, action } = trackErrorStatusFilterOptions(status);
+ Tracking.event(category, action);
return this.filterByStatus(status);
},
+ sortErrorsByField(field) {
+ const { category, action } = trackErrorSortedByField(field);
+ Tracking.event(category, action);
+ return this.sortByField(field);
+ },
updateErrosStatus({ errorId, status }) {
// eslint-disable-next-line promise/catch-or-return
this.updateStatus({
@@ -371,7 +383,7 @@ export default {
<gl-dropdown-item
v-for="(label, field) in $options.sortFields"
:key="field"
- @click="sortByField(field)"
+ @click="sortErrorsByField(field)"
>
<span class="d-flex">
<gl-icon
diff --git a/app/assets/javascripts/error_tracking/events_tracking.js b/app/assets/javascripts/error_tracking/events_tracking.js
new file mode 100644
index 00000000000..aaef274d0cd
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/events_tracking.js
@@ -0,0 +1,60 @@
+const category = 'Error Tracking'; // eslint-disable-line @gitlab/require-i18n-strings
+
+/**
+ * Tracks snowplow event when User clicks on error link to Sentry
+ * @param {String} externalUrl that will be send as a property for the event
+ */
+export const trackClickErrorLinkToSentryOptions = (url) => ({
+ category,
+ action: 'click_error_link_to_sentry',
+ label: 'Error Link', // eslint-disable-line @gitlab/require-i18n-strings
+ property: url,
+});
+
+/**
+ * Tracks snowplow event when user views error list
+ */
+export const trackErrorListViewsOptions = {
+ category,
+ action: 'view_errors_list',
+};
+
+/**
+ * Tracks snowplow event when user views error details
+ */
+export const trackErrorDetailsViewsOptions = {
+ category,
+ action: 'view_error_details',
+};
+
+/**
+ * Tracks snowplow event when error status is updated
+ */
+export const trackErrorStatusUpdateOptions = (status) => ({
+ category,
+ action: `update_${status}_status`,
+});
+
+/**
+ * Tracks snowplow event when error list is filter by status
+ */
+export const trackErrorStatusFilterOptions = (status) => ({
+ category,
+ action: `filter_${status}_status`,
+});
+
+/**
+ * Tracks snowplow event when error list is sorted by field
+ */
+export const trackErrorSortedByField = (field) => ({
+ category,
+ action: `sort_by_${field}`,
+});
+
+/**
+ * Tracks snowplow event when the Create Issue button is clicked
+ */
+export const trackCreateIssueFromError = {
+ category,
+ action: 'click_create_issue_from_error',
+};
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/utils.js b/app/assets/javascripts/error_tracking/utils.js
deleted file mode 100644
index aeed5450022..00000000000
--- a/app/assets/javascripts/error_tracking/utils.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-
-/**
- * Tracks snowplow event when User clicks on error link to Sentry
- * @param {String} externalUrl that will be send as a property for the event
- */
-export const trackClickErrorLinkToSentryOptions = (url) => ({
- category: 'Error Tracking',
- action: 'click_error_link_to_sentry',
- label: 'Error Link',
- property: url,
-});
-
-/**
- * Tracks snowplow event when user views error list
- */
-export const trackErrorListViewsOptions = {
- category: 'Error Tracking',
- action: 'view_errors_list',
-};
-
-/**
- * Tracks snowplow event when user views error details
- */
-export const trackErrorDetailsViewsOptions = {
- category: 'Error Tracking',
- action: 'view_error_details',
-};
-
-/**
- * Tracks snowplow event when error status is updated
- */
-export const trackErrorStatusUpdateOptions = (status) => ({
- category: 'Error Tracking',
- action: `update_${status}_status`,
-});
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/featurable/constants.js b/app/assets/javascripts/featurable/constants.js
new file mode 100644
index 00000000000..23f1c5e415d
--- /dev/null
+++ b/app/assets/javascripts/featurable/constants.js
@@ -0,0 +1,6 @@
+// Matches `app/models/concerns/featurable.rb`
+
+export const FEATURABLE_DISABLED = 'disabled';
+export const FEATURABLE_PRIVATE = 'private';
+export const FEATURABLE_ENABLED = 'enabled';
+export const FEATURABLE_PUBLIC = 'public';
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/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 93510870915..34e0b94af3b 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -187,7 +187,7 @@ export default {
data-testid="feature-flags-tab-title"
class="page-title gl-font-size-h-display gl-my-0"
>
- {{ s__('FeatureFlags|Feature Flags') }}
+ {{ s__('FeatureFlags|Feature flags') }}
</h2>
<gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 286b214b511..37a0c679287 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge, GlButton, GlTooltipDirective, GlModal, GlToggle } from '@gitlab/ui';
+import { GlBadge, GlButton, GlTooltipDirective, GlIcon, GlModal, GlToggle } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { labelForStrategy } from '../utils';
@@ -15,6 +15,7 @@ export default {
components: {
GlBadge,
GlButton,
+ GlIcon,
GlModal,
GlToggle,
StrategyLabel,
@@ -108,7 +109,7 @@ export default {
{{ s__('FeatureFlags|Status') }}
</div>
<div class="table-section section-20" role="columnheader">
- {{ s__('FeatureFlags|Feature Flag') }}
+ {{ s__('FeatureFlags|Feature flag') }}
</div>
<div class="table-section section-40" role="columnheader">
{{ s__('FeatureFlags|Environment Specs') }}
@@ -116,7 +117,12 @@ export default {
</div>
<template v-for="featureFlag in featureFlags">
- <div :key="featureFlag.id" class="gl-responsive-table-row" role="row">
+ <div
+ :key="featureFlag.id"
+ :data-testid="featureFlag.id"
+ class="gl-responsive-table-row"
+ role="row"
+ >
<div class="table-section section-10" role="gridcell">
<div class="table-mobile-header" role="rowheader">{{ s__('FeatureFlags|ID') }}</div>
<div class="table-mobile-content js-feature-flag-id">
@@ -148,16 +154,21 @@ export default {
<div class="table-section section-20" role="gridcell">
<div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Feature Flag') }}
+ {{ s__('FeatureFlags|Feature flag') }}
</div>
<div class="table-mobile-content d-flex flex-column js-feature-flag-title">
<div class="gl-display-flex gl-align-items-center">
<div class="feature-flag-name text-monospace text-truncate">
{{ featureFlag.name }}
</div>
- </div>
- <div class="feature-flag-description text-secondary text-truncate">
- {{ featureFlag.description }}
+ <div class="feature-flag-description">
+ <gl-icon
+ v-if="featureFlag.description"
+ v-gl-tooltip.hover="featureFlag.description"
+ class="gl-ml-3 gl-mr-3"
+ name="information-o"
+ />
+ </div>
</div>
</div>
</div>
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/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 1d7a79f926a..6c8a2d90209 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -138,7 +138,7 @@ export default {
<template #description>
{{ $options.i18n.strategyTypeDescription }}
<gl-link :href="strategyTypeDocsPagePath" target="_blank">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</template>
<gl-form-select
@@ -202,7 +202,7 @@ export default {
{{ $options.i18n.environmentsSelectDescription }}
</span>
<gl-link :href="environmentsScopeDocsPath" target="_blank">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</div>
</div>
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..4d3647cdf5c 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,40 @@ 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: '=',
+ },
+ ],
+ };
+
+ if (gon.features.mrApprovedFilter) {
+ IssuableTokenKeys.tokenKeys.splice(3, 0, approvedToken.token);
+ IssuableTokenKeys.conditions.push(...approvedToken.conditions);
+ }
+
const approvedBy = {
token: {
formattedKey: TOKEN_TITLE_APPROVED_BY,
@@ -117,8 +151,8 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
],
};
- const tokenPosition = 3;
- IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
+ const tokenPosition = gon.features.mrApprovedFilter ? 4 : 3;
+ 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/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
index 148d9a35b81..c2c46e4265a 100644
--- a/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
const InputSetter = {
init(hook) {
this.hook = hook;
@@ -33,11 +31,15 @@ const InputSetter = {
setInput(config, selectedItem) {
const input = config.input || this.hook.trigger;
const newValue = selectedItem.getAttribute(config.valueAttribute);
- const inputAttribute = config.inputAttribute;
-
- if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue);
- if (input.tagName === 'INPUT') return (input.value = newValue);
- return (input.textContent = newValue);
+ const { inputAttribute } = config;
+
+ if (input.hasAttribute(inputAttribute)) {
+ input.setAttribute(inputAttribute, newValue);
+ } else if (input.tagName === 'INPUT') {
+ input.value = newValue;
+ } else {
+ input.textContent = newValue;
+ }
},
destroy() {
diff --git a/app/assets/javascripts/filtered_search/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js
index d7f49bf19d8..3d3470a16d0 100644
--- a/app/assets/javascripts/filtered_search/droplab/utils.js
+++ b/app/assets/javascripts/filtered_search/droplab/utils.js
@@ -1,5 +1,3 @@
-/* eslint-disable */
-
import { template as _template } from 'lodash';
import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants';
@@ -26,7 +24,7 @@ const utils = {
closest(thisTag, stopTag) {
while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') {
- thisTag = thisTag.parentNode;
+ thisTag = thisTag.parentNode; // eslint-disable-line no-param-reassign
}
return thisTag;
},
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 16c70fdd069..684375177bb 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,7 +1,14 @@
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 {
+ STATUS_ALL,
+ STATUS_CLOSED,
+ STATUS_MERGED,
+ STATUS_OPEN,
+ WORKSPACE_PROJECT,
+} from '~/issues/constants';
import {
ENTER_KEY_CODE,
BACKSPACE_KEY_CODE,
@@ -42,7 +49,7 @@ export default class FilteredSearchManager {
this.isGroupAncestor = isGroupAncestor;
this.isGroupDecendent = isGroupDecendent;
this.useDefaultState = useDefaultState;
- this.states = ['opened', 'closed', 'merged', 'all'];
+ this.states = [STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED, STATUS_ALL];
this.page = page;
this.container = FilteredSearchContainer.container;
@@ -82,7 +89,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);
}
@@ -742,7 +749,7 @@ export default class FilteredSearchManager {
const { tokens, searchToken } = this.getSearchTokens();
let currentState = state || getParameterByName('state');
if (!currentState && this.useDefaultState) {
- currentState = 'opened';
+ currentState = STATUS_OPEN;
}
if (this.states.includes(currentState)) {
paths.push(`state=${currentState}`);
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/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 81da8409873..b778e05c7b1 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -343,7 +343,9 @@ class GfmAutoComplete {
icon,
availabilityStatus:
availability && isUserBusy(availability)
- ? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>`
+ ? `<span class="badge badge-warning badge-pill gl-badge sm gl-ml-2"> ${s__(
+ 'UserProfile|Busy',
+ )}</span>`
: '',
});
}
diff --git a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
index bf71f682048..f19e047061f 100644
--- a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
+++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue
@@ -5,7 +5,7 @@ import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml?raw';
import { logError } from '~/lib/logger';
import { s__ } from '~/locale';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import pagesMarkOnboardingComplete from '../queries/mark_onboarding_complete.graphql';
export const i18n = {
@@ -57,7 +57,7 @@ export default {
async onDone() {
this.loading = true;
await this.updateOnboardingState();
- redirectTo(this.redirectToWhenDone);
+ redirectTo(this.redirectToWhenDone); // eslint-disable-line import/no-deprecated
},
},
};
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index 60f1b7f5aa4..09ee7de3b6e 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -54,6 +54,7 @@ import { __ } from '~/locale';
const errorMessageClass = 'gl-field-error';
const inputErrorClass = 'gl-field-error-outline';
+const validInputHintClass = '.gl-field-hint-valid';
const errorAnchorSelector = '.gl-field-error-anchor';
const ignoreInputSelector = '.gl-field-error-ignore';
@@ -151,6 +152,7 @@ export default class GlFieldError {
renderInvalid() {
this.inputElement.addClass(inputErrorClass);
this.scopedSiblings.addClass('hidden');
+ this.inputElement.parents('.form-group').find(validInputHintClass).addClass('hidden');
return this.fieldErrorElement.removeClass('hidden');
}
diff --git a/app/assets/javascripts/google_cloud/aiml/panel.vue b/app/assets/javascripts/google_cloud/aiml/panel.vue
new file mode 100644
index 00000000000..f591c47ac40
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/aiml/panel.vue
@@ -0,0 +1,63 @@
+<script>
+import GoogleCloudMenu from '../components/google_cloud_menu.vue';
+import IncubationBanner from '../components/incubation_banner.vue';
+import ServiceTable from './service_table.vue';
+
+export default {
+ components: {
+ IncubationBanner,
+ GoogleCloudMenu,
+ ServiceTable,
+ },
+ props: {
+ configurationUrl: {
+ type: String,
+ required: true,
+ },
+ deploymentsUrl: {
+ type: String,
+ required: true,
+ },
+ databasesUrl: {
+ type: String,
+ required: true,
+ },
+ aimlUrl: {
+ type: String,
+ required: true,
+ },
+ visionAiUrl: {
+ type: String,
+ required: true,
+ },
+ translationAiUrl: {
+ type: String,
+ required: true,
+ },
+ languageAiUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <incubation-banner />
+
+ <google-cloud-menu
+ active="aiml"
+ :configuration-url="configurationUrl"
+ :deployments-url="deploymentsUrl"
+ :databases-url="databasesUrl"
+ :aiml-url="aimlUrl"
+ />
+
+ <service-table
+ :language-ai-url="languageAiUrl"
+ :translation-ai-url="translationAiUrl"
+ :vision-ai-url="visionAiUrl"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/aiml/service_table.vue b/app/assets/javascripts/google_cloud/aiml/service_table.vue
new file mode 100644
index 00000000000..b53baaa5c6f
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/aiml/service_table.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlButton, GlTable } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const KEY_VISION_AI = 'key-vision-ai';
+const KEY_NATURAL_LANGUAGE_AI = 'key-natural-language-ai';
+const KEY_TRANSLATION_AI = 'key-translation-ai';
+
+const i18n = {
+ visionAi: s__('CloudSeed|Vision AI'),
+ visionAiDescription: s__(
+ 'CloudSeed|Derive insights from your images in the cloud or at the edge',
+ ),
+ naturalLanguageAi: s__('CloudSeed|Language AI'),
+ naturalLanguageAiDescription: s__(
+ 'CloudSeed|Derive insights from unstructured text using Google machine learning',
+ ),
+ translationAi: s__('CloudSeed|Translation AI'),
+ translationAiDescription: s__(
+ 'CloudSeed|Make your content and apps multilingual with fast, dynamic machine translation',
+ ),
+ aiml: s__('CloudSeed|AI / ML'),
+ aimlDescription: s__(
+ "CloudSeed|Google Cloud's AI tools are armed with the best of Google's research and technology to help developers focus exclusively on solving problems that matter",
+ ),
+ configureViaMergeRequest: s__('CloudSeed|Configure via Merge Request'),
+ service: s__('CloudSeed|Service'),
+ description: s__('CloudSeed|Description'),
+};
+
+export default {
+ components: { GlButton, GlTable },
+ props: {
+ visionAiUrl: {
+ type: String,
+ required: true,
+ },
+ languageAiUrl: {
+ type: String,
+ required: true,
+ },
+ translationAiUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ actionUrl(key) {
+ switch (key) {
+ case KEY_VISION_AI:
+ return this.visionAiUrl;
+ case KEY_NATURAL_LANGUAGE_AI:
+ return this.languageAiUrl;
+ case KEY_TRANSLATION_AI:
+ return this.translationAiUrl;
+ default:
+ return '#';
+ }
+ },
+ },
+ fields: [
+ { key: 'title', label: i18n.service },
+ { key: 'description', label: i18n.description },
+ { key: 'action', label: '' },
+ ],
+ items: [
+ {
+ title: i18n.naturalLanguageAi,
+ description: i18n.naturalLanguageAiDescription,
+ action: {
+ key: KEY_NATURAL_LANGUAGE_AI,
+ testId: 'button-natural-language-ai',
+ title: i18n.configureViaMergeRequest,
+ },
+ },
+ {
+ title: i18n.translationAi,
+ description: i18n.translationAiDescription,
+ action: {
+ key: KEY_TRANSLATION_AI,
+ testId: 'button-translation-ai',
+ title: i18n.configureViaMergeRequest,
+ disabled: true,
+ },
+ },
+ {
+ title: i18n.visionAi,
+ description: i18n.visionAiDescription,
+ action: {
+ key: KEY_VISION_AI,
+ testId: 'button-vision-ai',
+ title: i18n.configureViaMergeRequest,
+ },
+ },
+ ],
+ i18n,
+};
+</script>
+<template>
+ <div class="gl-mx-3">
+ <h2 class="gl-font-size-h2">{{ $options.i18n.aiml }}</h2>
+ <p>{{ $options.i18n.aimlDescription }}</p>
+ <gl-table :fields="$options.fields" :items="$options.items">
+ <template #cell(action)="{ value }">
+ <gl-button
+ :disabled="value.disabled"
+ :href="actionUrl(value.key)"
+ :data-testid="value.testId"
+ >
+ {{ value.title }}
+ </gl-button>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
index d6b7c702b54..69b9c6133f1 100644
--- a/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
+++ b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue
@@ -4,11 +4,13 @@ import { s__ } from '~/locale';
const CONFIGURATION_KEY = 'configuration';
const DEPLOYMENTS_KEY = 'deployments';
const DATABASES_KEY = 'databases';
+const AIML_KEY = 'aiml';
const i18n = {
configuration: { title: s__('CloudSeed|Configuration') },
deployments: { title: s__('CloudSeed|Deployments') },
databases: { title: s__('CloudSeed|Databases') },
+ aiml: { title: s__('CloudSeed|AI / ML') },
};
export default {
@@ -29,6 +31,11 @@ export default {
type: String,
required: true,
},
+ aimlUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
isConfigurationActive() {
@@ -40,6 +47,9 @@ export default {
isDatabasesActive() {
return this.active === DATABASES_KEY;
},
+ isAimlActive() {
+ return this.active === AIML_KEY;
+ },
},
i18n,
};
@@ -80,6 +90,17 @@ export default {
{{ $options.i18n.databases.title }}
</a>
</li>
+ <li role="presentation" class="nav-item">
+ <a
+ data-testid="aimlLink"
+ role="tab"
+ :href="aimlUrl"
+ class="nav-link gl-tab-nav-item hidden"
+ :class="{ 'gl-tab-nav-item-active': isAimlActive }"
+ >
+ {{ $options.i18n.aiml.title }}
+ </a>
+ </li>
</ul>
</div>
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index 98c9db1fc9a..0a1a7a74d21 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -123,17 +123,6 @@ export const trackSaasTrialSubmit = () => {
pushEvent('saasTrialSubmit');
};
-export const trackSaasTrialSkip = () => {
- if (!isSupported()) {
- return;
- }
-
- const skipLink = document.querySelector('.js-skip-trial');
- skipLink.addEventListener('click', () => {
- pushEvent('saasTrialSkip');
- });
-};
-
export const trackSaasTrialGroup = () => {
if (!isSupported()) {
return;
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/index.js b/app/assets/javascripts/grafana_integration/index.js
index 208a92c97c7..9ade29dae69 100644
--- a/app/assets/javascripts/grafana_integration/index.js
+++ b/app/assets/javascripts/grafana_integration/index.js
@@ -4,6 +4,9 @@ import store from './store';
export default () => {
const el = document.querySelector('.js-grafana-integration');
+
+ if (!el) return false;
+
return new Vue({
el,
store: store(el.dataset),
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..5ba46697496 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -22,7 +22,10 @@ export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package';
export const TYPENAME_PROJECT = 'Project';
export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPENAME_SITE_PROFILE = 'DastSiteProfile';
+export const TYPENAME_TODO = 'Todo';
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';
+export const TYPE_WORKSPACE = 'RemoteDevelopment::Workspace';
diff --git a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
index 794fe0a6151..a15889613f5 100644
--- a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql
@@ -5,7 +5,6 @@ fragment AlertDetailItem on AlertManagementAlert {
...AlertListItem
createdAt
monitoringTool
- metricsDashboardUrl
service
description
updatedAt
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 316bc746051..d0b0a485fe6 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -6,8 +6,9 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap
import createDefaultClient from '~/lib/graphql';
import typeDefs from '~/work_items/graphql/typedefs.graphql';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
-import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import { findHierarchyWidgetChildren } from '~/work_items/utils';
+import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
export const config = {
typeDefs,
@@ -81,6 +82,14 @@ export const config = {
});
},
},
+ userPermissions: {
+ read(permission = {}) {
+ return {
+ ...permission,
+ setWorkItemMetadata: false,
+ };
+ },
+ },
},
},
MemberInterfaceConnection: {
@@ -126,6 +135,33 @@ export const config = {
};
},
},
+ Group: {
+ fields: {
+ projects: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ descendantGroups: {
+ keyArgs: ['includeSubgroups', 'search'],
+ },
+ },
+ },
+ ProjectConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ GroupConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
+ Board: {
+ fields: {
+ epics: {
+ keyArgs: ['boardId'],
+ },
+ },
+ },
BoardEpicConnection: {
merge(existing = { nodes: [] }, incoming, { args }) {
if (!args.after) {
@@ -146,7 +182,7 @@ export const config = {
export const resolvers = {
Mutation: {
addHierarchyChild: (_, { id, workItem }, { cache }) => {
- const queryArgs = { query: getWorkItemLinksQuery, variables: { id } };
+ const queryArgs = { query: workItemQuery, variables: { id } };
const sourceData = cache.readQuery(queryArgs);
const data = produce(sourceData, (draftState) => {
@@ -156,7 +192,7 @@ export const resolvers = {
cache.writeQuery({ ...queryArgs, data });
},
removeHierarchyChild: (_, { id, workItem }, { cache }) => {
- const queryArgs = { query: getWorkItemLinksQuery, variables: { id } };
+ const queryArgs = { query: workItemQuery, variables: { id } };
const sourceData = cache.readQuery(queryArgs);
const data = produce(sourceData, (draftState) => {
@@ -174,6 +210,29 @@ export const resolvers = {
});
cache.writeQuery({ query: getIssueStateQuery, data });
},
+ setActiveBoardItem(_, { boardItem }, { cache }) {
+ cache.writeQuery({
+ query: activeBoardItemQuery,
+ data: { activeBoardItem: boardItem },
+ });
+ return boardItem;
+ },
+ clientToggleListCollapsed(_, { list = {}, collapsed = false }) {
+ return {
+ list: {
+ ...list,
+ collapsed,
+ },
+ };
+ },
+ clientToggleEpicListCollapsed(_, { list = {}, collapsed = false }) {
+ return {
+ list: {
+ ...list,
+ collapsed,
+ },
+ };
+ },
},
};
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 4a5536986bd..f35886716ee 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -21,7 +21,8 @@
"Epic",
"EpicIssue",
"Issue",
- "MergeRequest"
+ "MergeRequest",
+ "WorkItemWidgetCurrentUserTodos"
],
"DependencyLinkMetadata": [
"NugetDependencyLinkMetadata"
@@ -39,6 +40,10 @@
"BoardEpic",
"Epic"
],
+ "ExternalAuditEventDestinationInterface": [
+ "ExternalAuditEventDestination",
+ "InstanceExternalAuditEventDestination"
+ ],
"Issuable": [
"Epic",
"Issue",
@@ -84,6 +89,22 @@
"NugetMetadata",
"PypiMetadata"
],
+ "Registrable": [
+ "CiSecureFileRegistry",
+ "ContainerRepositoryRegistry",
+ "DependencyProxyBlobRegistry",
+ "DependencyProxyManifestRegistry",
+ "JobArtifactRegistry",
+ "LfsObjectRegistry",
+ "MergeRequestDiffRegistry",
+ "PackageFileRegistry",
+ "PagesDeploymentRegistry",
+ "PipelineArtifactRegistry",
+ "ProjectWikiRepositoryRegistry",
+ "SnippetRepositoryRegistry",
+ "TerraformStateVersionRegistry",
+ "UploadRegistry"
+ ],
"ResolvableInterface": [
"Discussion",
"Note"
@@ -145,6 +166,8 @@
],
"WorkItemWidget": [
"WorkItemWidgetAssignees",
+ "WorkItemWidgetAwardEmoji",
+ "WorkItemWidgetCurrentUserTodos",
"WorkItemWidgetDescription",
"WorkItemWidgetHealthStatus",
"WorkItemWidgetHierarchy",
@@ -152,6 +175,7 @@
"WorkItemWidgetLabels",
"WorkItemWidgetMilestone",
"WorkItemWidgetNotes",
+ "WorkItemWidgetNotifications",
"WorkItemWidgetProgress",
"WorkItemWidgetRequirementLegacy",
"WorkItemWidgetStartAndDueDate",
@@ -159,4 +183,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/graphql_shared/queries/merge_request.query.graphql b/app/assets/javascripts/graphql_shared/queries/merge_request.query.graphql
new file mode 100644
index 00000000000..a6ef5935162
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/merge_request.query.graphql
@@ -0,0 +1,9 @@
+query mergeRequestId($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ id
+ mergeRequest(iid: $iid) {
+ id
+ preparedAt
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql b/app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql
new file mode 100644
index 00000000000..ba658f56ebd
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/subscriptions/merge_request_prepared.subscription.graphql
@@ -0,0 +1,8 @@
+subscription mergeRequestPrepared($issuableId: IssuableID!) {
+ mergeRequestMergeStatusUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ preparedAt
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql
index d8760f147e1..28405d9dc9b 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql
+++ b/app/assets/javascripts/graphql_shared/subscriptions/work_item_dates.subscription.graphql
@@ -10,5 +10,9 @@ subscription issuableDatesUpdated($issuableId: IssuableID!) {
}
}
}
+ ... on Issue {
+ id
+ dueDate
+ }
}
}
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 806e89d6e9f..6a64e8a2fa8 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -1,4 +1,5 @@
import { isArray } from 'lodash';
+import Visibility from 'visibilityjs';
/**
* Ids generated by GraphQL endpoints are usually in the format
@@ -15,7 +16,10 @@ export const isGid = (id) => {
return false;
};
-const parseGid = (gid) => parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10);
+const parseGid = (gid) => {
+ const [type, id] = `${gid}`.replace(/gid:\/\/gitlab\//g, '').split('/');
+ return { type, id };
+};
/**
* Ids generated by GraphQL endpoints are usually in the format
@@ -26,8 +30,24 @@ const parseGid = (gid) => parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, '')
* @returns {Number}
*/
export const getIdFromGraphQLId = (gid = '') => {
- const parsedGid = parseGid(gid);
- return Number.isInteger(parsedGid) ? parsedGid : null;
+ const rawId = isGid(gid) ? parseGid(gid).id : gid;
+ const id = parseInt(rawId, 10);
+ return Number.isInteger(id) ? id : null;
+};
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Environments/123. This method extracts Type string
+ * from the Id path
+ *
+ * @param {String} gid GraphQL global ID
+ * @returns {String}
+ */
+export const getTypeFromGraphQLId = (gid = '') => {
+ if (!isGid(gid)) return null;
+
+ const { type } = parseGid(gid);
+ return type || null;
};
export const MutationOperationMode = {
@@ -116,3 +136,29 @@ export const convertNodeIdsFromGraphQLIds = (nodes) => {
export const getNodesOrDefault = (queryData, nodesField = 'nodes') => {
return queryData?.[nodesField] ?? [];
};
+
+export const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
+ const stopStartQuery = (query) => {
+ if (!Visibility.hidden()) {
+ query.startPolling(interval);
+ } else {
+ query.stopPolling();
+ }
+ };
+
+ stopStartQuery(queryRef);
+ Visibility.change(stopStartQuery.bind(null, queryRef));
+};
+
+export const etagQueryHeaders = (featureCorrelation, etagResource = '') => {
+ return {
+ fetchOptions: {
+ method: 'GET',
+ },
+ headers: {
+ 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': featureCorrelation,
+ 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ };
+};
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/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
index 8011090f1cb..a4ec48ffd2f 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -1,7 +1,16 @@
<script>
import { GlToggle, GlAlert } from '@gitlab/ui';
+import { sprintf } from '~/locale';
import { updateGroup } from '~/api/groups_api';
-import { I18N_UPDATE_ERROR_MESSAGE, I18N_REFRESH_MESSAGE } from '../constants';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import {
+ I18N_CONFIRM_MESSAGE,
+ I18N_CONFIRM_OK,
+ I18N_CONFIRM_CANCEL,
+ I18N_CONFIRM_TITLE,
+ I18N_UPDATE_ERROR_MESSAGE,
+ I18N_REFRESH_MESSAGE,
+} from '../constants';
export default {
components: {
@@ -10,6 +19,8 @@ export default {
},
inject: [
'groupId',
+ 'groupName',
+ 'groupIsEmpty',
'sharedRunnersSetting',
'parentSharedRunnersSetting',
'runnerEnabledValue',
@@ -39,9 +50,28 @@ export default {
},
},
methods: {
- onSharedRunnersToggle(value) {
- const newSetting = value ? this.runnerEnabledValue : this.runnerDisabledValue;
- this.updateSetting(newSetting);
+ async onSharedRunnersToggle(enabled) {
+ if (enabled) {
+ this.updateSetting(this.runnerEnabledValue);
+ return;
+ }
+ if (this.groupIsEmpty) {
+ this.updateSetting(this.runnerDisabledValue);
+ return;
+ }
+
+ // Confirm when disabling for a group with subgroups or projects
+ const confirmDisabled = await confirmAction(I18N_CONFIRM_MESSAGE, {
+ title: sprintf(I18N_CONFIRM_TITLE, { groupName: this.groupName }),
+ cancelBtnText: I18N_CONFIRM_CANCEL,
+ primaryBtnText: I18N_CONFIRM_OK,
+ primaryBtnVariant: 'danger',
+ size: 'md',
+ });
+
+ if (confirmDisabled) {
+ this.updateSetting(this.runnerDisabledValue);
+ }
},
onOverrideToggle(value) {
const newSetting = value ? this.runnerAllowOverrideValue : this.runnerDisabledValue;
diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js
index 1b44161903d..d4ac7d94bf4 100644
--- a/app/assets/javascripts/group_settings/constants.js
+++ b/app/assets/javascripts/group_settings/constants.js
@@ -1,4 +1,13 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+
+export const I18N_CONFIRM_MESSAGE = s__(
+ 'Runners|Shared runners will be disabled for all projects and subgroups in this group. If you proceed, you must manually re-enable shared runners in the settings of each project and subgroup.',
+);
+export const I18N_CONFIRM_OK = s__('Runners|Yes, disable shared runners');
+export const I18N_CONFIRM_CANCEL = s__('Runners|No, keep shared runners enabled');
+export const I18N_CONFIRM_TITLE = s__(
+ 'Runners|Are you sure you want to disable shared runners for %{groupName}?',
+);
export const I18N_UPDATE_ERROR_MESSAGE = __('An error occurred while updating configuration.');
export const I18N_REFRESH_MESSAGE = __('Refresh the page and try again.');
diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js
index e7e104d61b3..0767330cd54 100644
--- a/app/assets/javascripts/group_settings/mount_shared_runners.js
+++ b/app/assets/javascripts/group_settings/mount_shared_runners.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import UpdateSharedRunnersForm from './components/shared_runners_form.vue';
export default (containerId = 'update-shared-runners-form') => {
@@ -6,6 +7,8 @@ export default (containerId = 'update-shared-runners-form') => {
const {
groupId,
+ groupName,
+ groupIsEmpty,
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
@@ -17,6 +20,8 @@ export default (containerId = 'update-shared-runners-form') => {
el: containerEl,
provide: {
groupId,
+ groupName,
+ groupIsEmpty: parseBoolean(groupIsEmpty),
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
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/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
index 535758750f9..cba13c11c5d 100644
--- a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue
@@ -8,14 +8,14 @@ export default {
i18n: {
title: s__('GroupsEmptyState|No archived projects.'),
},
- inject: ['newProjectIllustration'],
+ inject: ['emptyProjectsIllustration'],
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
- :svg-path="newProjectIllustration"
+ :svg-path="emptyProjectsIllustration"
:svg-height="100"
/>
</template>
diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
index 7223321bf3e..7c691b56a43 100644
--- a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue
@@ -8,14 +8,14 @@ export default {
i18n: {
title: s__('GroupsEmptyState|No shared projects.'),
},
- inject: ['newProjectIllustration'],
+ inject: ['emptyProjectsIllustration'],
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
- :svg-path="newProjectIllustration"
+ :svg-path="emptyProjectsIllustration"
:svg-height="100"
/>
</template>
diff --git a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
index 955cb1ca63e..0bd95d59022 100644
--- a/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
+++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue
@@ -43,6 +43,7 @@ export default {
'newProjectPath',
'newSubgroupIllustration',
'newProjectIllustration',
+ 'emptyProjectsIllustration',
'emptySubgroupIllustration',
'canCreateSubgroups',
'canCreateProjects',
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index d9781ef9c84..8d202194de7 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,8 +16,12 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
-import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants';
+import {
+ VISIBILITY_LEVELS_STRING_TO_INTEGER,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+} from '~/visibility_level/constants';
+import { ITEM_TYPE } from '../constants';
import eventHub from '../event_hub';
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/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index a4c163b0a81..5674e28f5da 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -2,12 +2,7 @@
import { GlBadge } from '@gitlab/ui';
import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import {
- ITEM_TYPE,
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- PROJECT_VISIBILITY_TYPE,
-} from '../constants';
+import { ITEM_TYPE } from '../constants';
import ItemStatsValue from './item_stats_value.vue';
export default {
@@ -24,15 +19,6 @@ export default {
},
},
computed: {
- visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.item.visibility];
- },
- visibilityTooltip() {
- if (this.item.type === ITEM_TYPE.GROUP) {
- return GROUP_VISIBILITY_TYPE[this.item.visibility];
- }
- return PROJECT_VISIBILITY_TYPE[this.item.visibility];
- },
isProject() {
return this.item.type === ITEM_TYPE.PROJECT;
},
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 6fb12cd6270..a5854632040 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,9 +1,4 @@
import { __, s__ } from '~/locale';
-import {
- VISIBILITY_LEVEL_PRIVATE_STRING,
- VISIBILITY_LEVEL_INTERNAL_STRING,
- VISIBILITY_LEVEL_PUBLIC_STRING,
-} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -12,7 +7,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 = {
@@ -31,36 +25,6 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
-export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The group and any public projects can be viewed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- 'Private - The group and its projects can only be viewed by members.',
- ),
-};
-
-export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The project can be accessed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The project can be accessed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
- ),
-};
-
-export const VISIBILITY_TYPE_ICON = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
- [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
- [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
-};
-
export const OVERVIEW_TABS_SORTING_ITEMS = [
{
label: __('Name'),
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index c3bf3f28509..f6711bde7d0 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -51,6 +51,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups,
canCreateProjects,
@@ -63,6 +64,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
diff --git a/app/assets/javascripts/groups/init_group_readme.js b/app/assets/javascripts/groups/init_group_readme.js
new file mode 100644
index 00000000000..7cde64fed4d
--- /dev/null
+++ b/app/assets/javascripts/groups/init_group_readme.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import apolloProvider from '~/repository/graphql';
+import FilePreview from '~/repository/components/preview/index.vue';
+
+Vue.use(VueApollo);
+
+export const initGroupReadme = () => {
+ const el = document.getElementById('js-group-readme');
+
+ if (!el) return false;
+
+ const { webPath, name } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(FilePreview, {
+ props: {
+ blob: { webPath, name },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
index 664d07ca13d..4064520d1ca 100644
--- a/app/assets/javascripts/groups/init_overview_tabs.js
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -44,6 +44,7 @@ export const initGroupOverviewTabs = () => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups,
canCreateProjects,
@@ -62,6 +63,7 @@ export const initGroupOverviewTabs = () => {
newProjectPath,
newSubgroupIllustration,
newProjectIllustration,
+ emptyProjectsIllustration,
emptySubgroupIllustration,
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
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/settings/components/group_settings_readme.vue b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue
new file mode 100644
index 00000000000..123c7fc58f5
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/components/group_settings_readme.vue
@@ -0,0 +1,147 @@
+<script>
+import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { createProject } from '~/rest_api';
+import { createAlert } from '~/alert';
+import { openWebIDE } from '~/lib/utils/web_ide_navigator';
+import { README_MODAL_ID, GITLAB_README_PROJECT, README_FILE } from '../constants';
+
+export default {
+ name: 'GroupSettingsReadme',
+ i18n: {
+ readme: __('README'),
+ addReadme: __('Add README'),
+ cancel: __('Cancel'),
+ createProjectAndReadme: s__('Groups|Create and add README'),
+ creatingReadme: s__('Groups|Creating README'),
+ existingProjectNewReadme: s__('Groups|This will create a README.md for project %{path}.'),
+ newProjectAndReadme: s__('Groups|This will create a project %{path} and add a README.md.'),
+ errorCreatingProject: s__('Groups|There was an error creating the Group README.'),
+ },
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ groupReadmePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ readmeProjectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ groupPath: {
+ type: String,
+ required: true,
+ },
+ groupId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ creatingReadme: false,
+ };
+ },
+ computed: {
+ hasReadme() {
+ return this.groupReadmePath.length > 0;
+ },
+ hasReadmeProject() {
+ return this.readmeProjectPath.length > 0;
+ },
+ pathToReadmeProject() {
+ return this.hasReadmeProject
+ ? this.readmeProjectPath
+ : `${this.groupPath}/${GITLAB_README_PROJECT}`;
+ },
+ modalBody() {
+ return this.hasReadmeProject
+ ? this.$options.i18n.existingProjectNewReadme
+ : this.$options.i18n.newProjectAndReadme;
+ },
+ modalSubmitButtonText() {
+ return this.hasReadmeProject
+ ? this.$options.i18n.addReadme
+ : this.$options.i18n.createProjectAndReadme;
+ },
+ },
+ methods: {
+ hideModal() {
+ this.$refs.modal.hide();
+ },
+ createReadme() {
+ if (this.hasReadmeProject) {
+ openWebIDE(this.readmeProjectPath, README_FILE);
+ } else {
+ this.createProjectWithReadme();
+ }
+ },
+ createProjectWithReadme() {
+ this.creatingReadme = true;
+
+ const projectData = {
+ name: GITLAB_README_PROJECT,
+ namespace_id: this.groupId,
+ };
+
+ createProject(projectData)
+ .then(({ path_with_namespace: pathWithNamespace }) => {
+ openWebIDE(pathWithNamespace, README_FILE);
+ })
+ .catch(() => {
+ this.hideModal();
+ this.creatingReadme = false;
+ createAlert({ message: this.$options.i18n.errorCreatingProject });
+ });
+ },
+ },
+ README_MODAL_ID,
+};
+</script>
+
+<template>
+ <div>
+ <gl-button v-if="hasReadme" icon="doc-text" :href="groupReadmePath">{{
+ $options.i18n.readme
+ }}</gl-button>
+ <gl-button
+ v-else
+ v-gl-modal="$options.README_MODAL_ID"
+ variant="dashed"
+ icon="file-addition"
+ data-testid="group-settings-add-readme-button"
+ >{{ $options.i18n.addReadme }}</gl-button
+ >
+ <gl-modal ref="modal" :modal-id="$options.README_MODAL_ID" :title="$options.i18n.addReadme">
+ <div data-testid="group-settings-modal-readme-body">
+ <gl-sprintf :message="modalBody">
+ <template #path>
+ <code>{{ pathToReadmeProject }}</code>
+ </template>
+ </gl-sprintf>
+ </div>
+ <template #modal-footer>
+ <gl-button variant="default" @click="hideModal">{{ $options.i18n.cancel }}</gl-button>
+ <gl-button v-if="creatingReadme" variant="default" loading disabled>{{
+ $options.i18n.creatingReadme
+ }}</gl-button>
+ <gl-button
+ v-else
+ variant="confirm"
+ data-testid="group-settings-modal-create-readme-button"
+ @click="createReadme"
+ >{{ modalSubmitButtonText }}</gl-button
+ >
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js
index c91c2a20529..023ddf29b36 100644
--- a/app/assets/javascripts/groups/settings/constants.js
+++ b/app/assets/javascripts/groups/settings/constants.js
@@ -1,3 +1,7 @@
export const LEVEL_TYPES = {
GROUP: 'group',
};
+
+export const README_MODAL_ID = 'add_group_readme_modal';
+export const GITLAB_README_PROJECT = 'gitlab-profile';
+export const README_FILE = 'README.md';
diff --git a/app/assets/javascripts/groups/settings/init_group_settings_readme.js b/app/assets/javascripts/groups/settings/init_group_settings_readme.js
new file mode 100644
index 00000000000..d126228d854
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/init_group_settings_readme.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import GroupSettingsReadme from './components/group_settings_readme.vue';
+
+export const initGroupSettingsReadme = () => {
+ const el = document.getElementById('js-group-settings-readme');
+
+ if (!el) return false;
+
+ const { groupReadmePath, readmeProjectPath, groupPath, groupId } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(GroupSettingsReadme, {
+ props: {
+ groupReadmePath,
+ readmeProjectPath,
+ groupPath,
+ groupId,
+ },
+ });
+ },
+ });
+};
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..25a84d17379 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) {
@@ -37,7 +37,7 @@ function initStatusTriggers() {
const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl);
Tracking.event(undefined, 'click_button', {
label: 'user_edit_status',
- property: buttonWithinTopNav ? 'navigation_top' : undefined,
+ property: buttonWithinTopNav ? 'navigation_top' : 'nav_user_menu',
});
import(
@@ -135,6 +135,8 @@ function initNewNavToggle() {
});
}
-requestIdleCallback(initStatusTriggers);
+if (!gon?.use_new_navigation) {
+ requestIdleCallback(initStatusTriggers);
+}
requestIdleCallback(initNavUserDropdownTracking);
requestIdleCallback(initNewNavToggle);
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index ace0d77c431..422ec27346e 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -1,7 +1,6 @@
<script>
import {
GlSearchBoxByType,
- GlOutsideDirective as Outside,
GlIcon,
GlToken,
GlTooltipDirective,
@@ -12,10 +11,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,
@@ -26,6 +35,7 @@ import {
IS_SEARCHING,
IS_FOCUSED,
IS_NOT_FOCUSED,
+ DROPDOWN_CLOSE_TIMEOUT,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -34,28 +44,16 @@ 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 },
+ directives: { GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
@@ -67,7 +65,6 @@ export default {
},
data() {
return {
- showDropdown: false,
isFocused: false,
currentFocusIndex: SEARCH_BOX_INDEX,
};
@@ -93,7 +90,7 @@ export default {
return Boolean(gon?.current_username);
},
showSearchDropdown() {
- if (!this.showDropdown || !this.isLoggedIn) {
+ if (!this.isFocused || !this.isLoggedIn) {
return false;
}
return this.searchOptions?.length > 0;
@@ -110,12 +107,11 @@ export default {
}
return FIRST_DROPDOWN_INDEX;
},
-
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 +119,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 +150,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,
});
},
@@ -162,29 +158,18 @@ export default {
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);
+ this.isFocused = true;
+ this.$emit('expandSearchBar');
- Tracking.event(undefined, 'focus_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- }
- },
- closeDropdown() {
- this.showDropdown = false;
+ Tracking.event(undefined, 'focus_input', {
+ label: 'global_search',
+ property: 'navigation_top',
+ });
},
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
+ // without timeout dropdown closes
+ // before click event is dispatched
setTimeout(() => {
- this.showDropdown = false;
this.isFocused = false;
this.$emit('collapseSearchBar');
@@ -192,7 +177,7 @@ export default {
label: 'global_search',
property: 'navigation_top',
});
- }, 200);
+ }, DROPDOWN_CLOSE_TIMEOUT);
},
submitSearch() {
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
@@ -228,9 +213,8 @@ export default {
<template>
<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"
@@ -241,17 +225,16 @@ export default {
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
- data-qa-selector="search_term_field"
+ data-qa-selector="global_search_input"
autocomplete="off"
- :placeholder="$options.i18n.searchGitlab"
+ :placeholder="$options.i18n.SEARCH_GITLAB"
:aria-activedescendant="currentFocusedId"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
- @focus="openDropdown"
- @click="openDropdown"
- @blur="collapseAndCloseSearchBar"
+ @focusin="openDropdown"
+ @focusout="collapseAndCloseSearchBar"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
- @keydown.esc.stop.prevent="closeDropdown"
+ @keydown.esc.stop.prevent="collapseAndCloseSearchBar"
/>
<gl-token
v-if="showScopeHelp"
@@ -267,7 +250,7 @@ export default {
:size="16"
/>{{
getTruncatedScope(
- sprintf($options.i18n.searchResultsScope, {
+ sprintf($options.i18n.SEARCH_RESULTS_SCOPE, {
scope: infieldHelpContent,
}),
)
@@ -277,7 +260,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">{{
@@ -303,7 +286,7 @@ export default {
:max="searchOptions.length - 1"
:min="$options.FIRST_DROPDOWN_INDEX"
:default-index="defaultIndex"
- @tab="closeDropdown"
+ :enable-cycle="true"
/>
<header-search-default-items
v-if="showDefaultItems"
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..47aeb2f9caa 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,8 @@ 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';
+
+export const DROPDOWN_CLOSE_TIMEOUT = 200;
diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js
index 4e9404007ec..64502d13ee2 100644
--- a/app/assets/javascripts/header_search/init.js
+++ b/app/assets/javascripts/header_search/init.js
@@ -2,29 +2,18 @@ import * as Sentry from '@sentry/browser';
import { HEADER_INIT_EVENTS } from './constants';
async function eventHandler(callback = () => {}) {
- if (this.newHeaderSearchFeatureFlag) {
- const { initHeaderSearchApp } = await import(
- /* webpackChunkName: 'globalSearch' */ '~/header_search'
- ).catch((error) => Sentry.captureException(error));
-
- // In case the user started searching before we bootstrapped,
- // let's pass the search along.
- const initialSearchValue = this.searchInputBox.value;
- initHeaderSearchApp(initialSearchValue);
-
- // this is new #search input element. We need to re-find it.
- // And re-focus in it.
- document.querySelector('#search').focus();
- callback();
- return;
- }
-
- const { default: initSearchAutocomplete } = await import(
- /* webpackChunkName: 'globalSearch' */ '../search_autocomplete'
+ const { initHeaderSearchApp } = await import(
+ /* webpackChunkName: 'globalSearch' */ '~/header_search'
).catch((error) => Sentry.captureException(error));
- const searchDropdown = initSearchAutocomplete();
- searchDropdown.onSearchInputFocus();
+ // In case the user started searching before we bootstrapped,
+ // let's pass the search along.
+ const initialSearchValue = this.searchInputBox.value;
+ initHeaderSearchApp(initialSearchValue);
+
+ // this is new #search input element. We need to re-find it.
+ // And re-focus in it.
+ document.querySelector('#search').focus();
callback();
}
@@ -40,10 +29,7 @@ function initHeaderSearch() {
HEADER_INIT_EVENTS.forEach((eventType) => {
searchInputBox?.addEventListener(
eventType,
- eventHandler.bind(
- { searchInputBox, newHeaderSearchFeatureFlag: gon?.features?.newHeaderSearch },
- cleanEventListeners,
- ),
+ eventHandler.bind({ searchInputBox }, cleanEventListeners),
{ once: true },
);
});
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/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 92dacf8c94a..d788104edc8 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -43,6 +43,7 @@ export default {
data-container="body"
data-placement="right"
data-qa-selector="edit_mode_tab"
+ data-testid="edit-mode-button"
type="button"
class="ide-sidebar-link js-ide-edit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)"
@@ -60,6 +61,7 @@ export default {
:aria-label="s__('IDE|Review')"
data-container="body"
data-placement="right"
+ data-testid="review-mode-button"
type="button"
class="ide-sidebar-link js-ide-review-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)"
@@ -78,6 +80,7 @@ export default {
data-container="body"
data-placement="right"
data-qa-selector="commit_mode_tab"
+ data-testid="commit-mode-button"
type="button"
class="ide-sidebar-link js-ide-commit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 2799ea1378e..d05aa960f01 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -82,7 +82,7 @@ export default {
{{ __('Commit Message') }}
<div id="ide-commit-message-popover-container">
<span id="ide-commit-message-question" class="form-text text-muted gl-ml-3">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</span>
<gl-popover
target="ide-commit-message-question"
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/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index ea1dbee4669..9f83de840b9 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -69,7 +69,7 @@ export default {
>
<gl-icon name="ellipsis_v" />
</button>
- <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right">
+ <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right" data-testid="dropdown-menu">
<template v-if="type === 'tree'">
<li>
<item-button
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/pipelines/empty_state.vue b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
index 194deb2ece0..25e1698e3f4 100644
--- a/app/assets/javascripts/ide/components/pipelines/empty_state.vue
+++ b/app/assets/javascripts/ide/components/pipelines/empty_state.vue
@@ -28,6 +28,7 @@ export default {
<gl-empty-state
:title="$options.i18n.title"
:svg-path="pipelinesEmptyStateSvgPath"
+ :svg-height="150"
:description="$options.i18n.description"
:primary-button-text="$options.i18n.primaryButtonText"
:primary-button-link="ciHelpPagePath"
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/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 64ec2cc67c7..0fe909fcce8 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -91,6 +91,7 @@ export default {
:disabled="tab.pending"
type="button"
class="multi-file-tab-close"
+ data-testid="close-button"
@click.stop.prevent="closeFile(tab)"
>
<gl-icon v-if="!showChangedIcon" :size="12" name="close" />
diff --git a/app/assets/javascripts/ide/components/shared/commit_message_field.vue b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
index 7fca7429ad7..428cf7f55ac 100644
--- a/app/assets/javascripts/ide/components/shared/commit_message_field.vue
+++ b/app/assets/javascripts/ide/components/shared/commit_message_field.vue
@@ -82,7 +82,7 @@ export default {
<div>{{ __('Commit Message') }}</div>
<div id="commit-message-popover-container">
<span id="commit-message-question" class="gl-gray-700 gl-ml-3">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</span>
<gl-popover
target="commit-message-question"
diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
index 4d3cefcb107..51af73decad 100644
--- a/app/assets/javascripts/ide/init_gitlab_web_ide.js
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -67,6 +67,7 @@ export const initGitlabWebIDE = async (el) => {
links: {
feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE,
userPreferences: el.dataset.userPreferencesPath,
+ signIn: el.dataset.signInPath,
},
editorFont: {
srcUrl: editorFontSrcUrl,
diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
index dbb68b7facd..e131fb669ea 100644
--- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
+++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js
@@ -1,17 +1,15 @@
import { cleanEndingSeparator, joinPaths } from '~/lib/utils/url_utility';
-const getBaseUrl = () => {
- const path = joinPaths(
- '/',
- window.gon.relative_url_root || '',
- process.env.GITLAB_WEB_IDE_PUBLIC_PATH,
- );
+const getGitLabUrl = (gitlabPath = '') => {
+ const path = joinPaths('/', window.gon.relative_url_root || '', gitlabPath);
const baseUrlObj = new URL(path, window.location.origin);
return cleanEndingSeparator(baseUrlObj.href);
};
export const getBaseConfig = () => ({
- baseUrl: getBaseUrl(),
- gitlabUrl: window.gon.gitlab_url,
+ // baseUrl - The URL which hosts the Web IDE static web assets
+ baseUrl: getGitLabUrl(process.env.GITLAB_WEB_IDE_PUBLIC_PATH),
+ // baseUrl - The URL for the GitLab instance
+ gitlabUrl: getGitLabUrl(''),
});
diff --git a/app/assets/javascripts/ide/lib/languages/codeowners.js b/app/assets/javascripts/ide/lib/languages/codeowners.js
new file mode 100644
index 00000000000..e2eed713801
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/codeowners.js
@@ -0,0 +1,39 @@
+const conf = {
+ comments: {
+ lineComment: '#',
+ },
+ autoClosingPairs: [{ open: '[', close: ']' }],
+ surroundingPairs: [{ open: '[', close: ']' }],
+};
+
+const language = {
+ tokenizer: {
+ root: [
+ // comment
+ [/^#.*$/, 'comment'],
+
+ // optional approval
+ [/^\^/, 'constant.numeric'],
+
+ // number of approvers
+ [/\[\d+\]$/, 'constant.numeric'],
+
+ // section
+ [/\[(?!\d+\])[^\]]+\]/, 'namespace'],
+
+ // pattern
+ [/^\s*(\S+)/, 'regexp'],
+
+ // owner
+ [/\S*@.*$/, 'variable.value'],
+ ],
+ },
+};
+
+export default {
+ id: 'codeowners',
+ extensions: ['codeowners'],
+ aliases: ['CODEOWNERS'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
index f758cb7dd86..c2ab954eb73 100644
--- a/app/assets/javascripts/ide/lib/languages/index.js
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -1,6 +1,7 @@
import hcl from './hcl';
import vue from './vue';
+import codeowners from './codeowners';
-const languages = [vue, hcl];
+const languages = [vue, hcl, codeowners];
export default languages;
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..06751b926b5 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -1,4 +1,5 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import { STATUS_OPEN } from '~/issues/constants';
import { __ } from '~/locale';
import { leftSidebarViews, PERMISSION_READ_MR, MAX_MR_FILES_AUTO_OPEN } from '../../constants';
import service from '../../services';
@@ -16,7 +17,7 @@ export const getMergeRequestsForBranch = (
.getProjectMergeRequests(`${projectId}`, {
source_branch: branchId,
source_project_id: state.projects[projectId].id,
- state: 'opened',
+ state: STATUS_OPEN,
order_by: 'created_at',
per_page: 1,
})
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..79a8ccf2285 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,4 +1,4 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import { addNumericSuffix } from '~/ide/utils';
import { sprintf, __ } from '~/locale';
import { leftSidebarViews } from '../../../constants';
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/import/constants.js b/app/assets/javascripts/import/constants.js
new file mode 100644
index 00000000000..b9814b5ca60
--- /dev/null
+++ b/app/assets/javascripts/import/constants.js
@@ -0,0 +1,28 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __, s__ } from '~/locale';
+
+const STATISTIC_ITEMS = {
+ diff_note: __('Diff notes'),
+ issue: __('Issues'),
+ issue_attachment: s__('GithubImporter|Issue links'),
+ issue_event: __('Issue events'),
+ label: __('Labels'),
+ lfs_object: __('LFS objects'),
+ merge_request_attachment: s__('GithubImporter|Merge request links'),
+ milestone: __('Milestones'),
+ note: __('Notes'),
+ 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 links'),
+};
+
+// support both camel case and snake case versions
+Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
+
+export { STATISTIC_ITEMS };
diff --git a/app/assets/javascripts/import/details/api.js b/app/assets/javascripts/import/details/api.js
new file mode 100644
index 00000000000..1fb3ee526d7
--- /dev/null
+++ b/app/assets/javascripts/import/details/api.js
@@ -0,0 +1,11 @@
+import axios from '~/lib/utils/axios_utils';
+
+export const fetchImportFailures = (failuresPath, { projectId, page, perPage }) => {
+ return axios.get(failuresPath, {
+ params: {
+ project_id: projectId,
+ page,
+ per_page: perPage,
+ },
+ });
+};
diff --git a/app/assets/javascripts/import/details/components/import_details_app.vue b/app/assets/javascripts/import/details/components/import_details_app.vue
new file mode 100644
index 00000000000..13483fa8ba2
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_app.vue
@@ -0,0 +1,18 @@
+<script>
+import { s__ } from '~/locale';
+import ImportDetailsTable from './import_details_table.vue';
+
+export default {
+ components: { ImportDetailsTable },
+ i18n: {
+ pageTitle: s__('Import|GitHub import details'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h1>{{ $options.i18n.pageTitle }}</h1>
+ <import-details-table />
+ </div>
+</template>
diff --git a/app/assets/javascripts/import/details/components/import_details_table.vue b/app/assets/javascripts/import/details/components/import_details_table.vue
new file mode 100644
index 00000000000..813dc1f2645
--- /dev/null
+++ b/app/assets/javascripts/import/details/components/import_details_table.vue
@@ -0,0 +1,160 @@
+<script>
+import { GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { getParameterValues } from '~/lib/utils/url_utility';
+
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import { STATISTIC_ITEMS } from '../../constants';
+import { fetchImportFailures } from '../api';
+
+const DEFAULT_PAGE_SIZE = 20;
+
+export default {
+ components: {
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlTable,
+ PaginationBar,
+ },
+ STATISTIC_ITEMS,
+ LOCAL_STORAGE_KEY: 'gl-import-details-page-size',
+ fields: [
+ {
+ key: 'type',
+ label: __('Type'),
+ tdClass: 'gl-white-space-nowrap',
+ },
+ {
+ key: 'title',
+ label: __('Title'),
+ tdClass: 'gl-md-w-30 gl-word-break-word',
+ },
+ {
+ key: 'provider_url',
+ label: __('URL'),
+ tdClass: 'gl-white-space-nowrap',
+ },
+ {
+ key: 'details',
+ label: __('Details'),
+ },
+ ],
+
+ i18n: {
+ fetchErrorMessage: s__('Import|An error occurred while fetching import details.'),
+ emptyText: s__('Import|No import details'),
+ },
+
+ inject: {
+ failuresPath: {
+ default: undefined,
+ },
+ },
+
+ data() {
+ return {
+ items: [],
+ loading: false,
+ page: 1,
+ perPage: DEFAULT_PAGE_SIZE,
+ totalPages: 0,
+ total: 0,
+ };
+ },
+
+ computed: {
+ hasItems() {
+ return this.items.length > 0;
+ },
+
+ pageInfo() {
+ return {
+ page: this.page,
+ perPage: this.perPage,
+ totalPages: this.totalPages,
+ total: this.total,
+ };
+ },
+ },
+
+ mounted() {
+ this.loadImportFailures();
+ },
+
+ methods: {
+ setPage(page) {
+ this.page = page;
+ this.loadImportFailures();
+ },
+
+ setPageSize(size) {
+ this.perPage = size;
+ this.page = 1;
+ this.loadImportFailures();
+ },
+
+ async loadImportFailures() {
+ if (!this.failuresPath) {
+ return;
+ }
+
+ this.loading = true;
+ try {
+ const response = await fetchImportFailures(this.failuresPath, {
+ projectId: getParameterValues('project_id')[0],
+ page: this.page,
+ perPage: this.perPage,
+ });
+
+ const { page, perPage, totalPages, total } = parseIntPagination(
+ normalizeHeaders(response.headers),
+ );
+ this.page = page;
+ this.perPage = perPage;
+ this.totalPages = totalPages;
+ this.total = total;
+ this.items = response.data;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.fetchErrorMessage });
+ }
+ this.loading = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-table :fields="$options.fields" :items="items" class="gl-mt-5" :busy="loading" show-empty>
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+
+ <template #empty>
+ <gl-empty-state :title="$options.i18n.emptyText" />
+ </template>
+
+ <template #cell(type)="{ item: { type } }">
+ {{ $options.STATISTIC_ITEMS[type] }}
+ </template>
+ <template #cell(provider_url)="{ item: { provider_url } }">
+ <gl-link v-if="provider_url" :href="provider_url" target="_blank">
+ {{ provider_url }}
+ <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-table>
+ <pagination-bar
+ v-if="hasItems"
+ :page-info="pageInfo"
+ class="gl-mt-5"
+ :storage-key="$options.LOCAL_STORAGE_KEY"
+ @set-page="setPage"
+ @set-page-size="setPageSize"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/import/details/index.js b/app/assets/javascripts/import/details/index.js
new file mode 100644
index 00000000000..7421846f103
--- /dev/null
+++ b/app/assets/javascripts/import/details/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ImportDetailsApp from './components/import_details_app.vue';
+
+export default () => {
+ const el = document.querySelector('.js-import-details');
+
+ if (!el) {
+ return null;
+ }
+
+ const { failuresPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'ImportDetailsRoot',
+ provide: {
+ failuresPath,
+ },
+ render(createElement) {
+ return createElement(ImportDetailsApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue
index f351a9a392f..1c31c04a416 100644
--- a/app/assets/javascripts/import_entities/components/group_dropdown.vue
+++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue
@@ -3,8 +3,8 @@ import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import { createAlert } from '~/alert';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
@@ -28,7 +28,7 @@ export default {
},
apollo: {
namespaces: {
- query: searchNamespacesWhereUserCanCreateProjectsQuery,
+ query: searchNamespacesWhereUserCanImportProjectsQuery,
variables() {
return {
search: this.searchTerm,
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 6dc0b2cec24..6c84684dedc 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,31 +1,10 @@
<script>
-import { GlAccordion, GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import { STATUSES } from '../constants';
-
-const STATISTIC_ITEMS = {
- diff_note: __('Diff notes'),
- issue: __('Issues'),
- issue_attachment: s__('GithubImporter|Issue attachments'),
- issue_event: __('Issue events'),
- label: __('Labels'),
- lfs_object: __('LFS objects'),
- merge_request_attachment: s__('GithubImporter|Merge request attachments'),
- milestone: __('Milestones'),
- note: __('Notes'),
- note_attachment: s__('GithubImporter|Note attachments'),
- protected_branch: __('Protected branches'),
- pull_request: s__('GithubImporter|Pull requests'),
- pull_request_merged_by: s__('GithubImporter|PR mergers'),
- pull_request_review: s__('GithubImporter|PR reviews'),
- pull_request_review_request: s__('GithubImporter|PR reviews'),
- release: __('Releases'),
- release_attachment: s__('GithubImporter|Release attachments'),
-};
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-// support both camel case and snake case versions
-Object.assign(STATISTIC_ITEMS, convertObjectPropsToCamelCase(STATISTIC_ITEMS));
+import { STATISTIC_ITEMS } from '~/import/constants';
+import { STATUSES } from '../constants';
const SCHEDULED_STATUS = {
icon: 'status-scheduled',
@@ -77,8 +56,20 @@ export default {
GlAccordionItem,
GlBadge,
GlIcon,
+ GlLink,
+ },
+ mixins: [glFeatureFlagMixin()],
+ inject: {
+ detailsPath: {
+ default: undefined,
+ },
},
props: {
+ projectId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
status: {
type: String,
required: true,
@@ -102,13 +93,16 @@ export default {
return this.stats && this.knownStats.length > 0;
},
+ isIncomplete() {
+ return this.status === STATUSES.FINISHED && this.stats && isIncompleteImport(this.stats);
+ },
+
mappedStatus() {
if (this.status === STATUSES.FINISHED) {
- const isIncomplete = this.stats && isIncompleteImport(this.stats);
- return isIncomplete
+ return this.isIncomplete
? {
icon: 'status-alert',
- text: __('Partial import'),
+ text: s__('Import|Partially completed'),
variant: 'warning',
}
: {
@@ -120,6 +114,22 @@ export default {
return STATUS_MAP[this.status];
},
+
+ showDetails() {
+ return (
+ Boolean(this.detailsPathForProject) &&
+ this.glFeatures.importDetailsPage &&
+ this.isIncomplete
+ );
+ },
+
+ detailsPathForProject() {
+ if (!this.projectId || !this.detailsPath) {
+ return null;
+ }
+
+ return `${this.detailsPath}?project_id=${this.projectId}`;
+ },
},
methods: {
@@ -140,25 +150,22 @@ export default {
},
STATISTIC_ITEMS,
+ i18n: {
+ detailsLink: s__('Import|See failures'),
+ },
};
</script>
<template>
<div>
- <div class="gl-display-inline-block gl-w-13">
- <gl-badge
- :icon="mappedStatus.icon"
- :variant="mappedStatus.variant"
- size="md"
- icon-size="sm"
- class="gl-mr-2"
- >
+ <div class="gl-display-inline-block">
+ <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" icon-size="sm">
{{ mappedStatus.text }}
</gl-badge>
</div>
<gl-accordion v-if="hasStats" :header-level="3">
<gl-accordion-item :title="__('Details')">
- <ul class="gl-p-0 gl-list-style-none gl-font-sm">
+ <ul class="gl-p-0 gl-mb-3 gl-list-style-none gl-font-sm">
<li v-for="key in knownStats" :key="key">
<div class="gl-display-flex gl-w-20 gl-align-items-center">
<gl-icon
@@ -173,6 +180,9 @@ export default {
</div>
</li>
</ul>
+ <gl-link v-if="showDetails" :href="detailsPathForProject">{{
+ $options.i18n.detailsLink
+ }}</gl-link>
</gl-accordion-item>
</gl-accordion>
</div>
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..246d27d3b94 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';
@@ -25,7 +24,7 @@ import { getGroupPathAvailability } from '~/rest_api';
import axios from '~/lib/utils/axios_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import searchNamespacesWhereUserCanImportProjectsQuery from '~/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { STATUSES } from '../../constants';
@@ -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',
}),
};
@@ -120,7 +118,7 @@ export default {
},
},
availableNamespaces: {
- query: searchNamespacesWhereUserCanCreateProjectsQuery,
+ query: searchNamespacesWhereUserCanImportProjectsQuery,
update(data) {
return data.currentUser.groups.nodes;
},
@@ -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/components/github_organizations_box.vue b/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue
new file mode 100644
index 00000000000..5d5965e33da
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue
@@ -0,0 +1,73 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { __, s__ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlCollapsibleListbox,
+ },
+ inject: ['statusImportGithubGroupPath'],
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return { organizationsLoading: true, organizations: [], organizationFilter: '' };
+ },
+ computed: {
+ toggleText() {
+ return this.value || this.$options.i18n.allOrganizations;
+ },
+ dropdownItems() {
+ return [
+ { text: this.$options.i18n.allOrganizations, value: '' },
+ ...this.organizations
+ .filter((entry) =>
+ entry.name.toLowerCase().includes(this.organizationFilter.toLowerCase()),
+ )
+ .map((entry) => ({
+ text: entry.name,
+ value: entry.name,
+ })),
+ ];
+ },
+ },
+ async mounted() {
+ try {
+ this.organizationsLoading = true;
+ const {
+ data: { provider_groups: organizations },
+ } = await axios.get(this.statusImportGithubGroupPath);
+ this.organizations = organizations;
+ } catch (e) {
+ createAlert({
+ message: __('Something went wrong on our end.'),
+ });
+ Sentry.captureException(e);
+ } finally {
+ this.organizationsLoading = false;
+ }
+ },
+ i18n: {
+ allOrganizations: s__('ImportProjects|All organizations'),
+ },
+};
+</script>
+<template>
+ <gl-collapsible-listbox
+ :loading="organizationsLoading"
+ :toggle-text="toggleText"
+ :header-text="s__('ImportProjects|Organizations')"
+ :items="dropdownItems"
+ searchable
+ role="button"
+ tabindex="0"
+ @search="organizationFilter = $event"
+ @select="$emit('input', $event)"
+ />
+</template>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue
new file mode 100644
index 00000000000..20dcd0356cd
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlButton, GlSearchBoxByClick, GlTabs, GlTab } from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { s__ } from '~/locale';
+import ImportProjectsTable from './import_projects_table.vue';
+import GithubOrganizationsBox from './github_organizations_box.vue';
+
+export default {
+ components: {
+ ImportProjectsTable,
+ GithubOrganizationsBox,
+ GlButton,
+ GlSearchBoxByClick,
+ GlTab,
+ GlTabs,
+ },
+ inheritAttrs: false,
+ data() {
+ return {
+ selectedRelationTypeTabIdx: 0,
+ };
+ },
+ computed: {
+ ...mapState({
+ selectedOrganization: (state) => state.filter.organization_login ?? '',
+ nameFilter: (state) => state.filter.filter ?? '',
+ }),
+ ...mapGetters(['isImportingAnyRepo', 'hasImportableRepos']),
+ isNameFilterDisabled() {
+ return (
+ this.$options.relationTypes[this.selectedRelationTypeTabIdx].showOrganizationFilter &&
+ !this.selectedOrganization
+ );
+ },
+ },
+ watch: {
+ selectedRelationTypeTabIdx: {
+ immediate: true,
+ handler(newIdx) {
+ const { backendFilter } = this.$options.relationTypes[newIdx];
+ this.setFilter({ ...backendFilter, organization_login: '', filter: '' });
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['setFilter']),
+ selectOrganization(org) {
+ this.selectedOrganization = org;
+ this.setFilter();
+ },
+ },
+
+ relationTypes: [
+ { title: s__('ImportProjects|Owned'), backendFilter: { relation_type: 'owned' } },
+ { title: s__('ImportProjects|Collaborated'), backendFilter: { relation_type: 'collaborated' } },
+ {
+ title: s__('ImportProjects|Organization'),
+ backendFilter: { relation_type: 'organization' },
+ showOrganizationFilter: true,
+ },
+ ],
+};
+</script>
+<template>
+ <import-projects-table v-bind="$attrs">
+ <template #filter="{ importAllButtonText, showImportAllModal }">
+ <gl-tabs v-model="selectedRelationTypeTabIdx" content-class="gl-py-0! gl-mb-3">
+ <gl-tab v-for="tab in $options.relationTypes" :key="tab.title" :title="tab.title">
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-gap-3 gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-0 gl-border-b-gray-100 gl-border-b-1"
+ >
+ <form class="gl-display-flex gl-flex-grow-1 gl-mr-3" novalidate @submit.prevent>
+ <github-organizations-box
+ v-if="tab.showOrganizationFilter"
+ class="gl-mr-3"
+ :value="selectedOrganization"
+ @input="setFilter({ organization_login: $event })"
+ />
+ <gl-search-box-by-click
+ data-qa-selector="githubish_import_filter_field"
+ name="filter"
+ :disabled="isNameFilterDisabled"
+ :value="nameFilter"
+ :placeholder="__('Filter by name')"
+ autofocus
+ @submit="setFilter({ filter: $event })"
+ @clear="setFilter({ filter: '' })"
+ />
+ </form>
+ <gl-button
+ variant="confirm"
+ :loading="isImportingAnyRepo"
+ :disabled="!hasImportableRepos"
+ type="button"
+ @click="showImportAllModal"
+ >
+ {{ importAllButtonText }}
+ </gl-button>
+ </div>
+ </gl-tab>
+ </gl-tabs>
+ </template>
+ </import-projects-table>
+</template>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index aaa37f145aa..1c830d8c2c5 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -8,6 +8,7 @@ import {
} from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { n__, __, sprintf } from '~/locale';
+
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import AdvancedSettings from './advanced_settings.vue';
@@ -57,7 +58,7 @@ export default {
data() {
return {
optionalStagesSelection: Object.fromEntries(
- this.optionalStages.map(({ name }) => [name, false]),
+ this.optionalStages.map(({ name, selected }) => [name, selected]),
),
};
},
@@ -123,6 +124,10 @@ export default {
'setFilter',
'importAll',
]),
+
+ showImportAllModal() {
+ this.$refs.importAllModal.show();
+ },
},
};
</script>
@@ -135,43 +140,30 @@ export default {
<template v-if="hasIncompatibleRepos">
<slot name="incompatible-repos-warning"></slot>
</template>
- <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-mb-5">
- <gl-button
- variant="confirm"
- :loading="isImportingAnyRepo"
- :disabled="!hasImportableRepos"
- type="button"
- @click="$refs.importAllModal.show()"
- >{{ importAllButtonText }}</gl-button
- >
- <gl-modal
- ref="importAllModal"
- modal-id="import-all-modal"
- :title="s__('ImportProjects|Import repositories')"
- :ok-title="__('Import')"
- @ok="importAll({ optionalStages: optionalStagesSelection })"
- >
- {{
- n__(
- 'Are you sure you want to import %d repository?',
- 'Are you sure you want to import %d repositories?',
- importAllCount,
- )
- }}
- </gl-modal>
-
- <slot name="actions"></slot>
- <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
- <gl-search-box-by-click
- data-qa-selector="githubish_import_filter_field"
- name="filter"
- :placeholder="__('Filter by name')"
- autofocus
- @submit="setFilter"
- @clear="setFilter('')"
- />
- </form>
- </div>
+ <slot name="filter" v-bind="{ showImportAllModal, importAllButtonText }">
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap gl-mb-5">
+ <gl-button
+ variant="confirm"
+ :loading="isImportingAnyRepo"
+ :disabled="!hasImportableRepos"
+ type="button"
+ @click="showImportAllModal"
+ >{{ importAllButtonText }}</gl-button
+ >
+
+ <slot name="actions"></slot>
+ <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent>
+ <gl-search-box-by-click
+ data-qa-selector="githubish_import_filter_field"
+ name="filter"
+ :placeholder="__('Filter by name')"
+ autofocus
+ @submit="setFilter({ filter: $event })"
+ @clear="setFilter({ filter: '' })"
+ />
+ </form>
+ </div>
+ </slot>
<advanced-settings
v-if="optionalStages && optionalStages.length"
v-model="optionalStagesSelection"
@@ -179,19 +171,36 @@ export default {
:is-initially-expanded="isAdvancedSettingsPanelInitiallyExpanded"
class="gl-mb-5"
/>
+ <gl-modal
+ ref="importAllModal"
+ modal-id="import-all-modal"
+ :title="s__('ImportProjects|Import repositories')"
+ :ok-title="__('Import')"
+ @ok="importAll({ optionalStages: optionalStagesSelection })"
+ >
+ {{
+ n__(
+ 'Are you sure you want to import %d repository?',
+ 'Are you sure you want to import %d repositories?',
+ importAllCount,
+ )
+ }}
+ </gl-modal>
<div v-if="repositories.length" class="gl-w-full">
- <table>
- <thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100">
- <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ fromHeaderText }}
- </th>
- <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ __('To GitLab') }}
- </th>
- <th class="gl-p-4 gl-vertical-align-top gl-border-b-1">
- {{ __('Status') }}
- </th>
- <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th>
+ <table class="table gl-table">
+ <thead>
+ <tr>
+ <th class="gl-w-half">
+ {{ fromHeaderText }}
+ </th>
+ <th class="gl-w-half">
+ {{ __('To GitLab') }}
+ </th>
+ <th>
+ {{ __('Status') }}
+ </th>
+ <th></th>
+ </tr>
</thead>
<tbody>
<template v-for="repo in repositories">
@@ -207,7 +216,7 @@ export default {
</table>
</div>
<gl-intersection-observer
- v-if="paginatable && pageInfo.hasNextPage"
+ v-if="!isLoadingRepos && paginatable && pageInfo.hasNextPage"
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 265cca9070e..735939f991f 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -70,7 +70,7 @@ export default {
...mapGetters(['getImportTarget']),
displayFullPath() {
- return this.repo.importedProject.fullPath.replace(/^\//, '');
+ return this.repo.importedProject?.fullPath.replace(/^\//, '');
},
isFinished() {
@@ -105,6 +105,10 @@ export default {
return this.getImportTarget(this.repo.importSource.id);
},
+ importedProjectId() {
+ return this.repo.importedProject?.id;
+ },
+
importButtonText() {
if (this.ciCdOnly) {
return __('Connect');
@@ -155,16 +159,16 @@ export default {
<template>
<tr
- class="gl-h-11 gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100 gl-h-11 gl-vertical-align-top"
+ class="gl-h-11"
data-qa-selector="project_import_row"
:data-qa-source-project="repo.importSource.fullName"
>
- <td class="gl-p-4 gl-vertical-align-top">
+ <td>
<gl-link :href="repo.importSource.providerLink" target="_blank" data-testid="providerLink"
>{{ repo.importSource.fullName }}
<gl-icon v-if="repo.importSource.providerLink" name="external-link" />
</gl-link>
- <div v-if="isFinished" class="gl-font-sm">
+ <div v-if="isFinished" class="gl-font-sm gl-mt-2">
<gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
<template #link>
<gl-link
@@ -179,52 +183,50 @@ export default {
</gl-sprintf>
</div>
</td>
- <td
- class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
- data-testid="fullPath"
- data-qa-selector="project_path_content"
- >
- <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
- <template v-else-if="isImportNotStarted || isSelectedForReimport">
- <div class="gl-display-flex gl-align-items-stretch gl-w-full">
- <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
- <template v-if="namespaces.length">
- <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
- <gl-dropdown-item
- v-for="ns in namespaces"
- :key="ns.fullPath"
- data-qa-selector="target_group_dropdown_item"
- :data-qa-group-name="ns.fullPath"
- @click="updateImportTarget({ targetNamespace: ns.fullPath })"
- >
- {{ ns.fullPath }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- </template>
- <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
- userNamespace
- }}</gl-dropdown-item>
- </import-group-dropdown>
- <div
- class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
- >
- /
+ <td data-testid="fullPath" data-qa-selector="project_path_content">
+ <div class="gl-display-flex gl-sm-flex-wrap">
+ <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
+ <template v-else-if="isImportNotStarted || isSelectedForReimport">
+ <div class="gl-display-flex gl-align-items-stretch gl-w-full">
+ <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
+ <template v-if="namespaces.length">
+ <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="ns in namespaces"
+ :key="ns.fullPath"
+ data-qa-selector="target_group_dropdown_item"
+ :data-qa-group-name="ns.fullPath"
+ @click="updateImportTarget({ targetNamespace: ns.fullPath })"
+ >
+ {{ ns.fullPath }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+ <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
+ <gl-dropdown-item @click="updateImportTarget({ targetNamespace: userNamespace })">{{
+ userNamespace
+ }}</gl-dropdown-item>
+ </import-group-dropdown>
+ <div
+ class="gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ >
+ /
+ </div>
+ <gl-form-input
+ ref="newNameInput"
+ v-model="newNameInput"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ data-qa-selector="project_path_field"
+ />
</div>
- <gl-form-input
- ref="newNameInput"
- v-model="newNameInput"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
- data-qa-selector="project_path_field"
- />
- </div>
- </template>
- <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
+ </template>
+ <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
+ </div>
</td>
- <td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator">
- <import-status :status="importStatus" :stats="stats" />
+ <td data-qa-selector="import_status_indicator">
+ <import-status :project-id="importedProjectId" :status="importStatus" :stats="stats" />
</td>
- <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap">
+ <td data-testid="actions" class="gl-white-space-nowrap">
<gl-tooltip :target="() => $refs.cancelButton.$el">
<div class="gl-text-left">
<p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p>
diff --git a/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql b/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql
new file mode 100644
index 00000000000..8c41f7116b3
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_projects/graphql/queries/search_namespaces_where_user_can_import_projects.query.graphql
@@ -0,0 +1,18 @@
+query searchNamespacesWhereUserCanImportProjects($search: String) {
+ currentUser {
+ id
+ groups(permissionScope: IMPORT_PROJECTS, search: $search) {
+ nodes {
+ id
+ fullPath
+ name
+ visibility
+ webUrl
+ }
+ }
+ namespace {
+ id
+ fullPath
+ }
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 485511510f7..6ee637b1ce8 100644
--- a/app/assets/javascripts/import_entities/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -61,18 +61,27 @@ export default function mountImportProjectsTable({
mountElement,
Component = ImportProjectsTable,
extraProps = () => ({}),
+ extraProvide = () => ({}),
}) {
if (!mountElement) return undefined;
const store = initStoreFromElement(mountElement);
const props = initPropsFromElement(mountElement);
+ const { detailsPath } = mountElement.dataset;
return new Vue({
el: mountElement,
+ name: 'ImportProjectsRoot',
store,
apolloProvider,
+ provide: {
+ detailsPath,
+ ...extraProvide(mountElement.dataset),
+ },
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..4305f8d4db5 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';
@@ -83,7 +83,7 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit })
.get(
pathWithParams({
path: reposPath,
- filter: filter ?? '',
+ ...(filter ?? {}),
...paginationParams({ state }),
}),
)
@@ -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,
});
});
};
@@ -203,7 +203,7 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d
eTagPoll = new Poll({
resource: {
- fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter: state.filter })),
+ fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, ...state.filter })),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index 734e7b10a77..df529449f90 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -23,8 +23,8 @@ const processLegacyEntries = ({ newRepositories, existingRepositories, factory }
};
export default {
- [types.SET_FILTER](state, filter) {
- state.filter = filter;
+ [types.SET_FILTER](state, newFilter) {
+ state.filter = { ...state.filter, ...newFilter };
state.repositories = [];
state.pageInfo = {
page: 0,
diff --git a/app/assets/javascripts/import_entities/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js
index c384848f0a0..62dcefd3339 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/state.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/state.js
@@ -4,7 +4,7 @@ export default () => ({
customImportTargets: {},
isLoadingRepos: false,
ciCdOnly: false,
- filter: '',
+ filter: {},
pageInfo: {
page: 0,
startCursor: null,
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index f8e70fea7aa..e15cb2224f4 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -12,6 +12,7 @@ import {
GlEmptyState,
} from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
+import { STATUS_CLOSED } from '~/issues/constants';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { s__, n__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/constants';
@@ -301,6 +302,9 @@ export default {
getEscalationStatus(escalationStatus) {
return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus;
},
+ isClosed(item) {
+ return item.state === STATUS_CLOSED;
+ },
showIncidentLink({ iid }) {
return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid);
},
@@ -397,7 +401,7 @@ export default {
<template #cell(title)="{ item }">
<div
:class="{
- 'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed',
+ 'gl-display-flex gl-align-items-center gl-max-w-full': isClosed(item),
}"
>
<gl-link
@@ -411,7 +415,7 @@ export default {
</tooltip-on-truncate>
</gl-link>
<gl-icon
- v-if="item.state === 'closed'"
+ v-if="isClosed(item)"
name="issue-close"
class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0"
:size="16"
diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js
index ee3f30de880..6f8d5cf5f89 100644
--- a/app/assets/javascripts/incidents/constants.js
+++ b/app/assets/javascripts/incidents/constants.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { s__ } from '~/locale';
export const I18N = {
@@ -44,7 +43,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' };
@@ -52,11 +50,13 @@ export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
+const category = 'Incident Management'; // eslint-disable-line @gitlab/require-i18n-strings
+
/**
* Tracks snowplow event when user clicks create new incident
*/
export const trackIncidentCreateNewOptions = {
- category: 'Incident Management',
+ category,
action: 'create_incident_button_clicks',
};
@@ -64,7 +64,7 @@ export const trackIncidentCreateNewOptions = {
* Tracks snowplow event when user views incidents list
*/
export const trackIncidentListViewsOptions = {
- category: 'Incident Management',
+ category,
action: 'view_incidents_list',
};
@@ -72,6 +72,6 @@ export const trackIncidentListViewsOptions = {
* Tracks snowplow event when user views incident details
*/
export const trackIncidentDetailsViewsOptions = {
- category: 'Incident Management',
+ category,
action: 'view_incident_details',
};
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/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js
index 8413fe92f89..d48741c794e 100644
--- a/app/assets/javascripts/init_diff_stats_dropdown.js
+++ b/app/assets/javascripts/init_diff_stats_dropdown.js
@@ -1,12 +1,7 @@
import Vue from 'vue';
import DiffStatsDropdown from '~/vue_shared/components/diff_stats_dropdown.vue';
-import { stickyMonitor } from './lib/utils/sticky';
-
-export const initDiffStatsDropdown = (stickyTop) => {
- if (stickyTop) {
- stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop, false);
- }
+export const initDiffStatsDropdown = () => {
const el = document.querySelector('.js-diff-stats-dropdown');
if (!el) {
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..59a29f81727 100644
--- a/app/assets/javascripts/integrations/index/components/integrations_table.vue
+++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue
@@ -1,6 +1,5 @@
<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';
@@ -59,13 +58,10 @@ export default {
];
},
filteredIntegrations() {
- if (this.glFeatures.integrationSlackAppNotifications) {
- return this.integrations.filter(
- (integration) =>
- !(integration.name === INTEGRATION_TYPE_SLACK && integration.active === false),
- );
- }
- return this.integrations;
+ return this.integrations.filter(
+ (integration) =>
+ !(integration.name === 'prometheus' && this.glFeatures.removeMonitorMetrics),
+ );
},
},
methods: {
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/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index b4e9a3a1559..10c08d63612 100644
--- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -2,13 +2,14 @@
import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
-import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
import eventHub from '../event_hub';
import {
displaySuccessfulInvitationAlert,
reloadOnInvitationSuccess,
} from '../utils/trigger_successful_invite_alert';
+import { PROJECT_SELECT_LABEL_ID } from '../constants';
import ProjectSelect from './project_select.vue';
export default {
@@ -80,11 +81,17 @@ export default {
openModal() {
this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId);
},
+ closeModal() {
+ this.$root.$emit(BV_HIDE_MODAL, this.$options.modalId);
+ },
resetFields() {
this.invalidFeedbackMessage = '';
this.projectToBeImported = {};
},
- submitImport() {
+ submitImport(e) {
+ // We never want to hide when submitting
+ e.preventDefault();
+
this.isLoading = true;
return importProjectMembers(this.projectId, this.projectToBeImported.id)
.then(this.onInviteSuccess)
@@ -130,7 +137,7 @@ export default {
defaultError: s__('ImportAProjectModal|Unable to import project members'),
successMessage: s__('ImportAProjectModal|Successfully imported'),
},
- projectSelectLabelId: 'project-select',
+ projectSelectLabelId: PROJECT_SELECT_LABEL_ID,
modalId: uniqueId('import-a-project-modal-'),
};
</script>
@@ -143,6 +150,7 @@ export default {
:title="$options.i18n.modalTitle"
:action-primary="actionPrimary"
:action-cancel="actionCancel"
+ no-focus-on-show
@primary="submitImport"
@hidden="resetFields"
>
@@ -157,10 +165,11 @@ export default {
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
data-testid="form-group"
+ label-cols="auto"
+ label-class="gl-pt-3!"
+ :label="$options.i18n.projectLabel"
+ :label-for="$options.projectSelectLabelId"
>
- <label :id="$options.projectSelectLabelId" class="col-form-label">{{
- $options.i18n.projectLabel
- }}</label>
<project-select v-model="projectToBeImported" />
</gl-form-group>
<p>{{ $options.i18n.modalHelpText }}</p>
diff --git a/app/assets/javascripts/invite_members/components/invite_group_notification.vue b/app/assets/javascripts/invite_members/components/invite_group_notification.vue
index 767675cc64c..aaa04dc4b43 100644
--- a/app/assets/javascripts/invite_members/components/invite_group_notification.vue
+++ b/app/assets/javascripts/invite_members/components/invite_group_notification.vue
@@ -1,12 +1,7 @@
<script>
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { GROUP_MODAL_ALERT_BODY } from '../constants';
-
-const SHARE_GROUP_LINK =
- 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group';
export default {
- SHARE_GROUP_LINK,
name: 'InviteGroupNotification',
components: { GlAlert, GlSprintf, GlLink },
inject: ['freeUsersLimit'],
@@ -15,18 +10,23 @@ export default {
type: String,
required: true,
},
- },
- i18n: {
- body: GROUP_MODAL_ALERT_BODY,
+ notificationText: {
+ type: String,
+ required: true,
+ },
+ notificationLink: {
+ type: String,
+ required: true,
+ },
},
};
</script>
<template>
<gl-alert variant="warning" :dismissible="false">
- <gl-sprintf :message="$options.i18n.body">
+ <gl-sprintf :message="notificationText">
<template #link="{ content }">
- <gl-link :href="$options.SHARE_GROUP_LINK" target="_blank" class="gl-label-link">{{
+ <gl-link :href="notificationLink" target="_blank" class="gl-label-link">{{
content
}}</gl-link>
</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
index 3be3b9df747..51355baef99 100644
--- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue
@@ -190,7 +190,13 @@ export default {
@submit="sendInvite"
>
<template #alert>
- <invite-group-notification v-if="freeUserCapEnabled" :name="name" />
+ <invite-group-notification
+ v-if="freeUserCapEnabled"
+ :name="name"
+ :notification-text="$options.labels[inviteTo].notificationText"
+ :notification-link="$options.labels[inviteTo].notificationLink"
+ class="gl-mb-5"
+ />
</template>
<template #select>
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..e99a61caf3f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -13,20 +13,21 @@ import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
import Tracking from '~/tracking';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import {
+ memberName,
+ triggerExternalAlert,
+ qualifiesForTasksToBeDone,
+} from 'ee_else_ce/invite_members/utils/member_utils';
+import {
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
- LEARN_GITLAB,
INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
} from '../constants';
import eventHub from '../event_hub';
import { responseFromSuccess } from '../utils/response_message_parser';
-import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import {
displaySuccessfulInvitationAlert,
@@ -51,6 +52,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'],
@@ -168,11 +171,7 @@ export default {
);
},
tasksToBeDoneEnabled() {
- return (
- (getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
- this.isOnLearnGitlab) &&
- this.tasksToBeDoneOptions.length
- );
+ return qualifiesForTasksToBeDone(this.source) && this.tasksToBeDoneOptions.length;
},
showTasksToBeDone() {
return (
@@ -191,9 +190,6 @@ export default {
? this.selectedTaskProject.id
: '';
},
- isOnLearnGitlab() {
- return this.source === LEARN_GITLAB;
- },
showUserLimitNotification() {
return !isEmpty(this.usersLimitDataset.alertVariant);
},
@@ -246,14 +242,10 @@ export default {
eventHub.$on('openModal', (options) => {
this.openModal(options);
- if (this.isOnLearnGitlab) {
- this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source);
- }
});
if (this.tasksToBeDoneEnabled) {
this.openModal({ source: 'in_product_marketing_email' });
- this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
},
methods: {
@@ -281,16 +273,29 @@ export default {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- trackEvent(experimentName, eventName) {
- const tracking = new ExperimentTracking(experimentName);
- tracking.event(eventName);
- },
showEmptyInvitesAlert() {
this.invalidFeedbackMessage = this.$options.labels.placeHolder;
this.shouldShowEmptyInvitesAlert = true;
this.$refs.alerts.focus();
},
- sendInvite({ accessLevel, expiresAt }) {
+ getInvitePayload({ accessLevel, expiresAt }) {
+ const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+
+ const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
+ const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+
+ return {
+ format: 'json',
+ expires_at: expiresAt,
+ access_level: accessLevel,
+ invite_source: this.source,
+ tasks_to_be_done: this.tasksToBeDoneForPost,
+ tasks_project_id: this.tasksProjectForPost,
+ ...email,
+ ...userId,
+ };
+ },
+ async sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.clearValidation();
@@ -299,40 +304,28 @@ export default {
return;
}
- const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
+ this.trackInviteMembersForTask();
const apiAddByInvite = this.isProject
? Api.inviteProjectMembers.bind(Api)
: Api.inviteGroupMembers.bind(Api);
- const email = usersToInviteByEmail !== '' ? { email: usersToInviteByEmail } : {};
- const userId = usersToAddById !== '' ? { user_id: usersToAddById } : {};
+ try {
+ const payload = this.getInvitePayload({ accessLevel, expiresAt });
+ const response = await apiAddByInvite(this.id, payload);
- this.trackinviteMembersForTask();
+ const { error, message } = responseFromSuccess(response);
- apiAddByInvite(this.id, {
- format: 'json',
- expires_at: expiresAt,
- access_level: accessLevel,
- invite_source: this.source,
- tasks_to_be_done: this.tasksToBeDoneForPost,
- tasks_project_id: this.tasksProjectForPost,
- ...email,
- ...userId,
- })
- .then((response) => {
- const { error, message } = responseFromSuccess(response);
-
- if (error) {
- this.showMemberErrors(message);
- } else {
- this.onInviteSuccess();
- }
- })
- .catch((e) => this.showInvalidFeedbackMessage(e))
- .finally(() => {
- this.isLoading = false;
- });
+ if (error) {
+ this.showMemberErrors(message);
+ } else {
+ this.onInviteSuccess();
+ }
+ } catch (e) {
+ this.showInvalidFeedbackMessage(e);
+ } finally {
+ this.isLoading = false;
+ }
},
showMemberErrors(message) {
this.invalidMembers = message;
@@ -342,11 +335,10 @@ export default {
// initial token creation hits this and nothing is found... so safe navigation
return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
},
- trackinviteMembersForTask() {
+ trackInviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
- const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
- tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
+ this.track(INVITE_MEMBERS_FOR_TASK.submit, { label, property });
},
onCancel() {
this.track('click_cancel', { label: this.source });
@@ -375,9 +367,7 @@ export default {
}
},
showSuccessMessage() {
- if (this.isOnLearnGitlab) {
- eventHub.$emit('showSuccessfulInvitationsAlert');
- } else {
+ if (!triggerExternalAlert(this.source)) {
this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
@@ -421,7 +411,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"
@@ -430,7 +419,9 @@ export default {
@access-level="onAccessLevelUpdate"
>
<template #intro-text-before>
- <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
+ <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1">
+ <gl-emoji data-name="tada" />
+ </div>
</template>
<template #intro-text-after>
<br />
@@ -504,6 +495,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..6efb7a6cdf1 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,17 @@
<script>
-import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlDropdownItem, GlDisclosureDropdownItem } 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,
+ TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
} from '../constants';
export default {
- components: { GlButton, GlLink, GlIcon },
+ components: { GlButton, GlLink, GlDropdownItem, GlDisclosureDropdownItem },
props: {
displayText: {
type: String,
@@ -40,16 +42,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,39 +50,43 @@ 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;
+ },
+ item() {
+ return { text: this.displayText };
+ },
+ isButtonTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_BUTTON;
+ },
+ isWithEmojiTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_WITH_EMOJI;
+ },
+ isDropdownWithEmojiTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI;
+ },
+ isDisclosureTrigger() {
+ return this.triggerElement === TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN;
},
},
methods: {
- checkTrigger(targetTriggerElement) {
- return this.triggerElement === targetTriggerElement;
- },
openModal() {
eventHub.$emit('openModal', { source: this.triggerSource });
},
+ handleDisclosureDropdownAction() {
+ this.openModal();
+ this.$emit('modal-opened');
+ },
},
- TRIGGER_ELEMENT_BUTTON,
- TRIGGER_ELEMENT_SIDE_NAV,
};
</script>
<template>
<gl-button
- v-if="checkTrigger($options.TRIGGER_ELEMENT_BUTTON)"
+ v-if="isButtonTrigger"
v-bind="componentAttributes"
:variant="variant"
:icon="icon"
@@ -98,17 +94,25 @@ export default {
>
{{ displayText }}
</gl-button>
- <gl-link
- v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)"
+ <gl-link v-else-if="isWithEmojiTrigger" v-bind="componentAttributes" @click="openModal">
+ {{ 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="isDropdownWithEmojiTrigger"
v-bind="componentAttributes"
- data-is-link="true"
+ button-class="top-nav-menu-item"
@click="openModal"
>
- <span class="nav-icon-container">
- <gl-icon :name="icon" />
- </span>
- <span class="nav-item-name"> {{ displayText }} </span>
- </gl-link>
+ {{ displayText }}
+ <gl-emoji class="gl-vertical-align-baseline gl-reset-font-size gl-mr-1" :data-name="icon" />
+ </gl-dropdown-item>
+ <gl-disclosure-dropdown-item
+ v-else-if="isDisclosureTrigger"
+ v-bind="componentAttributes"
+ :item="item"
+ @action="handleDisclosureDropdownAction"
+ />
<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..91b623821dd 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,11 @@ 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"
+ no-focus-on-show
@shown="onShowModal"
- @primary="onSubmit"
- @cancel="onCancel"
@close="onClose"
@hidden="onReset"
>
@@ -330,5 +338,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/components/project_select.vue b/app/assets/javascripts/invite_members/components/project_select.vue
index c1114c240b9..640df5cdb88 100644
--- a/app/assets/javascripts/invite_members/components/project_select.vue
+++ b/app/assets/javascripts/invite_members/components/project_select.vue
@@ -4,7 +4,7 @@ import { debounce } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { getProjects } from '~/rest_api';
-import { SEARCH_DELAY, GROUP_FILTERS } from '../constants';
+import { SEARCH_DELAY, GROUP_FILTERS, PROJECT_SELECT_LABEL_ID } from '../constants';
// We can have GlCollapsibleListbox dropdown panel with full
// width once we implement
@@ -96,6 +96,7 @@ export default {
errorFetchingProjects: s__(
'ProjectSelect|There was an error fetching the projects. Please try again.',
),
+ projectSelectLabelId: PROJECT_SELECT_LABEL_ID,
},
defaultFetchOptions: {
exclude_internal: true,
@@ -110,6 +111,7 @@ export default {
:items="projects"
:searching="isFetching"
:toggle-text="selectedProjectName"
+ :toggle-aria-labelled-by="$options.projectSelectLabelId"
:search-placeholder="$options.i18n.searchPlaceholder"
:no-results-text="$options.i18n.emptySearchResult"
data-testid="project-select-dropdown"
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index ac0b708c55e..9afcaff6e16 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,12 +1,12 @@
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+export const PROJECT_SELECT_LABEL_ID = 'project-select';
export const SEARCH_DELAY = 200;
export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100';
export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100';
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
- name: 'invite_members_for_task',
- view: 'modal_opened_from_email',
submit: 'submit',
};
export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully';
@@ -19,7 +19,10 @@ 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 TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN = 'dropdown-text';
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');
@@ -59,9 +62,18 @@ export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__(
"InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.",
);
-export const GROUP_MODAL_ALERT_BODY = s__(
- 'InviteMembersModal| Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
+export const GROUP_MODAL_TO_GROUP_ALERT_BODY = s__(
+ 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
);
+export const GROUP_MODAL_TO_GROUP_ALERT_LINK = helpPagePath('user/group/manage', {
+ anchor: 'share-a-group-with-another-group',
+});
+export const GROUP_MODAL_TO_PROJECT_ALERT_BODY = s__(
+ 'InviteMembersModal|Inviting a group %{linkStart}adds its members to your project%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.',
+);
+export const GROUP_MODAL_TO_PROJECT_ALERT_LINK = helpPagePath('user/project/members/index', {
+ anchor: 'add-groups-to-a-project',
+});
export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite');
export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite');
@@ -76,7 +88,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:',
@@ -128,16 +139,19 @@ export const GROUP_MODAL_LABELS = {
title: GROUP_MODAL_DEFAULT_TITLE,
toGroup: {
introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
+ notificationText: GROUP_MODAL_TO_GROUP_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_GROUP_ALERT_LINK,
},
toProject: {
introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
+ notificationText: GROUP_MODAL_TO_PROJECT_ALERT_BODY,
+ notificationLink: GROUP_MODAL_TO_PROJECT_ALERT_LINK,
},
searchField: GROUP_SEARCH_FIELD,
placeHolder: GROUP_PLACEHOLDER,
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
};
-export const LEARN_GITLAB = 'learn_gitlab';
export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal';
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
index d85162626f1..240a3a89686 100644
--- a/app/assets/javascripts/invite_members/utils/member_utils.js
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -1,4 +1,14 @@
+import { getParameterValues } from '~/lib/utils/url_utility';
+
export function memberName(member) {
// user defined tokens(invites by email) will have email in `name` and will not contain `username`
return member.username || member.name;
}
+
+export function triggerExternalAlert() {
+ return false;
+}
+
+export function qualifiesForTasksToBeDone() {
+ return getParameterValues('open_modal')[0] === 'invite_members_for_task';
+}
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..b492194d1cf 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -1,14 +1,7 @@
<script>
-import {
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlTooltipDirective,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlDropdownItem, 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';
@@ -17,24 +10,18 @@ export default {
exportAsCsvButtonText: __('Export as CSV'),
importCsvText: __('Import CSV'),
importFromJiraText: __('Import from Jira'),
- importIssuesText: __('Import issues'),
},
- name: 'CsvImportExportButtons',
components: {
- GlButtonGroup,
- GlButton,
- GlDropdown,
GlDropdownItem,
CsvExportModal,
CsvImportModal,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
inject: {
issuableType: {
- default: ISSUABLE_TYPE.issues,
+ default: TYPE_ISSUE,
},
showExportButton: {
default: false,
@@ -42,18 +29,12 @@ export default {
showImportButton: {
default: false,
},
- containerClass: {
- default: '',
- },
canEdit: {
default: false,
},
projectImportJiraPath: {
default: null,
},
- showLabel: {
- default: false,
- },
},
props: {
exportCsvPath: {
@@ -74,48 +55,30 @@ export default {
importModalId() {
return `${this.issuableType}-import-modal`;
},
- importButtonTooltipText() {
- return this.showLabel ? null : this.$options.i18n.importIssuesText;
- },
- importButtonIcon() {
- return this.showLabel ? null : 'import';
- },
},
};
</script>
<template>
- <div :class="containerClass">
- <gl-button-group class="gl-w-full">
- <gl-button
- v-if="showExportButton"
- v-gl-tooltip="$options.i18n.exportAsCsvButtonText"
- v-gl-modal="exportModalId"
- icon="export"
- :aria-label="$options.i18n.exportAsCsvButtonText"
- data-qa-selector="export_as_csv_button"
- />
- <gl-dropdown
- v-if="showImportButton"
- v-gl-tooltip="importButtonTooltipText"
- data-qa-selector="import_issues_dropdown"
- :text="$options.i18n.importIssuesText"
- :text-sr-only="!showLabel"
- :icon="importButtonIcon"
- class="gl-w-full gl-md-w-auto"
- >
- <gl-dropdown-item v-gl-modal="importModalId">
- {{ $options.i18n.importCsvText }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="canEdit"
- :href="projectImportJiraPath"
- data-qa-selector="import_from_jira_link"
- >
- {{ $options.i18n.importFromJiraText }}
- </gl-dropdown-item>
- </gl-dropdown>
- </gl-button-group>
+ <ul class="gl-display-contents">
+ <gl-dropdown-item
+ v-if="showExportButton"
+ v-gl-modal="exportModalId"
+ data-qa-selector="export_as_csv_button"
+ >
+ {{ $options.i18n.exportAsCsvButtonText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="showImportButton" v-gl-modal="importModalId">
+ {{ $options.i18n.importCsvText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showImportButton && canEdit"
+ :href="projectImportJiraPath"
+ data-qa-selector="import_from_jira_link"
+ >
+ {{ $options.i18n.importFromJiraText }}
+ </gl-dropdown-item>
+
<csv-export-modal
v-if="showExportButton"
:modal-id="exportModalId"
@@ -123,5 +86,5 @@ export default {
:issuable-count="issuableCount"
/>
<csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
- </div>
+ </ul>
</template>
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 0e58f3793bc..403997779ac 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -2,18 +2,18 @@
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';
-const NoteableTypeText = {
+const noteableTypeText = {
issue: __('issue'),
merge_request: __('merge request'),
};
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 [
@@ -46,7 +46,7 @@ export default {
visible: this.hidden,
dataTestId: 'hidden',
tooltip: sprintf(__('This %{issuable} is hidden because its author has been banned'), {
- issuable: NoteableTypeText[this.getNoteableData.targetType],
+ issuable: noteableTypeText[this.getNoteableData.targetType],
}),
},
];
@@ -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/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue
index 21f35690f6d..d8cbc45684b 100644
--- a/app/assets/javascripts/issuable/components/issue_assignees.vue
+++ b/app/assets/javascripts/issuable/components/issue_assignees.vue
@@ -84,6 +84,7 @@ export default {
:link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-css-classes="imgCssClasses"
+ img-css-wrapper-classes="gl-display-inline-flex"
:img-src="avatarUrl(assignee)"
:img-size="iconSize"
class="js-no-trigger author-link"
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index 608c1deac64..df50a30abb7 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -4,12 +4,13 @@ import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab
import SafeHtml from '~/vue_shared/directives/safe_html';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import relatedIssuableMixin from '../mixins/related_issuable_mixin';
import IssueAssignees from './issue_assignees.vue';
import IssueMilestone from './issue_milestone.vue';
@@ -26,12 +27,18 @@ export default {
IssueDueDate,
GlButton,
WorkItemDetailModal,
+ AbuseCategorySelector,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
mixins: [relatedIssuableMixin],
+ inject: {
+ reportAbusePath: {
+ default: '',
+ },
+ },
props: {
canReorder: {
type: Boolean,
@@ -54,6 +61,13 @@ export default {
default: '',
},
},
+ data() {
+ return {
+ isReportDrawerOpen: false,
+ reportedUserId: 0,
+ reportedUrl: '',
+ };
+ },
computed: {
stateTitle() {
return sprintf(
@@ -71,6 +85,9 @@ export default {
workItemId() {
return convertToGraphQLId(TYPENAME_WORK_ITEM, this.idKey);
},
+ workItemIid() {
+ return String(this.iid);
+ },
},
methods: {
handleTitleClick(event) {
@@ -80,18 +97,26 @@ export default {
}
event.preventDefault();
this.$refs.modal.show();
- this.updateWorkItemIdUrlQuery(this.idKey);
+ this.updateWorkItemIidUrlQuery(this.iid);
}
},
handleWorkItemDeleted(workItemId) {
this.$emit('relatedIssueRemoveRequest', workItemId);
},
- updateWorkItemIdUrlQuery(workItemId) {
+ updateWorkItemIidUrlQuery(iid) {
updateHistory({
- url: setUrlParams({ work_item_id: workItemId }),
+ url: setUrlParams({ work_item_iid: iid }),
replace: true,
});
},
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url;
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
+ openReportAbuseDrawer(reply) {
+ this.toggleReportAbuseDrawer(true, reply);
+ },
},
};
</script>
@@ -101,27 +126,24 @@ 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"
>
<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>
- <gl-tooltip :target="() => $refs.iconElementXL">
+ <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
+ <gl-icon
+ v-if="hasState"
+ :id="`iconElementXL-${itemId}`"
+ ref="iconElementXL"
+ :class="iconClasses"
+ :name="iconName"
+ :title="stateTitle"
+ :aria-label="state"
+ />
+ <gl-tooltip :target="`iconElementXL-${itemId}`">
<span v-safe-html="stateTitle"></span>
</gl-tooltip>
<gl-icon
@@ -129,42 +151,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-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 +200,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 +224,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 +245,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 gl-mr-2"
data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
@@ -236,8 +256,17 @@ export default {
<work-item-detail-modal
ref="modal"
:work-item-id="workItemId"
- @close="updateWorkItemIdUrlQuery"
+ :work-item-iid="workItemIid"
+ @close="updateWorkItemIidUrlQuery"
@workItemDeleted="handleWorkItemDeleted"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen && reportAbusePath"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="isReportDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
</div>
</template>
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 0c75e44443d..799c0a18444 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';
},
@@ -126,8 +125,13 @@ export default {
</script>
<template>
- <gl-badge class="issuable-status-badge gl-mr-3" :class="badgeClass" :variant="badgeVariant">
- <gl-icon :name="badgeIcon" />
+ <gl-badge
+ class="issuable-status-badge gl-mr-3"
+ :class="badgeClass"
+ :variant="badgeVariant"
+ :aria-label="badgeText"
+ >
+ <gl-icon :name="badgeIcon" class="gl-badge-icon" />
<span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span>
</gl-badge>
</template>
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/index.js b/app/assets/javascripts/issuable/index.js
index ed336deb2ed..acc0161bf6a 100644
--- a/app/assets/javascripts/issuable/index.js
+++ b/app/assets/javascripts/issuable/index.js
@@ -36,11 +36,9 @@ export function initCsvImportExportButtons() {
email,
exportCsvPath,
importCsvIssuesPath,
- containerClass,
canEdit,
projectImportJiraPath,
maxAttachmentSize,
- showLabel,
} = el.dataset;
return new Vue({
@@ -52,11 +50,9 @@ export function initCsvImportExportButtons() {
issuableType,
email,
importCsvIssuesPath,
- containerClass,
canEdit: parseBoolean(canEdit),
projectImportJiraPath,
maxAttachmentSize,
- showLabel,
},
render: (createElement) =>
createElement(CsvImportExportButtons, {
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_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 8a094d5d688..a1525ad2bec 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -6,12 +6,13 @@ import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'
import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select';
import ZenMode from '~/zen_mode';
+import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
const DATA_ISSUES_NEW_PATH = 'data-new-issue-path';
-function organizeQuery(obj, isFallbackKey = false) {
+export function organizeQuery(obj, isFallbackKey = false) {
if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
return obj;
}
@@ -83,11 +84,10 @@ export default class IssuableForm {
this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH));
this.fallbackKey = getFallbackKey();
this.titleField = this.form.find('input[name*="[title]"]');
- this.descriptionField = this.form.find('textarea[name*="[description]"]');
+ this.descriptionField = () => this.form.find('textarea[name*="[description]"]');
+ this.submitButton = this.form.find('.js-issuable-submit-button');
this.draftCheck = document.querySelector('input.js-toggle-draft');
- if (!(this.titleField.length && this.descriptionField.length)) {
- return;
- }
+ if (!this.titleField.length) return;
this.autosaves = this.initAutosave();
this.form.on('submit', this.handleSubmit);
@@ -99,7 +99,7 @@ export default class IssuableForm {
if ($issuableDueDate.length) {
const calendar = new Pikaday({
field: $issuableDueDate.get(0),
- theme: 'gitlab-theme animate-picker',
+ theme: 'gl-datepicker-theme animate-picker',
format: 'yyyy-mm-dd',
container: $issuableDueDate.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
@@ -125,13 +125,6 @@ export default class IssuableForm {
);
IssuableForm.addAutosave(
autosaveMap,
- 'description',
- this.form.find('textarea[name*="[description]"]').get(0),
- this.searchTerm,
- this.fallbackKey,
- );
- IssuableForm.addAutosave(
- autosaveMap,
'confidential',
this.form.find('input:checkbox[name*="[confidential]"]').get(0),
this.searchTerm,
@@ -148,7 +141,21 @@ export default class IssuableForm {
return autosaveMap;
}
- handleSubmit() {
+ async handleSubmit(event) {
+ event.preventDefault();
+
+ const form = event.target;
+ const descriptionText = this.descriptionField().val();
+
+ if (containsSensitiveToken(descriptionText)) {
+ const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt);
+ if (!confirmed) {
+ this.submitButton.removeAttr('disabled');
+ this.submitButton.removeClass('disabled');
+ return false;
+ }
+ }
+ form.submit();
return this.resetAutosave();
}
diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js
index ad8bbf04d6f..b4e277a0b31 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 { VARIANT_EMBEDDED } 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,
+ variant: VARIANT_EMBEDDED,
+ workspaceType: WORKSPACE_PROJECT,
},
render(createElement) {
return createElement(IssuableLabelSelector);
diff --git a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
index 4a6edae0c06..289d6fdf372 100644
--- a/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js
@@ -1,4 +1,5 @@
import { isEmpty } from 'lodash';
+import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
import { sprintf, __ } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -14,6 +15,11 @@ const mixins = {
type: Number,
required: true,
},
+ iid: {
+ type: Number,
+ required: false,
+ default: undefined,
+ },
displayReference: {
type: String,
required: true,
@@ -107,13 +113,13 @@ const mixins = {
return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
},
isOpen() {
- return this.state === 'opened' || this.state === 'reopened';
+ return this.state === STATUS_OPEN || this.state === STATUS_REOPENED;
},
isClosed() {
- return this.state === 'closed';
+ return this.state === STATUS_CLOSED;
},
isMerged() {
- return this.state === 'merged';
+ return this.state === STATUS_MERGED;
},
hasTitle() {
return this.title.length > 0;
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/issuable/popover/index.js b/app/assets/javascripts/issuable/popover/index.js
index de3c8160b7a..9430419685b 100644
--- a/app/assets/javascripts/issuable/popover/index.js
+++ b/app/assets/javascripts/issuable/popover/index.js
@@ -6,6 +6,7 @@ import MRPopover from './components/mr_popover.vue';
const componentsByReferenceType = {
issue: IssuePopover,
+ work_item: IssuePopover,
merge_request: MRPopover,
};
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index ba05dd731f7..c79612ad5d0 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -1,36 +1,33 @@
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 STATUS_LOCKED = 'locked';
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 IssuableStatusText = {
+export const WORKSPACE_GROUP = 'group';
+export const WORKSPACE_PROJECT = 'project';
+
+export const issuableStatusText = {
[STATUS_CLOSED]: __('Closed'),
[STATUS_OPEN]: __('Open'),
[STATUS_REOPENED]: __('Open'),
+ [STATUS_MERGED]: __('Merged'),
+ [STATUS_LOCKED]: __('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',
+export const IssuableTypeText = {
+ [TYPE_ISSUE]: __('issue'),
+ [TYPE_MERGE_REQUEST]: __('merge request'),
};
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index 977a505437d..de0334b4ffe 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;
@@ -404,7 +432,7 @@ export default class CreateMergeRequestDropdown {
let xhr = null;
event.preventDefault();
- if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) {
+ if (isConfidentialIssue() && !event.currentTarget.classList.contains('js-create-target')) {
this.droplab.hooks.forEach((hook) => hook.list.toggle());
return;
@@ -414,9 +442,9 @@ export default class CreateMergeRequestDropdown {
return;
}
- if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
+ if (event.currentTarget.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
- } else if (event.target.dataset.action === CREATE_BRANCH) {
+ } else if (event.currentTarget.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
@@ -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..b9e4d0df3f2 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -1,15 +1,19 @@
<script>
-import { GlButton, GlEmptyState, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlEmptyState,
+ GlFilteredSearchToken,
+ GlTooltipDirective,
+} from '@gitlab/ui';
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 +53,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';
@@ -63,9 +67,9 @@ const MilestoneToken = () =>
export default {
i18n,
- IssuableListTabs,
+ issuableListTabs,
components: {
- GlButton,
+ GlDisclosureDropdown,
GlEmptyState,
IssuableList,
IssueCardStatistics,
@@ -93,7 +97,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 +114,7 @@ export default {
pageInfo: {},
pageParams: getInitialPageParams(),
sortKey,
- state: state || IssuableStates.Opened,
+ state: state || STATUS_OPEN,
};
},
apollo: {
@@ -132,7 +136,6 @@ export default {
skip() {
return !this.hasSearch;
},
- debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
@@ -149,7 +152,6 @@ export default {
skip() {
return !this.hasSearch;
},
- debounce: 200,
context: {
isSingleRequest: true,
},
@@ -159,6 +161,12 @@ export default {
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
+ dropdownItems() {
+ return [
+ { href: this.rssPath, text: i18n.rssLabel },
+ { href: this.calendarPath, text: i18n.calendarLabel },
+ ];
+ },
emptyStateDescription() {
return this.hasSearch ? this.$options.i18n.noSearchResultsDescription : undefined;
},
@@ -179,7 +187,6 @@ export default {
return {
hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn,
isSignedIn: this.isSignedIn,
- search: this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
@@ -314,9 +321,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() {
@@ -324,7 +331,6 @@ export default {
},
urlParams() {
return {
- search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
...this.urlFilterParams,
@@ -388,14 +394,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();
},
@@ -449,7 +455,7 @@ export default {
show-work-item-type-icon
:sort-options="sortOptions"
:tab-counts="tabCounts"
- :tabs="$options.IssuableListTabs"
+ :tabs="$options.issuableListTabs"
truncate-counts
:url-params="urlParams"
use-keyset-pagination
@@ -461,12 +467,15 @@ 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-disclosure-dropdown
+ v-gl-tooltip="$options.i18n.actionsLabel"
+ category="tertiary"
+ icon="ellipsis_v"
+ :items="dropdownItems"
+ no-caret
+ text-sr-only
+ :toggle-text="$options.i18n.actionsLabel"
+ />
</template>
<template #timeframe="{ issuable = {} }">
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 5b5f1d273d0..4d2df9e3602 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -3,12 +3,10 @@ import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
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 { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
import { initRelatedIssues } from '~/related_issues';
import {
@@ -38,19 +36,18 @@ export function initFilteredSearchServiceDesk() {
}
export function initForm() {
- new GLForm($('.issue-form')); // eslint-disable-line no-new
new IssuableForm($('.issue-form')); // eslint-disable-line no-new
IssuableLabelSelector();
- new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new
new LabelsSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initTitleSuggestions();
initTypePopover();
+ initTypeSelect();
mountMilestoneDropdown();
}
-export function initShow() {
+export function initShow({ notesParams } = {}) {
const el = document.getElementById('js-issuable-app');
if (!el) {
@@ -59,11 +56,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);
@@ -74,7 +71,7 @@ export function initShow() {
new ZenMode(); // eslint-disable-line no-new
initIssuableHeaderWarnings(store);
initIssuableSidebar();
- initNotesApp();
+ initNotesApp(notesParams);
initRelatedMergeRequests();
initSentryErrorStackTrace();
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/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
index 652d4e0fb42..98429f3ffd1 100644
--- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
+++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
@@ -12,6 +12,7 @@ export default {
components: {
CsvImportExportButtons,
GlButton,
+ GlDropdown,
GlEmptyState,
GlLink,
GlSprintf,
@@ -71,12 +72,19 @@ export default {
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
- <csv-import-export-buttons
+
+ <gl-dropdown
v-if="showCsvButtons"
class="gl-w-full gl-sm-w-auto gl-sm-mr-3"
- :export-csv-path="exportCsvPathWithQuery"
- :issuable-count="currentTabCount"
- />
+ :text="$options.i18n.importIssues"
+ data-qa-selector="import_issues_dropdown"
+ >
+ <csv-import-export-buttons
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="currentTabCount"
+ />
+ </gl-dropdown>
+
<new-resource-dropdown
v-if="showNewIssueDropdown"
class="gl-align-self-center"
diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
index d11540ad3dd..dde1a4fd2d6 100644
--- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
@@ -74,11 +74,16 @@ export default {
<span>
<span
v-if="issue.milestone"
- class="issuable-milestone gl-mr-3"
+ class="issuable-milestone gl-mr-3 gl-text-truncate gl-max-w-26 gl-display-inline-block gl-vertical-align-bottom"
data-testid="issuable-milestone"
>
- <gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
- <gl-icon name="clock" />
+ <gl-link
+ v-gl-tooltip
+ :href="milestoneLink"
+ :title="milestoneDate"
+ class="gl-font-sm gl-text-gray-500!"
+ >
+ <gl-icon name="clock" :size="12" />
{{ issue.milestone.title }}
</gl-link>
</span>
@@ -90,7 +95,7 @@ export default {
:title="__('Due date')"
data-testid="issuable-due-date"
>
- <gl-icon name="calendar" />
+ <gl-icon name="calendar" :size="12" />
{{ dueDate }}
</span>
<span
@@ -100,7 +105,7 @@ export default {
:title="__('Estimate')"
data-testid="time-estimate"
>
- <gl-icon name="timer" />
+ <gl-icon name="timer" :size="12" />
{{ timeEstimate }}
</span>
<slot></slot>
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..5fb83dfd1ab 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -1,18 +1,31 @@
<script>
-import { GlButton, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFilteredSearchToken,
+ GlTooltipDirective,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+} 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 { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
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 +59,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 +69,6 @@ import {
i18n,
ISSUE_REFERENCE,
MAX_LIST_SIZE,
- PAGE_SIZE,
PARAM_FIRST_PAGE_SIZE,
PARAM_LAST_PAGE_SIZE,
PARAM_PAGE_AFTER,
@@ -103,12 +115,15 @@ const CrmOrganizationToken = () =>
export default {
i18n,
- IssuableListTabs,
+ issuableListTabs,
components: {
CsvImportExportButtons,
EmptyStateWithAnyIssues,
EmptyStateWithoutAnyIssues,
GlButton,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
IssuableByEmail,
IssuableList,
IssueCardStatistics,
@@ -177,8 +192,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 +221,8 @@ export default {
Sentry.captureException(error);
},
skip() {
- return !this.hasAnyIssues;
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
},
- debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
@@ -223,9 +237,8 @@ export default {
Sentry.captureException(error);
},
skip() {
- return !this.hasAnyIssues;
+ return !this.hasAnyIssues || isEmpty(this.pageParams);
},
- debounce: 200,
context: {
isSingleRequest: true,
},
@@ -240,16 +253,16 @@ export default {
iid: isIidSearch ? this.searchQuery.slice(1) : undefined,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
- search: isIidSearch ? undefined : this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
...this.apiFilterParams,
+ search: isIidSearch ? undefined : this.searchQuery,
types: this.apiFilterParams.types || this.defaultWorkItemTypes,
};
},
namespace() {
- return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ return this.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP;
},
defaultWorkItemTypes() {
return [...defaultWorkItemTypes, ...this.eeWorkItemTypes];
@@ -275,7 +288,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 +462,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 +474,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() {
@@ -471,7 +484,6 @@ export default {
},
urlParams() {
return {
- search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
...this.urlFilterParams,
@@ -726,7 +738,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 +762,7 @@ export default {
getParameterByName(PARAM_PAGE_BEFORE),
);
this.sortKey = sortKey;
- this.state = state || IssuableStates.Opened;
+ this.state = state || STATUS_OPEN;
},
},
};
@@ -771,7 +783,7 @@ export default {
:issuables="issues"
:error="issuesError"
label-filter-param="label_name"
- :tabs="$options.IssuableListTabs"
+ :tabs="$options.issuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:truncate-counts="!isProject"
@@ -799,26 +811,6 @@ export default {
>
<template #nav-actions>
<gl-button
- v-gl-tooltip
- :href="rssPath"
- icon="rss"
- :title="$options.i18n.rssLabel"
- :aria-label="$options.i18n.rssLabel"
- />
- <gl-button
- v-gl-tooltip
- :href="calendarPath"
- icon="calendar"
- :title="$options.i18n.calendarLabel"
- :aria-label="$options.i18n.calendarLabel"
- />
- <csv-import-export-buttons
- v-if="showCsvButtons"
- class="gl-md-mr-3"
- :export-csv-path="exportCsvPathWithQuery"
- :issuable-count="currentTabCount"
- />
- <gl-button
v-if="canBulkUpdate"
:disabled="isBulkEditButtonDisabled"
@click="handleBulkUpdateClick"
@@ -839,6 +831,30 @@ export default {
:query-variables="newIssueDropdownQueryVariables"
:extract-projects="extractProjects"
/>
+ <gl-dropdown
+ v-gl-tooltip.hover="$options.i18n.actionsLabel"
+ category="tertiary"
+ icon="ellipsis_v"
+ no-caret
+ :text="$options.i18n.actionsLabel"
+ text-sr-only
+ data-qa-selector="issues_list_more_actions_dropdown"
+ >
+ <csv-import-export-buttons
+ v-if="showCsvButtons"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="currentTabCount"
+ />
+
+ <gl-dropdown-divider v-if="showCsvButtons" />
+
+ <gl-dropdown-item :href="rssPath">
+ {{ $options.i18n.rssLabel }}
+ </gl-dropdown-item>
+ <gl-dropdown-item :href="calendarPath">
+ {{ $options.i18n.calendarLabel }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
<template #timeframe="{ issuable = {} }">
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 31a43c95f5e..56d3a57457b 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -5,6 +5,7 @@ import {
FILTER_NONE,
FILTER_STARTED,
FILTER_UPCOMING,
+ FILTERED_SEARCH_TERM,
OPERATOR_IS,
OPERATOR_NOT,
OPERATOR_OR,
@@ -33,7 +34,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';
@@ -76,15 +76,17 @@ export const SPECIAL_FILTER = 'specialFilter';
export const ALTERNATIVE_FILTER = 'alternativeFilter';
export const i18n = {
+ actionsLabel: __('Actions'),
calendarLabel: __('Subscribe to calendar'),
- closed: __('CLOSED'),
- closedMoved: __('CLOSED (MOVED)'),
+ closed: __('Closed'),
+ closedMoved: __('Closed (moved)'),
confidentialNo: __('No'),
confidentialYes: __('Yes'),
downvotes: __('Downvotes'),
- editIssues: __('Edit issues'),
+ editIssues: __('Bulk edit'),
errorFetchingCounts: __('An error occurred while getting issue counts'),
errorFetchingIssues: __('An error occurred while loading issues'),
+ importIssues: __('Import issues'),
issueRepositioningMessage: __(
'Issues are being rebalanced at the moment, so manual reordering is disabled.',
),
@@ -154,13 +156,13 @@ export const specialFilterValues = [
export const TYPE_TOKEN_OBJECTIVE_OPTION = {
icon: 'issue-type-objective',
- title: 'objective',
+ title: s__('WorkItem|Objective'),
value: 'objective',
};
export const TYPE_TOKEN_KEY_RESULT_OPTION = {
icon: 'issue-type-keyresult',
- title: 'key_result',
+ title: s__('WorkItem|Key Result'),
value: 'key_result',
};
@@ -174,13 +176,23 @@ export const defaultWorkItemTypes = [
];
export const defaultTypeTokenOptions = [
- { icon: 'issue-type-issue', title: 'issue', value: 'issue' },
- { icon: 'issue-type-incident', title: 'incident', value: 'incident' },
- { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' },
- { icon: 'issue-type-task', title: 'task', value: 'task' },
+ { icon: 'issue-type-issue', title: s__('WorkItem|Issue'), value: 'issue' },
+ { icon: 'issue-type-incident', title: s__('WorkItem|Incident'), value: 'incident' },
+ { icon: 'issue-type-test-case', title: s__('WorkItem|Test case'), value: 'test_case' },
+ { icon: 'issue-type-task', title: s__('WorkItem|Task'), value: 'task' },
];
-export const filters = {
+export const filtersMap = {
+ [FILTERED_SEARCH_TERM]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ [URL_PARAM]: {
+ [undefined]: {
+ [NORMAL_FILTER]: 'search',
+ },
+ },
+ },
[TOKEN_TYPE_AUTHOR]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'authorUsername',
diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js
index b590006929a..e64870152bd 100644
--- a/app/assets/javascripts/issues/list/graphql.js
+++ b/app/assets/javascripts/issues/list/graphql.js
@@ -1,7 +1,9 @@
import produce from 'immer';
-import createDefaultClient from '~/lib/graphql';
+import createDefaultClient, { createApolloClientWithCaching } from '~/lib/graphql';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+let client;
+
const resolvers = {
Mutation: {
reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
@@ -22,6 +24,10 @@ const resolvers = {
},
};
-export const gqlClient = gon.features?.frontendCaching
- ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' })
- : createDefaultClient(resolvers);
+export async function gqlClient() {
+ if (client) return client;
+ client = gon.features?.frontendCaching
+ ? await createApolloClientWithCaching(resolvers, { localCacheKey: 'issues_list' })
+ : createDefaultClient(resolvers);
+ return client;
+}
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index aca894549e4..a97b59c1e4f 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -6,7 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue';
import { gqlClient } from './graphql';
-export function mountJiraIssuesListApp() {
+export async function mountJiraIssuesListApp() {
const el = document.querySelector('.js-jira-issues-import-status-root');
if (!el) {
@@ -27,7 +27,7 @@ export function mountJiraIssuesListApp() {
el,
name: 'JiraIssuesImportStatusRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render(createComponent) {
return createComponent(JiraIssuesImportStatusApp, {
@@ -42,7 +42,7 @@ export function mountJiraIssuesListApp() {
});
}
-export function mountIssuesListApp() {
+export async function mountIssuesListApp() {
const el = document.querySelector('.js-issues-list-root');
if (!el) {
@@ -100,7 +100,7 @@ export function mountIssuesListApp() {
el,
name: 'IssuesListRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
router: new VueRouter({
base: window.location.pathname,
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index bbd081843ca..d053400dd03 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -1,4 +1,3 @@
-import { createTerm } from '@gitlab/ui/src/components/base/filtered_search/filtered_search_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -16,6 +15,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,
@@ -27,7 +27,7 @@ import {
CREATED_DESC,
DUE_DATE_ASC,
DUE_DATE_DESC,
- filters,
+ filtersMap,
HEALTH_STATUS_ASC,
HEALTH_STATUS_DESC,
LABEL_PRIORITY_ASC,
@@ -35,7 +35,6 @@ import {
MILESTONE_DUE_ASC,
MILESTONE_DUE_DESC,
NORMAL_FILTER,
- PAGE_SIZE,
PARAM_ASSIGNEE_ID,
POPULARITY_ASC,
POPULARITY_DESC,
@@ -56,7 +55,7 @@ import {
export const getInitialPageParams = (
pageSize,
- firstPageSize = pageSize ?? PAGE_SIZE,
+ firstPageSize = pageSize ?? DEFAULT_PAGE_SIZE,
lastPageSize,
afterCursor,
beforeCursor,
@@ -196,10 +195,10 @@ export const getSortOptions = ({
return sortOptions;
};
-const tokenTypes = Object.keys(filters);
+const tokenTypes = Object.keys(filtersMap);
const getUrlParams = (tokenType) =>
- Object.values(filters[tokenType][URL_PARAM]).flatMap((filterObj) => Object.values(filterObj));
+ Object.values(filtersMap[tokenType][URL_PARAM]).flatMap((filterObj) => Object.values(filterObj));
const urlParamKeys = tokenTypes.flatMap(getUrlParams);
@@ -207,11 +206,11 @@ const getTokenTypeFromUrlParamKey = (urlParamKey) =>
tokenTypes.find((tokenType) => getUrlParams(tokenType).includes(urlParamKey));
const getOperatorFromUrlParamKey = (tokenType, urlParamKey) =>
- Object.entries(filters[tokenType][URL_PARAM]).find(([, filterObj]) =>
+ Object.entries(filtersMap[tokenType][URL_PARAM]).find(([, filterObj]) =>
Object.values(filterObj).includes(urlParamKey),
)[0];
-const convertToFilteredTokens = (locationSearch) =>
+export const getFilterTokens = (locationSearch) =>
Array.from(new URLSearchParams(locationSearch).entries())
.filter(([key]) => urlParamKeys.includes(key))
.map(([key, data]) => {
@@ -223,26 +222,8 @@ const convertToFilteredTokens = (locationSearch) =>
};
});
-const convertToFilteredSearchTerms = (locationSearch) =>
- new URLSearchParams(locationSearch)
- .get('search')
- ?.split(' ')
- .map((word) => ({
- type: FILTERED_SEARCH_TERM,
- value: {
- data: word,
- },
- })) || [];
-
-export const getFilterTokens = (locationSearch) => {
- if (!locationSearch) {
- return [createTerm()];
- }
- const filterTokens = convertToFilteredTokens(locationSearch);
- const searchTokens = convertToFilteredSearchTerms(locationSearch);
- const tokens = filterTokens.concat(searchTokens);
- return tokens.length ? tokens : [createTerm()];
-};
+const isNotEmptySearchToken = (token) =>
+ !(token.type === FILTERED_SEARCH_TERM && !token.value.data);
const isSpecialFilter = (type, data) => {
const isAssigneeIdParam =
@@ -289,50 +270,48 @@ 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)
- .forEach((token) => {
- const filterType = getFilterType(token);
- const apiField = filters[token.type][API_PARAM][filterType];
- let obj;
- if (token.value.operator === OPERATOR_NOT) {
- obj = not;
- } else if (token.value.operator === OPERATOR_OR) {
- obj = or;
- } else {
- obj = params;
- }
- const data = formatData(token);
- Object.assign(obj, {
- [apiField]: obj[apiField] ? [obj[apiField], data].flat() : data,
- });
- });
+ filterTokens.filter(isNotEmptySearchToken).forEach((token) => {
+ const filterType = getFilterType(token);
+ const apiField = filtersMap[token.type][API_PARAM][filterType];
+ let obj;
+ if (token.value.operator === OPERATOR_NOT) {
+ obj = not;
+ } else if (token.value.operator === OPERATOR_OR) {
+ obj = or;
+ } else {
+ obj = params;
+ }
+ const data = formatData(token);
+ 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
- .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,
- });
- }, {});
+export const convertToUrlParams = (filterTokens) => {
+ const urlParamsMap = filterTokens.filter(isNotEmptySearchToken).reduce((acc, token) => {
+ const filterType = getFilterType(token);
+ const urlParam = filtersMap[token.type][URL_PARAM][token.value.operator]?.[filterType];
+ 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/new/components/title_suggestions_item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
index a01f4f747b9..be2237ae2a2 100644
--- a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
+++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue
@@ -1,6 +1,7 @@
<script>
import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
+import { STATUS_CLOSED } from '~/issues/constants';
import { __ } from '~/locale';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
@@ -42,7 +43,7 @@ export default {
].filter(({ count }) => count);
},
isClosed() {
- return this.suggestion.state === 'closed';
+ return this.suggestion.state === STATUS_CLOSED;
},
stateIconClass() {
return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500';
diff --git a/app/assets/javascripts/issues/new/components/type_select.vue b/app/assets/javascripts/issues/new/components/type_select.vue
new file mode 100644
index 00000000000..81c3a769d26
--- /dev/null
+++ b/app/assets/javascripts/issues/new/components/type_select.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlCollapsibleListbox, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE, TYPE_INCIDENT } from '~/issues/constants';
+import { visitUrl } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ selectType: __('Select type'),
+ issuableType: {
+ [TYPE_ISSUE]: __('Issue'),
+ [TYPE_INCIDENT]: __('Incident'),
+ },
+ },
+ components: {
+ GlCollapsibleListbox,
+ GlIcon,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ selectedType: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ isIssueAllowed: {
+ required: false,
+ default: false,
+ type: Boolean,
+ },
+ isIncidentAllowed: {
+ required: false,
+ default: false,
+ type: Boolean,
+ },
+ issuePath: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ incidentPath: {
+ required: false,
+ default: '',
+ type: String,
+ },
+ },
+ data() {
+ return {
+ selected: this.selectedType,
+ };
+ },
+ computed: {
+ toggleText() {
+ return this.selectedType
+ ? this.$options.i18n.issuableType[this.selectedType]
+ : this.$options.i18n.selectType;
+ },
+ dropdownItems() {
+ const issueItem = this.isIssueAllowed
+ ? {
+ value: TYPE_ISSUE,
+ text: __('Issue'),
+ icon: 'issue-type-issue',
+ href: this.issuePath,
+ }
+ : null;
+ const incidentItem = this.isIncidentAllowed
+ ? {
+ value: TYPE_INCIDENT,
+ text: __('Incident'),
+ icon: 'issue-type-incident',
+ href: this.incidentPath,
+ tracking: {
+ action: 'select_issue_type_incident',
+ label: 'select_issue_type_incident_dropdown_option',
+ },
+ }
+ : null;
+
+ return [issueItem, incidentItem].filter(Boolean);
+ },
+ },
+ methods: {
+ selectType(type) {
+ const selectedItem = this.dropdownItems.find((item) => item.value === type);
+ if (selectedItem.tracking) {
+ const { action, label } = selectedItem.tracking;
+ this.track(action, { label });
+ }
+
+ visitUrl(selectedItem.href);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-collapsible-listbox
+ v-model="selected"
+ :header-text="$options.i18n.selectType"
+ :toggle-text="toggleText"
+ :items="dropdownItems"
+ block
+ class="js-issuable-type-filter-dropdown-wrap"
+ @select="selectType"
+ >
+ <template #list-item="{ item }">
+ <gl-icon :name="item.icon" :size="16" />
+ {{ item.text }}
+ </template>
+ </gl-collapsible-listbox>
+</template>
diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js
index 91599502996..84a170e6564 100644
--- a/app/assets/javascripts/issues/new/index.js
+++ b/app/assets/javascripts/issues/new/index.js
@@ -1,8 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import TitleSuggestions from './components/title_suggestions.vue';
import TypePopover from './components/type_popover.vue';
+import TypeSelect from './components/type_select.vue';
export function initTitleSuggestions() {
const el = document.getElementById('js-suggestions');
@@ -56,3 +58,28 @@ export function initTypePopover() {
render: (createElement) => createElement(TypePopover),
});
}
+
+export function initTypeSelect() {
+ const el = document.getElementById('js-type-select');
+
+ if (!el) {
+ return undefined;
+ }
+
+ const { selectedType, isIssueAllowed, isIncidentAllowed, issuePath, incidentPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'TypeSelectRoot',
+ render: (createElement) =>
+ createElement(TypeSelect, {
+ props: {
+ selectedType,
+ isIssueAllowed: parseBoolean(isIssueAllowed),
+ isIncidentAllowed: parseBoolean(isIncidentAllowed),
+ issuePath,
+ incidentPath,
+ },
+ }),
+ });
+}
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..8490ffd33cd 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"
@@ -106,15 +116,10 @@ export default {
:is-merge-request="true"
:pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
path-id-separator="!"
+ class="gl-mx-n2"
/>
</li>
</ul>
</div>
- <div
- 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..86311b99f7c 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -1,19 +1,21 @@
<script>
import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import {
- IssuableStatusText,
+ 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 { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection';
+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 +27,7 @@ import PinnedLinks from './pinned_links.vue';
import TitleComponent from './title.vue';
export default {
- WorkspaceType,
+ WORKSPACE_PROJECT,
components: {
GlIcon,
GlBadge,
@@ -52,11 +54,6 @@ export default {
required: true,
type: Boolean,
},
- showInlineEditButton: {
- type: Boolean,
- required: false,
- default: true,
- },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -99,10 +96,10 @@ export default {
required: false,
default: '',
},
- initialTaskStatus: {
- type: String,
+ initialTaskCompletionStatus: {
+ type: Object,
required: false,
- default: '',
+ default: () => ({}),
},
updatedAt: {
type: String,
@@ -191,11 +188,6 @@ export default {
required: false,
default: null,
},
- issueIid: {
- type: Number,
- required: false,
- default: null,
- },
},
data() {
const store = new Store({
@@ -206,7 +198,7 @@ export default {
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
- taskStatus: this.initialTaskStatus,
+ taskCompletionStatus: this.initialTaskCompletionStatus,
lock_version: this.lockVersion,
});
@@ -231,9 +223,6 @@ export default {
formState() {
return this.store.formState;
},
- hasUpdated() {
- return Boolean(this.state.updatedAt);
- },
issueChanged() {
const {
store: {
@@ -274,14 +263,14 @@ export default {
return this.isClosed ? 'info' : 'success';
},
statusText() {
- return IssuableStatusText[this.issuableStatus];
+ return issuableStatusText[this.issuableStatus];
},
shouldShowStickyHeader() {
return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType);
},
},
created() {
- this.flashContainer = null;
+ this.alert = null;
this.service = new Service(this.endpoint);
this.poll = new Poll({
resource: this.service,
@@ -388,7 +377,7 @@ export default {
this.showForm = false;
},
- updateIssuable() {
+ async updateIssuable() {
this.setFormState({ updateLoading: true });
const {
@@ -399,7 +388,15 @@ export default {
? { ...formState, issue_type: issueState.issueType }
: formState;
- this.clearFlash();
+ this.alert?.dismiss();
+
+ if (containsSensitiveToken(issuablePayload.description)) {
+ const confirmed = await confirmSensitiveAction(i18n.descriptionPrompt);
+ if (!confirmed) {
+ this.setFormState({ updateLoading: false });
+ return false;
+ }
+ }
return this.service
.updateIssuable(issuablePayload)
@@ -407,14 +404,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 +432,7 @@ export default {
errMsg += `. ${message}`;
}
- this.flashContainer = createAlert({
+ this.alert = createAlert({
message: errMsg,
});
})
@@ -452,13 +449,6 @@ export default {
this.isStickyHeaderShowing = true;
},
- clearFlash() {
- if (this.flashContainer) {
- this.flashContainer.close();
- this.flashContainer = null;
- }
- },
-
handleSaveDescription(description) {
this.updateFormState();
this.setFormState({ description });
@@ -499,6 +489,7 @@ export default {
:project-namespace="projectNamespace"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
+ :issue-id="issueId"
:issuable-type="issuableType"
@updateForm="setFormState"
/>
@@ -509,7 +500,6 @@ export default {
:can-update="canUpdate"
:title-html="state.titleHtml"
:title-text="state.titleText"
- :show-inline-edit-button="showInlineEditButton"
/>
<gl-intersection-observer
@@ -538,7 +528,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,12 +560,10 @@ export default {
<component
:is="descriptionComponent"
:issue-id="issueId"
- :issue-iid="issueIid"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
- :task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
:lock-version="state.lock_version"
@@ -588,7 +576,7 @@ export default {
/>
<edited-component
- v-if="hasUpdated"
+ :task-completion-status="state.taskCompletionStatus"
:updated-at="state.updatedAt"
:updated-by-name="state.updatedByName"
:updated-by-path="state.updatedByPath"
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..3721f224d5e 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,35 +1,25 @@
<script>
-import { GlModalDirective, GlToast } from '@gitlab/ui';
-import $ from 'jquery';
-import { uniqueId } from 'lodash';
+import { GlToast } from '@gitlab/ui';
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 +41,8 @@ const workItemTypes = {
export default {
directives: {
SafeHtml,
- GlModal: GlModalDirective,
},
- components: {
- WorkItemDetailModal,
- },
- mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
+ mixins: [animateMixin],
inject: ['fullPath', 'hasIterationsFeature'],
props: {
canUpdate: {
@@ -72,11 +58,6 @@ export default {
required: false,
default: '',
},
- taskStatus: {
- type: String,
- required: false,
- default: '',
- },
issuableType: {
type: String,
required: false,
@@ -97,11 +78,6 @@ export default {
required: false,
default: null,
},
- issueIid: {
- type: Number,
- required: false,
- default: null,
- },
isUpdating: {
type: Boolean,
required: false,
@@ -109,18 +85,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 +99,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 +117,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() {
@@ -168,7 +132,10 @@ export default {
},
watch: {
descriptionHtml(newDescription, oldDescription) {
- if (!this.initialUpdate && newDescription !== oldDescription) {
+ if (
+ !this.initialUpdate &&
+ this.stripClientState(newDescription) !== this.stripClientState(oldDescription)
+ ) {
this.animateChange();
} else {
this.initialUpdate = false;
@@ -178,22 +145,12 @@ export default {
this.renderGFM();
});
},
- taskStatus() {
- this.updateTaskStatusText();
- },
},
mounted() {
eventHub.$on('convert-task-list-item', this.convertTaskListItem);
eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
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 +183,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;
}
@@ -318,24 +275,6 @@ export default {
this.$emit('taskListUpdateFailed');
},
- updateTaskStatusText() {
- const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
- const $issuableHeader = $('.issuable-meta');
- const $tasks = $('#task_status', $issuableHeader);
- const $tasksShort = $('#task_status_short', $issuableHeader);
-
- if (taskRegexMatches) {
- $tasks.text(this.taskStatus);
- $tasksShort.text(
- `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${
- taskRegexMatches[2] > 1 ? 's' : ''
- }`,
- );
- } else {
- $tasks.text('');
- $tasksShort.text('');
- }
- },
createTaskListItemActions(provide) {
const app = new Vue({
el: document.createElement('div'),
@@ -358,59 +297,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,26 +324,8 @@ 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);
+ stripClientState(description) {
+ return description.replaceAll('<details open="true">', '<details>');
},
async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
@@ -468,7 +347,7 @@ export default {
},
projectPath: this.fullPath,
title,
- workItemTypeId: this.taskWorkItemType,
+ workItemTypeId: this.taskWorkItemTypeId,
};
const { data } = await this.$apollo.mutate({
@@ -532,16 +411,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 +438,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/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 5138a4530e9..6a0edb59b65 100644
--- a/app/assets/javascripts/issues/show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -1,13 +1,20 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
TimeAgoTooltip,
+ GlLink,
GlSprintf,
},
props: {
+ taskCompletionStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
updatedAt: {
type: String,
required: false,
@@ -25,36 +32,61 @@ export default {
},
},
computed: {
+ completedCount() {
+ return this.taskCompletionStatus.completed_count;
+ },
+ count() {
+ return this.taskCompletionStatus.count;
+ },
hasUpdatedBy() {
return this.updatedByName && this.updatedByPath;
},
+ showCheck() {
+ return this.completedCount === this.count;
+ },
+ taskStatus() {
+ const { completedCount, count } = this;
+ if (!count) {
+ return undefined;
+ }
+
+ return sprintf(
+ n__(
+ '%{completedCount} of %{count} checklist item completed',
+ '%{completedCount} of %{count} checklist items completed',
+ count,
+ ),
+ { completedCount, count },
+ );
+ },
},
};
</script>
<template>
<small class="edited-text js-issue-widgets">
- <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')">
- <template #timeago>
- <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
- </template>
- </gl-sprintf>
- <gl-sprintf v-else-if="!updatedAt" :message="__('Edited by %{author}')">
- <template #author>
- <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline">
- <span>{{ updatedByName }}</span>
- </a>
- </template>
- </gl-sprintf>
- <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')">
- <template #timeago>
- <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
- </template>
- <template #author>
- <a :href="updatedByPath" class="author-link gl-hover-text-decoration-underline">
- <span>{{ updatedByName }}</span>
- </a>
- </template>
- </gl-sprintf>
+ <template v-if="taskStatus">
+ <template v-if="showCheck">&check;</template>
+ {{ taskStatus }}
+ <template v-if="updatedAt">&middot;</template>
+ </template>
+
+ <template v-if="updatedAt">
+ <gl-sprintf v-if="!hasUpdatedBy" :message="__('Edited %{timeago}')">
+ <template #timeago>
+ <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="__('Edited %{timeago} by %{author}')">
+ <template #timeago>
+ <time-ago-tooltip :time="updatedAt" tooltip-placement="bottom" />
+ </template>
+ <template #author>
+ <gl-link :href="updatedByPath" class="gl-font-sm gl-hover-text-gray-900 gl-text-gray-700">
+ {{ updatedByName }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
</small>
</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 3bc24e8ce01..c8ea8fb7ab2 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"
@@ -83,6 +82,7 @@ export default {
/>
<markdown-field
v-else
+ class="gl-mt-3"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue
index 98f92c97f77..380b676cbbb 100644
--- a/app/assets/javascripts/issues/show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue
@@ -60,7 +60,7 @@ export default {
:data-project-path="projectPath"
:data-project-id="projectId"
:data-data="issuableTemplatesJson"
- class="dropdown-menu-toggle js-issuable-selector gl-button"
+ class="dropdown-menu-toggle js-issuable-selector gl-button gl-py-3!"
type="button"
data-field-name="issuable_template"
data-selected="null"
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/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index bcea9cf57a7..2e99c03d250 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -1,7 +1,11 @@
<script>
import { GlAlert } from '@gitlab/ui';
+import ConvertDescriptionModal from 'ee_component/issues/show/components/convert_description_modal.vue';
import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_ISSUE, TYPENAME_USER } from '~/graphql_shared/constants';
import { TYPE_ISSUE } from '~/issues/constants';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import EditActions from './edit_actions.vue';
import DescriptionField from './fields/description.vue';
@@ -12,6 +16,7 @@ import LockedWarning from './locked_warning.vue';
export default {
components: {
+ ConvertDescriptionModal,
DescriptionField,
DescriptionTemplateField,
EditActions,
@@ -20,6 +25,7 @@ export default {
IssuableTypeField,
LockedWarning,
},
+ mixins: [glFeatureFlagMixin()],
props: {
endpoint: {
type: String,
@@ -73,6 +79,11 @@ export default {
required: false,
default: '',
},
+ issueId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
const autosaveKey = [document.location.pathname, document.location.search];
@@ -100,6 +111,12 @@ export default {
isIssueType() {
return this.issuableType === TYPE_ISSUE;
},
+ resourceId() {
+ return this.issueId && convertToGraphQLId(TYPENAME_ISSUE, this.issueId);
+ },
+ userId() {
+ return convertToGraphQLId(TYPENAME_USER, gon.current_user_id);
+ },
},
watch: {
formData: {
@@ -158,6 +175,9 @@ export default {
updateDraft(this.descriptionAutosaveKey, description, this.formState.lock_version);
}
},
+ setDescription(desc) {
+ this.formData.description = desc;
+ },
},
};
</script>
@@ -185,12 +205,12 @@ export default {
<issuable-title-field ref="title" v-model="formData.title" @input="updateTitleDraft" />
</div>
</div>
- <div class="row">
+ <div class="row gl-gap-3">
<div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
<issuable-type-field ref="issue-type" />
</div>
- <div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2">
+ <div v-if="hasIssuableTemplates" class="col-12 col-md-4 gl-md-pl-0 gl-md-pr-0">
<description-template-field
v-model="formData.description"
:issuable-templates="issuableTemplates"
@@ -199,6 +219,14 @@ export default {
:project-namespace="projectNamespace"
/>
</div>
+
+ <convert-description-modal
+ v-if="issueId && glFeatures.generateDescriptionAi"
+ class="gl-pl-5 gl-sm-pl-0"
+ :resource-id="resourceId"
+ :user-id="userId"
+ @contentGenerated="setDescription"
+ />
</div>
<description-field
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 9d92b5cf954..4d9b69ddf99 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -9,17 +9,30 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
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 { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
+import { STATUS_CLOSED, TYPE_INCIDENT, TYPE_ISSUE, IssuableTypeText } from '~/issues/constants';
+import {
+ ISSUE_STATE_EVENT_CLOSE,
+ ISSUE_STATE_EVENT_REOPEN,
+ NEW_ACTIONS_POPOVER_KEY,
+} from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__, __, sprintf } from '~/locale';
import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
+import toast from '~/vue_shared/plugins/global_toast';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
+import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
+import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
+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 +48,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.',
),
@@ -42,6 +57,8 @@ export default {
'The issue was successfully promoted to an epic. Redirecting to epic...',
),
reportAbuse: __('Report abuse to administrator'),
+ referenceFetchError: __('An error occurred while fetching reference'),
+ copyReferenceText: __('Copy reference'),
},
components: {
DeleteIssueModal,
@@ -52,12 +69,15 @@ export default {
GlLink,
GlModal,
AbuseCategorySelector,
+ NewHeaderActionsPopover,
+ SidebarSubscriptionsWidget,
+ IssuableLockForm,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
- mixins: [trackingMixin],
+ mixins: [trackingMixin, glFeatureFlagMixin()],
inject: {
canCreateIssue: {
default: false,
@@ -80,6 +100,9 @@ export default {
iid: {
default: '',
},
+ issuableId: {
+ default: '',
+ },
isIssueAuthor: {
default: false,
},
@@ -87,7 +110,7 @@ export default {
default: '',
},
issueType: {
- default: IssueType.Issue,
+ default: TYPE_ISSUE,
},
newIssuePath: {
default: '',
@@ -104,22 +127,53 @@ export default {
reportedFromUrl: {
default: '',
},
+ issuableEmailAddress: {
+ default: '',
+ },
+ fullPath: {
+ default: '',
+ },
},
data() {
return {
isReportAbuseDrawerOpen: false,
};
},
+ apollo: {
+ issuableReference: {
+ query: issueReferenceQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.reference || '';
+ },
+ skip() {
+ return !this.isMrSidebarMoved;
+ },
+ error(error) {
+ createAlert({ message: this.$options.i18n.referenceFetchError });
+ Sentry.captureException(error);
+ },
+ },
+ },
computed: {
...mapState(['isToggleStateButtonLoading']),
...mapGetters(['openState', 'getBlockedByIssues']),
+ ...mapGetters(['getNoteableData']),
+ isLocked() {
+ return this.getNoteableData.discussion_locked;
+ },
isClosed() {
return this.openState === STATUS_CLOSED;
},
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;
@@ -156,6 +210,17 @@ export default {
hasMobileDropdown() {
return this.hasDesktopDropdown || this.showToggleIssueStateButton;
},
+ copyMailAddressText() {
+ return sprintf(__('Copy %{issueType} email address'), {
+ issueType: IssuableTypeText[this.issueType],
+ });
+ },
+ isMrSidebarMoved() {
+ return this.glFeatures.movedMrSidebar;
+ },
+ showLockIssueOption() {
+ return this.isMrSidebarMoved && this.issueType === TYPE_ISSUE;
+ },
},
created() {
eventHub.$on('toggle.issuable.state', this.toggleIssueState);
@@ -165,6 +230,7 @@ export default {
},
methods: {
...mapActions(['toggleStateButtonLoading']),
+ ...mapActions(['updateLockedAttribute']),
toggleIssueState() {
if (!this.isClosed && this.getBlockedByIssues?.length) {
this.$refs.blockedByIssuesModal.show();
@@ -240,12 +306,27 @@ export default {
this.toggleStateButtonLoading(false);
});
},
+ edit() {
+ issuesEventHub.$emit('open.form');
+ },
+ dismissPopover() {
+ if (this.isMrSidebarMoved && !parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`))) {
+ setCookie(NEW_ACTIONS_POPOVER_KEY, true);
+ }
+ },
+ copyReference() {
+ toast(__('Reference copied'));
+ },
+ copyEmailAddress() {
+ toast(__('Email address copied'));
+ },
},
+ TYPE_ISSUE,
};
</script>
<template>
- <div class="detail-page-header-actions gl-display-flex gl-align-self-start">
+ <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-gap-3">
<gl-dropdown
v-if="hasMobileDropdown"
class="gl-sm-display-none! w-100"
@@ -255,6 +336,24 @@ export default {
data-testid="mobile-dropdown"
:loading="isToggleStateButtonLoading"
>
+ <template v-if="isMrSidebarMoved">
+ <sidebar-subscriptions-widget
+ :iid="String(iid)"
+ :full-path="fullPath"
+ :issuable-type="$options.TYPE_ISSUE"
+ data-testid="notification-toggle"
+ />
+
+ <gl-dropdown-divider />
+ </template>
+
+ <template v-if="showLockIssueOption">
+ <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
+ </template>
+
+ <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}`"
@@ -268,9 +367,21 @@ export default {
<gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic">
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
+ <template v-if="isMrSidebarMoved">
+ <gl-dropdown-item
+ :data-clipboard-text="issuableReference"
+ data-testid="copy-reference"
+ @click="copyReference"
+ >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
+ >
+ <gl-dropdown-item
+ v-if="issuableEmailAddress"
+ :data-clipboard-text="issuableEmailAddress"
+ data-testid="copy-email"
+ @click="copyEmailAddress"
+ >{{ copyMailAddressText }}</gl-dropdown-item
+ >
+ </template>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
@@ -289,13 +400,33 @@ export default {
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
+ <gl-dropdown-item
+ v-if="!isIssueAuthor"
+ data-testid="report-abuse-item"
+ @click="toggleReportAbuseDrawer(true)"
+ >
+ {{ $options.i18n.reportAbuse }}
+ </gl-dropdown-item>
</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!"
:data-qa-selector="qaSelector"
:loading="isToggleStateButtonLoading"
+ data-testid="toggle-button"
@click="toggleIssueState"
>
{{ buttonText }}
@@ -303,8 +434,9 @@ export default {
<gl-dropdown
v-if="hasDesktopDropdown"
+ id="new-actions-header-dropdown"
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!"
icon="ellipsis_v"
category="tertiary"
data-qa-selector="issue_actions_ellipsis_dropdown"
@@ -315,7 +447,19 @@ export default {
data-testid="desktop-dropdown"
no-caret
right
+ @shown="dismissPopover"
>
+ <template v-if="isMrSidebarMoved">
+ <sidebar-subscriptions-widget
+ :iid="String(iid)"
+ :full-path="fullPath"
+ :issuable-type="$options.TYPE_ISSUE"
+ data-testid="notification-toggle"
+ />
+
+ <gl-dropdown-divider />
+ </template>
+
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
@@ -327,9 +471,24 @@ export default {
>
{{ __('Promote to epic') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!isIssueAuthor" @click="toggleReportAbuseDrawer(true)">
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
+ <template v-if="showLockIssueOption">
+ <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" />
+ </template>
+ <template v-if="isMrSidebarMoved">
+ <gl-dropdown-item
+ :data-clipboard-text="issuableReference"
+ data-testid="copy-reference"
+ @click="copyReference"
+ >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item
+ >
+ <gl-dropdown-item
+ v-if="issuableEmailAddress"
+ :data-clipboard-text="issuableEmailAddress"
+ data-testid="copy-email"
+ @click="copyEmailAddress"
+ >{{ copyMailAddressText }}</gl-dropdown-item
+ >
+ </template>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
@@ -349,8 +508,16 @@ export default {
{{ deleteButtonText }}
</gl-dropdown-item>
</template>
+ <gl-dropdown-item
+ v-if="!isIssueAuthor"
+ data-testid="report-abuse-item"
+ @click="toggleReportAbuseDrawer(true)"
+ >
+ {{ $options.i18n.reportAbuse }}
+ </gl-dropdown-item>
</gl-dropdown>
+ <new-header-actions-popover v-if="isMrSidebarMoved" :issue-type="issueType" />
<gl-modal
ref="blockedByIssuesModal"
modal-id="blocked-by-issues-modal"
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..8267c0130a3 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
@@ -199,7 +199,7 @@ export default {
<p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
</div>
</div>
- <gl-form-group v-if="glFeatures.incidentEventTags">
+ <gl-form-group>
<label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags">
{{ $options.i18n.tagsLabel }}
<timeline-events-tags-popover />
@@ -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/new_header_actions_popover.vue b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
new file mode 100644
index 00000000000..8262b3ac0ff
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/new_header_actions_popover.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlPopover, GlButton } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { getCookie, parseBoolean, setCookie } from '~/lib/utils/common_utils';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { NEW_ACTIONS_POPOVER_KEY } from '~/issues/show/constants';
+import { IssuableTypeText } from '~/issues/constants';
+
+export default {
+ name: 'NewHeaderActionsPopover',
+ i18n: {
+ popoverText: s__(
+ 'HeaderAction|Notifications and other %{issueType} actions have moved to this menu.',
+ ),
+ confirmButtonText: s__('HeaderAction|Okay!'),
+ },
+ components: {
+ GlPopover,
+ GlButton,
+ },
+ mixins: [glFeatureFlagMixin()],
+ props: {
+ issueType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dismissKey: NEW_ACTIONS_POPOVER_KEY,
+ popoverDismissed: parseBoolean(getCookie(`${NEW_ACTIONS_POPOVER_KEY}`)),
+ };
+ },
+ computed: {
+ popoverText() {
+ return sprintf(this.$options.i18n.popoverText, {
+ issueType: IssuableTypeText[this.issueType],
+ });
+ },
+ showPopover() {
+ return !this.popoverDismissed && this.isMrSidebarMoved;
+ },
+ isMrSidebarMoved() {
+ return this.glFeatures.movedMrSidebar;
+ },
+ },
+ methods: {
+ dismissPopover() {
+ this.popoverDismissed = true;
+ setCookie(this.dismissKey, this.popoverDismissed);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-popover
+ v-if="showPopover"
+ target="new-actions-header-dropdown"
+ container="viewport"
+ placement="left"
+ :show="showPopover"
+ triggers="manual"
+ content="text"
+ :css-classes="['gl-p-2 new-header-popover']"
+ >
+ <template #title>
+ <div class="gl-font-base gl-font-weight-normal">
+ {{ popoverText }}
+ </div>
+ </template>
+ <gl-button
+ data-testid="confirm-button"
+ variant="confirm"
+ type="submit"
+ @click="dismissPopover"
+ >{{ $options.i18n.confirmButtonText }}</gl-button
+ >
+ </gl-popover>
+ </div>
+</template>
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..5160903c762 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
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -10,38 +10,48 @@ export default {
taskActions: s__('WorkItem|Task actions'),
},
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
},
- inject: ['canUpdate', 'toggleClass'],
+ inject: ['canUpdate'],
methods: {
convertToTask() {
eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
+ this.closeDropdown();
},
deleteTaskListItem() {
eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos);
+ this.closeDropdown();
+ },
+ closeDropdown() {
+ this.$refs.dropdown.close();
},
},
};
</script>
<template>
- <gl-dropdown
+ <gl-disclosure-dropdown
+ v-if="canUpdate"
+ ref="dropdown"
class="task-list-item-actions-wrapper"
category="tertiary"
icon="ellipsis_v"
- lazy
no-caret
- right
- :text="$options.i18n.taskActions"
+ placement="right"
+ :toggle-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 }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="canUpdate" variant="danger" @click="deleteTaskListItem">
- {{ $options.i18n.delete }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown-item class="gl-ml-2!" @action="convertToTask">
+ <template #list-item>
+ {{ $options.i18n.convertToTask }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item class="gl-ml-2!" @action="deleteTaskListItem">
+ <template #list-item>
+ <span class="gl-text-red-500!">{{ $options.i18n.delete }}</span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</template>
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..6320e4ef266 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';
@@ -18,3 +17,5 @@ export const issueState = {
issueType: undefined,
isDirty: false,
};
+
+export const NEW_ACTIONS_POPOVER_KEY = 'new-actions-popover-viewed';
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 1793ce66ad4..5a51ac18446 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,
@@ -157,6 +164,7 @@ export function initHeaderActions(store, type = '') {
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid,
+ issuableId: el.dataset.issuableId,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
issuePath: el.dataset.issuePath,
issueType: el.dataset.issueType,
@@ -167,6 +175,8 @@ export function initHeaderActions(store, type = '') {
reportedUserId: parseInt(el.dataset.reportedUserId, 10),
reportedFromUrl: el.dataset.reportedFromUrl,
submitAsSpamPath: el.dataset.submitAsSpamPath,
+ issuableEmailAddress: el.dataset.issuableEmailAddress,
+ fullPath: el.dataset.projectPath,
},
render: (createElement) => createElement(HeaderActions),
});
diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js
index 8c5dc88f183..184635e63f3 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/api.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/api.js
@@ -16,15 +16,6 @@ export const setApiBaseURL = (baseURL = null) => {
axiosInstance.defaults.baseURL = baseURL;
};
-export const addSubscription = async (addPath, namespace) => {
- const jwt = await getJwt();
-
- return axiosInstance.post(addPath, {
- jwt,
- namespace_path: namespace,
- });
-};
-
export const removeSubscription = async (removePath) => {
const jwt = await getJwt();
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
index fa1c2e1912c..cd0f4c2f66f 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item.vue
@@ -1,27 +1,15 @@
<script>
import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
-import { addSubscription } from '~/jira_connect/subscriptions/api';
-import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GroupItemName from '../group_item_name.vue';
-import {
- INTEGRATIONS_DOC_LINK,
- I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE,
-} from '../../constants';
+import { I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE } from '../../constants';
export default {
components: {
GlButton,
GroupItemName,
},
- mixins: [glFeatureFlagMixin()],
inject: {
- addSubscriptionsPath: {
- default: '',
- },
subscriptionsPath: {
default: '',
},
@@ -42,43 +30,19 @@ export default {
isLoading: false,
};
},
- computed: {
- oauthEnabled() {
- return this.glFeatures.jiraConnectOauth;
- },
- },
methods: {
...mapActions(['addSubscription']),
async onClick() {
- if (this.oauthEnabled) {
- this.isLoading = true;
+ this.isLoading = true;
+ try {
await this.addSubscription({
namespacePath: this.group.full_path,
subscriptionsPath: this.subscriptionsPath,
});
- this.isLoading = false;
- } else {
- this.deprecatedAddSubscription();
+ } catch (error) {
+ this.$emit('error', error?.response?.data?.error || I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE);
}
- },
- deprecatedAddSubscription() {
- this.isLoading = true;
-
- addSubscription(this.addSubscriptionsPath, this.group.full_path)
- .then(() => {
- persistAlert({
- title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- linkUrl: INTEGRATIONS_DOC_LINK,
- variant: 'success',
- });
-
- reloadPage();
- })
- .catch((error) => {
- this.$emit('error', error?.response?.data?.error || I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE);
- this.isLoading = false;
- });
+ this.isLoading = false;
},
},
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index ec42b533dd4..7e79572f76d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -4,7 +4,6 @@ import { isEmpty } from 'lodash';
import { mapState, mapMutations, mapActions } from 'vuex';
import { retrieveAlert } from '~/jira_connect/subscriptions/utils';
import AccessorUtilities from '~/lib/utils/accessor';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants';
import { SET_ALERT } from '../store/mutation_types';
import SignInPage from '../pages/sign_in/sign_in_page.vue';
@@ -23,11 +22,7 @@ export default {
SubscriptionsPage,
UserLink,
},
- mixins: [glFeatureFlagMixin()],
inject: {
- usersPath: {
- default: '',
- },
subscriptionsPath: {
default: '',
},
@@ -45,21 +40,14 @@ export default {
return !isEmpty(this.subscriptions);
},
userSignedIn() {
- if (this.isOauthEnabled) {
- return Boolean(this.currentUser);
- }
-
- return Boolean(!this.usersPath);
- },
- isOauthEnabled() {
- return this.glFeatures.jiraConnectOauth;
+ return Boolean(this.currentUser);
},
/**
* Returns false if the GitLab for Jira app doesn't support the user's browser.
* Any web API that the GitLab for Jira app depends on should be checked here.
*/
isBrowserSupported() {
- return !this.isOauthEnabled || AccessorUtilities.canUseCrypto();
+ return AccessorUtilities.canUseCrypto();
},
gitlabUrl() {
return gon.gitlab_url;
@@ -80,11 +68,10 @@ export default {
}),
...mapActions(['fetchSubscriptions']),
/**
- * Fetch subscriptions from the REST API,
- * if the jiraConnectOauth flag is enabled.
+ * Fetch subscriptions from the REST API.
*/
fetchSubscriptionsOauth() {
- if (!this.isOauthEnabled || !this.userSignedIn) return;
+ if (!this.userSignedIn) return;
this.fetchSubscriptions(this.subscriptionsPath);
},
@@ -113,12 +100,7 @@ export default {
<gl-link :href="gitlabUrl" target="_blank">
<img :src="gitlabLogo" class="gl-h-6" :alt="__('GitLab')" />
</gl-link>
- <user-link
- :user-signed-in="userSignedIn"
- :has-subscriptions="hasSubscriptions"
- :user="currentUser"
- class="gl-fixed gl-right-4"
- />
+ <user-link v-if="userSignedIn" :user="currentUser" class="gl-fixed gl-right-4" />
</header>
<main class="jira-connect-app gl-px-5 gl-pt-7 gl-mx-auto">
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
deleted file mode 100644
index ec718d5b3ca..00000000000
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
-import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- usersPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- signInURL: '',
- };
- },
- created() {
- this.setSignInURL();
- },
- methods: {
- async setSignInURL() {
- this.signInURL = await getGitlabSignInURL(this.usersPath);
- },
- },
- i18n: {
- defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
- },
-};
-</script>
-<template>
- <gl-button category="primary" variant="info" :href="signInURL" target="_blank">
- <slot>
- {{ $options.i18n.defaultButtonText }}
- </slot>
- </gl-button>
-</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
index 65c69bcfa82..bc8cdf35701 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -12,6 +12,7 @@ import {
I18N_OAUTH_FAILED_MESSAGE,
OAUTH_SELF_MANAGED_DOC_LINK,
OAUTH_WINDOW_OPTIONS,
+ OAUTH_CALLBACK_MESSAGE_TYPE,
PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM,
} from '~/jira_connect/subscriptions/constants';
import { fetchOAuthApplicationId, fetchOAuthToken } from '~/jira_connect/subscriptions/api';
@@ -130,6 +131,11 @@ export default {
}
},
async handleWindowMessage(event) {
+ // Make sure this ia a message from the OAuth flow in pages/jira_connect/oauth_callbacks/index.js
+ if (event.data?.type !== OAUTH_CALLBACK_MESSAGE_TYPE) {
+ return;
+ }
+
if (window.origin !== event.origin) {
this.loading = false;
return;
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
index b253f888d22..cc0af0b9ab7 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
@@ -1,44 +1,24 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
-import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
export default {
components: {
GlLink,
GlSprintf,
- SignInOauthButton: () => import('./sign_in_oauth_button.vue'),
},
- mixins: [glFeatureFlagMixin()],
inject: {
- usersPath: {
- default: '',
- },
gitlabUserPath: {
default: '',
},
},
props: {
- userSignedIn: {
- type: Boolean,
- required: true,
- },
- hasSubscriptions: {
- type: Boolean,
- required: true,
- },
user: {
type: Object,
required: false,
default: null,
},
},
- data() {
- return {
- signInURL: '',
- };
- },
computed: {
gitlabUserName() {
return gon.current_username ?? this.user?.username;
@@ -54,15 +34,8 @@ export default {
? this.$options.i18n.signedInAsUserText
: this.$options.i18n.signedInText;
},
- isOauthEnabled() {
- return this.glFeatures.jiraConnectOauth;
- },
- },
- async created() {
- this.signInURL = await getGitlabSignInURL(this.usersPath);
},
i18n: {
- signInText: __('Sign in to GitLab'),
signedInAsUserText: __('Signed in to GitLab as %{user_link}'),
signedInText: __('Signed in to GitLab'),
},
@@ -70,22 +43,12 @@ export default {
</script>
<template>
<div class="gl-font-base">
- <gl-sprintf v-if="userSignedIn" :message="signedInText">
+ <gl-sprintf :message="signedInText">
<template #user_link>
<gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank">
{{ gitlabUserHandle }}
</gl-link>
</template>
</gl-sprintf>
-
- <template v-else-if="hasSubscriptions">
- <sign-in-oauth-button v-if="isOauthEnabled" category="tertiary">
- {{ $options.i18n.signInText }}
- </sign-in-oauth-button>
-
- <gl-link v-else data-testid="sign-in-link" :href="signInURL" target="_blank">
- {{ $options.i18n.signInText }}
- </gl-link>
- </template>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index bb22a4ef252..321d10205e6 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -54,6 +54,8 @@ export const OAUTH_WINDOW_OPTIONS = [
`top=${window.screen.height / 2 - OAUTH_WINDOW_SIZE / 2}`,
].join(',');
+export const OAUTH_CALLBACK_MESSAGE_TYPE = 'jiraConnectOauthCallback';
+
export const PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM = {
long: 'SHA-256',
short: 'S256',
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index 21ff85e58e2..8854157054d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -2,7 +2,6 @@ import '~/webpack';
import setConfigs from '@gitlab/ui/dist/config';
import Vue from 'vue';
-import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin';
import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
@@ -17,14 +16,11 @@ export function initJiraConnect() {
setConfigs();
Vue.use(Translate);
- Vue.use(GlFeatureFlagsPlugin);
const {
groupsPath,
subscriptions,
- addSubscriptionsPath,
subscriptionsPath,
- usersPath,
gitlabUserPath,
oauthMetadata,
publicKeyStorageEnabled,
@@ -38,9 +34,7 @@ export function initJiraConnect() {
store,
provide: {
groupsPath,
- addSubscriptionsPath,
subscriptionsPath,
- usersPath,
gitlabUserPath,
oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null,
publicKeyStorageEnabled,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
index 6de3f507a39..113ce34fdcd 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
@@ -2,29 +2,20 @@
import { s__ } from '~/locale';
import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SubscriptionsList from '../../components/subscriptions_list.vue';
export default {
name: 'SignInGitlabCom',
components: {
SubscriptionsList,
- SignInLegacyButton: () => import('../../components/sign_in_legacy_button.vue'),
SignInOauthButton: () => import('../../components/sign_in_oauth_button.vue'),
},
- mixins: [glFeatureFlagMixin()],
- inject: ['usersPath'],
props: {
hasSubscriptions: {
type: Boolean,
required: true,
},
},
- computed: {
- useSignInOauthButton() {
- return this.glFeatures.jiraConnectOauth;
- },
- },
i18n: {
signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
signInText: s__('JiraService|Sign in to GitLab to get started.'),
@@ -44,16 +35,12 @@ export default {
<div v-if="hasSubscriptions">
<div class="gl-display-flex gl-justify-content-end gl-mb-3">
<sign-in-oauth-button
- v-if="useSignInOauthButton"
:gitlab-base-path="$options.GITLAB_COM_BASE_PATH"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
>
{{ $options.i18n.signInButtonTextWithSubscriptions }}
</sign-in-oauth-button>
- <sign-in-legacy-button v-else :users-path="usersPath">
- {{ $options.i18n.signInButtonTextWithSubscriptions }}
- </sign-in-legacy-button>
</div>
<subscriptions-list />
@@ -61,12 +48,10 @@ export default {
<div v-else class="gl-text-center">
<p class="gl-mb-7">{{ $options.i18n.signInText }}</p>
<sign-in-oauth-button
- v-if="useSignInOauthButton"
:gitlab-base-path="$options.GITLAB_COM_BASE_PATH"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
/>
- <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
index 7c6ff002014..8cc107930d1 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
@@ -12,7 +12,6 @@ import {
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import SignInOauthButton from '../../../components/sign_in_oauth_button.vue';
-import SetupInstructions from './setup_instructions.vue';
import VersionSelectForm from './version_select_form.vue';
export default {
@@ -20,31 +19,23 @@ export default {
components: {
GlButton,
SignInOauthButton,
- SetupInstructions,
VersionSelectForm,
},
data() {
return {
gitlabBasePath: null,
loadingVersionSelect: false,
- showSetupInstructions: false,
};
},
computed: {
hasSelectedVersion() {
return this.gitlabBasePath !== null;
},
- subtitle() {
- return this.hasSelectedVersion
- ? this.$options.i18n.signInSubtitle
- : this.$options.i18n.versionSelectSubtitle;
- },
},
mounted() {
this.gitlabBasePath = retrieveBaseUrl();
- setApiBaseURL(this.gitlabBasePath);
if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) {
- this.showSetupInstructions = true;
+ setApiBaseURL(this.gitlabBasePath);
}
},
methods: {
@@ -70,9 +61,6 @@ export default {
this.loadingVersionSelect = false;
});
},
- onSetupNext() {
- this.showSetupInstructions = false;
- },
onSignInError() {
this.$emit('error');
},
@@ -80,7 +68,6 @@ export default {
i18n: {
title: s__('JiraService|Welcome to GitLab for Jira'),
signInSubtitle: s__('JiraService|Sign in to GitLab to link namespaces.'),
- versionSelectSubtitle: s__('JiraService|What version of GitLab are you using?'),
changeVersionButtonText: s__('JiraService|Change GitLab version'),
},
};
@@ -90,7 +77,6 @@ export default {
<div>
<div class="gl-text-center">
<h2>{{ $options.i18n.title }}</h2>
- <p data-testid="subtitle">{{ subtitle }}</p>
</div>
<version-select-form
@@ -101,9 +87,8 @@ export default {
/>
<template v-else>
- <setup-instructions v-if="showSetupInstructions" @next="onSetupNext" />
-
- <div v-else class="gl-text-center">
+ <div class="gl-text-center">
+ <p data-testid="subtitle">{{ $options.i18n.signInSubtitle }}</p>
<sign-in-oauth-button
class="gl-mb-5"
:gitlab-base-path="gitlabBasePath"
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue
new file mode 100644
index 00000000000..8ddbbffa708
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/self_managed_alert.vue
@@ -0,0 +1,22 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlAlert,
+ },
+ i18n: {
+ title: s__('JiraService|Are you a GitLab administrator?'),
+ body: s__(
+ "JiraService|Setting up this integration is only possible if you're a GitLab administrator.",
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="warning" :title="$options.i18n.title" :dismissible="false">
+ {{ $options.i18n.body }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
index 00fa739b518..621bcccd19a 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
- <div class="gl-max-w-62 gl-mx-auto gl-mt-7">
+ <div class="gl-mt-5">
<h3>{{ s__('JiraService|Continue setup in GitLab') }}</h3>
<p>
{{
@@ -28,8 +28,9 @@ export default {
>
</p>
- <gl-button variant="confirm" @click="$emit('next')">
- {{ __('Next') }}
- </gl-button>
+ <div class="gl-display-flex gl-justify-content-space-between">
+ <gl-button @click="$emit('back')">{{ __('Back') }}</gl-button>
+ <gl-button variant="confirm" @click="$emit('next')">{{ __('Next') }}</gl-button>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
index 37a65946b3f..3a080afd3c5 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
@@ -10,6 +10,8 @@ import {
import { __, s__ } from '~/locale';
import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
+import SelfManagedAlert from './self_managed_alert.vue';
+import SetupInstructions from './setup_instructions.vue';
const RADIO_OPTIONS = {
saas: 'saas',
@@ -27,6 +29,8 @@ export default {
GlFormInput,
GlFormRadio,
GlButton,
+ SelfManagedAlert,
+ SetupInstructions,
},
props: {
loading: {
@@ -39,25 +43,54 @@ export default {
return {
selected: DEFAULT_RADIO_OPTION,
selfManagedBasePathInput: '',
+ showSetupInstructions: false,
+ showSelfManagedInstanceInput: false,
};
},
computed: {
isSelfManagedSelected() {
return this.selected === RADIO_OPTIONS.selfManaged;
},
+ submitText() {
+ return this.isSelfManagedSelected
+ ? this.$options.i18n.buttonNext
+ : this.$options.i18n.buttonSave;
+ },
+ showVersonSelect() {
+ return !this.showSetupInstructions && !this.showSelfManagedInstanceInput;
+ },
},
methods: {
onSubmit() {
- const gitlabBasePath =
- this.selected === RADIO_OPTIONS.saas ? GITLAB_COM_BASE_PATH : this.selfManagedBasePathInput;
+ if (this.isSelfManagedSelected && !this.showSelfManagedInstanceInput) {
+ this.showSetupInstructions = true;
+ return;
+ }
+
+ const gitlabBasePath = this.isSelfManagedSelected
+ ? this.selfManagedBasePathInput
+ : GITLAB_COM_BASE_PATH;
this.$emit('submit', gitlabBasePath);
},
+
+ onSetupNext() {
+ this.showSetupInstructions = false;
+ this.showSelfManagedInstanceInput = true;
+ },
+
+ onSetupBack() {
+ this.showSetupInstructions = false;
+ this.showSelfManagedInstanceInput = false;
+ },
},
radioOptions: RADIO_OPTIONS,
i18n: {
+ title: s__('JiraService|What version of GitLab are you using?'),
saasRadioLabel: __('GitLab.com (SaaS)'),
saasRadioHelp: __('Most common'),
selfManagedRadioLabel: __('GitLab (self-managed)'),
+ buttonNext: __('Next'),
+ buttonSave: __('Save'),
instanceURLInputLabel: s__('JiraService|GitLab instance URL'),
instanceURLInputDescription: s__('JiraService|For example: https://gitlab.example.com'),
},
@@ -66,30 +99,50 @@ export default {
<template>
<gl-form class="gl-max-w-62 gl-mx-auto" @submit.prevent="onSubmit">
- <gl-form-radio-group v-model="selected" class="gl-mb-3" name="gitlab_version">
- <gl-form-radio :value="$options.radioOptions.saas">
- {{ $options.i18n.saasRadioLabel }}
- <template #help>
- {{ $options.i18n.saasRadioHelp }}
- </template>
- </gl-form-radio>
- <gl-form-radio :value="$options.radioOptions.selfManaged">
- {{ $options.i18n.selfManagedRadioLabel }}
- </gl-form-radio>
- </gl-form-radio-group>
+ <div v-if="showVersonSelect">
+ <h5 class="gl-mb-5">{{ $options.i18n.title }}</h5>
+ <gl-form-radio-group v-model="selected" class="gl-mb-3" name="gitlab_version">
+ <gl-form-radio :value="$options.radioOptions.saas">
+ {{ $options.i18n.saasRadioLabel }}
+ <template #help>
+ {{ $options.i18n.saasRadioHelp }}
+ </template>
+ </gl-form-radio>
+ <gl-form-radio :value="$options.radioOptions.selfManaged">
+ {{ $options.i18n.selfManagedRadioLabel }}
+ </gl-form-radio>
+ </gl-form-radio-group>
+ <self-managed-alert v-if="isSelfManagedSelected" />
+
+ <div class="gl-display-flex gl-justify-content-end gl-mt-5">
+ <gl-button variant="confirm" type="submit" :loading="loading" data-testid="submit-button">{{
+ submitText
+ }}</gl-button>
+ </div>
+ </div>
- <gl-form-group
- v-if="isSelfManagedSelected"
- class="gl-ml-6"
- :label="$options.i18n.instanceURLInputLabel"
- :description="$options.i18n.instanceURLInputDescription"
- label-for="self-managed-instance-input"
- >
- <gl-form-input id="self-managed-instance-input" v-model="selfManagedBasePathInput" required />
- </gl-form-group>
+ <setup-instructions v-else-if="showSetupInstructions" @next="onSetupNext" @back="onSetupBack" />
- <div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="confirm" type="submit" :loading="loading">{{ __('Save') }}</gl-button>
+ <div v-else-if="showSelfManagedInstanceInput">
+ <gl-form-group
+ :label="$options.i18n.instanceURLInputLabel"
+ :description="$options.i18n.instanceURLInputDescription"
+ label-for="self-managed-instance-input"
+ >
+ <gl-form-input
+ id="self-managed-instance-input"
+ v-model="selfManagedBasePathInput"
+ required
+ />
+ </gl-form-group>
+ <div class="gl-display-flex gl-justify-content-space-between">
+ <gl-button data-testid="back-button" @click.prevent="onSetupBack">{{
+ __('Back')
+ }}</gl-button>
+ <gl-button variant="confirm" type="submit" :loading="loading">{{
+ $options.i18n.buttonSave
+ }}</gl-button>
+ </div>
</div>
</gl-form>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
index e6a94ffbaa4..ee20e21011f 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
@@ -1,12 +1,10 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SignInGitlabCom from './sign_in_gitlab_com.vue';
import SignInGitlabMultiversion from './sign_in_gitlab_multiversion/index.vue';
export default {
name: 'SignInPage',
components: { SignInGitlabCom, SignInGitlabMultiversion },
- mixins: [glFeatureFlagMixin()],
props: {
hasSubscriptions: {
type: Boolean,
@@ -19,7 +17,7 @@ export default {
},
computed: {
isOauthSelfManagedEnabled() {
- return this.glFeatures.jiraConnectOauth && this.publicKeyStorageEnabled;
+ return this.publicKeyStorageEnabled;
},
},
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
index fff34e1d75d..3b8e4c7540b 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
@@ -14,8 +14,6 @@ import {
SET_SUBSCRIPTIONS,
SET_SUBSCRIPTIONS_LOADING,
SET_SUBSCRIPTIONS_ERROR,
- ADD_SUBSCRIPTION_LOADING,
- ADD_SUBSCRIPTION_ERROR,
SET_ALERT,
SET_CURRENT_USER,
SET_CURRENT_USER_ERROR,
@@ -51,25 +49,17 @@ export const addSubscription = async (
{ commit, state, dispatch },
{ namespacePath, subscriptionsPath },
) => {
- try {
- commit(ADD_SUBSCRIPTION_LOADING, true);
-
- await addJiraConnectSubscription(namespacePath, {
- jwt: await getJwt(),
- accessToken: state.accessToken,
- });
+ await addJiraConnectSubscription(namespacePath, {
+ jwt: await getJwt(),
+ accessToken: state.accessToken,
+ });
- commit(SET_ALERT, {
- title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
- message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
- linkUrl: INTEGRATIONS_DOC_LINK,
- variant: 'success',
- });
+ commit(SET_ALERT, {
+ title: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
+ message: I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
+ linkUrl: INTEGRATIONS_DOC_LINK,
+ variant: 'success',
+ });
- dispatch('fetchSubscriptions', subscriptionsPath);
- } catch (e) {
- commit(ADD_SUBSCRIPTION_ERROR, e);
- } finally {
- commit(ADD_SUBSCRIPTION_LOADING, false);
- }
+ dispatch('fetchSubscriptions', subscriptionsPath);
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js
index d4893fbcaf6..63aad27aeb6 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutation_types.js
@@ -4,9 +4,6 @@ export const SET_SUBSCRIPTIONS = 'SET_SUBSCRIPTIONS';
export const SET_SUBSCRIPTIONS_LOADING = 'SET_SUBSCRIPTIONS_LOADING';
export const SET_SUBSCRIPTIONS_ERROR = 'SET_SUBSCRIPTIONS_ERROR';
-export const ADD_SUBSCRIPTION_LOADING = 'ADD_SUBSCRIPTION_LOADING';
-export const ADD_SUBSCRIPTION_ERROR = 'ADD_SUBSCRIPTION_ERROR';
-
export const SET_CURRENT_USER = 'SET_CURRENT_USER';
export const SET_CURRENT_USER_ERROR = 'SET_CURRENT_USER_ERROR';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js
index 60076c918fd..270ce9fab66 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/mutations.js
@@ -3,8 +3,6 @@ import {
SET_SUBSCRIPTIONS,
SET_SUBSCRIPTIONS_LOADING,
SET_SUBSCRIPTIONS_ERROR,
- ADD_SUBSCRIPTION_LOADING,
- ADD_SUBSCRIPTION_ERROR,
SET_CURRENT_USER,
SET_CURRENT_USER_ERROR,
SET_ACCESS_TOKEN,
@@ -25,13 +23,6 @@ export default {
state.subscriptionsError = subscriptionsError;
},
- [ADD_SUBSCRIPTION_LOADING](state, loading) {
- state.addSubscriptionLoading = loading;
- },
- [ADD_SUBSCRIPTION_ERROR](state, error) {
- state.addSubscriptionError = error;
- },
-
[SET_CURRENT_USER](state, currentUser) {
state.currentUser = currentUser;
},
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
index 82a8517b511..f35f79e3f53 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
@@ -10,9 +10,6 @@ export default function createState({
subscriptionsLoading,
subscriptionsError: false,
- addSubscriptionLoading: false,
- addSubscriptionError: false,
-
currentUser,
currentUserError: null,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js
index 6db8b62d692..0b88e83f8da 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/utils.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js
@@ -1,5 +1,4 @@
import AccessorUtilities from '~/lib/utils/accessor';
-import { objectToQuery } from '~/lib/utils/url_utility';
import { ALERT_LOCALSTORAGE_KEY, BASE_URL_LOCALSTORAGE_KEY } from './constants';
const isFunction = (fn) => typeof fn === 'function';
@@ -76,18 +75,6 @@ export const getJwt = () => {
});
};
-export const getLocation = () => {
- return new Promise((resolve) => {
- if (isFunction(AP?.getLocation)) {
- AP.getLocation((location) => {
- resolve(location);
- });
- } else {
- resolve();
- }
- });
-};
-
export const reloadPage = () => {
if (isFunction(AP?.navigator?.reload)) {
AP.navigator.reload();
@@ -101,17 +88,3 @@ export const sizeToParent = () => {
AP.sizeToParent();
}
};
-
-export const getGitlabSignInURL = async (signInURL) => {
- const location = await getLocation();
-
- if (location) {
- const queryParams = {
- return_to: location,
- };
-
- return `${signInURL}?${objectToQuery(queryParams)}`;
- }
-
- return signInURL;
-};
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..d3b2ddc5422 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 { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
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); // eslint-disable-line import/no-deprecated
},
- 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/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
index 2018942a7e8..1c7ba1d331b 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
@@ -45,7 +45,9 @@ export default {
data-testid="artifacts-remove-timeline"
>
<span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span>
- <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span>
+ <span v-if="willExpire" data-qa-selector="artifacts_unlocked_message_content">{{
+ s__('Job|The artifacts will be removed')
+ }}</span>
<timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
<gl-link
:href="helpUrl"
@@ -53,11 +55,11 @@ export default {
rel="noopener noreferrer nofollow"
data-testid="artifact-expired-help-link"
>
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
</gl-link>
</p>
<p v-else-if="isLocked" class="build-detail-row">
- <span data-testid="job-locked-message">{{
+ <span data-testid="job-locked-message" data-qa-selector="artifacts_locked_message_content">{{
s__(
'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.',
)
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_detail_row.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
index 05567328660..0ba34eafa58 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
@@ -22,6 +22,11 @@ export default {
required: false,
default: '',
},
+ path: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasTitle() {
@@ -35,10 +40,19 @@ export default {
</script>
<template>
<p class="gl-display-flex gl-justify-content-space-between gl-mb-2">
- <span v-if="hasTitle"
- ><b>{{ title }}:</b> {{ value }}</span
- >
- <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank">
+ <span v-if="hasTitle">
+ <b>{{ title }}:</b>
+ <gl-link
+ v-if="path"
+ :href="path"
+ class="gl-text-blue-600!"
+ data-testid="job-sidebar-value-link"
+ >
+ {{ value }}
+ </gl-link>
+ <span v-else>{{ value }}</span>
+ </span>
+ <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank" data-testid="job-sidebar-help-link">
<gl-icon name="question-o" />
</gl-link>
</p>
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/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
index 8300a22cb67..3cd90eb3bca 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
@@ -70,6 +70,9 @@ export default {
timeoutSource: this.job.metadata.timeout_source,
});
},
+ runnerAdminPath() {
+ return this.job?.runner?.admin_path || '';
+ },
},
i18n: {
COVERAGE: __('Coverage'),
@@ -104,7 +107,12 @@ export default {
data-testid="job-timeout"
:title="$options.i18n.TIMEOUT"
/>
- <detail-row v-if="job.runner" :value="runnerId" :title="$options.i18n.RUNNER" />
+ <detail-row
+ v-if="job.runner"
+ :value="runnerId"
+ :title="$options.i18n.RUNNER"
+ :path="runnerAdminPath"
+ />
<detail-row v-if="job.coverage" :value="coverage" :title="$options.i18n.COVERAGE" />
<p v-if="hasTags" class="build-detail-row" data-testid="job-tags">
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
index e3afe9b7c67..28a17abb20b 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import { s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index 9647582b81d..ba1801f5c58 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -1,5 +1,7 @@
<script>
import { mapState, mapActions } from 'vuex';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import { getLocationHash } from '~/lib/utils/url_utility';
import CollapsibleLogSection from './collapsible_section.vue';
import LogLine from './line.vue';
@@ -25,13 +27,25 @@ export default {
},
updated() {
this.$nextTick(() => {
- this.handleScrollDown();
+ if (!window.location.hash) {
+ this.handleScrollDown();
+ }
});
},
mounted() {
- this.$nextTick(() => {
- this.handleScrollDown();
- });
+ if (window.location.hash) {
+ const lineNumber = getLocationHash();
+
+ this.unwatchJobLog = this.$watch('jobLog', async () => {
+ if (this.jobLog.length) {
+ await this.$nextTick();
+
+ const el = document.getElementById(lineNumber);
+ scrollToElement(el);
+ this.unwatchJobLog();
+ }
+ });
+ }
},
methods: {
...mapActions(['toggleCollapsibleLine', 'scrollBottom']),
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 17766b4d162..d97f6f6ff8c 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -1,7 +1,14 @@
<script>
-import { GlButton, GlButtonGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlButtonGroup,
+ GlModal,
+ GlModalDirective,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import {
ACTIONS_DOWNLOAD_ARTIFACTS,
ACTIONS_START_NOW,
@@ -49,6 +56,7 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
inject: {
admin: {
@@ -130,7 +138,7 @@ export default {
} else if (redirect) {
// Retry and Play actions redirect to job detail view
// we don't need to refetch with jobActionPerformed event
- redirectTo(job.detailedStatus.detailsPath);
+ redirectTo(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated
} else {
eventHub.$emit('jobActionPerformed');
}
@@ -178,11 +186,12 @@ export default {
<template v-if="canReadJob && canUpdateJob">
<gl-button
v-if="isActive"
- data-testid="cancel-button"
+ v-gl-tooltip
icon="cancel"
:title="$options.CANCEL"
:aria-label="$options.CANCEL"
:disabled="cancelBtnDisabled"
+ data-testid="cancel-button"
@click="cancelJob()"
/>
<template v-else-if="isScheduled">
@@ -191,6 +200,7 @@ export default {
</gl-button>
<gl-button
v-gl-modal-directive="$options.playJobModalId"
+ v-gl-tooltip
icon="play"
:title="$options.ACTIONS_START_NOW"
:aria-label="$options.ACTIONS_START_NOW"
@@ -206,6 +216,7 @@ export default {
</gl-sprintf>
</gl-modal>
<gl-button
+ v-gl-tooltip
icon="time-out"
:title="$options.ACTIONS_UNSCHEDULE"
:aria-label="$options.ACTIONS_UNSCHEDULE"
@@ -218,6 +229,7 @@ export default {
<!--Note: This is the manual job play button -->
<gl-button
v-if="manualJobPlayable"
+ v-gl-tooltip
icon="play"
:title="$options.ACTIONS_PLAY"
:aria-label="$options.ACTIONS_PLAY"
@@ -227,6 +239,7 @@ export default {
/>
<gl-button
v-else-if="isRetryable"
+ v-gl-tooltip
icon="retry"
:title="retryButtonTitle"
:aria-label="retryButtonTitle"
@@ -239,6 +252,7 @@ export default {
</template>
<gl-button
v-if="shouldDisplayArtifacts"
+ v-gl-tooltip
icon="download"
:title="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
:aria-label="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
index d1b2da4d115..11593fa355a 100644
--- a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { formatTime } from '~/lib/utils/datetime_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -25,7 +25,7 @@ export default {
return this.job?.duration;
},
durationFormatted() {
- return durationTimeFormatted(this.duration);
+ return formatTime(this.duration * 1000);
},
},
};
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.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index 9ee4439b618..84479ec421e 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -2,6 +2,8 @@
import { GlTable } from '@gitlab/ui';
import { s__ } from '~/locale';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
+import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue';
+import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue';
import ActionsCell from './cells/actions_cell.vue';
import DurationCell from './cells/duration_cell.vue';
import JobCell from './cells/job_cell.vue';
@@ -19,6 +21,8 @@ export default {
GlTable,
JobCell,
PipelineCell,
+ ProjectCell,
+ RunnerCell,
},
props: {
jobs: {
@@ -30,6 +34,11 @@ export default {
required: false,
default: () => DEFAULT_FIELDS,
},
+ admin: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
formatCoverage(coverage) {
@@ -66,6 +75,14 @@ export default {
<pipeline-cell :job="item" />
</template>
+ <template v-if="admin" #cell(project)="{ item }">
+ <project-cell :job="item" />
+ </template>
+
+ <template v-if="admin" #cell(runner)="{ item }">
+ <runner-cell :job="item" />
+ </template>
+
<template #cell(stage)="{ item }">
<div class="gl-text-truncate">
<span data-testid="job-stage-name">{{ item.stage.name }}</span>
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..09fa006cb88 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,13 @@
<script>
-import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, 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 JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue';
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,20 +15,21 @@ 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:
- 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-gray-100 gl-border-b',
+ 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100',
components: {
GlAlert,
- GlSkeletonLoader,
JobsFilteredSearch,
JobsTable,
JobsTableEmptyState,
JobsTableTabs,
GlIntersectionObserver,
GlLoadingIcon,
+ JobsSkeletonLoader,
},
inject: {
fullPath: {
@@ -43,15 +46,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 +80,11 @@ export default {
jobs: {
list: [],
},
- hasError: false,
- isAlertDismissed: false,
+ error: '',
scope: null,
infiniteScrollingTriggered: false,
filterSearchTriggered: false,
+ jobsCount: null,
count: 0,
};
},
@@ -72,9 +92,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 +112,6 @@ export default {
showFilteredSearch() {
return !this.scope;
},
- jobsCount() {
- return this.jobs.count;
- },
validatedQueryString() {
const queryStringObject = queryToObject(window.location.search);
@@ -116,17 +130,37 @@ export default {
},
},
methods: {
+ updateHistoryAndFetchCount(status = null) {
+ this.$apollo.queries.jobsCount.refetch({ statuses: status });
+
+ updateHistory({
+ url: setUrlParams({ statuses: status }, window.location.href, true),
+ });
+ },
fetchJobsByStatus(scope) {
this.infiniteScrollingTriggered = false;
+ if (this.scope === scope) return;
+
this.scope = scope;
+ if (!this.scope) this.updateHistoryAndFetchCount();
+
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
filterJobsBySearch(filters) {
this.infiniteScrollingTriggered = false;
this.filterSearchTriggered = true;
+ // all filters have been cleared reset query param
+ // and refetch jobs/count with defaults
+ if (!filters.length) {
+ this.updateHistoryAndFetchCount();
+ this.$apollo.queries.jobs.refetch({ statuses: null });
+
+ return;
+ }
+
// Eventually there will be more tokens available
// this code is written to scale for those tokens
filters.forEach((filter) => {
@@ -141,10 +175,7 @@ export default {
}
if (filter.type === 'status') {
- updateHistory({
- url: setUrlParams({ statuses: filter.value.data }, window.location.href, true),
- });
-
+ this.updateHistoryAndFetchCount(filter.value.data);
this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
}
});
@@ -168,14 +199,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
@@ -190,26 +221,11 @@ export default {
/>
</div>
- <div v-if="showSkeletonLoader" class="gl-mt-5">
- <gl-skeleton-loader :width="1248" :height="73">
- <circle cx="748.031" cy="37.7193" r="15.0307" />
- <circle cx="787.241" cy="37.7193" r="15.0307" />
- <circle cx="827.759" cy="37.7193" r="15.0307" />
- <circle cx="866.969" cy="37.7193" r="15.0307" />
- <circle cx="380" cy="37" r="18" />
- <rect x="432" y="19" width="126.587" height="15" />
- <rect x="432" y="41" width="247" height="15" />
- <rect x="158" y="19" width="86.1" height="15" />
- <rect x="158" y="41" width="168" height="15" />
- <rect x="22" y="19" width="96" height="36" />
- <rect x="924" y="30" width="96" height="15" />
- <rect x="1057" y="20" width="166" height="35" />
- </gl-skeleton-loader>
- </div>
+ <jobs-skeleton-loader v-if="showSkeletonLoader" class="gl-mt-5" />
<jobs-table-empty-state v-else-if="showEmptyState" />
- <jobs-table v-else :jobs="jobs.list" />
+ <jobs-table v-else :jobs="jobs.list" class="gl-table-no-top-border" />
<gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
<gl-loading-icon
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
index 68c6c669a1a..797facb1eb8 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -1,6 +1,7 @@
<script>
import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
+import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue';
import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility';
export default {
@@ -9,11 +10,16 @@ export default {
GlTab,
GlTabs,
GlLoadingIcon,
+ CancelJobs,
},
inject: {
jobStatuses: {
default: {},
},
+ url: {
+ type: String,
+ default: '',
+ },
},
props: {
allJobsCount: {
@@ -24,6 +30,11 @@ export default {
type: Boolean,
required: true,
},
+ showCancelAllJobsButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
tabs() {
@@ -51,23 +62,27 @@ export default {
</script>
<template>
- <gl-tabs content-class="gl-py-0">
- <gl-tab
- v-for="tab in tabs"
- :key="tab.text"
- :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- 'data-testid': tab.testId,
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- @click="$emit('fetchJobsByStatus', tab.scope)"
- >
- <template #title>
- <span>{{ tab.text }}</span>
- <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" />
+ <div class="gl-display-flex align-items-lg-center">
+ <gl-tabs content-class="gl-py-0">
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.text"
+ :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
+ 'data-testid': tab.testId,
+ } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ @click="$emit('fetchJobsByStatus', tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.text }}</span>
+ <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" />
- <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge">
- {{ tab.count }}
- </gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
+ <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge">
+ {{ tab.count }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ <div class="gl-flex-grow-1"></div>
+ <cancel-jobs v-if="showCancelAllJobsButton" :url="url" />
+ </div>
</template>
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/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 87c00ad4d70..b7d7006ee61 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -33,8 +33,7 @@ export default {
// the job log response will not have a defined
// html or size. We keep the old value otherwise these
// will be set to `null`
-
- state.jobLog = log.lines ? logLinesParser(log.lines) : state.jobLog;
+ state.jobLog = log.lines ? logLinesParser(log.lines, [], window.location.hash) : state.jobLog;
state.jobLogSize = log.size || state.jobLogSize;
}
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index a7b95154c1b..bc76901026d 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -18,12 +18,26 @@ export const parseLine = (line = {}, lineNumber) => ({
* @param Object line
* @param Number lineNumber
*/
-export const parseHeaderLine = (line = {}, lineNumber) => ({
- isClosed: parseBoolean(line.section_options?.collapsed),
- isHeader: true,
- line: parseLine(line, lineNumber),
- lines: [],
-});
+export const parseHeaderLine = (line = {}, lineNumber, hash) => {
+ // if a hash is present in the URL then we ensure
+ // all sections are visible so we can scroll to the hash
+ // in the DOM
+ if (hash) {
+ return {
+ isClosed: false,
+ isHeader: true,
+ line: parseLine(line, lineNumber),
+ lines: [],
+ };
+ }
+
+ return {
+ isClosed: parseBoolean(line.section_options?.collapsed),
+ isHeader: true,
+ line: parseLine(line, lineNumber),
+ lines: [],
+ };
+};
/**
* Finds the matching header section
@@ -104,7 +118,7 @@ export const getIncrementalLineNumber = (acc) => {
* @param Array accumulator
* @returns Array parsed log lines
*/
-export const logLinesParser = (lines = [], accumulator = []) =>
+export const logLinesParser = (lines = [], accumulator = [], hash = '') =>
lines.reduce(
(acc, line, index) => {
const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
@@ -113,7 +127,7 @@ export const logLinesParser = (lines = [], accumulator = []) =>
// If the object is an header, we parse it into another structure
if (line.section_header) {
- acc.push(parseHeaderLine(line, lineNumber));
+ acc.push(parseHeaderLine(line, lineNumber, hash));
} else if (isCollapsibleSection(acc, last, line)) {
// if the object belongs to a nested section, we append it to the new `lines` array of the
// previously formatted header
diff --git a/app/assets/javascripts/labels/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue
index 2be404de1e1..904e1ba9a47 100644
--- a/app/assets/javascripts/labels/components/delete_label_modal.vue
+++ b/app/assets/javascripts/labels/components/delete_label_modal.vue
@@ -81,14 +81,9 @@ export default {
</gl-sprintf>
<template #modal-footer>
<gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
- <gl-button
- category="primary"
- variant="danger"
- :href="destroyPath"
- data-method="delete"
- data-testid="delete-button"
- >{{ __('Delete label') }}</gl-button
- >
+ <gl-button category="primary" variant="danger" :href="destroyPath" data-method="delete">{{
+ __('Delete label')
+ }}</gl-button>
</template>
</gl-modal>
</template>
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..fa0104fcf12 100644
--- a/app/assets/javascripts/labels/create_label_dropdown.js
+++ b/app/assets/javascripts/labels/create_label_dropdown.js
@@ -1,5 +1,3 @@
-/* eslint-disable func-names */
-
import $ from 'jquery';
import Api from '~/api';
import { humanize } from '~/lib/utils/text_utility';
@@ -37,6 +35,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');
@@ -47,6 +47,7 @@ export default class CreateLabelDropdown {
addBinding() {
const self = this;
+ // eslint-disable-next-line func-names
this.$colorSuggestions.on('click', function (e) {
const $this = $(this);
self.addColorValue(e, $this);
@@ -54,6 +55,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 +78,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 +109,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/index.js b/app/assets/javascripts/labels/index.js
index 0d4113bba4c..c7c17607af6 100644
--- a/app/assets/javascripts/labels/index.js
+++ b/app/assets/javascripts/labels/index.js
@@ -120,16 +120,15 @@ export function initAdminLabels() {
const emptyState = document.querySelector('.js-admin-labels-empty-state');
function removeLabelSuccessCallback() {
- this.closest('li').classList.add('gl-display-none!');
+ this.closest('li.label-list-item').classList.add('gl-display-none!');
const labelsCount = document.querySelectorAll(
- 'ul.manage-labels-list li:not(.gl-display-none\\!)',
+ 'ul.manage-labels-list li.label-list-item:not(.gl-display-none\\!)',
).length;
// display the empty state if there are no more labels
if (labelsCount < 1 && !pagination && emptyState) {
emptyState.classList.remove('gl-display-none');
- labelsContainer.classList.add('gl-display-none');
}
}
diff --git a/app/assets/javascripts/labels/label_manager.js b/app/assets/javascripts/labels/label_manager.js
index be515869bff..e3d56df53f8 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';
@@ -106,7 +106,7 @@ export default class LabelManager {
if (action === 'remove') {
$('.js-priority-badge', $label).remove();
} else {
- $('.label-links', $label).append(this.$badgeItemTemplate.clone().html());
+ $('.label-links', $label).prepend(this.$badgeItemTemplate.clone().html());
}
}
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/apollo/indexed_db_persistent_storage.js b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js
new file mode 100644
index 00000000000..5d2a002bf85
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js
@@ -0,0 +1,97 @@
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable class-methods-use-this */
+import { db } from './local_db';
+
+/**
+ * IndexedDB implementation of apollo-cache-persist [PersistentStorage][1]
+ *
+ * [1]: https://github.com/apollographql/apollo-cache-persist/blob/d536c741d1f2828a0ef9abda343a9186dd8dbff2/src/types/index.ts#L15
+ */
+export class IndexedDBPersistentStorage {
+ static async create() {
+ await db.open();
+
+ return new IndexedDBPersistentStorage();
+ }
+
+ async getItem(queryId) {
+ const resultObj = {};
+ const selectedQuery = await db.table('queries').get(queryId);
+ const tableNames = new Set(db.tables.map((table) => table.name));
+
+ if (selectedQuery) {
+ resultObj.ROOT_QUERY = selectedQuery;
+
+ const lookupTable = [];
+
+ const parseObjectsForRef = async (selObject) => {
+ const ops = Object.values(selObject).map(async (child) => {
+ if (!child) {
+ return;
+ }
+
+ if (child.__ref) {
+ const pathId = child.__ref;
+ const [refType, ...refKeyParts] = pathId.split(':');
+ const refKey = refKeyParts.join(':');
+
+ if (
+ !resultObj[pathId] &&
+ !lookupTable.includes(pathId) &&
+ tableNames.has(refType.toLowerCase())
+ ) {
+ lookupTable.push(pathId);
+ const selectedEntity = await db.table(refType.toLowerCase()).get(refKey);
+ if (selectedEntity) {
+ await parseObjectsForRef(selectedEntity);
+ resultObj[pathId] = selectedEntity;
+ }
+ }
+ } else if (typeof child === 'object') {
+ await parseObjectsForRef(child);
+ }
+ });
+
+ return Promise.all(ops);
+ };
+
+ await parseObjectsForRef(resultObj.ROOT_QUERY);
+ }
+
+ return resultObj;
+ }
+
+ async setItem(key, value) {
+ await this.#setQueryResults(key, JSON.parse(value));
+ }
+
+ async removeItem() {
+ // apollo-cache-persist only ever calls this when we're removing everything, so let's blow it all away
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113745#note_1329175993
+
+ await Promise.all(
+ db.tables.map((table) => {
+ return table.clear();
+ }),
+ );
+ }
+
+ async #setQueryResults(queryId, results) {
+ await Promise.all(
+ Object.keys(results).map((id) => {
+ const objectType = id.split(':')[0];
+ if (objectType === 'ROOT_QUERY') {
+ return db.table('queries').put(results[id], queryId);
+ }
+ const key = objectType.toLowerCase();
+ const tableExists = db.tables.some((table) => table.name === key);
+ if (tableExists) {
+ return db.table(key).put(results[id], id);
+ }
+ return new Promise((resolve) => {
+ resolve();
+ });
+ }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/lib/apollo/local_db.js b/app/assets/javascripts/lib/apollo/local_db.js
new file mode 100644
index 00000000000..cda30ff9d42
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/local_db.js
@@ -0,0 +1,14 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import Dexie from 'dexie';
+
+export const db = new Dexie('GLLocalCache');
+db.version(1).stores({
+ pages: 'url, timestamp',
+ queries: '',
+ project: 'id',
+ group: 'id',
+ usercore: 'id',
+ issue: 'id, state, title',
+ label: 'id, title',
+ milestone: 'id',
+});
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index c0e923b2670..a4c13f9e40e 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,7 +1,7 @@
import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core';
import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createUploadLink } from 'apollo-upload-client';
-import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist';
+import { persistCache } from 'apollo3-cache-persist';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import possibleTypes from '~/graphql_shared/possible_types.json';
@@ -53,6 +53,15 @@ export const typePolicies = {
TreeEntry: {
keyFields: ['webPath'],
},
+ Subscription: {
+ fields: {
+ aiCompletionResponse: {
+ read(value) {
+ return value ?? null;
+ },
+ },
+ },
+ },
};
export const stripWhitespaceFromQuery = (url, path) => {
@@ -104,7 +113,7 @@ Object.defineProperty(window, 'pendingApolloRequests', {
},
});
-export default (resolvers = {}, config = {}) => {
+function createApolloClient(resolvers = {}, config = {}) {
const {
baseUrl,
batchMax = 10,
@@ -113,8 +122,10 @@ export default (resolvers = {}, config = {}) => {
typeDefs,
path = '/api/graphql',
useGet = false,
- localCacheKey = null,
} = config;
+
+ const shouldUnbatch = gon.features?.unbatchGraphqlQueries;
+
let ac = null;
let uri = `${gon.relative_url_root || ''}${path}`;
@@ -152,7 +163,7 @@ export default (resolvers = {}, config = {}) => {
};
const requestLink = ApolloLink.split(
- () => useGet,
+ () => useGet || shouldUnbatch,
new HttpLink({ ...httpOptions, fetch: fetchIntervention }),
new BatchHttpLink(httpOptions),
);
@@ -171,6 +182,7 @@ export default (resolvers = {}, config = {}) => {
config: {
url: httpResponse.url,
operationName: operation.operationName,
+ method: operation.getContext()?.fetchOptions?.method || 'POST', // If method is not explicitly set, we default to POST request
},
headers: {
'x-request-id': httpResponse.headers.get('x-request-id'),
@@ -237,16 +249,6 @@ export default (resolvers = {}, config = {}) => {
},
});
- if (localCacheKey) {
- persistCacheSync({
- cache: newCache,
- // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode
- debug: process.env.NODE_ENV === 'development',
- storage: new LocalStorageWrapper(window.localStorage),
- persistenceMapper,
- });
- }
-
ac = new ApolloClient({
typeDefs,
link: appLink,
@@ -262,5 +264,42 @@ export default (resolvers = {}, config = {}) => {
acs.push(ac);
- return ac;
+ return { client: ac, cache: newCache };
+}
+
+export async function createApolloClientWithCaching(resolvers = {}, config = {}) {
+ const { localCacheKey = null } = config;
+ const { client, cache } = createApolloClient(resolvers, config);
+
+ if (localCacheKey) {
+ let storage;
+
+ // Test that we can use IndexedDB. If not, no persisting for you!
+ try {
+ const { IndexedDBPersistentStorage } = await import(
+ /* webpackChunkName: 'indexed_db_persistent_storage' */ './apollo/indexed_db_persistent_storage'
+ );
+
+ storage = await IndexedDBPersistentStorage.create();
+ } catch (error) {
+ return client;
+ }
+
+ await persistCache({
+ cache,
+ // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode
+ debug: process.env.NODE_ENV === 'development',
+ storage,
+ key: localCacheKey,
+ persistenceMapper,
+ });
+ }
+
+ return client;
+}
+
+export default (resolvers = {}, config = {}) => {
+ const { client } = createApolloClient(resolvers, config);
+
+ return client;
};
diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js
index c72561ce69d..bbc1d8ae1e1 100644
--- a/app/assets/javascripts/lib/mermaid.js
+++ b/app/assets/javascripts/lib/mermaid.js
@@ -6,17 +6,20 @@ const setIframeRenderedSize = (h, w) => {
window.parent.postMessage({ h, w }, origin);
};
-const drawDiagram = (source) => {
+const drawDiagram = async (source) => {
const element = document.getElementById('app');
const insertSvg = (svgCode) => {
// eslint-disable-next-line no-unsanitized/property
element.innerHTML = svgCode;
- const height = parseInt(element.firstElementChild.getAttribute('height'), 10);
- const width = parseInt(element.firstElementChild.style.maxWidth, 10);
+ element.firstElementChild.removeAttribute('height');
+ const { height, width } = element.firstElementChild.getBoundingClientRect();
+
setIframeRenderedSize(height, width);
};
- mermaid.mermaidAPI.render('mermaid', source, insertSvg);
+
+ const { svg } = await mermaid.mermaidAPI.render('mermaid', source);
+ insertSvg(svg);
};
const darkModeEnabled = () => getParameterByName('darkMode') === 'true';
diff --git a/app/assets/javascripts/lib/mousetrap.js b/app/assets/javascripts/lib/mousetrap.js
new file mode 100644
index 00000000000..ef3f54ec314
--- /dev/null
+++ b/app/assets/javascripts/lib/mousetrap.js
@@ -0,0 +1,59 @@
+// This is the only file allowed to import directly from the package.
+// eslint-disable-next-line no-restricted-imports
+import Mousetrap from 'mousetrap';
+
+const additionalStopCallbacks = [];
+const originalStopCallback = Mousetrap.prototype.stopCallback;
+
+Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
+ for (const callback of additionalStopCallbacks) {
+ const returnValue = callback.call(this, e, element, combo);
+ if (returnValue !== undefined) return returnValue;
+ }
+
+ return originalStopCallback.call(this, e, element, combo);
+};
+
+/**
+ * Add a stop callback to Mousetrap.
+ *
+ * This allows overriding the default behaviour of Mousetrap#stopCallback,
+ * which is to stop the bound key handler/callback from being called if the key
+ * combo is pressed inside form fields (input, select, textareas, etc). See
+ * https://craig.is/killing/mice#api.stopCallback.
+ *
+ * The stopCallback registered here has the same signature as
+ * Mousetrap#stopCallback, with the one difference being that the callback
+ * should return `undefined` if it has no opinion on whether the current key
+ * combo should be stopped or not, and the next stop callback should be
+ * consulted instead. If a boolean is returned, no other stop callbacks are
+ * called.
+ *
+ * Note: This approach does not always work as expected when coupled with
+ * Mousetrap's pause plugin, which is used for enabling/disabling all keyboard
+ * shortcuts. That plugin assumes it's the first to execute and overwrite
+ * Mousetrap's `stopCallback` method, whereas to work correctly with this, it
+ * must execute last. This is not guaranteed or even attempted.
+ *
+ * To work correctly, we may need to reimplement the pause plugin here.
+ *
+ * @param {(e: Event, element: Element, combo: string) => boolean|undefined}
+ * stopCallback The additional stop callback function to add to the chain
+ * of stop callbacks.
+ * @returns {void}
+ */
+export const addStopCallback = (stopCallback) => {
+ // Unshift, since we want to iterate through them in reverse order, so that
+ // the most recently added handler is called first, and the original
+ // stopCallback method is called last.
+ additionalStopCallbacks.unshift(stopCallback);
+};
+
+/**
+ * Clear additionalStopCallbacks. Used only for tests.
+ */
+export const clearStopCallbacksForTests = () => {
+ additionalStopCallbacks.length = 0;
+};
+
+export { Mousetrap };
diff --git a/app/assets/javascripts/lib/swagger.js b/app/assets/javascripts/lib/swagger.js
index ed646176604..fcdab18c623 100644
--- a/app/assets/javascripts/lib/swagger.js
+++ b/app/assets/javascripts/lib/swagger.js
@@ -1,6 +1,13 @@
import { SwaggerUIBundle } from 'swagger-ui-dist';
import { safeLoad } from 'js-yaml';
import { isObject } from '~/lib/utils/type_utility';
+import { getParameterByName } from '~/lib/utils/url_utility';
+import { resetServiceWorkersPublicPath } from '~/lib/utils/webpack';
+
+const resetWebpackPublicPath = () => {
+ window.gon = { relative_url_root: getParameterByName('relativeRootPath') };
+ resetServiceWorkersPublicPath();
+};
const renderSwaggerUI = (value) => {
/* SwaggerUIBundle accepts openapi definition
@@ -12,6 +19,8 @@ const renderSwaggerUI = (value) => {
spec = safeLoad(spec, { json: true });
}
+ resetWebpackPublicPath();
+
Promise.all([import(/* webpackChunkName: 'openapi' */ 'swagger-ui-dist/swagger-ui.css')])
.then(() => {
SwaggerUIBundle({
diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js
index 7da3bab0a4b..520d7f627f6 100644
--- a/app/assets/javascripts/lib/utils/chart_utils.js
+++ b/app/assets/javascripts/lib/utils/chart_utils.js
@@ -1,3 +1,6 @@
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import { __ } from '~/locale';
+
const commonTooltips = () => ({
mode: 'x',
intersect: false,
@@ -98,3 +101,38 @@ export const firstAndLastY = (data) => {
return [firstY, lastY];
};
+
+const toolboxIconSvgPath = async (name) => {
+ return `path://${await getSvgIconPathContent(name)}`;
+};
+
+export const getToolboxOptions = async () => {
+ const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath);
+
+ try {
+ const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises);
+
+ return {
+ toolbox: {
+ feature: {
+ dataZoom: {
+ icon: { zoom: marqueeSelectionPath, back: redoPath },
+ },
+ restore: {
+ icon: repeatPath,
+ },
+ saveAsImage: {
+ icon: downloadPath,
+ },
+ },
+ },
+ };
+ } catch (e) {
+ if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line no-console
+ console.warn(__('SVG could not be rendered correctly: '), e);
+ }
+
+ return {};
+ }
+};
diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
index 3d8df4fde05..a9f4257e28b 100644
--- a/app/assets/javascripts/lib/utils/color_utils.js
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -8,7 +8,7 @@ const colorValidatorEl = document.createElement('div');
* element’s color property. If the color expression is valid,
* the DOM API will accept the value.
*
- * @param {String} color color expression rgba, hex, hsla, etc.
+ * @param {String} colorExpression color expression rgba, hex, hsla, etc.
*/
export const isValidColorExpression = (colorExpression) => {
colorValidatorEl.style.color = '';
@@ -18,32 +18,6 @@ export const isValidColorExpression = (colorExpression) => {
};
/**
- * Convert hex color to rgb array
- *
- * @param hex string
- * @returns array|null
- */
-export const hexToRgb = (hex) => {
- // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
- const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
- const fullHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b);
-
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(fullHex);
- return result
- ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
- : null;
-};
-
-export const textColorForBackground = (backgroundColor) => {
- const [r, g, b] = hexToRgb(backgroundColor);
-
- if (r + g + b > 500) {
- return '#333333';
- }
- return '#FFFFFF';
-};
-
-/**
* Check whether a color matches the expected hex format
*
* This matches any hex (0-9 and A-F) value which is either 3 or 6 characters in length
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js
index 3bfbfea7f22..a6081303bf8 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js
@@ -12,6 +12,7 @@ export function confirmAction(
modalHtmlMessage,
title,
hideCancel,
+ size,
} = {},
) {
return new Promise((resolve) => {
@@ -36,6 +37,7 @@ export function confirmAction(
title,
modalHtmlMessage,
hideCancel,
+ size,
},
on: {
confirmed() {
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
index ea91ccec546..24be1485379 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue
@@ -56,6 +56,11 @@ export default {
required: false,
default: false,
},
+ size: {
+ type: String,
+ required: false,
+ default: 'sm',
+ },
},
computed: {
primaryAction() {
@@ -103,9 +108,9 @@ export default {
<template>
<gl-modal
ref="modal"
- size="sm"
modal-id="confirmationModal"
body-class="gl-display-flex"
+ :size="size"
:title="title"
:action-primary="primaryAction"
:action-cancel="cancelAction"
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/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js
index e4f68dd1b6c..87cc69bad61 100644
--- a/app/assets/javascripts/lib/utils/css_utils.js
+++ b/app/assets/javascripts/lib/utils/css_utils.js
@@ -23,3 +23,28 @@ export function loadCSSFile(path) {
export function getCssVariable(variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}
+
+/**
+ * Return the measured width and height of a temporary element with the given
+ * CSS classes.
+ *
+ * Multiple classes can be given by separating them with spaces.
+ *
+ * Since this forces a layout calculation, do not call this frequently or in
+ * loops.
+ *
+ * Finally, this assumes the styles for the given classes are loaded.
+ *
+ * @param {string} className CSS class(es) to apply to a temporary element and
+ * measure.
+ * @returns {{ width: number, height: number }} Measured width and height in
+ * CSS pixels.
+ */
+export function getCssClassDimensions(className) {
+ const el = document.createElement('div');
+ el.className = className;
+ document.body.appendChild(el);
+ const { width, height } = el.getBoundingClientRect();
+ el.remove();
+ return { width, height };
+}
diff --git a/app/assets/javascripts/lib/utils/datetime/constants.js b/app/assets/javascripts/lib/utils/datetime/constants.js
new file mode 100644
index 00000000000..869ade45ebd
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/constants.js
@@ -0,0 +1,7 @@
+// Keys for the memoized Intl dateTime formatters
+export const DATE_WITH_TIME_FORMAT = 'DATE_WITH_TIME_FORMAT';
+export const DATE_ONLY_FORMAT = 'DATE_ONLY_FORMAT';
+
+export const DEFAULT_DATE_TIME_FORMAT = DATE_WITH_TIME_FORMAT;
+
+export const DATE_TIME_FORMATS = [DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT];
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 04a82836f69..e1a57bf4589 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -162,16 +162,24 @@ export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = fals
* @returns {string}
*/
export const formatTime = (milliseconds) => {
- const remainingSeconds = Math.floor(milliseconds / 1000) % 60;
- const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60;
- const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60);
+ const seconds = Math.round(milliseconds / 1000);
+ const absSeconds = Math.abs(seconds);
+
+ const remainingSeconds = Math.floor(absSeconds) % 60;
+ const remainingMinutes = Math.floor(absSeconds / 60) % 60;
+ const hours = Math.floor(absSeconds / 60 / 60);
+
let formattedTime = '';
- if (remainingHours < 10) formattedTime += '0';
- formattedTime += `${remainingHours}:`;
+ if (hours < 10) formattedTime += '0';
+ formattedTime += `${hours}:`;
if (remainingMinutes < 10) formattedTime += '0';
formattedTime += `${remainingMinutes}:`;
if (remainingSeconds < 10) formattedTime += '0';
formattedTime += remainingSeconds;
+
+ if (seconds < 0) {
+ return `-${formattedTime}`;
+ }
return formattedTime;
};
@@ -203,7 +211,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
const isNonZero = Boolean(unitValue);
if (fullNameFormat && isNonZero) {
- // Remove traling 's' if unit value is singular
+ // Remove trailing 's' if unit value is singular
const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, '');
return `${memo} ${unitValue} ${formattedUnitName}`;
}
@@ -387,26 +395,6 @@ export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, mont
return '-';
};
-export const durationTimeFormatted = (duration) => {
- const date = new Date(duration * 1000);
-
- let hh = date.getUTCHours();
- let mm = date.getUTCMinutes();
- let ss = date.getSeconds();
-
- if (hh < 10) {
- hh = `0${hh}`;
- }
- if (mm < 10) {
- mm = `0${mm}`;
- }
- if (ss < 10) {
- ss = `0${ss}`;
- }
-
- return `${hh}:${mm}:${ss}`;
-};
-
/**
* Converts a numeric utc offset in seconds to +/- hours
* ie -32400 => -9 hours
diff --git a/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
new file mode 100644
index 00000000000..64c77bf1080
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js
@@ -0,0 +1,13 @@
+import { stringifyTime, parseSeconds } from './date_format_utility';
+
+/**
+ * Formats seconds into a human readable value of elapsed time,
+ * optionally limiting it to hours.
+ * @param {Number} seconds Seconds to format
+ * @param {Boolean} limitToHours Whether or not to limit the elapsed time to be expressed in hours
+ * @return {String} Provided seconds in human readable elapsed time format
+ */
+export const formatTimeSpent = (seconds, limitToHours) => {
+ const negative = seconds < 0;
+ return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds, { limitToHours }));
+};
diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
index 05f34db662a..a973cd890ba 100644
--- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js
@@ -1,6 +1,7 @@
import * as timeago from 'timeago.js';
import { languageCode, s__, createDateTimeFormat } from '~/locale';
import { formatDate } from './date_format_utility';
+import { DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT, DEFAULT_DATE_TIME_FORMAT } from './constants';
/**
* Timeago uses underscores instead of dashes to separate language from country code.
@@ -106,26 +107,39 @@ timeago.register(timeagoLanguageCode, memoizedLocale());
timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration());
-let memoizedFormatter = null;
+const setupAbsoluteFormatters = () => {
+ const cache = {};
-function setupAbsoluteFormatter() {
- if (memoizedFormatter === null) {
- const formatter = createDateTimeFormat({
- dateStyle: 'medium',
- timeStyle: 'short',
- });
+ // Intl.DateTimeFormat options (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options)
+ const formats = {
+ [DATE_WITH_TIME_FORMAT]: () => ({ dateStyle: 'medium', timeStyle: 'short' }),
+ [DATE_ONLY_FORMAT]: () => ({ dateStyle: 'medium' }),
+ };
+
+ return (formatName = DEFAULT_DATE_TIME_FORMAT) => {
+ if (cache[formatName]) {
+ return cache[formatName];
+ }
+
+ let format = formats[formatName] && formats[formatName]();
+ if (!format) {
+ format = formats[DEFAULT_DATE_TIME_FORMAT]();
+ }
+
+ const formatter = createDateTimeFormat(format);
- memoizedFormatter = {
+ cache[formatName] = {
format(date) {
return formatter.format(date instanceof Date ? date : new Date(date));
},
};
- }
- return memoizedFormatter;
-}
+ return cache[formatName];
+ };
+};
+const memoizedFormatters = setupAbsoluteFormatters();
-export const getTimeago = () =>
- window.gon?.time_display_relative === false ? setupAbsoluteFormatter() : timeago;
+export const getTimeago = (formatName) =>
+ window.gon?.time_display_relative === false ? memoizedFormatters(formatName) : timeago;
/**
* For the given elements, sets a tooltip with a formatted date.
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index c1081239544..a6331bc6551 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,4 +1,6 @@
+export * from './datetime/constants';
export * from './datetime/timeago_utility';
export * from './datetime/date_format_utility';
export * from './datetime/date_calculation_utility';
export * from './datetime/pikaday_utility';
+export * from './datetime/time_spent_utility';
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 317c401e404..198f2da385c 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -39,7 +39,7 @@ export const toggleContainerClasses = (containerEl, classList) => {
* Return a object mapping element dataset names to booleans.
*
* This is useful for data- attributes whose presense represent
- * a truthiness, no matter the value of the attribute. The absense of the
+ * a truthiness, no matter the value of the attribute. The absence of the
* attribute represents falsiness.
*
* This can be useful when Rails-provided boolean-like values are passed
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..febf83a4d38
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/error_message.js
@@ -0,0 +1,15 @@
+/**
+ * Utility to parse an error object returned from API.
+ *
+ *
+ * @param { Object } error - An error object directly from API response
+ * @param { string } error.message - The error message, returned from API.
+ * @param { string } defaultMessage - Default user-facing error message
+ * @returns { string } - A transformed user-facing error message, or defaultMessage
+ */
+export const parseErrorMessage = (error = {}, defaultMessage = '') => {
+ const messageString = error.message || '';
+ return messageString.startsWith(window.gon.uf_error_prefix)
+ ? messageString.replace(window.gon.uf_error_prefix, '').trim()
+ : defaultMessage;
+};
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/keys.js b/app/assets/javascripts/lib/utils/keys.js
index bd47f10b3ac..7cfcd11ece9 100644
--- a/app/assets/javascripts/lib/utils/keys.js
+++ b/app/assets/javascripts/lib/utils/keys.js
@@ -1,3 +1,7 @@
export const ESC_KEY = 'Escape';
export const ENTER_KEY = 'Enter';
export const BACKSPACE_KEY = 'Backspace';
+export const ARROW_DOWN_KEY = 'ArrowDown';
+export const ARROW_UP_KEY = 'ArrowUp';
+export const END_KEY = 'End';
+export const HOME_KEY = 'Home';
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/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js
new file mode 100644
index 00000000000..2807911c9bb
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/secret_detection.js
@@ -0,0 +1,45 @@
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { s__, __ } from '~/locale';
+
+export const i18n = {
+ defaultPrompt: s__(
+ 'SecretDetection|This comment appears to have a token in it. Are you sure you want to add it?',
+ ),
+ descriptionPrompt: s__(
+ 'SecretDetection|This description appears to have a token in it. Are you sure you want to add it?',
+ ),
+ primaryBtnText: __('Proceed'),
+};
+
+const sensitiveDataPatterns = [
+ {
+ name: 'GitLab Personal Access Token',
+ regex: 'glpat-[0-9a-zA-Z_-]{20}',
+ },
+ {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Feed Token',
+ regex: 'feed_token=[0-9a-zA-Z_-]{20}',
+ },
+];
+
+export const containsSensitiveToken = (message) => {
+ for (const rule of sensitiveDataPatterns) {
+ const regex = new RegExp(rule.regex, 'gi');
+ if (regex.test(message)) {
+ return true;
+ }
+ }
+ return false;
+};
+
+export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) {
+ const confirmed = await confirmAction(prompt, {
+ primaryBtnVariant: 'danger',
+ primaryBtnText: i18n.primaryBtnText,
+ });
+ if (!confirmed) {
+ return false;
+ }
+ return true;
+}
diff --git a/app/assets/javascripts/lib/utils/sticky.js b/app/assets/javascripts/lib/utils/sticky.js
deleted file mode 100644
index a6d53358cb8..00000000000
--- a/app/assets/javascripts/lib/utils/sticky.js
+++ /dev/null
@@ -1,60 +0,0 @@
-export const createPlaceholder = () => {
- const placeholder = document.createElement('div');
- placeholder.classList.add('sticky-placeholder');
-
- return placeholder;
-};
-
-export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => {
- const top = Math.floor(el.offsetTop - scrollY);
-
- if (top <= stickyTop && !el.classList.contains('is-stuck')) {
- const placeholder = insertPlaceholder ? createPlaceholder() : null;
- const heightBefore = el.offsetHeight;
-
- el.classList.add('is-stuck');
-
- if (insertPlaceholder) {
- el.parentNode.insertBefore(placeholder, el.nextElementSibling);
-
- placeholder.style.height = `${heightBefore - el.offsetHeight}px`;
- }
- } else if (top > stickyTop && el.classList.contains('is-stuck')) {
- el.classList.remove('is-stuck');
-
- if (
- insertPlaceholder &&
- el.nextElementSibling &&
- el.nextElementSibling.classList.contains('sticky-placeholder')
- ) {
- el.nextElementSibling.remove();
- }
- }
-};
-
-/**
- * Create a listener that will toggle a 'is-stuck' class, based on the current scroll position.
- *
- * - If the current environment does not support `position: sticky`, do nothing.
- *
- * @param {HTMLElement} el The `position: sticky` element.
- * @param {Number} stickyTop Used to determine when an element is stuck.
- * @param {Boolean} insertPlaceholder Should a placeholder element be created when element is stuck?
- */
-export const stickyMonitor = (el, stickyTop, insertPlaceholder = true) => {
- if (!el) return;
-
- if (
- typeof CSS === 'undefined' ||
- !CSS.supports('(position: -webkit-sticky) or (position: sticky)')
- )
- return;
-
- document.addEventListener(
- 'scroll',
- () => isSticky(el, window.scrollY, stickyTop, insertPlaceholder),
- {
- passive: true,
- },
- );
-};
diff --git a/app/assets/javascripts/lib/utils/tappable_promise.js b/app/assets/javascripts/lib/utils/tappable_promise.js
new file mode 100644
index 00000000000..8d327dabe1b
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/tappable_promise.js
@@ -0,0 +1,49 @@
+/**
+ * A promise that is also tappable, i.e. something you can subscribe
+ * to to get progress of a promise until it resolves.
+ *
+ * @example Usage
+ * const tp = new TappablePromise((resolve, reject, tap) => {
+ * for (let i = 0; i < 10; i++) {
+ * tap(i/10);
+ * }
+ * resolve();
+ * });
+ *
+ * tp.tap((progress) => {
+ * console.log(progress);
+ * }).then(() => {
+ * console.log('done');
+ * });
+ *
+ * // Output:
+ * // 0
+ * // 0.1
+ * // 0.2
+ * // ...
+ * // 0.9
+ * // done
+ *
+ *
+ * @param {(resolve: Function, reject: Function, tap: Function) => void} callback
+ * @returns {Promise & { tap: Function }}}
+ */
+export default function TappablePromise(callback) {
+ let progressCallback;
+
+ const promise = new Promise((resolve, reject) => {
+ try {
+ const tap = (progress) => progressCallback?.(progress);
+ resolve(callback(tap, resolve, reject));
+ } catch (e) {
+ reject(e);
+ }
+ });
+
+ promise.tap = function tap(_progressCallback) {
+ progressCallback = _progressCallback;
+ return this;
+ };
+
+ return promise;
+}
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 05ed08931bb..a2873622682 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 = ' ';
@@ -370,7 +371,7 @@ export function insertMarkdownText({
});
}
-function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
+export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
const $textArea = $(textArea);
textArea = $textArea.get(0);
const text = $textArea.val();
@@ -625,10 +626,9 @@ 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 $allToolbarBtns = $(form)
+ .off('click', '.js-md')
+ .on('click', '.js-md', function () {
const $toolbarBtn = $(this);
return updateTextForToolbarBtn($toolbarBtn);
@@ -669,3 +669,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..963041dd5d0 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,
@@ -482,7 +483,7 @@ export const markdownConfig = {
'ul',
'var',
],
- ALLOWED_ATTR: ['class', 'style', 'href', 'src'],
+ ALLOWED_ATTR: ['class', 'style', 'href', 'src', 'dir'],
ALLOW_DATA_ATTR: false,
};
@@ -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/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index f33484f4192..f16ff188edb 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -5,8 +5,11 @@ const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
const SHA_REGEX = /[\da-f]{40}/gi;
+// GitLab default domain (override in jh)
+export const DOMAIN = 'gitlab.com';
+
// About GitLab default host (overwrite in jh)
-export const PROMO_HOST = 'about.gitlab.com';
+export const PROMO_HOST = `about.${DOMAIN}`; // about.gitlab.com
// About Gitlab default url (overwrite in jh)
export const PROMO_URL = `https://${PROMO_HOST}`;
@@ -269,6 +272,11 @@ export const setUrlFragment = (url, fragment) => {
return `${rootUrl}#${encodedFragment}`;
};
+/**
+ * Navigates to a URL
+ * @param {*} url - url to navigate to
+ * @param {*} external - if true, open a new page or tab
+ */
export function visitUrl(url, external = false) {
if (external) {
// Simulate `target="_blank" rel="noopener noreferrer"`
@@ -281,6 +289,19 @@ export function visitUrl(url, external = false) {
}
}
+export function refreshCurrentPage() {
+ visitUrl(window.location.href);
+}
+
+/**
+ * Navigates to a URL
+ * @deprecated Use visitUrl from ~/lib/utils/url_utility.js instead
+ * @param {*} url
+ */
+export function redirectTo(url) {
+ return window.location.assign(url);
+}
+
export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) {
if (win.history) {
if (replace) {
@@ -291,14 +312,6 @@ export function updateHistory({ state = {}, title = '', url, replace = false, wi
}
}
-export function refreshCurrentPage() {
- visitUrl(window.location.href);
-}
-
-export function redirectTo(url) {
- return window.location.assign(url);
-}
-
export const escapeFileUrl = (fileUrl) => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
export function webIDEUrl(route = undefined) {
@@ -318,15 +331,6 @@ export function getBaseURL() {
}
/**
- * Takes a URL and returns content from the start until the final '/'
- *
- * @param {String} url - full url, including protocol and host
- */
-export function stripFinalUrlSegment(url) {
- return new URL('.', url).href;
-}
-
-/**
* Returns true if url is an absolute URL
*
* @param {String} url
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/get_instance_from_directive.js b/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js
new file mode 100644
index 00000000000..b69f5e0c546
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js
@@ -0,0 +1,9 @@
+// See https://v3-migration.vuejs.org/breaking-changes/custom-directives.html#edge-case-accessing-the-component-instance
+export function getInstanceFromDirective({ binding, vnode }) {
+ if (binding.instance) {
+ // this is Vue.js 3, even in compat mode
+ return binding.instance;
+ }
+
+ return vnode.context;
+}
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/lib/utils/vue3compat/normalize_children.js b/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js
new file mode 100644
index 00000000000..616bd4786a9
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js
@@ -0,0 +1,11 @@
+export function normalizeChildren(children) {
+ if (typeof children !== 'object' || Array.isArray(children)) {
+ return children;
+ }
+
+ if (typeof children.default === 'function') {
+ return children.default();
+ }
+
+ return children;
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js
new file mode 100644
index 00000000000..fd08d34a80e
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js
@@ -0,0 +1,78 @@
+import Vue from 'vue';
+import { createApolloProvider } from '@vue/apollo-option';
+import { ApolloMutation } from '@vue/apollo-components';
+
+export { ApolloMutation };
+
+const installed = new WeakMap();
+
+function callLifecycle(hookName, ...extraArgs) {
+ const { GITLAB_INTERNAL_ADDED_MIXINS: addedMixins } = this.$;
+ if (!addedMixins) {
+ return [];
+ }
+
+ return addedMixins.map((m) => m[hookName]?.apply(this, extraArgs));
+}
+
+function createMixinForLateInit({ install, shouldInstall }) {
+ return {
+ created() {
+ callLifecycle.call(this, 'created');
+ },
+ // @vue/compat normalizez lifecycle hook names so there is no error here
+ destroyed() {
+ callLifecycle.call(this, 'unmounted');
+ },
+
+ data(...args) {
+ const extraData = callLifecycle.call(this, 'data', ...args);
+ if (!extraData.length) {
+ return {};
+ }
+
+ return Object.assign({}, ...extraData);
+ },
+
+ beforeCreate() {
+ if (shouldInstall(this)) {
+ const { mixins } = this.$.appContext;
+ const globalMixinsBeforeInit = new Set(mixins);
+ install(this);
+
+ this.$.GITLAB_INTERNAL_ADDED_MIXINS = mixins.filter((m) => !globalMixinsBeforeInit.has(m));
+
+ callLifecycle.call(this, 'beforeCreate');
+ }
+ },
+ };
+}
+
+export default class VueCompatApollo {
+ constructor(...args) {
+ // eslint-disable-next-line no-constructor-return
+ return createApolloProvider(...args);
+ }
+
+ static install() {
+ Vue.mixin(
+ createMixinForLateInit({
+ shouldInstall: (vm) =>
+ vm.$options.apolloProvider &&
+ !installed.get(vm.$.appContext.app)?.has(vm.$options.apolloProvider),
+ install: (vm) => {
+ const { app } = vm.$.appContext;
+ const { apolloProvider } = vm.$options;
+
+ if (!installed.has(app)) {
+ installed.set(app, new WeakSet());
+ }
+
+ installed.get(app).add(apolloProvider);
+
+ vm.$.appContext.app.use(vm.$options.apolloProvider);
+ },
+ }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
new file mode 100644
index 00000000000..aa2963ece31
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js
@@ -0,0 +1,115 @@
+import Vue from 'vue';
+import {
+ createRouter,
+ createMemoryHistory,
+ createWebHistory,
+ createWebHashHistory,
+} from 'vue-router-vue3';
+
+const mode = (value, options) => {
+ if (!value) return null;
+ let history;
+ // eslint-disable-next-line default-case
+ switch (value) {
+ case 'history':
+ history = createWebHistory(options.base);
+ break;
+ case 'hash':
+ history = createWebHashHistory();
+ break;
+ case 'abstract':
+ history = createMemoryHistory();
+ break;
+ }
+ return { history };
+};
+
+const base = () => null;
+
+const toNewCatchAllPath = (path) => {
+ if (path === '*') return '/:pathMatch(.*)*';
+ return path;
+};
+
+const routes = (value) => {
+ if (!value) return null;
+ const newRoutes = value.reduce(function handleRoutes(acc, route) {
+ const newRoute = {
+ ...route,
+ path: toNewCatchAllPath(route.path),
+ };
+ if (route.children) {
+ newRoute.children = route.children.reduce(handleRoutes, []);
+ }
+ acc.push(newRoute);
+ return acc;
+ }, []);
+ return { routes: newRoutes };
+};
+
+const scrollBehavior = (value) => {
+ return {
+ scrollBehavior(...args) {
+ const { x, y, left, top } = value(...args);
+ return { left: x || left, top: y || top };
+ },
+ };
+};
+
+const transformers = {
+ mode,
+ base,
+ routes,
+ scrollBehavior,
+};
+
+const transformOptions = (options = {}) => {
+ const defaultConfig = {
+ routes: [],
+ history: createWebHashHistory(),
+ };
+ return Object.keys(options).reduce((acc, key) => {
+ const value = options[key];
+ if (key in transformers) {
+ Object.assign(acc, transformers[key](value, options));
+ } else {
+ acc[key] = value;
+ }
+ return acc;
+ }, defaultConfig);
+};
+
+const installed = new WeakMap();
+
+export default class VueRouterCompat {
+ constructor(options) {
+ // eslint-disable-next-line no-constructor-return
+ return new Proxy(createRouter(transformOptions(options)), {
+ get(target, prop) {
+ const result = target[prop];
+ // eslint-disable-next-line no-underscore-dangle
+ if (result?.__v_isRef) {
+ return result.value;
+ }
+
+ return result;
+ },
+ });
+ }
+
+ static install() {
+ Vue.mixin({
+ beforeCreate() {
+ const { app } = this.$.appContext;
+ const { router } = this.$options;
+ if (router && !installed.get(app)?.has(router)) {
+ if (!installed.has(app)) {
+ installed.set(app, new WeakSet());
+ }
+ installed.get(app).add(router);
+ this.$.appContext.app.use(this.$options.router);
+ }
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/lib/utils/vue3compat/vuex.js b/app/assets/javascripts/lib/utils/vue3compat/vuex.js
new file mode 100644
index 00000000000..ff94ff3d04a
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vue3compat/vuex.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import {
+ createStore,
+ mapState,
+ mapGetters,
+ mapActions,
+ mapMutations,
+ createNamespacedHelpers,
+} from 'vuex-vue3';
+
+export { mapState, mapGetters, mapActions, mapMutations, createNamespacedHelpers };
+
+const installedStores = new WeakMap();
+
+export default {
+ Store: class VuexCompatStore {
+ constructor(...args) {
+ // eslint-disable-next-line no-constructor-return
+ return createStore(...args);
+ }
+ },
+
+ install() {
+ Vue.mixin({
+ beforeCreate() {
+ const { app } = this.$.appContext;
+ const { store } = this.$options;
+ if (store && !installedStores.get(app)?.has(store)) {
+ if (!installedStores.has(app)) {
+ installedStores.set(app, new WeakSet());
+ }
+ installedStores.get(app).add(store);
+ this.$.appContext.app.use(this.$options.store);
+ }
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/lib/utils/web_ide_navigator.js b/app/assets/javascripts/lib/utils/web_ide_navigator.js
new file mode 100644
index 00000000000..f0579b5886d
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/web_ide_navigator.js
@@ -0,0 +1,24 @@
+import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility';
+
+/**
+ * Takes a project path and optional file path and branch
+ * and then redirects the user to the web IDE.
+ *
+ * @param {string} projectPath - Full path to project including namespace (ex. flightjs/Flight)
+ * @param {string} filePath - optional path to file to be edited, otherwise will open at base directory (ex. README.md)
+ * @param {string} branch - optional branch to open the IDE, defaults to 'main'
+ */
+
+export const openWebIDE = (projectPath, filePath, branch = 'main') => {
+ if (!projectPath) {
+ throw new TypeError('projectPath parameter is required');
+ }
+
+ const pathnameSegments = [projectPath, 'edit', branch, '-'];
+
+ if (filePath) {
+ pathnameSegments.push(filePath);
+ }
+
+ visitUrl(webIDEUrl(`/${pathnameSegments.join('/')}/`));
+};
diff --git a/app/assets/javascripts/listbox/redirect_behavior.js b/app/assets/javascripts/listbox/redirect_behavior.js
index 38d9d84f889..28b0892d126 100644
--- a/app/assets/javascripts/listbox/redirect_behavior.js
+++ b/app/assets/javascripts/listbox/redirect_behavior.js
@@ -1,5 +1,5 @@
import { initListbox } from '~/listbox';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
/**
* Instantiates GlCollapsibleListbox components with redirect behavior for tags created
@@ -15,7 +15,7 @@ export function initRedirectListboxBehavior() {
return elements.map((el) =>
initListbox(el, {
onChange({ href }) {
- redirectTo(href);
+ redirectTo(href); // eslint-disable-line import/no-deprecated
},
}),
);
diff --git a/app/assets/javascripts/locale/ensure_single_line.cjs b/app/assets/javascripts/locale/ensure_single_line.cjs
index c2c63777001..f7790cadc48 100644
--- a/app/assets/javascripts/locale/ensure_single_line.cjs
+++ b/app/assets/javascripts/locale/ensure_single_line.cjs
@@ -1,5 +1,3 @@
-/* eslint-disable import/no-commonjs */
-
const SPLIT_REGEX = /\s*[\r\n]+\s*/;
/**
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..fd002e29afc 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -89,9 +89,11 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
- initTopNav();
+ if (!gon.use_new_navigation) {
+ initTopNav();
+ initTodoToggle();
+ }
initBreadcrumbs();
- initTodoToggle();
initPrefetchLinks('.js-prefetch-document');
initLogoAnimation();
initServicePingConsent();
@@ -104,14 +106,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/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index 76b286f94ad..8cdaa76e673 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -1,10 +1,11 @@
<script>
import { mapState } from 'vuex';
+import { __ } from '~/locale';
import {
getParameterByName,
setUrlParams,
queryToObject,
- redirectTo,
+ redirectTo, // eslint-disable-line import/no-deprecated
} from '~/lib/utils/url_utility';
import {
SORT_QUERY_PARAM_NAME,
@@ -46,6 +47,10 @@ export default {
return false;
}
+ if (token.type === 'user_type' && !gon.features?.serviceAccountsCrud) {
+ return false;
+ }
+
return this.filteredSearchBar.tokens?.includes(token.type);
});
},
@@ -94,6 +99,14 @@ export default {
};
}
} else {
+ // Remove this block after this issue is closed: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2159
+ if (value.data === __('Service account')) {
+ return {
+ ...accumulator,
+ [type]: 'service_account',
+ };
+ }
+
return {
...accumulator,
[type]: value.data,
@@ -106,6 +119,7 @@ export default {
const sortParamValue = getParameterByName(SORT_QUERY_PARAM_NAME);
const activeTabParamValue = getParameterByName(ACTIVE_TAB_QUERY_PARAM_NAME);
+ // eslint-disable-next-line import/no-deprecated
redirectTo(
setUrlParams(
{
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index e066b023fbb..a85bb09e17b 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -81,19 +81,18 @@ export default {
return;
}
- this.updateMemberRole({
- memberId: this.member.id,
- accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
- })
- .then(() => {
- this.$toast.show(s__('Members|Role updated successfully.'));
- })
- .catch((error) => {
- Sentry.captureException(error);
- })
- .finally(() => {
- this.busy = false;
+ try {
+ await this.updateMemberRole({
+ memberId: this.member.id,
+ accessLevel: { integerValue: newRoleValue, stringValue: newRoleName },
});
+
+ this.$toast.show(s__('Members|Role updated successfully.'));
+ } catch (error) {
+ Sentry.captureException(error);
+ } finally {
+ this.busy = false;
+ }
},
},
};
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/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index 20ee9a17fa0..af66600089f 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -33,7 +33,7 @@ export default {
i18n: {
commitStatSummary: __('Showing %{conflict}'),
resolveInfo: __(
- 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}',
+ 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}.',
),
},
computed: {
@@ -148,10 +148,10 @@ export default {
</gl-button>
</div>
</div>
- <div class="diff-content diff-wrap-lines">
+ <div class="diff-content diff-wrap-lines gl-rounded-bottom-base">
<div
v-if="file.resolveMode === 'interactive' && file.type === 'text'"
- class="file-content"
+ class="file-content gl-rounded-bottom-base"
>
<parallel-conflict-lines v-if="isParallel" :file="file" />
<inline-conflict-lines v-else :file="file" />
@@ -164,8 +164,7 @@ export default {
</div>
</div>
</div>
- <hr />
- <div class="resolve-conflicts-form">
+ <div class="resolve-conflicts-form gl-mt-6">
<div class="form-group row">
<div class="col-md-4">
<h4 class="gl-mt-0">
@@ -180,9 +179,7 @@ export default {
<code>{{ s__('MergeConflict|Use theirs') }}</code>
</template>
<template #branch_name>
- <a class="ref-name" :href="sourceBranchPath">
- {{ conflictsData.sourceBranch }}
- </a>
+ <a class="ref-name" :href="sourceBranchPath">{{ conflictsData.sourceBranch }}</a>
</template>
</gl-sprintf>
</div>
@@ -204,7 +201,7 @@ export default {
<gl-button
:disabled="!isReadyToCommit"
variant="confirm"
- class="js-submit-button"
+ class="js-submit-button gl-mr-2"
@click="submitResolvedConflicts(resolveConflictsPath)"
>
{{ getCommitButtonText }}
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..cef224d83e2 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,7 +1,9 @@
/* eslint-disable class-methods-use-this */
import $ from 'jquery';
import Vue from 'vue';
-import { createAlert } from '~/flash';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+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';
@@ -13,9 +15,15 @@ import axios from './lib/utils/axios_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
-import { __ } from './locale';
+import { __, s__ } from './locale';
import syntaxHighlight from './syntax_highlight';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
// MergeRequestTabs
//
// Handles persisting and restoring the current tab selection and lazily-loading
@@ -75,18 +83,6 @@ function scrollToContainer(container) {
}
}
-function computeTopOffset(tabs) {
- const navbar = document.querySelector('.navbar-gitlab');
- const peek = document.getElementById('js-peek');
- let stickyTop;
-
- stickyTop = navbar ? navbar.offsetHeight : 0;
- stickyTop = peek ? stickyTop + peek.offsetHeight : stickyTop;
- stickyTop = tabs ? stickyTop + tabs.offsetHeight : stickyTop;
-
- return stickyTop;
-}
-
function mountPipelines() {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
const { mrWidgetData } = gl;
@@ -94,10 +90,13 @@ function mountPipelines() {
components: {
CommitPipelinesTable: () => import('~/commit/pipelines/pipelines_table.vue'),
},
+ apolloProvider,
provide: {
artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
+ fullPath: pipelineTableViewEl.dataset.fullPath,
+ manualActionsLimit: 50,
},
render(createElement) {
return createElement('commit-pipelines-table', {
@@ -134,11 +133,11 @@ function destroyPipelines(app) {
return null;
}
-function loadDiffs({ url, sticky, tabs }) {
+function loadDiffs({ url, tabs }) {
return axios.get(url).then(({ data }) => {
const $container = $('#diffs');
$container.html(data.html);
- initDiffStatsDropdown(sticky);
+ initDiffStatsDropdown();
localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
@@ -177,12 +176,14 @@ function getActionFromHref(href) {
}
const pageBundles = {
- show: () => import(/* webpackPrefetch: true */ '~/mr_notes/init_notes'),
+ show: () => import(/* webpackPrefetch: true */ 'ee_else_ce/mr_notes/mount_app'),
diffs: () => import(/* webpackPrefetch: true */ '~/diffs'),
};
export default class MergeRequestTabs {
constructor({ action, setUrl, stubLocation } = {}) {
+ const containers = document.querySelectorAll('.content-wrapper .container-fluid');
+ this.contentWrapper = containers[containers.length - 1];
this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container');
this.mergeRequestTabsAll =
this.mergeRequestTabs && this.mergeRequestTabs.querySelectorAll
@@ -208,7 +209,7 @@ export default class MergeRequestTabs {
this.diffsLoaded = false;
this.diffsClass = null;
this.commitsLoaded = false;
- this.fixedLayoutPref = null;
+ this.isFixedLayoutPreferred = this.contentWrapper.classList.contains('container-limited');
this.eventHub = createEventHub();
this.loadedPages = { [action]: true };
@@ -250,10 +251,11 @@ export default class MergeRequestTabs {
}
recallScroll(action) {
const storedPosition = this.scrollPositions[action];
+ if (storedPosition == null) return;
setTimeout(() => {
window.scrollTo({
- top: storedPosition && storedPosition > 0 ? storedPosition : 0,
+ top: storedPosition > 0 ? storedPosition : 0,
left: 0,
behavior: 'auto',
});
@@ -306,7 +308,7 @@ export default class MergeRequestTabs {
const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`);
if (tab) tab.classList.add('active');
- if (!this.loadedPages[action] && action in pageBundles) {
+ if (isInVueNoteablePage() && !this.loadedPages[action] && action in pageBundles) {
toggleLoader(true);
pageBundles[action]()
.then(({ default: init }) => {
@@ -316,7 +318,7 @@ export default class MergeRequestTabs {
})
.catch(() => {
toggleLoader(false);
- createAlert({ message: __('MergeRequest|Failed to load the page') });
+ createAlert({ message: s__('MergeRequest|Failed to load the page') });
});
}
@@ -468,12 +470,14 @@ export default class MergeRequestTabs {
axios
.get(`${source}.json`, { params: { page, per_page: 100 } })
- .then(({ data }) => {
+ .then(({ data: { html, count, next_page: nextPage } }) => {
toggleLoader(false);
+ document.querySelector('.js-commits-count').textContent = count;
+
const commitsDiv = document.querySelector('div#commits');
// eslint-disable-next-line no-unsanitized/property
- commitsDiv.innerHTML += data.html;
+ commitsDiv.innerHTML += html;
localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
scrollToContainer('#commits');
@@ -489,7 +493,7 @@ export default class MergeRequestTabs {
});
}
- if (!data.next_page) {
+ if (!nextPage) {
return import('./add_context_commits_modal');
}
@@ -521,7 +525,6 @@ export default class MergeRequestTabs {
loadDiffs({
url: diffUrl,
- sticky: computeTopOffset(this.mergeRequestTabs),
tabs: this,
})
.then(() => {
@@ -561,22 +564,12 @@ export default class MergeRequestTabs {
return action === 'diffs' || action === 'new/diffs';
}
- expandViewContainer(removeLimited = true) {
- const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs');
- if (this.fixedLayoutPref === null) {
- this.fixedLayoutPref = $wrapper.hasClass('container-limited');
- }
- if (this.diffViewType() === 'parallel' || removeLimited) {
- $wrapper.removeClass('container-limited');
- } else {
- $wrapper.toggleClass('container-limited', this.fixedLayoutPref);
- }
+ expandViewContainer() {
+ this.contentWrapper.classList.remove('container-limited');
}
resetViewContainer() {
- if (this.fixedLayoutPref !== null) {
- $('.content-wrapper .container-fluid').toggleClass('container-limited', this.fixedLayoutPref);
- }
+ this.contentWrapper.classList.toggle('container-limited', this.isFixedLayoutPreferred);
}
// Expand the issuable sidebar unless the user explicitly collapsed it
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/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index 525094271d9..e63b9613257 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -10,8 +10,31 @@ import StatusBox from '~/issuable/components/status_box.vue';
import DiscussionCounter from '~/notes/components/discussion_counter.vue';
import TodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import titleSubscription from '../queries/title.subscription.graphql';
export default {
+ apollo: {
+ $subscribe: {
+ title: {
+ query() {
+ return titleSubscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return !this.issuableId || !this.glFeatures.realtimeMrStatusChange;
+ },
+ result({ data: { mergeRequestMergeStatusUpdated } }) {
+ if (mergeRequestMergeStatusUpdated) {
+ this.titleHtml = mergeRequestMergeStatusUpdated.titleHtml;
+ }
+ },
+ },
+ },
+ },
components: {
GlIntersectionObserver,
GlLink,
@@ -36,6 +59,7 @@ export default {
return {
isStickyHeaderVisible: false,
discussionCounter: 0,
+ titleHtml: this.title,
};
},
computed: {
@@ -82,17 +106,17 @@ export default {
@disappear="setStickyHeaderVisible(true)"
>
<div
- class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-pt-3 gl-display-none gl-md-display-block"
+ class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-display-none gl-md-display-flex gl-flex-direction-column gl-justify-content-end gl-border-b"
:class="{ 'gl-visibility-hidden': !isStickyHeaderVisible }"
>
<div
- class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5"
+ class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5 gl-w-full"
:class="{ 'gl-max-w-container-xl': !isFluidLayout }"
>
<div class="gl-w-full gl-display-flex gl-align-items-center">
<status-box :initial-state="getNoteableData.state" issuable-type="merge_request" />
<p
- v-safe-html:[$options.safeHtmlConfig]="title"
+ v-safe-html:[$options.safeHtmlConfig]="titleHtml"
class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4"
></p>
<div class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/merge_requests/queries/title.subscription.graphql b/app/assets/javascripts/merge_requests/queries/title.subscription.graphql
new file mode 100644
index 00000000000..4f04e944f7e
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/queries/title.subscription.graphql
@@ -0,0 +1,8 @@
+subscription getTitleSubscription($issuableId: IssuableID!) {
+ mergeRequestMergeStatusUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ titleHtml
+ }
+ }
+}
diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 4b3c1bd7d10..c13bf50eba7 100644
--- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
@@ -1,9 +1,9 @@
<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';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, n__, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
@@ -76,7 +76,7 @@ Once deleted, it cannot be undone or recovered.`),
});
// follow the rediect to milestones overview page
- redirectTo(response.request.responseURL);
+ redirectTo(response.request.responseURL); // eslint-disable-line import/no-deprecated
})
.catch((error) => {
eventHub.$emit('deleteMilestoneModal.requestFinished', {
@@ -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/index.js b/app/assets/javascripts/milestones/index.js
index f90fdb04923..8780d931588 100644
--- a/app/assets/javascripts/milestones/index.js
+++ b/app/assets/javascripts/milestones/index.js
@@ -4,6 +4,7 @@ import initDatePicker from '~/behaviors/date_picker';
import GLForm from '~/gl_form';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Milestone from '~/milestones/milestone';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
import Translate from '~/vue_shared/translate';
@@ -12,6 +13,9 @@ import DeleteMilestoneModal from './components/delete_milestone_modal.vue';
import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
import eventHub from './event_hub';
+// See app/views/shared/milestones/_description.html.haml
+export const MILESTONE_DESCRIPTION_ELEMENT = '.milestone-detail .description';
+
export function initForm(initGFM = true) {
new ZenMode(); // eslint-disable-line no-new
initDatePicker();
@@ -34,6 +38,8 @@ export function initShow() {
new Milestone(); // eslint-disable-line no-new
new Sidebar(); // eslint-disable-line no-new
new MountMilestoneSidebar(); // eslint-disable-line no-new
+
+ renderGFM(document.querySelector(MILESTONE_DESCRIPTION_ELEMENT));
}
export function initPromoteMilestoneModal() {
@@ -64,8 +70,6 @@ export function initDeleteMilestoneModal() {
if (!successful) {
button.removeAttribute('disabled');
}
-
- button.querySelector('.js-loading-icon').classList.add('hidden');
};
const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
@@ -75,7 +79,6 @@ export function initDeleteMilestoneModal() {
`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`,
);
button.setAttribute('disabled', '');
- button.querySelector('.js-loading-icon').classList.remove('hidden');
eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished);
};
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/components/delete_button.vue b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue
new file mode 100644
index 00000000000..c10b0cb52b9
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/delete_button.vue
@@ -0,0 +1,104 @@
+<script>
+import {
+ GlModal,
+ GlDisclosureDropdown,
+ GlTooltipDirective,
+ GlDisclosureDropdownItem,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+export default {
+ components: {
+ GlModal,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
+ props: {
+ deletePath: {
+ type: String,
+ required: true,
+ },
+ deleteConfirmationText: {
+ type: String,
+ required: true,
+ },
+ actionPrimaryText: {
+ type: String,
+ required: true,
+ },
+ modalTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleteModalVisible: false,
+ modal: {
+ id: 'ml-experiments-delete-modal',
+ deleteConfirmation: this.deleteConfirmationText,
+ actionPrimary: {
+ text: this.actionPrimaryText,
+ attributes: { variant: 'danger' },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ },
+ };
+ },
+ methods: {
+ confirmDelete() {
+ this.$refs.deleteForm.submit();
+ },
+ },
+ csrf,
+};
+</script>
+
+<template>
+ <div>
+ <gl-disclosure-dropdown
+ placement="right"
+ category="tertiary"
+ :aria-label="__('More actions')"
+ icon="ellipsis_v"
+ no-caret
+ >
+ <gl-disclosure-dropdown-item
+ v-gl-modal-directive="modal.id"
+ :aria-label="actionPrimaryText"
+ variant="danger"
+ >
+ <template #list-item>
+ <span class="gl-text-red-500">
+ {{ actionPrimaryText }}
+ </span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
+
+ <form ref="deleteForm" method="post" :action="deletePath">
+ <input type="hidden" name="_method" value="delete" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ </form>
+
+ <gl-modal
+ :modal-id="modal.id"
+ :title="modalTitle"
+ :action-primary="modal.actionPrimary"
+ :action-cancel="modal.actionCancel"
+ @primary="confirmDelete"
+ >
+ <p>
+ {{ deleteConfirmationText }}
+ </p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
deleted file mode 100644
index d0c42905ee2..00000000000
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
+++ /dev/null
@@ -1,115 +0,0 @@
-<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';
-
-export default {
- name: 'MlCandidate',
- components: {
- IncubationAlert,
- GlLink,
- },
- props: {
- candidate: {
- type: Object,
- required: true,
- },
- },
- i18n: {
- titleLabel: __('Model candidate details'),
- infoLabel: __('Info'),
- idLabel: __('ID'),
- statusLabel: __('Status'),
- experimentLabel: __('Experiment'),
- artifactsLabel: __('Artifacts'),
- parametersLabel: __('Parameters'),
- metricsLabel: __('Metrics'),
- metadataLabel: __('Metadata'),
- },
- computed: {
- sections() {
- return [
- {
- sectionName: this.$options.i18n.parametersLabel,
- sectionValues: this.candidate.params,
- },
- {
- sectionName: this.$options.i18n.metricsLabel,
- sectionValues: this.candidate.metrics,
- },
- {
- sectionName: this.$options.i18n.metadataLabel,
- sectionValues: this.candidate.metadata,
- },
- ];
- },
- },
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
-};
-</script>
-
-<template>
- <div>
- <incubation-alert
- :feature-name="$options.FEATURE_NAME"
- :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
- />
-
- <h3>
- {{ $options.i18n.titleLabel }}
- </h3>
-
- <table class="candidate-details">
- <tbody>
- <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>{{ candidate.info.iid }}</td>
- </tr>
-
- <tr>
- <td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.statusLabel }}</td>
- <td>{{ candidate.info.status }}</td>
- </tr>
-
- <tr>
- <td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.experimentLabel }}</td>
- <td>
- <gl-link :href="candidate.info.path_to_experiment">{{
- candidate.info.experiment_name
- }}</gl-link>
- </td>
- </tr>
-
- <tr v-if="candidate.info.path_to_artifact">
- <td></td>
- <td class="gl-font-weight-bold">{{ $options.i18n.artifactsLabel }}</td>
- <td>
- <gl-link :href="candidate.info.path_to_artifact">{{
- $options.i18n.artifactsLabel
- }}</gl-link>
- </td>
- </tr>
-
- <template v-for="{ sectionName, sectionValues } in sections">
- <tr :key="sectionName" class="divider"></tr>
-
- <tr v-for="(item, index) in sectionValues" :key="item.name">
- <td v-if="index === 0" class="gl-text-secondary gl-font-weight-bold">
- {{ sectionName }}
- </td>
- <td v-else></td>
- <td class="gl-font-weight-bold">{{ item.name }}</td>
- <td>{{ item.value }}</td>
- </tr>
- </template>
- </tbody>
- </table>
- </div>
-</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
deleted file mode 100644
index c09aabb0d40..00000000000
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
+++ /dev/null
@@ -1,200 +0,0 @@
-<script>
-import { GlTable, GlLink, GlTooltipDirective } 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,
-} 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';
-
-export default {
- name: 'MlExperiment',
- components: {
- GlTable,
- GlLink,
- TimeAgo,
- IncubationAlert,
- RegistrySearch,
- KeysetPagination,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- inject: ['candidates', 'metricNames', 'paramNames', 'pageInfo'],
- data() {
- const query = queryToObject(window.location.search);
-
- const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : [];
-
- let orderBy = query.orderBy || LIST_KEY_CREATED_AT;
-
- if (query.orderByType === 'metric') {
- orderBy = `${METRIC_KEY_PREFIX}${orderBy}`;
- }
-
- return {
- filters: filter,
- sorting: {
- orderBy,
- sort: (query.sort || 'desc').toLowerCase(),
- },
- };
- },
- computed: {
- fields() {
- 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 },
- ...this.paramNames,
- ...this.metricNames,
- { key: 'details', label: '' },
- { key: 'artifact', label: '' },
- ];
- },
- displayPagination() {
- return this.candidates.length > 0;
- },
- sortableFields() {
- return [
- ...BASE_SORT_FIELDS,
- ...this.metricNames.map((name) => ({
- orderBy: `${METRIC_KEY_PREFIX}${name}`,
- label: capitalizeFirstCharacter(name),
- })),
- ];
- },
- parsedQuery() {
- const name = this.filters
- .map((f) => f.value.data)
- .join(' ')
- .trim();
-
- const filterByQuery = name === '' ? {} : { name };
-
- let orderByType = 'column';
- let { orderBy } = this.sorting;
- const { sort } = this.sorting;
-
- if (orderBy.startsWith(METRIC_KEY_PREFIX)) {
- orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length);
- orderByType = 'metric';
- }
-
- return { ...filterByQuery, orderBy, orderByType, sort };
- },
- },
- methods: {
- submitFilters() {
- return visitUrl(setUrlParams({ ...this.parsedQuery }));
- },
- updateFilters(newValue) {
- this.filters = newValue;
- },
- updateSorting(newValue) {
- this.sorting = { ...this.sorting, ...newValue };
- },
- updateSortingAndEmitUpdate(newValue) {
- this.updateSorting(newValue);
- 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'),
- },
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
-};
-</script>
-
-<template>
- <div>
- <incubation-alert
- :feature-name="$options.FEATURE_NAME"
- :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
- />
-
- <h3>
- {{ $options.i18n.titleLabel }}
- </h3>
-
- <registry-search
- :filters="filters"
- :sorting="sorting"
- :sortable-fields="sortableFields"
- @sorting:changed="updateSortingAndEmitUpdate"
- @filter:changed="updateFilters"
- @filter:submit="submitFilters"
- @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>
-
- <keyset-pagination v-if="displayPagination" v-bind="pageInfo" />
- </div>
-</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue b/app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue
new file mode 100644
index 00000000000..02869bacb66
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/components/model_experiments_header.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ props: {
+ pageTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ experimentBadgeLabel: __('Experiment'),
+ },
+ experimentDocHref: helpPagePath('user/project/ml/experiment_tracking/index.md'),
+};
+</script>
+
+<template>
+ <div class="detail-page-header gl-flex-wrap">
+ <div class="detail-page-header-body">
+ <div class="page-title gl-flex-grow-1 gl-display-flex gl-align-items-center">
+ <h3 class="gl-font-size-h-display gl-my-0">{{ pageTitle }}</h3>
+ <gl-badge class="gl-mx-4" variant="info" size="lg" :href="$options.experimentDocHref">
+ {{ $options.i18n.experimentBadgeLabel }}
+ </gl-badge>
+ </div>
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/constants.js b/app/assets/javascripts/ml/experiment_tracking/constants.js
index 15462b519e1..f18fbc7e2cd 100644
--- a/app/assets/javascripts/ml/experiment_tracking/constants.js
+++ b/app/assets/javascripts/ml/experiment_tracking/constants.js
@@ -1,21 +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'),
- },
-]);
-
-export const EMPTY_STATE_SVG = '/assets/illustrations/empty-state/empty-dag-md.svg';
+import { s__ } from '~/locale';
export const FEATURE_NAME = s__('MlExperimentTracking|Machine learning experiment tracking');
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue
new file mode 100644
index 00000000000..20c5248052b
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ name: 'CandidateDetailRow',
+ components: {
+ GlLink,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ text: {
+ type: [String, Number],
+ required: true,
+ },
+ href: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sectionLabel: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <tr>
+ <td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td>
+ <td class="gl-font-weight-bold">{{ label }}</td>
+ <td>
+ <gl-link v-if="href" :href="href">{{ text }}</gl-link>
+ <template v-else>{{ text }}</template>
+ </td>
+ </tr>
+</template>
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/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
new file mode 100644
index 00000000000..3ef73e7c874
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue
@@ -0,0 +1,123 @@
+<script>
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+import DetailRow from './components/candidate_detail_row.vue';
+
+import {
+ TITLE_LABEL,
+ INFO_LABEL,
+ ID_LABEL,
+ STATUS_LABEL,
+ EXPERIMENT_LABEL,
+ ARTIFACTS_LABEL,
+ PARAMETERS_LABEL,
+ METRICS_LABEL,
+ METADATA_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
+ MLFLOW_ID_LABEL,
+} from './translations';
+
+export default {
+ name: 'MlCandidatesShow',
+ components: {
+ ModelExperimentsHeader,
+ DeleteButton,
+ DetailRow,
+ },
+ props: {
+ candidate: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ TITLE_LABEL,
+ INFO_LABEL,
+ ID_LABEL,
+ STATUS_LABEL,
+ EXPERIMENT_LABEL,
+ ARTIFACTS_LABEL,
+ DELETE_CANDIDATE_CONFIRMATION_MESSAGE,
+ DELETE_CANDIDATE_PRIMARY_ACTION_LABEL,
+ DELETE_CANDIDATE_MODAL_TITLE,
+ MLFLOW_ID_LABEL,
+ },
+ computed: {
+ info() {
+ return Object.freeze(this.candidate.info);
+ },
+ sections() {
+ return [
+ {
+ sectionName: PARAMETERS_LABEL,
+ sectionValues: this.candidate.params,
+ },
+ {
+ sectionName: METRICS_LABEL,
+ sectionValues: this.candidate.metrics,
+ },
+ {
+ sectionName: METADATA_LABEL,
+ sectionValues: this.candidate.metadata,
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <model-experiments-header :page-title="$options.i18n.TITLE_LABEL">
+ <delete-button
+ :delete-path="info.path"
+ :delete-confirmation-text="$options.i18n.DELETE_CANDIDATE_CONFIRMATION_MESSAGE"
+ :action-primary-text="$options.i18n.DELETE_CANDIDATE_PRIMARY_ACTION_LABEL"
+ :modal-title="$options.i18n.DELETE_CANDIDATE_MODAL_TITLE"
+ />
+ </model-experiments-header>
+
+ <table class="candidate-details gl-w-full">
+ <tbody>
+ <tr class="divider"></tr>
+
+ <detail-row
+ :label="$options.i18n.ID_LABEL"
+ :section-label="$options.i18n.INFO_LABEL"
+ :text="info.iid"
+ />
+
+ <detail-row :label="$options.i18n.MLFLOW_ID_LABEL" :text="info.eid" />
+
+ <detail-row :label="$options.i18n.STATUS_LABEL" :text="info.status" />
+
+ <detail-row
+ :label="$options.i18n.EXPERIMENT_LABEL"
+ :text="info.experiment_name"
+ :href="info.path_to_experiment"
+ />
+
+ <detail-row
+ v-if="info.path_to_artifact"
+ :label="$options.i18n.ARTIFACTS_LABEL"
+ :href="info.path_to_artifact"
+ :text="$options.i18n.ARTIFACTS_LABEL"
+ />
+
+ <template v-for="{ sectionName, sectionValues } in sections">
+ <tr v-if="sectionValues" :key="sectionName" class="divider"></tr>
+
+ <detail-row
+ v-for="(item, index) in sectionValues"
+ :key="item.name"
+ :label="item.name"
+ :section-label="index === 0 ? sectionName : ''"
+ :text="item.value"
+ />
+ </template>
+ </tbody>
+ </table>
+ </div>
+</template>
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..66ee84adb4e
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js
@@ -0,0 +1,17 @@
+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 MLFLOW_ID_LABEL = s__('MlExperimentTracking|MLflow run 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');
+export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__(
+ 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.',
+);
+export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate');
+export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?');
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
index 4f2b8db3c00..66f94c6bee5 100644
--- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
@@ -1,20 +1,16 @@
<script>
import { GlTableLite, GlEmptyState, GlLink } from '@gitlab/ui';
-import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
import Pagination from '~/vue_shared/components/incubation/pagination.vue';
-import {
- FEATURE_NAME,
- FEATURE_FEEDBACK_ISSUE,
- EMPTY_STATE_SVG,
-} from '~/ml/experiment_tracking/constants';
+import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
import * as constants from '~/ml/experiment_tracking/routes/experiments/index/constants';
import * as translations from '~/ml/experiment_tracking/routes/experiments/index/translations';
+import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
export default {
name: 'MlExperimentsIndexApp',
components: {
Pagination,
- IncubationAlert,
+ ModelExperimentsHeader,
GlTableLite,
GlEmptyState,
GlLink,
@@ -28,6 +24,10 @@ export default {
type: Object,
required: true,
},
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
},
tableFields: constants.EXPERIMENTS_TABLE_FIELDS,
i18n: translations,
@@ -45,7 +45,6 @@ export default {
constants: {
FEATURE_NAME,
FEATURE_FEEDBACK_ISSUE,
- EMPTY_STATE_SVG,
...constants,
},
};
@@ -53,14 +52,7 @@ export default {
<template>
<div v-if="hasExperiments">
- <h1 class="page-title gl-font-size-h-display">
- {{ $options.i18n.TITLE_LABEL }}
- </h1>
-
- <incubation-alert
- :feature-name="$options.constants.FEATURE_NAME"
- :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE"
- />
+ <model-experiments-header :page-title="$options.i18n.TITLE_LABEL" />
<gl-table-lite :items="tableItems" :fields="$options.tableFields">
<template #cell(nameColumn)="data">
@@ -78,7 +70,7 @@ export default {
:title="$options.i18n.EMPTY_STATE_TITLE_LABEL"
:primary-button-text="$options.i18n.CREATE_NEW_LABEL"
:primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH"
- :svg-path="$options.constants.EMPTY_STATE_SVG"
+ :svg-path="emptyStateSvgPath"
:description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
class="gl-py-8"
/>
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/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
new file mode 100644
index 00000000000..25c06aa2f7f
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue
@@ -0,0 +1,254 @@
+<script>
+import { GlTableLite, GlLink, GlEmptyState, GlButton } 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 { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
+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 ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
+import DeleteButton from '~/ml/experiment_tracking/components/delete_button.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: 'MlExperimentsShow',
+ components: {
+ GlTableLite,
+ GlLink,
+ GlEmptyState,
+ GlButton,
+ TimeAgo,
+ RegistrySearch,
+ KeysetPagination,
+ ModelExperimentsHeader,
+ DeleteButton,
+ },
+ props: {
+ experiment: {
+ type: Object,
+ required: true,
+ },
+ candidates: {
+ type: Array,
+ required: true,
+ },
+ metricNames: {
+ type: Array,
+ required: true,
+ },
+ paramNames: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const query = queryToObject(window.location.search);
+
+ const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : [];
+
+ let orderBy = query.orderBy || LIST_KEY_CREATED_AT;
+
+ if (query.orderByType === 'metric') {
+ orderBy = `${METRIC_KEY_PREFIX}${orderBy}`;
+ }
+
+ return {
+ filters: filter,
+ sorting: {
+ orderBy,
+ sort: (query.sort || 'desc').toLowerCase(),
+ },
+ };
+ },
+ computed: {
+ fields() {
+ if (this.candidates.length === 0) return [];
+
+ return [
+ { 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: 'ci_job', label: this.$options.i18n.CI_JOB_LABEL },
+ { key: 'artifact', label: this.$options.i18n.ARTIFACTS_LABEL },
+ ];
+ },
+ displayPagination() {
+ return this.candidates.length > 0;
+ },
+ sortableFields() {
+ return [
+ ...BASE_SORT_FIELDS,
+ ...this.metricNames.map((name) => ({
+ orderBy: `${METRIC_KEY_PREFIX}${name}`,
+ label: capitalizeFirstCharacter(name),
+ })),
+ ];
+ },
+ parsedQuery() {
+ const name = this.filters
+ .map((f) => f.value.data)
+ .join(' ')
+ .trim();
+
+ const filterByQuery = name === '' ? {} : { name };
+
+ let orderByType = 'column';
+ let { orderBy } = this.sorting;
+ const { sort } = this.sorting;
+
+ if (orderBy.startsWith(METRIC_KEY_PREFIX)) {
+ orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length);
+ orderByType = 'metric';
+ }
+
+ 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;
+ },
+ deleteButtonInfo() {
+ return {
+ deletePath: this.experiment.path,
+ deleteConfirmationText: translations.DELETE_EXPERIMENT_CONFIRMATION_MESSAGE,
+ actionPrimaryText: translations.DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL,
+ modalTitle: translations.DELETE_EXPERIMENT_MODAL_TITLE,
+ };
+ },
+ },
+ methods: {
+ submitFilters() {
+ return visitUrl(setUrlParams({ ...this.parsedQuery }));
+ },
+ updateFilters(newValue) {
+ this.filters = newValue;
+ },
+ updateSorting(newValue) {
+ this.sorting = { ...this.sorting, ...newValue };
+ },
+ updateSortingAndEmitUpdate(newValue) {
+ this.updateSorting(newValue);
+ this.submitFilters();
+ },
+ downloadCsv() {
+ const currentPath = window.location.pathname;
+ const currentSearch = window.location.search;
+
+ visitUrl(`${currentPath}.csv${currentSearch}`);
+ },
+ },
+ i18n: translations,
+ constants: {
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
+ CREATE_CANDIDATE_HELP_PATH,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <model-experiments-header :page-title="experiment.name">
+ <gl-button class="gl-mr-3" @click="downloadCsv">{{
+ $options.i18n.DOWNLOAD_AS_CSV_LABEL
+ }}</gl-button>
+ <delete-button v-bind="deleteButtonInfo" />
+ </model-experiments-header>
+
+ <registry-search
+ :filters="filters"
+ :sorting="sorting"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSortingAndEmitUpdate"
+ @filter:changed="updateFilters"
+ @filter:submit="submitFilters"
+ @filter:clear="filters = []"
+ />
+
+ <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>
+
+ <template #cell(ci_job)="data">
+ <gl-link v-if="data.value" :href="data.value.path" target="_blank">{{
+ data.value.name
+ }}</gl-link>
+ <div v-else class="gl-font-style-italic gl-text-gray-500">
+ {{ $options.i18n.NO_JOB }}
+ </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="emptyStateSvgPath"
+ :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
+ class="gl-py-8"
+ />
+
+ <keyset-pagination v-if="displayPagination" v-bind="pageInfo" />
+ </div>
+</template>
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..3af33f53fbd
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/translations.js
@@ -0,0 +1,24 @@
+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 CI_JOB_LABEL = s__('MlExperimentTracking|CI Job');
+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 NO_JOB = s__('MlExperimentTracking|-');
+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');
+export const DELETE_EXPERIMENT_CONFIRMATION_MESSAGE = s__(
+ 'MlExperimentTracking|Deleting this experiment will also delete its candidates and their associated metadata.',
+);
+export const DELETE_EXPERIMENT_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete experiment');
+export const DELETE_EXPERIMENT_MODAL_TITLE = s__('MLExperimentTracking|Delete experiment?');
+export const DOWNLOAD_AS_CSV_LABEL = s__('MlExperimentTracking|Download as CSV');
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index a995497ab9c..b6eb1a23f87 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -1,8 +1,9 @@
<script>
import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
+import { hexToRgba } from '@gitlab/ui/dist/utils/utils';
+
import produce from 'immer';
import { flattenDeep, isNumber } from 'lodash';
-import { hexToRgb } from '~/lib/utils/color_utils';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants';
import { graphDataValidatorForAnomalyValues } from '../../utils';
@@ -20,7 +21,7 @@ const LOWER = 2;
*/
const AREA_COLOR = colorValues.anomalyAreaColor;
const AREA_OPACITY = areaOpacityValues.default;
-const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`;
+const AREA_COLOR_RGBA = hexToRgba(AREA_COLOR, AREA_OPACITY);
/**
* The anomaly component highlights when a metric shows
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 2c185794d17..752ba4241d8 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -8,17 +8,24 @@ import {
GlSprintf,
GlLink,
} from '@gitlab/ui';
-import Mousetrap from 'mousetrap';
import VueDraggable from 'vuedraggable';
import { mapActions, mapState, mapGetters } from 'vuex';
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
+import {
+ keysFor,
+ METRICS_COPY_LINK_TO_CHART,
+ METRICS_DOWNLOAD_CSV,
+ METRICS_EXPAND_PANEL,
+ METRICS_SHOW_ALERTS,
+} from '~/behaviors/shortcuts/keybindings';
import invalidUrl from '~/lib/utils/invalid_url';
import { ESC_KEY } from '~/lib/utils/keys';
+import { Mousetrap } from '~/lib/mousetrap';
import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { defaultTimeRange } from '~/vue_shared/constants';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { metricStates, keyboardShortcutKeys } from '../constants';
+import { metricStates } from '../constants';
import {
timeRangeFromUrl,
panelToUrl,
@@ -214,12 +221,28 @@ export default {
created() {
window.addEventListener('keyup', this.onKeyup);
- Mousetrap.bind(Object.values(keyboardShortcutKeys), this.runShortcut);
+ Mousetrap.bind(keysFor(METRICS_EXPAND_PANEL), () =>
+ this.runShortcut('onExpandFromKeyboardShortcut'),
+ );
+ Mousetrap.bind(keysFor(METRICS_SHOW_ALERTS), () =>
+ this.runShortcut('showAlertModalFromKeyboardShortcut'),
+ );
+ Mousetrap.bind(keysFor(METRICS_DOWNLOAD_CSV), () =>
+ this.runShortcut('downloadCsvFromKeyboardShortcut'),
+ );
+ Mousetrap.bind(keysFor(METRICS_COPY_LINK_TO_CHART), () =>
+ this.runShortcut('copyChartLinkFromKeyboardShotcut'),
+ );
},
destroyed() {
window.removeEventListener('keyup', this.onKeyup);
- Mousetrap.unbind(Object.values(keyboardShortcutKeys));
+ [
+ METRICS_COPY_LINK_TO_CHART,
+ METRICS_DOWNLOAD_CSV,
+ METRICS_EXPAND_PANEL,
+ METRICS_SHOW_ALERTS,
+ ].forEach((command) => Mousetrap.unbind(keysFor(command)));
},
mounted() {
if (!this.hasMetrics) {
@@ -339,42 +362,18 @@ export default {
},
/**
* TODO: Investigate this to utilize the eventBus from Vue
- * The intentation behind this cleanup is to allow for better tests
+ * The intention behind this cleanup is to allow for better tests
* as well as use the correct eventBus facilities that are compatible
* with Vue 3
* https://gitlab.com/gitlab-org/gitlab/-/issues/225583
*/
//
- runShortcut(e) {
+ runShortcut(actionToRun) {
const panel = this.$refs[this.hoveredPanel];
if (!panel) return;
const [panelInstance] = panel;
- let actionToRun = '';
-
- switch (e.key) {
- case keyboardShortcutKeys.EXPAND:
- actionToRun = 'onExpandFromKeyboardShortcut';
- break;
-
- case keyboardShortcutKeys.SHOW_ALERT:
- actionToRun = 'showAlertModalFromKeyboardShortcut';
- break;
-
- case keyboardShortcutKeys.DOWNLOAD_CSV:
- actionToRun = 'downloadCsvFromKeyboardShortcut';
- break;
-
- case keyboardShortcutKeys.CHART_COPY:
- actionToRun = 'copyChartLinkFromKeyboardShotcut';
- break;
-
- default:
- actionToRun = 'onExpandFromKeyboardShortcut';
- break;
- }
-
panelInstance[actionToRun]();
},
setHoveredPanel(groupKey, graphIndex) {
diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
index d67154b7697..29ce8572e9a 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
@@ -12,7 +12,7 @@ import {
import { mapState, mapGetters, mapActions } from 'vuex';
import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import invalidUrl from '~/lib/utils/invalid_url';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { PANEL_NEW_PAGE } from '../router/constants';
@@ -113,7 +113,7 @@ export default {
const dashboardPath = encodeURIComponent(
dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
);
- redirectTo(`${baseURL}/${dashboardPath}`);
+ redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated
},
},
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 7bb0d3874d1..44dde454983 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -13,7 +13,7 @@ import {
import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import invalidUrl from '~/lib/utils/invalid_url';
-import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
+import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
@@ -137,13 +137,13 @@ export default {
const dashboardPath = encodeURIComponent(
dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
);
- redirectTo(`${baseURL}/${dashboardPath}`);
+ redirectTo(`${baseURL}/${dashboardPath}`); // eslint-disable-line import/no-deprecated
},
debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
this.filterEnvironments(searchTerm);
}, 500),
onDateTimePickerInput(timeRange) {
- redirectTo(timeRangeToUrl(timeRange));
+ redirectTo(timeRangeToUrl(timeRange)); // eslint-disable-line import/no-deprecated
},
onDateTimePickerInvalid() {
this.$emit('dateTimePickerInvalid');
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 1b506c6564b..e35dcc350f2 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.
@@ -262,19 +256,6 @@ export const VARIABLE_TYPES = {
*/
export const VARIABLE_PREFIX = 'var-';
-/**
- * All of the actions inside each panel dropdown can be accessed
- * via keyboard shortcuts than can be activated via mouse hovers
- * and or focus via tabs.
- */
-
-export const keyboardShortcutKeys = {
- EXPAND: 'e',
- SHOW_ALERT: 'a',
- DOWNLOAD_CSV: 'd',
- CHART_COPY: 'c',
-};
-
export const thresholdModeTypes = {
ABSOLUTE: 'absolute',
PERCENTAGE: 'percentage',
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 0ef365c6368..32e85262882 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';
@@ -40,7 +40,7 @@ function prometheusMetricQueryParams(timeRange) {
* Extract error messages from API or HTTP request errors.
*
* - API errors are in `error.response.data.message`
- * - HTTP (axios) errors are in `error.messsage`
+ * - HTTP (axios) errors are in `error.message`
*
* @param {Object} error
* @returns {String} User friendly error message
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 0d849e1a2d8..5f4d2703d21 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -97,7 +97,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
-/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
@@ -107,13 +106,13 @@ export const generateLinkToChartOptions = (chartLink) => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
- ? 'Cluster Monitoring'
+ ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'generate_link_to_cluster_metric_chart'
: 'generate_link_to_metrics_chart';
- return { category, action, label: 'Chart link', property: chartLink };
+ return { category, action, label: 'Chart link', property: chartLink }; // eslint-disable-line @gitlab/require-i18n-strings
};
/**
@@ -125,13 +124,13 @@ export const downloadCSVOptions = (title) => {
const isCLusterHealthBoard = isClusterHealthBoard();
const category = isCLusterHealthBoard
- ? 'Cluster Monitoring'
+ ? 'Cluster Monitoring' // eslint-disable-line @gitlab/require-i18n-strings
: 'Incident Management::Embedded metrics';
const action = isCLusterHealthBoard
? 'download_csv_of_cluster_metric_chart'
: 'download_csv_of_metrics_dashboard_chart';
- return { category, action, label: 'Chart title', property: title };
+ return { category, action, label: 'Chart title', property: title }; // eslint-disable-line @gitlab/require-i18n-strings
};
/* eslint-enable @gitlab/require-i18n-strings */
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index a202923bd21..10fb89feccc 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -1,22 +1,3 @@
-import initCherryPickCommitModal from '~/projects/commit/init_cherry_pick_commit_modal';
-import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal';
-import { initMrStateLazyLoad } from '~/mr_notes/init';
-import MergeRequest from '../merge_request';
-import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+import initMrNotes from './init_mr_notes';
-export default function initMrNotes() {
- resetServiceWorkersPublicPath();
-
- const mrShowNode = document.querySelector('.merge-request');
- // eslint-disable-next-line no-new
- new MergeRequest({
- action: mrShowNode.dataset.mrAction,
- });
-
- initMrStateLazyLoad();
-
- document.addEventListener('merged:UpdateActions', () => {
- initRevertCommitModal('i_code_review_post_merge_submit_revert_modal');
- initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal');
- });
-}
+export default initMrNotes;
diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js
index 79447bc115d..9852efea95f 100644
--- a/app/assets/javascripts/mr_notes/init.js
+++ b/app/assets/javascripts/mr_notes/init.js
@@ -22,7 +22,7 @@ function setupMrNotesState(notesDataset) {
store.dispatch('setEndpoints', endpoints);
}
-export function initMrStateLazyLoad() {
+export function initMrStateLazyLoad({ reviewBarParams } = {}) {
store.dispatch('setActiveTab', window.mrTabs.getCurrentAction());
window.mrTabs.eventHub.$on('MergeRequestTabChange', (value) =>
store.dispatch('setActiveTab', value),
@@ -42,7 +42,7 @@ export function initMrStateLazyLoad() {
eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes'));
requestIdleCallback(() => {
- initReviewBar();
+ initReviewBar(reviewBarParams);
initOverviewTabCounter();
initDiscussionCounter();
});
diff --git a/app/assets/javascripts/mr_notes/init_mr_notes.js b/app/assets/javascripts/mr_notes/init_mr_notes.js
new file mode 100644
index 00000000000..e0a8d1f7e7d
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/init_mr_notes.js
@@ -0,0 +1,22 @@
+import initCherryPickCommitModal from '~/projects/commit/init_cherry_pick_commit_modal';
+import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal';
+import { initMrStateLazyLoad } from '~/mr_notes/init';
+import MergeRequest from '../merge_request';
+import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+
+export default function initMrNotes(lazyLoadParams) {
+ resetServiceWorkersPublicPath();
+
+ const mrShowNode = document.querySelector('.merge-request');
+ // eslint-disable-next-line no-new
+ new MergeRequest({
+ action: mrShowNode.dataset.mrAction,
+ });
+
+ initMrStateLazyLoad(lazyLoadParams);
+
+ document.addEventListener('merged:UpdateActions', () => {
+ initRevertCommitModal('i_code_review_post_merge_submit_revert_modal');
+ initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal');
+ });
+}
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index d968c125068..1795363f24c 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';
@@ -9,7 +12,7 @@ import NotesApp from '../notes/components/notes_app.vue';
import { getNotesFilterData } from '../notes/utils/get_notes_filter_data';
import initWidget from '../vue_merge_request_widget';
-export default () => {
+export default ({ editorAiActions = [] } = {}) => {
requestIdleCallback(
() => {
renderGFM(document.getElementById('diff-notes-app'));
@@ -22,6 +25,8 @@ export default () => {
return;
}
+ Vue.use(VueApollo);
+
const notesFilterProps = getNotesFilterData(el);
const notesDataset = el.dataset;
@@ -33,8 +38,12 @@ export default () => {
NotesApp,
},
store,
+ apolloProvider,
provide: {
reportAbusePath: notesDataset.reportAbusePath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
+ editorAiActions,
+ mrFilter: true,
},
data() {
const noteableData = JSON.parse(notesDataset.noteableData);
diff --git a/app/assets/javascripts/mr_notes/mount_app.js b/app/assets/javascripts/mr_notes/mount_app.js
new file mode 100644
index 00000000000..f635492e6a9
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/mount_app.js
@@ -0,0 +1,6 @@
+import initNotes from './init_notes';
+
+// this module is required for the EE functions to work properly with merge request tabs
+export default () => {
+ initNotes();
+};
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/actions.js b/app/assets/javascripts/mr_notes/stores/drawer/actions.js
new file mode 100644
index 00000000000..4f8c3bb7f20
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/actions.js
@@ -0,0 +1,5 @@
+import * as types from './mutation_types';
+
+export const setDrawer = ({ commit }, data) => {
+ return commit(types.default.SET_DRAWER, data);
+};
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/getters.js b/app/assets/javascripts/mr_notes/stores/drawer/getters.js
new file mode 100644
index 00000000000..dd61bc900fa
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/getters.js
@@ -0,0 +1 @@
+export const activeDrawer = (state) => state.activeDrawer;
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/index.js b/app/assets/javascripts/mr_notes/stores/drawer/index.js
new file mode 100644
index 00000000000..c4a7eacb78a
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/index.js
@@ -0,0 +1,13 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+export default () => ({
+ namespaced: true,
+ state: {
+ activeDrawer: {},
+ },
+ mutations,
+ actions,
+ getters,
+});
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js
new file mode 100644
index 00000000000..5fe8a9ba20d
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/mutation_types.js
@@ -0,0 +1,3 @@
+export default {
+ SET_DRAWER: 'SET_DRAWER',
+};
diff --git a/app/assets/javascripts/mr_notes/stores/drawer/mutations.js b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js
new file mode 100644
index 00000000000..eee43f2b316
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/stores/drawer/mutations.js
@@ -0,0 +1,7 @@
+import types from './mutation_types';
+
+export default {
+ [types.SET_DRAWER](state, drawer) {
+ Object.assign(state, { activeDrawer: drawer });
+ },
+};
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index 7527c685c71..1f8e61beff0 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -4,6 +4,7 @@ import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments'
import diffsModule from '~/diffs/store/modules';
import notesModule from '~/notes/stores/modules';
import mrPageModule from './modules';
+import findingsDrawer from './drawer';
Vue.use(Vuex);
@@ -12,6 +13,7 @@ export const createModules = () => ({
notes: notesModule(),
diffs: diffsModule(),
batchComments: batchCommentsModule(),
+ findingsDrawer: findingsDrawer(),
});
export const createStore = () =>
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..9db123da405 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -1,13 +1,12 @@
<script>
-import { GlBadge, GlToggle } from '@gitlab/ui';
+import { 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';
export default {
i18n: {
- badgeLabel: s__('NorthstarNavigation|Alpha'),
sectionTitle: s__('NorthstarNavigation|Navigation redesign'),
toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'),
toggleLabel: s__('NorthstarNavigation|Toggle new navigation'),
@@ -16,8 +15,8 @@ export default {
),
},
components: {
- GlBadge,
GlToggle,
+ GlDisclosureDropdownItem,
},
props: {
enabled: {
@@ -28,6 +27,11 @@ export default {
type: String,
required: true,
},
+ newNavigation: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -45,7 +49,7 @@ export default {
Tracking.event(undefined, 'click_toggle', {
label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta',
- property: this.enabled ? 'navigation' : 'navigation_top',
+ property: this.enabled ? 'nav_user_menu' : 'navigation_top',
});
window.location.reload();
@@ -61,12 +65,22 @@ 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"
>
<b>{{ $options.i18n.sectionTitle }}</b>
- <gl-badge variant="info">{{ $options.i18n.badgeLabel }}</gl-badge>
</div>
<div
@@ -74,7 +88,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/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index a7c2e572037..9d07f435620 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -10,12 +10,12 @@ export default class NewBranchForm {
}
addBinding() {
- this.name.addEventListener('blur', this.validate);
+ this.name.addEventListener('change', this.validate);
}
init() {
if (this.name != null && this.name.value.length > 0) {
- const event = new CustomEvent('blur');
+ const event = new CustomEvent('change');
this.name.dispatchEvent(event);
}
}
@@ -77,6 +77,7 @@ export default class NewBranchForm {
const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
this.branchNameError.textContent = errors.join(', ');
+ this.name.focus();
}
}
}
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index 037be8467cb..c76ffce9168 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-return-assign */
export default class NewCommitForm {
constructor(form) {
this.form = form;
@@ -21,6 +20,6 @@ export default class NewCommitForm {
this.createMergeRequestContainer.hide();
this.createMergeRequest.prop('checked', false);
}
- return (this.wasDifferent = different);
+ this.wasDifferent = different;
}
}
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 64e801a7516..211a12208c1 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -51,7 +51,7 @@ export default {
language="python"
:code="code"
:max-height="maxHeight"
- class="gl-border"
+ class="gl-border gl-p-4!"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe.vue b/app/assets/javascripts/notebook/cells/output/dataframe.vue
new file mode 100644
index 00000000000..4fe02ee6edf
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe.vue
@@ -0,0 +1,46 @@
+<script>
+import JSONTable from '~/behaviors/components/json_table.vue';
+import Prompt from '../prompt.vue';
+import { convertHtmlTableToJson } from './dataframe_util';
+
+export default {
+ name: 'DataframeOutput',
+ components: {
+ Prompt,
+ JSONTable,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ rawCode: {
+ type: String,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ showOutput() {
+ return this.index === 0;
+ },
+ dataframeAsJSONTable() {
+ return {
+ ...convertHtmlTableToJson(this.rawCode),
+ caption: '',
+ hasFilter: true,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="output">
+ <prompt type="Out" :count="count" :show-output="showOutput" />
+ <j-s-o-n-table v-bind="dataframeAsJSONTable" class="gl-overflow-auto" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe_util.js b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
new file mode 100644
index 00000000000..41149875d6c
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
@@ -0,0 +1,108 @@
+import { sanitize } from '~/lib/dompurify';
+
+function parseItems(itemIndexes, itemColumns) {
+ // Fetching items: if the dataframe has a single column index, the table is simple
+ // 0: tr > th(index0 value) th(column0 value) th(column1 value)
+ // 1: tr > th(index0 value) th(column0 value) th(column1 value)
+ //
+ // But if the dataframe has multiple column indexes, it uses rowspan, and the row below won't have a value for that
+ // index.
+ // 0: tr > th(index0 value, rowspan=2) th(index1 value) th(column0 value) th(column1 value)
+ // 1: tr > th(index1 value) th(column0 value) th(column1 value)
+ //
+ // So, when parsing row 1, and the count of <th> elements is less than indexCount, we fill with the first
+ // values of row 0
+ const indexCount = itemIndexes[0].length;
+ const rowCount = itemIndexes.length;
+
+ const filledIndexes = itemIndexes.map((row, rowIndex) => {
+ const indexesInRow = row.length;
+ if (indexesInRow === indexCount) {
+ return row;
+ }
+ return itemIndexes[rowIndex - 1].slice(0, -indexesInRow).concat(row);
+ });
+
+ const items = Array(rowCount);
+
+ for (let row = 0; row < rowCount; row += 1) {
+ items[row] = {
+ ...Object.fromEntries(filledIndexes[row].map((value, counter) => [`index${counter}`, value])),
+ ...Object.fromEntries(itemColumns[row].map((value, counter) => [`column${counter}`, value])),
+ };
+ }
+ return items;
+}
+
+function labelsToFields(labels, isIndex = true) {
+ return labels.map((label, counter) => ({
+ key: isIndex ? `index${counter}` : `column${counter}`,
+ label,
+ sortable: true,
+ class: isIndex ? 'gl-font-weight-bold' : '',
+ }));
+}
+
+function parseFields(columnAndIndexLabels, indexCount, columnCount) {
+ // Fetching the labels: if the dataframe has a single column index, it will be in the format:
+ // thead
+ // tr
+ // th(index0 label) th(column0 label) th(column1 label)
+ //
+ // If there are multiple index columns, it the header will actually have two rows:
+ // thead
+ // tr
+ // th() th() th(column 0 label) th(column1 label)
+ // tr
+ // th(index0 label) th(index1 label) th() th()
+
+ const columnLabels = columnAndIndexLabels[0].slice(-columnCount);
+ const indexLabels = columnAndIndexLabels[columnAndIndexLabels.length - 1].slice(0, indexCount);
+
+ const indexFields = labelsToFields(indexLabels, true);
+ const columnFields = labelsToFields(columnLabels, false);
+
+ return [...indexFields, ...columnFields];
+}
+
+/**
+ * Converts a dataframe in the output of a Jupyter Notebook cell to a json object
+ *
+ * @param {string} input - the dataframe
+ * @param {DOMParser} parser - the html parser
+ * @returns {Object} The converted JSON object with an `items` property containing the rows.
+ */
+export function convertHtmlTableToJson(input, domParser) {
+ const parser = domParser || new DOMParser();
+ const htmlDoc = parser.parseFromString(sanitize(input), 'text/html');
+
+ if (!htmlDoc) return { fields: [], items: [] };
+
+ const columnAndIndexLabels = [...htmlDoc.querySelectorAll('table > thead tr')].map((row) =>
+ [...row.querySelectorAll('th')].map((item) => item.innerText),
+ );
+
+ if (columnAndIndexLabels.length === 0) return { fields: [], items: [] };
+
+ const tableRows = [...htmlDoc.querySelectorAll('table > tbody > tr')];
+
+ const itemColumns = tableRows.map((row) =>
+ [...row.querySelectorAll('td')].map((item) => item.innerText),
+ );
+
+ const itemIndexes = tableRows.map((row) =>
+ [...row.querySelectorAll('th')].map((item) => item.innerText),
+ );
+
+ const fields = parseFields(columnAndIndexLabels, itemIndexes[0].length, itemColumns[0].length);
+ const items = parseItems(itemIndexes, itemColumns);
+
+ return { fields, items };
+}
+
+export function isDataframe(output) {
+ const htmlData = output.data['text/html'];
+ if (!htmlData) return false;
+
+ return htmlData.slice(0, 20).some((line) => line.includes('dataframe'));
+}
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..0437b85913b 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -4,8 +4,12 @@ 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';
+import DataframeOutput from './dataframe.vue';
+import { isDataframe } from './dataframe_util';
const TEXT_MARKDOWN = 'text/markdown';
+const ERROR_OUTPUT_TYPE = 'error';
export default {
props: {
@@ -28,6 +32,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,10 +62,14 @@ 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']) {
return ImageOutput;
+ } else if (isDataframe(output)) {
+ return DataframeOutput;
} else if (output.data['text/html']) {
return HtmlOutput;
} else if (output.data['text/latex']) {
@@ -80,6 +90,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..bde7d219e9f 100644
--- a/app/assets/javascripts/notes/components/comment_field_layout.vue
+++ b/app/assets/javascripts/notes/components/comment_field_layout.vue
@@ -67,7 +67,7 @@ export default {
</script>
<template>
<div
- class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
+ class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white gl-overflow-hidden"
>
<div
v-if="withAlertContainer"
@@ -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..6794f838c84 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,20 +1,20 @@
<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 { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import {
capitalizeFirstCharacter,
convertToCamelCase,
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 +35,7 @@ export default {
components: {
NoteSignedOutWidget,
DiscussionLockedWidget,
- MarkdownField,
+ MarkdownEditor,
GlAlert,
GlButton,
TimelineEntryItem,
@@ -61,6 +61,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: {
@@ -74,6 +82,9 @@ export default {
'hasDrafts',
]),
...mapState(['isToggleStateButtonLoading']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
noteableDisplayName() {
const displayNameMap = {
[constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue,
@@ -95,16 +106,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 +158,7 @@ export default {
canToggleIssueState() {
return (
this.getNoteableData.current_user.can_update &&
- this.openState !== constants.MERGED &&
+ this.openState !== STATUS_MERGED &&
!this.closedAndLocked
);
},
@@ -180,14 +186,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([
@@ -209,7 +228,7 @@ export default {
handleSaveDraft() {
this.handleSave({ isDraft: true });
},
- handleSave({ withIssueAction = false, isDraft = false } = {}) {
+ async handleSave({ withIssueAction = false, isDraft = false } = {}) {
this.errors = [];
if (this.note.length) {
@@ -231,8 +250,14 @@ export default {
noteData.data.note.type = constants.DISCUSSION_NOTE;
}
+ if (containsSensitiveToken(this.note)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ return;
+ }
+ }
+
this.note = ''; // Empty textarea while being requested. Repopulate in catch
- this.resizeTextarea();
this.stopPolling();
this.isSubmitting = true;
@@ -249,7 +274,6 @@ export default {
.catch(({ response }) => {
this.handleSaveError(response);
- this.discard(false);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
@@ -286,20 +310,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 +326,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 +363,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"
+ :autocomplete-data-sources="autocompleteDataSources"
+ supports-quick-actions
+ @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">
@@ -421,10 +411,10 @@ export default {
{{ $options.i18n.internal }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
- name="question"
+ name="question-o"
:size="16"
:title="$options.i18n.internalVisibility"
- class="gl-text-gray-500"
+ class="gl-text-blue-500"
/>
</gl-form-checkbox>
<comment-type-dropdown
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 37935e9c3c6..ba5ffc60917 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -157,7 +157,7 @@ export default {
v-if="resolveAllDiscussionsIssuePath && !allResolved"
:href="resolveAllDiscussionsIssuePath"
>
- {{ __('Create issue to resolve all threads') }}
+ {{ __('Resolve all with new issue') }}
</gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 21b48a2a666..692fd6cc500 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,5 +1,10 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -25,9 +30,10 @@ const SORT_OPTIONS = [
export default {
SORT_OPTIONS,
components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
LocalStorageSync,
},
mixins: [Tracking.mixin()],
@@ -164,44 +170,64 @@ export default {
as-string
@input="setDiscussionSortDirection({ direction: $event })"
/>
- <gl-dropdown
+ <gl-disclosure-dropdown
id="discussion-preferences-dropdown"
class="full-width-mobile"
data-qa-selector="discussion_preferences_dropdown"
- :text="__('Sort or filter')"
+ :toggle-text="__('Sort or filter')"
:disabled="isLoading"
- right
+ placement="right"
>
- <div id="discussion-sort">
- <gl-dropdown-item
+ <gl-disclosure-dropdown-group id="discussion-sort">
+ <gl-disclosure-dropdown-item
v-for="{ text, key, cls } in $options.SORT_OPTIONS"
:key="text"
:class="cls"
- is-check-item
- :is-checked="isSortDropdownItemActive(key)"
- @click="fetchSortedDiscussions(key)"
+ :is-selected="isSortDropdownItemActive(key)"
+ @action="fetchSortedDiscussions(key)"
>
- {{ text }}
- </gl-dropdown-item>
- </div>
- <gl-dropdown-divider />
- <div
+ <template #list-item>
+ <gl-icon
+ name="mobile-issue-close"
+ data-testid="dropdown-item-checkbox"
+ :class="[
+ 'gl-dropdown-item-check-icon',
+ { 'gl-visibility-hidden': !isSortDropdownItemActive(key) },
+ 'gl-text-blue-400',
+ ]"
+ />
+ {{ text }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ <gl-disclosure-dropdown-group
id="discussion-filter"
class="discussion-filter-container js-discussion-filter-container"
+ bordered
>
- <gl-dropdown-item
+ <gl-disclosure-dropdown-item
v-for="filter in filters"
:key="filter.value"
- is-check-item
- :is-checked="filter.value === currentValue"
+ :is-selected="filter.value === currentValue"
:class="{ 'is-active': filter.value === currentValue }"
:data-filter-type="filterType(filter.value)"
data-qa-selector="filter_menu_item"
- @click.prevent="selectFilter(filter.value)"
+ @action="selectFilter(filter.value)"
>
- {{ filter.title }}
- </gl-dropdown-item>
- </div>
- </gl-dropdown>
+ <template #list-item>
+ <gl-icon
+ name="mobile-issue-close"
+ data-testid="dropdown-item-checkbox"
+ :class="[
+ 'gl-dropdown-item-check-icon',
+ { 'gl-visibility-hidden': filter.value !== currentValue },
+ 'gl-text-blue-400',
+ ]"
+ />
+ {{ filter.title }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
index 39b3df899a5..d02327a37a7 100644
--- a/app/assets/javascripts/notes/components/discussion_filter_note.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -28,7 +28,9 @@ export default {
class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"
data-qa-selector="discussion_filter_container"
>
- <div class="timeline-icon d-none d-lg-flex">
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
<gl-icon name="comment" />
</div>
<div class="timeline-content gl-pl-8">
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/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index 03bdc7a2cc6..faef8595998 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -1,12 +1,11 @@
<script>
-/* global Mousetrap */
-import 'mousetrap';
import { throttle } from 'lodash';
import {
keysFor,
MR_NEXT_UNRESOLVED_DISCUSSION,
MR_PREVIOUS_UNRESOLVED_DISCUSSION,
} from '~/behaviors/shortcuts/keybindings';
+import { Mousetrap } from '~/lib/mousetrap';
import eventHub from '~/notes/event_hub';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
index 663163a7552..1dd07fe90d2 100644
--- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
@@ -1,4 +1,5 @@
<script>
+import { normalizeChildren } from '~/lib/utils/vue3compat/normalize_children';
/**
* Wrapper for discussion notes replies section.
*
@@ -26,7 +27,7 @@ export default {
);
}
- return children;
+ return normalizeChildren(children);
},
};
</script>
diff --git a/app/assets/javascripts/notes/components/mr_discussion_filter.vue b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
new file mode 100644
index 00000000000..2338c9eef67
--- /dev/null
+++ b/app/assets/javascripts/notes/components/mr_discussion_filter.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlCollapsibleListbox, GlButton, GlIcon, GlSprintf, GlButtonGroup } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { __ } from '~/locale';
+import { MR_FILTER_OPTIONS } from '~/notes/constants';
+
+export default {
+ components: {
+ GlCollapsibleListbox,
+ GlButton,
+ GlButtonGroup,
+ GlIcon,
+ GlSprintf,
+ LocalStorageSync,
+ },
+ data() {
+ return {
+ selectedFilters: MR_FILTER_OPTIONS.map((f) => f.value),
+ };
+ },
+ computed: {
+ ...mapState({
+ mergeRequestFilters: (state) => state.notes.mergeRequestFilters,
+ discussionSortOrder: (state) => state.notes.discussionSortOrder,
+ }),
+ selectedFilterText() {
+ const { length } = this.mergeRequestFilters;
+
+ if (length === 0) return __('None');
+
+ const firstSelected = MR_FILTER_OPTIONS.find(
+ ({ value }) => this.mergeRequestFilters[0] === value,
+ );
+
+ if (length === MR_FILTER_OPTIONS.length) {
+ return __('All activity');
+ } else if (length > 1) {
+ return `%{strongStart}${firstSelected.text}%{strongEnd} +${length - 1} more`;
+ }
+
+ return firstSelected.text;
+ },
+ isSortAsc() {
+ return this.discussionSortOrder === 'asc';
+ },
+ sortIcon() {
+ return this.isSortAsc ? 'sort-lowest' : 'sort-highest';
+ },
+ },
+ methods: {
+ ...mapActions(['updateMergeRequestFilters', 'setDiscussionSortDirection']),
+ updateSortDirection() {
+ this.setDiscussionSortDirection({
+ direction: this.isSortAsc ? 'desc' : 'asc',
+ });
+ },
+ applyFilters() {
+ this.updateMergeRequestFilters(this.selectedFilters);
+ },
+ localSyncFilters(filters) {
+ this.updateMergeRequestFilters(filters);
+ this.selectedFilters = filters;
+ },
+ },
+ MR_FILTER_OPTIONS,
+};
+</script>
+
+<template>
+ <div>
+ <local-storage-sync
+ :value="discussionSortOrder"
+ storage-key="sort_direction_merge_request"
+ as-string
+ @input="setDiscussionSortDirection({ direction: $event })"
+ />
+ <local-storage-sync
+ :value="mergeRequestFilters"
+ storage-key="mr_activity_filters"
+ @input="localSyncFilters"
+ />
+ <gl-button-group>
+ <gl-collapsible-listbox
+ v-model="selectedFilters"
+ :items="$options.MR_FILTER_OPTIONS"
+ multiple
+ placement="right"
+ @hidden="applyFilters"
+ >
+ <template #toggle>
+ <gl-button class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!">
+ <gl-sprintf :message="selectedFilterText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <gl-icon name="chevron-down" />
+ </gl-button>
+ </template>
+ <template #list-item="{ item }">
+ <strong v-if="item.value === '*'">{{ item.text }}</strong>
+ <span v-else>{{ item.text }}</span>
+ </template>
+ </gl-collapsible-listbox>
+ <gl-button :icon="sortIcon" @click="updateSortDirection" />
+ </gl-button-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index abed95a9706..27fb116d213 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,11 +1,16 @@
<script>
-import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlTooltipDirective,
+ GlIcon,
+ GlButton,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+} 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';
import eventHub from '~/sidebar/event_hub';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
@@ -21,7 +26,7 @@ export default {
editCommentLabel: __('Edit comment'),
deleteCommentLabel: __('Delete comment'),
moreActionsLabel: __('More actions'),
- reportAbuse: __('Report abuse to administrator'),
+ reportAbuse: __('Report abuse'),
},
name: 'NoteActions',
components: {
@@ -29,7 +34,8 @@ export default {
ReplyButton,
TimelineEventButton,
GlButton,
- GlDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
UserAccessRoleBadge,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
AbuseCategorySelector,
@@ -208,18 +214,23 @@ export default {
methods: {
...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
onEdit() {
+ this.closeMoreActionsDropdown();
this.$emit('handleEdit');
},
onDelete() {
+ this.closeMoreActionsDropdown();
this.$emit('handleDelete');
},
onResolve() {
this.$emit('handleResolve');
},
- closeTooltip() {
- this.$nextTick(() => {
- this.$root.$emit(BV_HIDE_TOOLTIP);
- });
+ onAbuse() {
+ this.closeMoreActionsDropdown();
+ this.toggleReportAbuseDrawer(true);
+ },
+ onCopyUrl() {
+ this.closeMoreActionsDropdown();
+ this.$toast.show(__('Link copied to clipboard.'));
},
handleAssigneeUpdate(assignees) {
this.$emit('updateAssignees', assignees);
@@ -230,6 +241,8 @@ export default {
let { assignees } = this;
const { project_id, iid } = this.getNoteableData;
+ this.closeMoreActionsDropdown();
+
if (this.isUserAssigned) {
assignees = assignees.filter((assignee) => assignee.id !== this.author.id);
} else {
@@ -258,6 +271,11 @@ export default {
toggleReportAbuseDrawer(isOpen) {
this.isReportAbuseDrawerOpen = isOpen;
},
+ closeMoreActionsDropdown() {
+ if (this.shouldShowActionsDropdown && this.$refs.moreActionsDropdown) {
+ this.$refs.moreActionsDropdown.close();
+ }
+ },
},
};
</script>
@@ -354,48 +372,61 @@ export default {
class="note-action-button js-note-delete"
@click="onDelete"
/>
- <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions">
- <!-- eslint-disable @gitlab/vue-no-data-toggle -->
- <gl-button
+ <div v-else-if="shouldShowActionsDropdown" class="more-actions dropdown">
+ <gl-disclosure-dropdown
+ ref="moreActionsDropdown"
v-gl-tooltip
:title="$options.i18n.moreActionsLabel"
:aria-label="$options.i18n.moreActionsLabel"
icon="ellipsis_v"
category="tertiary"
+ placement="right"
class="note-action-button more-actions-toggle"
- data-toggle="dropdown"
- @click="closeTooltip"
- />
- <!-- eslint-enable @gitlab/vue-no-data-toggle -->
- <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
- <gl-dropdown-item
+ no-caret
+ >
+ <gl-disclosure-dropdown-item
v-if="canEdit"
class="js-note-edit gl-sm-display-none!"
- @click.prevent="onEdit"
+ @action="onEdit"
>
- {{ __('Edit comment') }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ __('Edit comment') }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
v-if="canReportAsAbuse"
data-testid="report-abuse-button"
- @click="toggleReportAbuseDrawer(true)"
+ @action="onAbuse"
>
- {{ $options.i18n.reportAbuse }}
- </gl-dropdown-item>
- <gl-dropdown-item
+ <template #list-item>
+ {{ $options.i18n.reportAbuse }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
v-if="noteUrl"
class="js-btn-copy-note-link"
:data-clipboard-text="noteUrl"
+ @action="onCopyUrl"
+ >
+ <template #list-item>
+ {{ __('Copy link') }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item
+ v-if="canAssign"
+ data-testid="assign-user"
+ @action="assignUser"
>
- {{ __('Copy link') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="canAssign" data-testid="assign-user" @click="assignUser">
- {{ displayAssignUserText }}
- </gl-dropdown-item>
- <gl-dropdown-item v-if="canEdit" class="js-note-delete" @click.prevent="onDelete">
- <span class="text-danger">{{ __('Delete comment') }}</span>
- </gl-dropdown-item>
- </ul>
+ <template #list-item>
+ {{ displayAssignUserText }}
+ </template>
+ </gl-disclosure-dropdown-item>
+ <gl-disclosure-dropdown-item v-if="canEdit" class="js-note-delete" @action="onDelete">
+ <template #list-item>
+ <span class="text-danger">{{ __('Delete comment') }}</span>
+ </template>
+ </gl-disclosure-dropdown-item>
+ </gl-disclosure-dropdown>
</div>
<!-- IMPORTANT: show this component lazily because it causes layout thrashing -->
<!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 -->
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 9d59994788e..9c04a72375b 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';
@@ -35,9 +35,6 @@ export default {
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
- addButtonClass() {
- return this.isAuthoredByMe ? 'js-user-authored' : '';
- },
},
methods: {
...mapActions(['toggleAwardRequest']),
@@ -64,7 +61,6 @@ export default {
:awards="awards"
:can-award-emoji="canAwardEmoji"
:current-user-id="getUserData.id"
- :add-button-class="addButtonClass"
@award="handleAward($event)"
/>
</div>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 20cf21cd1b6..b4e5129ca0e 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -5,7 +5,6 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
-import autosave from '../mixins/autosave';
import NoteAttachment from './note_attachment.vue';
import NoteAwardsList from './note_awards_list.vue';
import NoteEditedText from './note_edited_text.vue';
@@ -22,7 +21,6 @@ export default {
directives: {
SafeHtml,
},
- mixins: [autosave],
props: {
note: {
type: Object,
@@ -96,21 +94,9 @@ export default {
},
mounted() {
this.renderGFM();
-
- if (this.isEditing) {
- this.initAutoSave(this.note);
- }
},
updated() {
this.renderGFM();
-
- if (this.isEditing) {
- if (!this.autosave) {
- this.initAutoSave(this.note);
- } else {
- this.setAutoSave();
- }
- }
},
methods: {
...mapActions([
@@ -208,7 +194,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_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index b6ede10d02b..2cf6e9bb180 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,10 +1,10 @@
<script>
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
-import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -15,13 +15,14 @@ export default {
i18n: COMMENT_FORM,
name: 'NoteForm',
components: {
- MarkdownField,
+ MarkdownEditor,
CommentFieldLayout,
GlButton,
GlSprintf,
GlLink,
+ GlFormCheckbox,
},
- mixins: [issuableStateMixin, resolvable],
+ mixins: [issuableStateMixin, resolvable, glFeaturesFlagMixin()],
props: {
noteBody: {
type: String,
@@ -95,20 +96,22 @@ export default {
},
},
data() {
- let updatedNoteBody = this.noteBody;
-
- if (!updatedNoteBody && this.autosaveKey) {
- updatedNoteBody = getDraft(this.autosaveKey) || '';
- }
-
return {
- updatedNoteBody,
+ updatedNoteBody: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
isResolving: this.resolveDiscussion,
isUnresolving: !this.resolveDiscussion,
resolveAsThread: true,
isSubmittingWithKeydown: false,
+ formFieldProps: {
+ id: 'note_note',
+ name: 'note[note]',
+ 'aria-label': __('Reply to comment'),
+ placeholder: this.$options.i18n.bodyPlaceholder,
+ class: 'note-textarea js-gfm-input js-note-text markdown-area js-vue-issue-note-form',
+ 'data-qa-selector': 'reply_field',
+ },
};
},
computed: {
@@ -123,6 +126,9 @@ export default {
withBatchComments: (state) => state.batchComments?.withBatchComments,
}),
...mapGetters('batchComments', ['hasDrafts']),
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
showBatchCommentsActions() {
return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit;
},
@@ -135,11 +141,6 @@ export default {
.some((n) => n.current_user?.can_resolve_discussion) || this.isDraft
);
},
- textareaPlaceholder() {
- return this.discussionNote?.internal
- ? this.$options.i18n.bodyPlaceholderInternal
- : this.$options.i18n.bodyPlaceholder;
- },
noteHash() {
if (this.noteId) {
return `#note_${this.noteId}`;
@@ -214,6 +215,9 @@ export default {
placeholder: { link: ['startTag', 'endTag'] },
};
},
+ enableContentEditor() {
+ return Boolean(this.glFeatures.contentEditorOnIssues);
+ },
},
watch: {
noteBody() {
@@ -225,7 +229,7 @@ export default {
},
},
mounted() {
- this.$refs.textarea.focus();
+ this.updatePlaceholder();
},
methods: {
...mapActions(['toggleResolveNote']),
@@ -252,19 +256,21 @@ export default {
},
cancelHandler(shouldConfirm = false) {
// check if any dropdowns are active before sending the cancelation event
- if (!this.$refs.textarea.classList.contains('at-who-active')) {
+ if (
+ !this.$refs.markdownEditor.$el
+ .querySelector('textarea')
+ ?.classList.contains('at-who-active')
+ ) {
this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
}
},
- onInput() {
- if (this.isSubmittingWithKeydown) {
- return;
- }
-
- if (this.autosaveKey) {
- const { autosaveKey, updatedNoteBody: text } = this;
- updateDraft(autosaveKey, text);
- }
+ updatePlaceholder() {
+ this.formFieldProps.placeholder = this.discussionNote?.internal
+ ? this.$options.i18n.bodyPlaceholderInternal
+ : this.$options.i18n.bodyPlaceholder;
+ },
+ onInput(value) {
+ this.updatedNoteBody = value;
},
handleKeySubmit() {
if (this.showBatchCommentsActions) {
@@ -273,6 +279,7 @@ export default {
this.isSubmittingWithKeydown = true;
this.handleUpdate();
}
+ this.updatedNoteBody = '';
},
handleUpdate(shouldResolve) {
const beforeSubmitDiscussionState = this.discussionResolved;
@@ -331,55 +338,49 @@ export default {
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
:noteable-data="getNoteableData"
- :is-internal-note="discussion.internal"
+ :is-internal-note="discussionNote.internal"
>
- <markdown-field
- :markdown-preview-path="markdownPreviewPath"
+ <markdown-editor
+ ref="markdownEditor"
+ :enable-content-editor="enableContentEditor"
+ :value="updatedNoteBody"
+ :render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:lines="lines"
- :note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
+ :note="discussionNote"
+ :form-field-props="formFieldProps"
:show-suggest-popover="showSuggestPopover"
- :textarea-value="updatedNoteBody"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :autosave-key="autosaveKey"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :disabled="isSubmitting"
+ supports-quick-actions
+ autofocus
+ @keydown.meta.enter="handleKeySubmit()"
+ @keydown.ctrl.enter="handleKeySubmit()"
+ @keydown.exact.up="editMyLastNote()"
+ @keydown.exact.esc="cancelHandler(true)"
+ @input="onInput"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
- >
- <template #textarea>
- <textarea
- id="note_note"
- ref="textarea"
- v-model="updatedNoteBody"
- :disabled="isSubmitting"
- data-supports-quick-actions="true"
- name="note[note]"
- class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
- data-qa-selector="reply_field"
- dir="auto"
- :aria-label="__('Reply to comment')"
- :placeholder="textareaPlaceholder"
- @keydown.meta.enter="handleKeySubmit()"
- @keydown.ctrl.enter="handleKeySubmit()"
- @keydown.exact.up="editMyLastNote()"
- @keydown.exact.esc="cancelHandler(true)"
- @input="onInput"
- ></textarea>
- </template>
- </markdown-field>
+ />
</comment-field-layout>
<div class="note-form-actions">
<template v-if="showBatchCommentsActions">
<p v-if="showResolveDiscussionToggle">
<label>
<template v-if="discussionResolved">
- <input v-model="isUnresolving" type="checkbox" class="js-unresolve-checkbox" />
- {{ __('Unresolve thread') }}
+ <gl-form-checkbox v-model="isUnresolving" class="js-unresolve-checkbox">
+ {{ __('Unresolve thread') }}
+ </gl-form-checkbox>
</template>
<template v-else>
- <input v-model="isResolving" type="checkbox" class="js-resolve-checkbox" />
- {{ __('Resolve thread') }}
+ <gl-form-checkbox v-model="isResolving" class="js-resolve-checkbox">
+ {{ __('Resolve thread') }}
+ </gl-form-checkbox>
</template>
</label>
</p>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index c83b3d870d7..5e776639a7a 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -68,6 +68,16 @@ export default {
required: false,
default: false,
},
+ noteUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ emailParticipant: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -85,11 +95,18 @@ 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;
},
+ isServiceDeskEmailParticipant() {
+ return (
+ !this.isInternalNote && this.author.username === 'support-bot' && this.emailParticipant
+ );
+ },
authorLinkClasses() {
return {
hover: this.isUsernameLinkHovered,
@@ -101,7 +118,7 @@ export default {
};
},
authorName() {
- return this.author.name;
+ return this.isServiceDeskEmailParticipant ? this.emailParticipant : this.author.name;
},
internalNoteTooltip() {
return s__('Notes|This internal note will always remain confidential');
@@ -152,22 +169,31 @@ export default {
</button>
</div>
<template v-if="hasAuthor">
+ <span
+ v-if="emailParticipant"
+ class="note-header-author-name gl-font-weight-bold"
+ data-testid="author-name"
+ v-text="authorName"
+ ></span>
<a
+ v-else
ref="authorNameLink"
:href="authorHref"
:class="authorLinkClasses"
: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"
+ data-testid="author-name"
+ v-text="authorName"
+ ></span>
</a>
- <span v-if="!isSystemNote" class="text-nowrap author-username">
+ <span v-if="!isSystemNote && !emailParticipant" 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>
@@ -175,6 +201,9 @@ export default {
<slot name="note-header-info"></slot>
<gitlab-team-member-badge v-if="author && author.is_gitlab_employee" />
</span>
+ <span v-if="emailParticipant" class="note-headline-light">{{
+ __('(external participant)')
+ }}</span>
</template>
<span v-else>{{ __('A deleted user') }}</span>
<span class="note-headline-light note-headline-meta">
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index ff801cdccea..375b16f6ce2 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';
@@ -11,6 +11,7 @@ import { s__, __, sprintf } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -207,12 +208,21 @@ export default {
this.isReplying = false;
clearDraft(this.autosaveKey);
}),
- saveReply(noteText, form, callback) {
+ async saveReply(noteText, form, callback) {
if (!noteText) {
this.cancelReplyForm();
callback();
return;
}
+
+ if (containsSensitiveToken(noteText)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ callback();
+ return;
+ }
+ }
+
const postData = {
in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType,
@@ -309,7 +319,7 @@ export default {
/>
<li
v-else-if="canShowReplyActions && showReplies"
- :class="{ 'is-replying': isReplying }"
+ :class="{ 'is-replying gl-bg-white! gl-pt-0!': isReplying }"
class="discussion-reply-holder gl-border-t-0! clearfix"
>
<discussion-actions
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 93575ad57ff..5929e419247 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -6,13 +6,14 @@ 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';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import { __, s__, sprintf } from '~/locale';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import { containsSensitiveToken, confirmSensitiveAction } from '~/lib/utils/secret_detection';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -294,10 +295,9 @@ export default {
this.isRequesting = false;
this.oldContent = null;
renderGFM(this.$refs.noteBody.$el);
- this.$refs.noteBody.resetAutoSave();
this.$emit('updateSuccess');
},
- formUpdateHandler({ noteText, callback, resolveDiscussion }) {
+ async formUpdateHandler({ noteText, callback, resolveDiscussion }) {
const position = {
...this.note.position,
};
@@ -320,6 +320,14 @@ export default {
if (this.isDraft) return;
+ if (containsSensitiveToken(noteText)) {
+ const confirmed = await confirmSensitiveAction();
+ if (!confirmed) {
+ callback();
+ return;
+ }
+ }
+
const data = {
endpoint: this.note.path,
note: {
@@ -383,7 +391,6 @@ export default {
});
if (!confirmed) return;
}
- this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
// eslint-disable-next-line vue/no-mutating-props
this.note.note_html = this.oldContent;
@@ -470,6 +477,7 @@ export default {
:note-id="note.id"
:is-internal-note="note.internal"
:noteable-type="noteableType"
+ :email-participant="note.external_author"
>
<template #note-header-info>
<slot name="note-header-info"></slot>
diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue
index 9c3b2139a5d..a91c825710d 100644
--- a/app/assets/javascripts/notes/components/notes_activity_header.vue
+++ b/app/assets/javascripts/notes/components/notes_activity_header.vue
@@ -1,15 +1,24 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DiscussionFilter from './discussion_filter.vue';
export default {
components: {
TimelineToggle: () => import('./timeline_toggle.vue'),
DiscussionFilter,
+ AiSummarizeNotes: () =>
+ import('ee_component/notes/components/note_actions/ai_summarize_notes.vue'),
+ MrDiscussionFilter: () => import('./mr_discussion_filter.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
showTimelineViewToggle: {
default: false,
},
+ resourceGlobalId: { default: null },
+ mrFilter: {
+ default: false,
+ },
},
props: {
notesFilters: {
@@ -21,18 +30,34 @@ export default {
default: undefined,
required: false,
},
+ aiLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ showAiActions() {
+ return this.resourceGlobalId && this.glFeatures.summarizeComments;
+ },
},
};
</script>
<template>
<div
- class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5"
+ class="gl-display-flex gl-sm-align-items-center gl-flex-direction-column gl-sm-flex-direction-row gl-justify-content-space-between gl-pt-5 gl-pb-3"
>
<h2 class="gl-font-size-h1 gl-m-0">{{ __('Activity') }}</h2>
<div class="gl-display-flex gl-gap-3 gl-w-full gl-sm-w-auto gl-mt-3 gl-sm-mt-0">
+ <ai-summarize-notes
+ v-if="showAiActions"
+ :resource-global-id="resourceGlobalId"
+ :loading="aiLoading"
+ />
<timeline-toggle v-if="showTimelineViewToggle" />
- <discussion-filter :filters="notesFilters" :selected-value="notesFilterValue" />
+ <mr-discussion-filter v-if="mrFilter && glFeatures.mrActivityFilters" />
+ <discussion-filter v-else :filters="notesFilters" :selected-value="notesFilterValue" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index fcf37217902..06c925002b6 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -35,6 +35,7 @@ export default {
SidebarSubscription,
DraftNote,
TimelineEntryItem,
+ AiSummary: () => import('ee_component/notes/components/ai_summary.vue'),
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -70,6 +71,8 @@ export default {
return {
currentFilter: null,
renderSkeleton: !this.shouldShow,
+ aiLoading: null,
+ isInitialEventTriggered: false,
};
},
computed: {
@@ -211,6 +214,9 @@ export default {
.then(this.$nextTick)
.then(() => eventHub.$emit('startReplying', discussionId));
},
+ setAiLoading(loading) {
+ this.aiLoading = loading;
+ },
},
systemNote: constants.SYSTEM_NOTE,
};
@@ -219,7 +225,13 @@ export default {
<template>
<div v-show="shouldShow" id="notes">
<sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" />
- <notes-activity-header :notes-filters="notesFilters" :notes-filter-value="notesFilterValue" />
+ <notes-activity-header
+ :notes-filters="notesFilters"
+ :notes-filter-value="notesFilterValue"
+ :ai-loading="aiLoading"
+ @set-ai-loading="setAiLoading"
+ />
+ <ai-summary v-if="aiLoading !== null" @set-ai-loading="setAiLoading" />
<ordered-layout :slot-keys="slotKeys">
<template #form>
<comment-form
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..419b427682e 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -1,4 +1,5 @@
-import { __ } from '~/locale';
+import { STATUS_CLOSED, STATUS_OPEN, STATUS_REOPENED } from '~/issues/constants';
+import { __, s__ } from '~/locale';
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
@@ -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,80 @@ 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.',
+ ),
},
};
+
+export const MR_FILTER_OPTIONS = [
+ {
+ text: __('Approvals'),
+ value: 'approval',
+ systemNoteIcons: ['approval', 'unapproval', 'check'],
+ },
+ {
+ text: __('Assignees & reviewers'),
+ value: 'assignees_reviewers',
+ noteText: [
+ s__('IssuableEvents|requested review from'),
+ s__('IssuableEvents|removed review request for'),
+ s__('IssuableEvents|assigned to'),
+ s__('IssuableEvents|unassigned'),
+ ],
+ },
+ {
+ text: __('Comments'),
+ value: 'comments',
+ noteType: ['DiscussionNote', 'DiffNote'],
+ individualNote: true,
+ noteText: [s__('IssuableEvents|resolved all threads')],
+ },
+ {
+ text: __('Commits & branches'),
+ value: 'commit_branches',
+ systemNoteIcons: ['commit', 'fork'],
+ },
+ {
+ text: __('Edits'),
+ value: 'edits',
+ systemNoteIcons: ['pencil', 'task-done'],
+ },
+ {
+ text: __('Labels'),
+ value: 'labels',
+ systemNoteIcons: ['label'],
+ },
+ {
+ text: __('Lock status'),
+ value: 'lock_status',
+ systemNoteIcons: ['lock', 'lock-open'],
+ },
+ {
+ text: __('Mentions'),
+ value: 'mentions',
+ systemNoteIcons: ['comment-dots'],
+ },
+ {
+ text: __('Merge request status'),
+ value: 'status',
+ systemNoteIcons: ['git-merge', 'issue-close', 'issues', 'merge-request-close'],
+ },
+ {
+ text: __('Tracking'),
+ value: 'tracking',
+ noteType: ['MilestoneNote'],
+ systemNoteIcons: ['timer'],
+ },
+];
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..724b47bf44b 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,16 +1,21 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getLocationHash } from '~/lib/utils/url_utility';
import NotesApp from './components/notes_app.vue';
import { store } from './stores';
import { getNotesFilterData } from './utils/get_notes_filter_data';
-export default () => {
+export default ({ editorAiActions = [] } = {}) => {
const el = document.getElementById('js-vue-notes');
if (!el) {
return;
}
+ Vue.use(VueApollo);
+
const notesFilterProps = getNotesFilterData(el);
const showTimelineViewToggle = parseBoolean(el.dataset.showTimelineViewToggle);
@@ -50,9 +55,13 @@ export default () => {
NotesApp,
},
store,
+ apolloProvider,
provide: {
showTimelineViewToggle,
reportAbusePath: notesDataset.reportAbusePath,
+ newCommentTemplatePath: notesDataset.newCommentTemplatePath,
+ resourceGlobalId: convertToGraphQLId(noteableData.noteableType, noteableData.id),
+ editorAiActions: editorAiActions.map((factory) => factory(noteableData)),
},
data() {
return {
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
deleted file mode 100644
index 17272d5abef..00000000000
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { s__ } from '~/locale';
-import Autosave from '~/autosave';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-export default {
- methods: {
- initAutoSave(noteable, extraKeys = []) {
- let keys = [
- s__('Autosave|Note'),
- capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
- noteable.id,
- ];
-
- if (extraKeys) {
- keys = keys.concat(extraKeys);
- }
-
- this.autosave = new Autosave(this.$refs.noteForm.$refs.textarea, keys);
- },
- resetAutoSave() {
- this.autosave.reset();
- },
- setAutoSave() {
- this.autosave.save();
- },
- disposeAutoSave() {
- this.autosave.dispose();
- },
- },
-};
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/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 3dbcf28d11c..90de7db8c1b 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -63,6 +63,7 @@ function getPreviousDiscussion() {
function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
const discussion = getDiscussion();
+
if (!isOverviewPage() && !discussion) {
window.mrTabs?.eventHub.$once('NotesAppReady', () => {
handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions);
@@ -71,9 +72,12 @@ function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
window.mrTabs?.tabShown('show', undefined, false);
return;
}
- const id = discussion.dataset.discussionId;
- ctx.expandDiscussion({ discussionId: id });
- scrollToElement(discussion, scrollOptions);
+
+ if (discussion) {
+ const id = discussion.dataset.discussionId;
+ ctx.expandDiscussion({ discussionId: id });
+ scrollToElement(discussion, scrollOptions);
+ }
}
export default {
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..dc7f1577bbb 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,
});
})
@@ -900,3 +897,6 @@ export const updateAssignees = ({ commit }, assignees) => {
export const updateDiscussionPosition = ({ commit }, updatedPosition) => {
commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition);
};
+
+export const updateMergeRequestFilters = ({ commit }, newFilters) =>
+ commit(types.SET_MERGE_REQUEST_FILTERS, newFilters);
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index f6373f24b74..3fb9913bdcb 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -22,10 +22,51 @@ const getDraftComments = (state) => {
.sort((a, b) => a.id - b.id);
};
+const hideActivity = (filters, discussion) => {
+ const firstNote = discussion.notes[0];
+
+ return constants.MR_FILTER_OPTIONS.some((f) => {
+ if (filters.includes(f.value) || f.value === '*') return false;
+
+ if (
+ // For all of the below firstNote is the first note of a discussion, whether that be
+ // the first in a discussion or a single note
+ // If the filter option filters based on icon check against the first notes system note icon
+ f.systemNoteIcons?.includes(firstNote.system_note_icon_name) ||
+ // If the filter option filters based on note type user the first notes type
+ f.noteType?.includes(firstNote.type) ||
+ // If the filter option filters based on the note text then check if it is sytem
+ // and filter based on the text of the system note
+ (firstNote.system && f.noteText?.some((t) => firstNote.note.includes(t))) ||
+ // For individual notes we filter if the discussion is a single note and is not a sytem
+ (f.individualNote === discussion.individual_note && !firstNote.system)
+ ) {
+ return true;
+ }
+
+ return false;
+ });
+};
+
export const discussions = (state, getters, rootState) => {
let discussionsInState = clone(state.discussions);
// NOTE: not testing bc will be removed when backend is finished.
+ if (
+ state.noteableData.targetType === 'merge_request' &&
+ window.gon?.features?.mrActivityFilters
+ ) {
+ discussionsInState = discussionsInState.reduce((acc, discussion) => {
+ if (hideActivity(state.mergeRequestFilters, discussion)) {
+ return acc;
+ }
+
+ acc.push(discussion);
+
+ return acc;
+ }, []);
+ }
+
if (state.isTimelineEnabled) {
discussionsInState = discussionsInState
.reduce((acc, discussion) => {
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 81c4c42a49a..317fe6442d4 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -1,4 +1,4 @@
-import { ASC } from '../../constants';
+import { ASC, MR_FILTER_OPTIONS } from '../../constants';
import * as actions from '../actions';
import * as getters from '../getters';
import mutations from '../mutations';
@@ -51,6 +51,7 @@ export default () => ({
isTimelineEnabled: false,
isFetching: false,
isPollingInitialized: false,
+ mergeRequestFilters: MR_FILTER_OPTIONS.map((f) => f.value),
},
actions,
getters,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index bc1d5b5bba4..4008b40b57f 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -61,3 +61,5 @@ export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPT
// Incidents
export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS';
+
+export const SET_MERGE_REQUEST_FILTERS = 'SET_MERGE_REQUEST_FILTERS';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 5d532b68f1b..c3407936847 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) {
@@ -431,4 +432,7 @@ export default {
[types.SET_IS_POLLING_INITIALIZED](state, value) {
state.isPollingInitialized = value;
},
+ [types.SET_MERGE_REQUEST_FILTERS](state, value) {
+ state.mergeRequestFilters = value;
+ },
};
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index 14e97fcef46..ed1c80e7a6e 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -1,5 +1,5 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { marked } from 'marked';
+import markedBidi from 'marked-bidi';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
@@ -8,12 +8,14 @@ import { markdownConfig } from '~/lib/utils/text_utility';
* @param {Boolean} enabled that will be send as a property for the event
*/
export const trackToggleTimelineView = (enabled) => ({
- category: 'Incident Management',
+ category: 'Incident Management', // eslint-disable-line @gitlab/require-i18n-strings
action: 'toggle_incident_comments_into_timeline_view',
- label: 'Status',
+ label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings
property: enabled,
});
+marked.use(markedBidi());
+
export const renderMarkdown = (rawMarkdown) => {
return sanitize(marked(rawMarkdown), markdownConfig);
};
diff --git a/app/assets/javascripts/oauth_application/components/oauth_secret.vue b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
new file mode 100644
index 00000000000..c4a928c5e07
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/components/oauth_secret.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlButton, GlModal } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+import {
+ CONFIRM_MODAL,
+ CONFIRM_MODAL_TITLE,
+ COPY_SECRET,
+ DESCRIPTION_SECRET,
+ RENEW_SECRET,
+ RENEW_SECRET_FAILURE,
+ RENEW_SECRET_SUCCESS,
+ WARNING_NO_SECRET,
+} from '../constants';
+
+export default {
+ CONFIRM_MODAL,
+ CONFIRM_MODAL_TITLE,
+ COPY_SECRET,
+ DESCRIPTION_SECRET,
+ RENEW_SECRET,
+ name: 'OAuthSecret',
+ components: {
+ GlButton,
+ GlModal,
+ InputCopyToggleVisibility,
+ },
+ inject: ['initialSecret', 'renewPath'],
+ data() {
+ return {
+ secret: this.initialSecret,
+ alert: null,
+ isModalVisible: false,
+ isLoading: false,
+ };
+ },
+ computed: {
+ actionPrimary() {
+ return {
+ text: this.$options.RENEW_SECRET,
+ attributes: {
+ variant: 'confirm',
+ loading: this.isLoading,
+ },
+ };
+ },
+ },
+ created() {
+ if (!this.secret) {
+ this.alert = createAlert({ message: WARNING_NO_SECRET, variant: VARIANT_WARNING });
+ }
+ },
+ methods: {
+ displayModal() {
+ this.isModalVisible = true;
+ },
+ async renewSecret(event) {
+ event.preventDefault();
+ this.isLoading = true;
+ this.alert?.dismiss();
+
+ try {
+ const { data } = await axios.put(this.renewPath);
+ this.alert = createAlert({ message: RENEW_SECRET_SUCCESS, variant: VARIANT_SUCCESS });
+ this.secret = data.secret;
+ } catch {
+ this.alert = createAlert({ message: RENEW_SECRET_FAILURE });
+ } finally {
+ this.isLoading = false;
+ this.isModalVisible = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <input-copy-toggle-visibility
+ v-if="secret"
+ :copy-button-title="$options.COPY_SECRET"
+ :value="secret"
+ class="gl-mt-n3 gl-mb-0"
+ >
+ <template #description>
+ {{ $options.DESCRIPTION_SECRET }}
+ </template>
+ </input-copy-toggle-visibility>
+
+ <gl-button category="secondary" class="gl-align-self-start" @click="displayModal">{{
+ $options.RENEW_SECRET
+ }}</gl-button>
+
+ <gl-modal
+ v-model="isModalVisible"
+ :title="$options.CONFIRM_MODAL_TITLE"
+ size="sm"
+ modal-id="modal-renew-secret"
+ :action-primary="actionPrimary"
+ @primary="renewSecret"
+ >
+ {{ $options.CONFIRM_MODAL }}
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/oauth_application/constants.js b/app/assets/javascripts/oauth_application/constants.js
new file mode 100644
index 00000000000..5eaacadda78
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/constants.js
@@ -0,0 +1,20 @@
+import { __, s__ } from '~/locale';
+
+export const CONFIRM_MODAL = s__(
+ 'AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab.',
+);
+export const CONFIRM_MODAL_TITLE = s__('AuthorizedApplication|Renew secret?');
+export const COPY_SECRET = __('Copy secret');
+export const DESCRIPTION_SECRET = __(
+ 'This is the only time the secret is accessible. Copy the secret and store it securely.',
+);
+export const RENEW_SECRET = s__('AuthorizedApplication|Renew secret');
+export const RENEW_SECRET_FAILURE = s__(
+ 'AuthorizedApplication|There was an error trying to renew the application secret. Please try again.',
+);
+export const RENEW_SECRET_SUCCESS = s__(
+ 'AuthorizedApplication|Application secret was successfully renewed.',
+);
+export const WARNING_NO_SECRET = __(
+ 'The secret is only available when you create the application or renew the secret.',
+);
diff --git a/app/assets/javascripts/oauth_application/index.js b/app/assets/javascripts/oauth_application/index.js
new file mode 100644
index 00000000000..f8f1f647a15
--- /dev/null
+++ b/app/assets/javascripts/oauth_application/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import OAuthSecret from './components/oauth_secret.vue';
+
+export const initOAuthApplicationSecret = () => {
+ const el = document.querySelector('#js-oauth-application-secret');
+
+ if (!el) {
+ return null;
+ }
+
+ const { initialSecret, renewPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'OAuthSecretRoot',
+ provide: { initialSecret, renewPath },
+ render(h) {
+ return h(OAuthSecret);
+ },
+ });
+};
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/index.js b/app/assets/javascripts/operation_settings/index.js
index faddf9c7b81..e56583963ad 100644
--- a/app/assets/javascripts/operation_settings/index.js
+++ b/app/assets/javascripts/operation_settings/index.js
@@ -5,6 +5,8 @@ import store from './store';
export default () => {
const el = document.querySelector('.js-operation-settings');
+ if (!el) return false;
+
return new Vue({
el,
store: store(el.dataset),
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/delete_modal.vue
index 2da8ca2d8a8..7922ff9cce3 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/delete_modal.vue
@@ -1,12 +1,13 @@
<script>
import { GlModal, GlSprintf, GlFormInput } from '@gitlab/ui';
-import { n__ } from '~/locale';
+import { __, n__ } from '~/locale';
import {
REMOVE_TAG_CONFIRMATION_TEXT,
REMOVE_TAGS_CONFIRMATION_TEXT,
DELETE_IMAGE_CONFIRMATION_TITLE,
DELETE_IMAGE_CONFIRMATION_TEXT,
-} from '../../constants';
+} from '../constants';
+import { getImageName } from '../utils';
export default {
components: {
@@ -28,12 +29,13 @@ export default {
},
data() {
return {
- projectPath: '',
+ inputImageName: '',
};
},
computed: {
- imageProjectPath() {
- return this.itemsToBeDeleted[0]?.project?.path;
+ imageName() {
+ const [item] = this.itemsToBeDeleted;
+ return getImageName(item);
},
modalTitle() {
if (this.deleteImage) {
@@ -49,7 +51,7 @@ export default {
if (this.deleteImage) {
return {
message: DELETE_IMAGE_CONFIRMATION_TEXT,
- item: this.imageProjectPath,
+ item: this.imageName,
};
}
if (this.itemsToBeDeleted.length > 1) {
@@ -66,7 +68,13 @@ export default {
};
},
disablePrimaryButton() {
- return this.deleteImage && this.projectPath !== this.imageProjectPath;
+ return this.deleteImage && this.inputImageName !== this.imageName;
+ },
+ primaryActionProps() {
+ return {
+ text: __('Delete'),
+ attributes: { variant: 'danger', disabled: this.disablePrimaryButton },
+ };
},
},
methods: {
@@ -74,25 +82,25 @@ export default {
this.$refs.deleteModal.show();
},
},
+ modal: {
+ cancelAction: {
+ text: __('Cancel'),
+ },
+ },
};
</script>
<template>
<gl-modal
ref="deleteModal"
- modal-id="delete-tag-modal"
+ modal-id="delete-modal"
ok-variant="danger"
size="sm"
- :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: __('Delete'),
- 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'),
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :action-primary="primaryActionProps"
+ :action-cancel="$options.modal.cancelAction"
@primary="$emit('confirmDelete')"
@cancel="$emit('cancelDelete')"
- @change="projectPath = ''"
+ @change="inputImageName = ''"
>
<template #modal-title>{{ modalTitle }}</template>
<p v-if="modalDescription" data-testid="description">
@@ -106,7 +114,7 @@ export default {
</gl-sprintf>
</p>
<div v-if="deleteImage">
- <gl-form-input v-model="projectPath" />
+ <gl-form-input v-model="inputImageName" />
</div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
index 5d77ff9dc0d..da88f768c03 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -4,9 +4,10 @@ import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { formatDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import {
- UPDATED_AT,
+ CREATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
CLEANUP_SCHEDULED_TEXT,
CLEANUP_ONGOING_TEXT,
@@ -24,6 +25,7 @@ import {
} from '../../constants/index';
import getContainerRepositoryMetadata from '../../graphql/queries/get_container_repository_metadata.query.graphql';
+import { getImageName } from '../../utils';
export default {
name: 'DetailsHeader',
@@ -65,11 +67,11 @@ export default {
visibilityIcon() {
return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
},
- timeAgo() {
- return this.timeFormatted(this.imageDetails.updatedAt);
+ formattedCreatedAtDate() {
+ return formatDate(this.imageDetails.createdAt, 'mmm d, yyyy HH:MM', true);
},
- updatedText() {
- return sprintf(UPDATED_AT, { time: this.timeAgo });
+ createdText() {
+ return sprintf(CREATED_AT, { time: this.formattedCreatedAtDate });
},
tagCountText() {
if (this.$apollo.queries.containerRepository.loading) {
@@ -99,7 +101,7 @@ export default {
return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
},
imageName() {
- return this.imageDetails.name || this.imageDetails.project?.path;
+ return getImageName(this.imageDetails);
},
formattedSize() {
const { size } = this.imageDetails;
@@ -145,9 +147,9 @@ export default {
<template #metadata-updated>
<metadata-item
:icon="visibilityIcon"
- :text="updatedText"
+ :text="createdText"
size="xl"
- data-testid="updated-and-visibility"
+ data-testid="created-and-visibility"
/>
</template>
<template #right-actions>
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..9ea1958a0d1 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,14 +1,18 @@
<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 Tracking from '~/tracking';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
-
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
+ ALERT_SUCCESS_TAG,
+ ALERT_DANGER_TAG,
+ ALERT_SUCCESS_TAGS,
+ ALERT_DANGER_TAGS,
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
GRAPHQL_PAGE_SIZE,
@@ -20,19 +24,22 @@ import {
NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
} from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
+import deleteContainerRepositoryTagsMutation from '../../graphql/mutations/delete_container_repository_tags.mutation.graphql';
+import DeleteModal from '../delete_modal.vue';
import TagsListRow from './tags_list_row.vue';
export default {
name: 'TagsList',
components: {
+ DeleteModal,
GlEmptyState,
TagsListRow,
TagsLoader,
RegistryList,
PersistedSearch,
},
+ mixins: [Tracking.mixin()],
inject: ['config'],
-
props: {
id: {
type: [Number, String],
@@ -77,6 +84,8 @@ export default {
return {
containerRepository: {},
filters: {},
+ itemsToBeDeleted: [],
+ mutationLoading: false,
sort: null,
};
},
@@ -87,6 +96,9 @@ export default {
tags() {
return this.containerRepository?.tags?.nodes || [];
},
+ hideBulkDelete() {
+ return !this.containerRepository?.canDelete;
+ },
tagsPageInfo() {
return this.containerRepository?.tags?.pageInfo;
},
@@ -98,14 +110,16 @@ export default {
sort: this.sort,
};
},
- showMultiDeleteButton() {
- return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
- },
hasNoTags() {
return this.tags.length === 0;
},
isLoading() {
- return this.isImageLoading || this.$apollo.queries.containerRepository.loading || !this.sort;
+ return (
+ this.isImageLoading ||
+ this.$apollo.queries.containerRepository.loading ||
+ this.mutationLoading ||
+ !this.sort
+ );
},
hasFilters() {
return this.filters?.name;
@@ -116,17 +130,61 @@ export default {
emptyStateDescription() {
return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : NO_TAGS_MESSAGE;
},
+ tracking() {
+ return {
+ label:
+ this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ };
+ },
},
methods: {
+ deleteTags(toBeDeleted) {
+ this.itemsToBeDeleted = toBeDeleted;
+ this.track('click_button');
+ this.$refs.deleteModal.show();
+ },
+ confirmDelete() {
+ this.handleDeleteTag();
+ },
+ async handleDeleteTag() {
+ this.track('confirm_delete');
+ const { itemsToBeDeleted } = this;
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: deleteContainerRepositoryTagsMutation,
+ variables: {
+ id: this.queryVariables.id,
+ tagNames: itemsToBeDeleted.map((item) => item.name),
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: getContainerRepositoryTagsQuery,
+ variables: this.queryVariables,
+ },
+ ],
+ });
+ if (data?.destroyContainerRepositoryTags?.errors[0]) {
+ throw new Error();
+ }
+ this.$emit(
+ 'delete',
+ itemsToBeDeleted.length === 1 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS,
+ );
+ this.itemsToBeDeleted = [];
+ } catch (e) {
+ this.$emit('delete', itemsToBeDeleted.length === 1 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS);
+ } finally {
+ this.mutationLoading = false;
+ }
+ },
fetchNextPage() {
this.$apollo.queries.containerRepository.fetchMore({
variables: {
after: this.tagsPageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
});
},
fetchPreviousPage() {
@@ -136,9 +194,6 @@ export default {
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
});
},
handleSearchUpdate({ sort, filters }) {
@@ -186,13 +241,14 @@ export default {
/>
<template v-else>
<registry-list
+ :hidden-delete="hideBulkDelete"
:title="listTitle"
:pagination="tagsPageInfo"
:items="tags"
id-property="name"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
- @delete="$emit('delete', $event)"
+ @delete="deleteTags"
>
<template #default="{ selectItem, isSelected, item, first }">
<tags-list-row
@@ -202,10 +258,17 @@ export default {
:is-mobile="isMobile"
:disabled="disabled"
@select="selectItem(item)"
- @delete="$emit('delete', [item])"
+ @delete="deleteTags([item])"
/>
</template>
</registry-list>
+
+ <delete-modal
+ ref="deleteModal"
+ :items-to-be-deleted="itemsToBeDeleted"
+ @confirmDelete="confirmDelete"
+ @cancel="track('cancel_delete')"
+ />
</template>
</template>
</div>
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/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 7bb69363743..7ac803a8ece 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -65,7 +65,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'ContainerRegistry|Invalid tag: missing manifest digest',
);
-export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}');
+export const CREATED_AT = s__('ContainerRegistry|Created %{time}');
export const NOT_AVAILABLE_TEXT = __('Not applicable.');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
index 9d0ecfd2dcb..71538ea5a07 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
@@ -1,14 +1,10 @@
import { s__ } from '~/locale';
-export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
- 'ContainerRegistry|Expiration policy will run in %{time}',
-);
-export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
- 'ContainerRegistry|Expiration policy is disabled.',
-);
+export const EXPIRATION_POLICY_WILL_RUN_IN = s__('ContainerRegistry|Cleanup will run in %{time}');
+export const EXPIRATION_POLICY_DISABLED_TEXT = s__('ContainerRegistry|Cleanup is not scheduled.');
export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
export const DELETE_ALERT_LINK_TEXT = s__(
- 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
+ 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}run cleanup now manually%{adminLinkEnd} or you can wait for the next scheduled run of the cleanup policy. %{docLinkStart}More information%{docLinkEnd}',
);
export const PARTIAL_CLEANUP_CONTINUE_MESSAGE = s__(
'ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}',
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index f2aa4916f48..89cdbf6acba 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -4,6 +4,7 @@ import { NAME_SORT_FIELD } from './common';
// Translations strings
export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
+export const SETTINGS_TEXT = s__('ContainerRegistry|Configure in settings');
export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
`ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
@@ -15,9 +16,6 @@ export const LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION = s__(
`ContainerRegistry|Image repository temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}`,
);
export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
-export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
- 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
-);
export const ROW_SCHEDULED_FOR_DELETION = s__(
`ContainerRegistry|This image repository is scheduled for deletion`,
);
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
index 850dca07a3f..f9820df4a12 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
@@ -6,7 +6,6 @@ Vue.use(VueApollo);
export const mergeVariables = (existing, incoming) => {
if (!incoming) return existing;
- if (!existing) return incoming;
return incoming;
};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
index e2036d9e63d..eae663acb48 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
@@ -7,7 +7,6 @@ query getContainerRepositoryDetails($id: ContainerRepositoryID!) {
location
canDelete
createdAt
- updatedAt
expirationPolicyStartedAt
expirationPolicyCleanupStatus
project {
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/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
index a558550c91f..afddf78203d 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
@@ -36,6 +36,7 @@ export default () => {
isGroupPage,
isAdmin,
showCleanupPolicyLink,
+ showContainerRegistrySettings,
showUnfinishedTagCleanupCallout,
connectionError,
invalidPathError,
@@ -69,6 +70,7 @@ export default () => {
isGroupPage: parseBoolean(isGroupPage),
isAdmin: parseBoolean(isAdmin),
showCleanupPolicyLink: parseBoolean(showCleanupPolicyLink),
+ showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings),
showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout),
connectionError: parseBoolean(connectionError),
invalidPathError: parseBoolean(invalidPathError),
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..3126af69c2c 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,36 +1,28 @@
<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';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
import DeleteImage from '../components/delete_image.vue';
import DeleteAlert from '../components/details_page/delete_alert.vue';
-import DeleteModal from '../components/details_page/delete_modal.vue';
+import DeleteModal from '../components/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue';
import {
- ALERT_SUCCESS_TAG,
- ALERT_DANGER_TAG,
- ALERT_SUCCESS_TAGS,
- ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
- GRAPHQL_PAGE_SIZE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '../constants/index';
-import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
-import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql';
-import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
export default {
name: 'RegistryDetailsPage',
@@ -76,7 +68,6 @@ export default {
mutationLoading: false,
deleteAlertType: null,
hidePartialCleanupWarning: false,
- deleteImageAlert: false,
};
},
computed: {
@@ -97,8 +88,7 @@ export default {
},
tracking() {
return {
- label:
- this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ label: 'registry_image_delete',
};
},
pageActionsAreDisabled() {
@@ -112,57 +102,8 @@ export default {
: MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
- deleteTags(toBeDeleted) {
- this.deleteImageAlert = false;
- this.itemsToBeDeleted = toBeDeleted;
- this.track('click_button');
- this.$refs.deleteModal.show();
- },
confirmDelete() {
- if (this.deleteImageAlert) {
- this.$refs.deleteImage.doDelete();
- } else {
- this.handleDeleteTag();
- }
- },
- async handleDeleteTag() {
- this.track('confirm_delete');
- const { itemsToBeDeleted } = this;
- this.itemsToBeDeleted = [];
- this.mutationLoading = true;
- try {
- const { data } = await this.$apollo.mutate({
- mutation: deleteContainerRepositoryTagsMutation,
- variables: {
- id: this.queryVariables.id,
- tagNames: itemsToBeDeleted.map((i) => i.name),
- },
- awaitRefetchQueries: true,
- refetchQueries: [
- {
- query: getContainerRepositoryTagsQuery,
- variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE },
- },
- {
- query: getContainerRepositoriesDetails,
- variables: {
- fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
- isGroupPage: this.config.isGroupPage,
- },
- },
- ],
- });
-
- if (data?.destroyContainerRepositoryTags?.errors[0]) {
- throw new Error();
- }
- this.deleteAlertType =
- itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
- } catch (e) {
- this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
- }
-
- this.mutationLoading = false;
+ this.$refs.deleteImage.doDelete();
},
handleResize() {
this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
@@ -174,7 +115,6 @@ export default {
});
},
deleteImage() {
- this.deleteImageAlert = true;
this.itemsToBeDeleted = [{ ...this.containerRepository }];
this.$refs.deleteModal.show();
},
@@ -185,6 +125,9 @@ export default {
this.itemsToBeDeleted = [];
this.mutationLoading = true;
},
+ showAlert(alertType) {
+ this.deleteAlertType = alertType;
+ },
},
};
</script>
@@ -222,7 +165,7 @@ export default {
:is-image-loading="isLoading"
:is-mobile="isMobile"
:disabled="pageActionsAreDisabled"
- @delete="deleteTags"
+ @delete="showAlert"
/>
<delete-image
@@ -237,7 +180,7 @@ export default {
<delete-modal
ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted"
- :delete-image="deleteImageAlert"
+ delete-image
@confirmDelete="confirmDelete"
@cancel="track('cancel_delete')"
/>
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..fe29fa8fdd7 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
@@ -1,8 +1,8 @@
<script>
import {
+ GlButton,
GlEmptyState,
GlTooltipDirective,
- GlModal,
GlSprintf,
GlLink,
GlAlert,
@@ -10,31 +10,33 @@ 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';
import DeleteImage from '../components/delete_image.vue';
import RegistryHeader from '../components/list_page/registry_header.vue';
+import DeleteModal from '../components/delete_modal.vue';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
- REMOVE_REPOSITORY_MODAL_TEXT,
- REMOVE_REPOSITORY_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
SORT_FIELDS,
+ SETTINGS_TEXT,
} from '../constants/index';
import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
export default {
name: 'RegistryListPage',
components: {
+ GlButton,
GlEmptyState,
ProjectEmptyState: () =>
import(
@@ -52,7 +54,7 @@ export default {
import(
/* webpackChunkName: 'container_registry_components' */ '~/packages_and_registries/shared/components/cli_commands.vue'
),
- GlModal,
+ DeleteModal,
GlSprintf,
GlLink,
GlAlert,
@@ -74,10 +76,9 @@ export default {
i18n: {
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
- REMOVE_REPOSITORY_MODAL_TEXT,
- REMOVE_REPOSITORY_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
+ SETTINGS_TEXT,
},
searchConfig: SORT_FIELDS,
apollo: {
@@ -144,8 +145,11 @@ export default {
}
return [];
},
+ itemsToBeDeleted() {
+ return this.itemToDelete?.id ? [this.itemToDelete] : [];
+ },
graphqlResource() {
- return this.config.isGroupPage ? 'group' : 'project';
+ return this.config.isGroupPage ? WORKSPACE_GROUP : WORKSPACE_PROJECT;
},
queryVariables() {
return {
@@ -306,6 +310,13 @@ export default {
:docker-push-command="dockerPushCommand"
:docker-login-command="dockerLoginCommand"
/>
+ <gl-button
+ v-if="config.showContainerRegistrySettings"
+ v-gl-tooltip="$options.i18n.SETTINGS_TEXT"
+ icon="settings"
+ :href="config.cleanupPoliciesSettingsPath"
+ :aria-label="$options.i18n.SETTINGS_TEXT"
+ />
</template>
</registry-header>
<persisted-search
@@ -367,26 +378,13 @@ export default {
@end="mutationLoading = false"
>
<template #default="{ doDelete }">
- <gl-modal
+ <delete-modal
ref="deleteModal"
- size="sm"
- modal-id="delete-image-modal"
- :action-primary="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- text: __('Remove'),
- attributes: { variant: 'danger' },
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- @primary="doDelete"
+ :items-to-be-deleted="itemsToBeDeleted"
+ delete-image
+ @confirmDelete="doDelete"
@cancel="track('cancel_delete')"
- >
- <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
- <p>
- <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
- <template #title>
- <b>{{ itemToDelete.path }}</b>
- </template>
- </gl-sprintf>
- </p>
- </gl-modal>
+ />
</template>
</delete-image>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
index ffdaf9f2f17..751ab5180a1 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/utils.js
@@ -1,5 +1,9 @@
import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
+export const getImageName = (image = {}) => {
+ return image.name || image.project?.path;
+};
+
export const timeTilRun = (time) => {
if (!time) 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..732d544816b 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -1,6 +1,7 @@
<script>
import {
GlAlert,
+ GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
@@ -8,8 +9,8 @@ import {
GlFormInputGroup,
GlModal,
GlModalDirective,
- GlSkeletonLoader,
GlSprintf,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { __, s__, n__, sprintf } from '~/locale';
import Api from '~/api';
@@ -24,13 +25,13 @@ import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency
export default {
components: {
GlAlert,
+ GlButton,
GlDropdown,
GlDropdownItem,
GlEmptyState,
GlFormGroup,
GlFormInputGroup,
GlModal,
- GlSkeletonLoader,
GlSprintf,
ClipboardButton,
TitleArea,
@@ -38,8 +39,9 @@ export default {
},
directives: {
GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
- inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache'],
+ inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache', 'settingsPath'],
i18n: {
proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
@@ -50,12 +52,13 @@ export default {
'DependencyProxy|All items in the cache are scheduled for removal.',
),
clearCache: s__('DependencyProxy|Clear cache'),
+ settingsText: s__('DependencyProxy|Configure in settings'),
},
confirmClearCacheModal: 'confirm-clear-cache-modal',
modalButtons: {
primary: {
text: s__('DependencyProxy|Clear cache'),
- attributes: [{ variant: 'danger' }],
+ attributes: { variant: 'danger' },
},
secondary: {
text: __('Cancel'),
@@ -114,10 +117,13 @@ export default {
);
},
showDeleteDropdown() {
- return this.group.dependencyProxyManifests?.nodes.length > 0 && this.canClearCache;
+ return this.manifests?.length > 0 && this.canClearCache;
+ },
+ dependencyProxyImagePrefix() {
+ return this.group.dependencyProxyImagePrefix;
},
showDependencyProxyImagePrefix() {
- return this.group.dependencyProxyImagePrefix?.length > 0;
+ return this.dependencyProxyImagePrefix?.length > 0;
},
},
methods: {
@@ -167,8 +173,9 @@ export default {
{{ deleteCacheAlertMessage }}
</gl-alert>
<title-area :title="$options.i18n.pageTitle">
- <template v-if="showDeleteDropdown" #right-actions>
+ <template #right-actions>
<gl-dropdown
+ v-if="showDeleteDropdown"
icon="ellipsis_v"
text="More actions"
:text-sr-only="true"
@@ -181,6 +188,14 @@ export default {
>{{ $options.i18n.clearCache }}</gl-dropdown-item
>
</gl-dropdown>
+ <gl-button
+ v-if="canClearCache"
+ v-gl-tooltip="$options.i18n.settingsText"
+ icon="settings"
+ data-testid="settings-link"
+ :href="settingsPath"
+ :aria-label="$options.i18n.settingsText"
+ />
</template>
</title-area>
@@ -208,23 +223,21 @@ export default {
</template>
</gl-form-group>
- <gl-skeleton-loader v-if="$apollo.queries.group.loading" />
-
- <div v-else data-testid="main-area">
- <manifests-list
- v-if="manifests && manifests.length"
- :manifests="manifests"
- :pagination="pageInfo"
- @prev-page="fetchPreviousPage"
- @next-page="fetchNextPage"
- />
+ <manifests-list
+ v-if="manifests && manifests.length"
+ :dependency-proxy-image-prefix="dependencyProxyImagePrefix"
+ :loading="$apollo.queries.group.loading"
+ :manifests="manifests"
+ :pagination="pageInfo"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ />
- <gl-empty-state
- v-else
- :svg-path="noManifestsIllustration"
- :title="$options.i18n.noManifestTitle"
- />
- </div>
+ <gl-empty-state
+ v-else
+ :svg-path="noManifestsIllustration"
+ :title="$options.i18n.noManifestTitle"
+ />
<gl-modal
:modal-id="$options.confirmClearCacheModal"
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
index 1bbd0c32dc4..254fd578cf1 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
@@ -3,11 +3,16 @@ import { GlIcon, GlSprintf } from '@gitlab/ui';
import { MANIFEST_PENDING_DESTRUCTION_STATUS } from '~/packages_and_registries/dependency_proxy/constants';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { s__ } from '~/locale';
+const SHORT_DIGEST_START_INDEX = 7;
+const SHORT_DIGEST_END_INDEX = 14;
+
export default {
name: 'ManifestRow',
components: {
+ ClipboardButton,
GlIcon,
GlSprintf,
ListItem,
@@ -18,13 +23,25 @@ export default {
type: Object,
required: true,
},
+ dependencyProxyImagePrefix: {
+ type: String,
+ default: '',
+ required: false,
+ },
},
computed: {
name() {
- return this.manifest?.imageName.split(':')[0];
+ if (this.containsDigestInImageName) {
+ return this.manifest?.imageName.split(':')[0];
+ }
+ return this.manifest?.imageName;
},
- version() {
- return this.manifest?.imageName.split(':')[1];
+ imageCopyText() {
+ const name = this.manifest?.imageName.replace(':sha256:', '@sha256:') ?? '';
+ return `${this.dependencyProxyImagePrefix}/${name}`;
+ },
+ containsDigestInImageName() {
+ return this.manifest?.imageName.includes(':sha256:');
},
isErrorStatus() {
return this.manifest?.status === MANIFEST_PENDING_DESTRUCTION_STATUS;
@@ -32,9 +49,16 @@ export default {
disabledRowStyle() {
return this.isErrorStatus ? 'gl-font-weight-normal gl-text-gray-500' : '';
},
+ shortDigest() {
+ // digest is in the format `sha256:995efde2e81b21d1ea7066aa77a59298a62a9e9fbb4b77f36c189774ec9b1089`
+ // for short digest, remove sha256: from the string, and show only the first 7 char
+ return this.manifest.digest?.substring(SHORT_DIGEST_START_INDEX, SHORT_DIGEST_END_INDEX);
+ },
},
i18n: {
cachedAgoMessage: s__('DependencyProxy|Cached %{time}'),
+ copyImagePathTitle: s__('DependencyProxy|Copy image path'),
+ digestLabel: s__('DependencyProxy|Digest: %{shortDigest}'),
scheduledForDeletion: s__('DependencyProxy|Scheduled for deletion'),
},
};
@@ -44,9 +68,21 @@ export default {
<list-item :disabled="isErrorStatus">
<template #left-primary>
<span :class="disabledRowStyle">{{ name }}</span>
+ <clipboard-button
+ class="gl-ml-2"
+ :text="imageCopyText"
+ :title="$options.i18n.copyImagePathTitle"
+ category="tertiary"
+ />
</template>
<template #left-secondary>
- {{ version }}
+ <span data-testid="manifest-row-short-digest">
+ <gl-sprintf :message="$options.i18n.digestLabel">
+ <template #shortDigest>
+ {{ shortDigest }}
+ </template>
+ </gl-sprintf>
+ </span>
<span v-if="isErrorStatus" class="gl-ml-4" data-testid="status"
><gl-icon name="clock" /> {{ $options.i18n.scheduledForDeletion }}</span
>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
index 005c8feea3a..9870841f1ff 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlKeysetPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoader } from '@gitlab/ui';
import { s__ } from '~/locale';
import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
@@ -8,6 +8,7 @@ export default {
components: {
ManifestRow,
GlKeysetPagination,
+ GlSkeletonLoader,
},
props: {
manifests: {
@@ -19,6 +20,16 @@ export default {
type: Object,
required: true,
},
+ loading: {
+ type: Boolean,
+ required: false,
+ default: () => false,
+ },
+ dependencyProxyImagePrefix: {
+ type: String,
+ default: '',
+ required: false,
+ },
},
i18n: {
listTitle: s__('DependencyProxy|Image list'),
@@ -34,19 +45,27 @@ export default {
<template>
<div class="gl-mt-6">
<h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3>
- <div
- class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
- >
- <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" />
- </div>
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-if="showPagination"
- v-bind="pagination"
- class="gl-mt-3"
- @prev="$emit('prev-page')"
- @next="$emit('next-page')"
- />
+ <gl-skeleton-loader v-if="loading" />
+ <div v-else data-testid="main-area">
+ <div
+ class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
+ >
+ <manifest-row
+ v-for="(manifest, index) in manifests"
+ :key="index"
+ :dependency-proxy-image-prefix="dependencyProxyImagePrefix"
+ :manifest="manifest"
+ />
+ </div>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pagination"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
index c1597625964..db0e596ba64 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
@@ -19,6 +19,7 @@ query getDependencyProxyDetails(
nodes {
id
createdAt
+ digest
imageName
status
}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
index 428d6d6cd75..74444d2c7ec 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
@@ -11,7 +11,7 @@ export const initDependencyProxyApp = () => {
if (!el) {
return null;
}
- const { groupPath, groupId, noManifestsIllustration, canClearCache } = el.dataset;
+ const { groupPath, groupId, noManifestsIllustration, canClearCache, settingsPath } = el.dataset;
return new Vue({
el,
apolloProvider,
@@ -20,6 +20,7 @@ export const initDependencyProxyApp = () => {
groupId,
noManifestsIllustration,
canClearCache: parseBoolean(canClearCache),
+ settingsPath,
},
render(createElement) {
return createElement(app);
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/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
index 9bab08b8548..a9d076afb92 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue
@@ -36,7 +36,7 @@ export default {
},
},
i18n: {
- LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'),
+ LIST_TITLE_TEXT: s__('InfrastructureRegistry|Terraform Module Registry'),
LIST_INTRO_TEXT: s__(
'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}',
),
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..0c3494ea812 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
@@ -1,39 +1,71 @@
<script>
-import { GlModal } from '@gitlab/ui';
-import { __, n__ } from '~/locale';
+import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
import {
+ DELETE_MODAL_CONTENT,
+ DELETE_MODAL_TITLE,
+ DELETE_PACKAGES_MODAL_DESCRIPTION,
DELETE_PACKAGES_MODAL_TITLE,
DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
+ DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT,
+ DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT,
+ DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
} from '~/packages_and_registries/package_registry/constants';
export default {
name: 'DeleteModal',
- i18n: {
- DELETE_PACKAGES_MODAL_TITLE,
- },
components: {
+ GlLink,
GlModal,
+ GlSprintf,
},
props: {
itemsToBeDeleted: {
type: Array,
required: true,
},
+ showRequestForwardingContent: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- description() {
- return n__(
- 'PackageRegistry|You are about to delete 1 package. This operation is irreversible.',
- `PackageRegistry|You are about to delete %d packages. This operation is irreversible.`,
- this.itemsToBeDeleted.length,
- );
+ itemToBeDeleted() {
+ return this.itemsToBeDeleted.length === 1 ? this.itemsToBeDeleted[0] : null;
+ },
+ title() {
+ return this.itemToBeDeleted ? DELETE_MODAL_TITLE : DELETE_PACKAGES_MODAL_TITLE;
+ },
+ packageDescription() {
+ return this.showRequestForwardingContent
+ ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT
+ : DELETE_MODAL_CONTENT;
+ },
+ packagesDescription() {
+ return this.showRequestForwardingContent
+ ? DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT
+ : DELETE_PACKAGES_MODAL_DESCRIPTION;
+ },
+ packagesDeletePrimaryActionProps() {
+ let text = DELETE_PACKAGE_MODAL_PRIMARY_ACTION;
+
+ if (this.showRequestForwardingContent) {
+ if (this.itemToBeDeleted) {
+ text = DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION;
+ } else {
+ text = DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION;
+ }
+ }
+ return {
+ text,
+ attributes: { variant: 'danger', category: 'primary' },
+ };
},
},
modal: {
- packagesDeletePrimaryAction: {
- text: DELETE_PACKAGE_MODAL_PRIMARY_ACTION,
- attributes: [{ variant: 'danger' }, { category: 'primary' }],
- },
cancelAction: {
text: __('Cancel'),
},
@@ -43,6 +75,9 @@ export default {
this.$refs.deleteModal.show();
},
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
};
</script>
@@ -51,12 +86,33 @@ export default {
ref="deleteModal"
size="sm"
modal-id="delete-packages-modal"
- :action-primary="$options.modal.packagesDeletePrimaryAction"
+ :action-primary="packagesDeletePrimaryActionProps"
:action-cancel="$options.modal.cancelAction"
- :title="$options.i18n.DELETE_PACKAGES_MODAL_TITLE"
+ :title="title"
@primary="$emit('confirm')"
@cancel="$emit('cancel')"
>
- <span>{{ description }}</span>
+ <p>
+ <gl-sprintf v-if="itemToBeDeleted" :message="packageDescription">
+ <template v-if="showRequestForwardingContent" #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ <template #version>
+ <strong>{{ itemToBeDeleted.version }}</strong>
+ </template>
+ <template #name>
+ <strong>{{ itemToBeDeleted.name }}</strong>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="packagesDescription">
+ <template v-if="showRequestForwardingContent" #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+
+ <template #count>
+ {{ itemsToBeDeleted.length }}
+ </template>
+ </gl-sprintf>
+ </p>
</gl-modal>
</template>
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..482249bc252 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -1,19 +1,29 @@
<script>
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { n__ } from '~/locale';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_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,
+ FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE,
+ GRAPHQL_PAGE_SIZE,
+ 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';
+import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql';
export default {
components: {
DeleteModal,
+ GlAlert,
VersionRow,
PackagesListLoader,
RegistryList,
@@ -25,49 +35,139 @@ export default {
required: false,
default: false,
},
- versions: {
- type: Array,
- required: true,
- default: () => [],
+ count: {
+ type: Number,
+ required: false,
+ default: 0,
},
- pageInfo: {
- type: Object,
- required: true,
+ isMutationLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- isLoading: {
+ isRequestForwardingEnabled: {
type: Boolean,
required: false,
default: false,
},
+ packageId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
itemsToBeDeleted: [],
+ packageVersions: {},
+ fetchPackageVersionsError: false,
};
},
+ apollo: {
+ packageVersions: {
+ query: getPackageVersionsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ skip() {
+ return this.isListEmpty;
+ },
+ update(data) {
+ return data.package?.versions ?? {};
+ },
+ error(error) {
+ this.fetchPackageVersionsError = true;
+ Sentry.captureException(error);
+ },
+ },
+ },
computed: {
+ itemToBeDeleted() {
+ return this.itemsToBeDeleted.length === 1 ? this.itemsToBeDeleted[0] : null;
+ },
+ isListEmpty() {
+ return this.count === 0;
+ },
+ isLoading() {
+ return this.$apollo.queries.packageVersions.loading || this.isMutationLoading;
+ },
+ pageInfo() {
+ return this.packageVersions?.pageInfo ?? {};
+ },
listTitle() {
return n__('%d version', '%d versions', this.versions.length);
},
- isListEmpty() {
- return this.versions.length === 0;
+ queryVariables() {
+ return {
+ id: this.packageId,
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
+ tracking() {
+ const category = this.itemToBeDeleted
+ ? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
+ : undefined;
+ return {
+ category,
+ };
+ },
+ versions() {
+ return this.packageVersions?.nodes ?? [];
},
},
methods: {
deleteItemsCanceled() {
- this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ if (this.itemToBeDeleted) {
+ this.track(CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ } else {
+ this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ }
+
this.itemsToBeDeleted = [];
},
deleteItemsConfirmation() {
this.$emit('delete', this.itemsToBeDeleted);
- this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ if (this.itemToBeDeleted) {
+ this.track(DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ }
this.itemsToBeDeleted = [];
},
setItemsToBeDeleted(items) {
this.itemsToBeDeleted = items;
- this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ if (items.length === 1) {
+ this.track(REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION);
+ } else {
+ this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ }
this.$refs.deletePackagesModal.show();
},
+ fetchPreviousVersionsPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo?.startCursor,
+ };
+ this.$apollo.queries.packageVersions.fetchMore({
+ variables,
+ });
+ },
+ fetchNextVersionsPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: GRAPHQL_PAGE_SIZE,
+ last: null,
+ after: this.pageInfo?.endCursor,
+ };
+
+ this.$apollo.queries.packageVersions.fetchMore({
+ variables,
+ });
+ },
+ },
+ i18n: {
+ errorMessage: FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE,
},
};
</script>
@@ -76,6 +176,9 @@ export default {
<div v-if="isLoading">
<packages-list-loader />
</div>
+ <gl-alert v-else-if="fetchPackageVersionsError" variant="danger" :dismissible="false">{{
+ $options.i18n.errorMessage
+ }}</gl-alert>
<slot v-else-if="isListEmpty" name="empty-state"></slot>
<div v-else>
<registry-list
@@ -85,17 +188,15 @@ export default {
:pagination="pageInfo"
:title="listTitle"
@delete="setItemsToBeDeleted"
- @prev-page="$emit('prev-page')"
- @next-page="$emit('next-page')"
+ @prev-page="fetchPreviousVersionsPage"
+ @next-page="fetchNextVersionsPage"
>
<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="setItemsToBeDeleted([item])"
@select="selectItem(item)"
/>
</template>
@@ -104,6 +205,7 @@ export default {
<delete-modal
ref="deletePackagesModal"
:items-to-be-deleted="itemsToBeDeleted"
+ :show-request-forwarding-content="isRequestForwardingEnabled"
@confirm="deleteItemsConfirmation"
@cancel="deleteItemsCanceled"
/>
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..37a6fe75f15 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,20 @@ export default {
</gl-sprintf>
</span>
</template>
+
+ <template v-if="packageEntity.canDestroy" #right-action>
+ <gl-dropdown
+ data-testid="delete-dropdown"
+ icon="ellipsis_v"
+ :text="$options.i18n.moreActions"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item data-testid="action-delete" variant="danger" @click="$emit('delete')">{{
+ $options.i18n.deletePackage
+ }}</gl-dropdown-item>
+ </gl-dropdown>
+ </template>
</list-item>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/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..4ec83a869b3 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,
@@ -21,7 +22,6 @@ import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/
import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
-import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -38,7 +38,6 @@ export default {
PackagePath,
PublishMethod,
ListItem,
- PackageIconAndName,
TimeagoTooltip,
},
directives: {
@@ -91,7 +90,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'),
@@ -150,9 +149,7 @@ export default {
</gl-sprintf>
</div>
- <package-icon-and-name>
- {{ packageType }}
- </package-icon-and-name>
+ <span class="gl-ml-2" data-testid="package-type">&middot; {{ packageType }}</span>
<package-path
v-if="isGroupPage"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
index 440e11a99f2..05359128af4 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
@@ -39,5 +39,8 @@ export default {
<template #metadata-amount>
<metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" />
</template>
+ <template #right-actions>
+ <slot name="settings-link"></slot>
+ </template>
</title-area>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index 486ab4fdc99..effed4891d8 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -1,7 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
-import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
@@ -14,16 +13,24 @@ import {
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGES_TRACKING_ACTION,
PACKAGE_ERROR_STATUS,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NPM,
+ PACKAGE_TYPE_PYPI,
} from '~/packages_and_registries/package_registry/constants';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import Tracking from '~/tracking';
+const forwardingFieldToPackageTypeMapping = {
+ mavenPackageRequestsForwarding: PACKAGE_TYPE_MAVEN,
+ npmPackageRequestsForwarding: PACKAGE_TYPE_NPM,
+ pypiPackageRequestsForwarding: PACKAGE_TYPE_PYPI,
+};
+
export default {
name: 'PackagesList',
components: {
GlAlert,
DeleteModal,
- DeletePackageModal,
PackagesListLoader,
PackagesListRow,
RegistryList,
@@ -44,16 +51,27 @@ export default {
type: Object,
required: true,
},
+ groupSettings: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
- itemToBeDeleted: null,
itemsToBeDeleted: [],
errorPackages: [],
};
},
computed: {
+ itemToBeDeleted() {
+ if (this.itemsToBeDeleted.length === 1) {
+ const [itemToBeDeleted] = this.itemsToBeDeleted;
+ return itemToBeDeleted;
+ }
+ return null;
+ },
listTitle() {
return n__('%d package', '%d packages', this.list.length);
},
@@ -77,6 +95,15 @@ export default {
showErrorPackageAlert() {
return this.errorPackages.length > 0;
},
+ packageTypesWithForwardingEnabled() {
+ return Object.keys(this.groupSettings)
+ .filter((field) => this.groupSettings[field])
+ .map((field) => forwardingFieldToPackageTypeMapping[field]);
+ },
+ isRequestForwardingEnabled() {
+ const selectedPackageTypes = new Set(this.itemsToBeDeleted.map((item) => item.packageType));
+ return this.packageTypesWithForwardingEnabled.some((type) => selectedPackageTypes.has(type));
+ },
},
watch: {
list(newVal) {
@@ -88,40 +115,36 @@ export default {
this.list.length > 0 ? this.list.filter((pkg) => pkg.status === PACKAGE_ERROR_STATUS) : [];
},
methods: {
- setItemToBeDeleted(item) {
- this.itemToBeDeleted = { ...item };
- this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
- },
setItemsToBeDeleted(items) {
+ this.itemsToBeDeleted = items;
if (items.length === 1) {
- const [item] = items;
- this.setItemToBeDeleted(item);
- return;
+ this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION);
}
- this.itemsToBeDeleted = items;
- this.track(REQUEST_DELETE_PACKAGES_TRACKING_ACTION);
this.$refs.deletePackagesModal.show();
},
deleteItemsConfirmation() {
this.$emit('delete', this.itemsToBeDeleted);
- this.track(DELETE_PACKAGES_TRACKING_ACTION);
+
+ if (this.itemToBeDeleted) {
+ this.track(DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGES_TRACKING_ACTION);
+ }
+
this.itemsToBeDeleted = [];
},
deleteItemsCanceled() {
- this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION);
+ if (this.itemToBeDeleted) {
+ this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
+ } else {
+ this.track(CANCEL_DELETE_PACKAGES_TRACKING_ACTION);
+ }
this.itemsToBeDeleted = [];
},
- deleteItemConfirmation() {
- this.$emit('delete', [this.itemToBeDeleted]);
- this.track(DELETE_PACKAGE_TRACKING_ACTION);
- this.itemToBeDeleted = null;
- },
- deleteItemCanceled() {
- this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
- this.itemToBeDeleted = null;
- },
showConfirmationModal() {
- this.setItemToBeDeleted(this.errorPackages[0]);
+ this.setItemsToBeDeleted([this.errorPackages[0]]);
},
},
i18n: {
@@ -165,21 +188,16 @@ export default {
:first="first"
:package-entity="item"
:selected="isSelected(item)"
- @delete="setItemToBeDeleted(item)"
+ @delete="setItemsToBeDeleted([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"
+ :show-request-forwarding-content="isRequestForwardingEnabled"
@confirm="deleteItemsConfirmation"
@cancel="deleteItemsCanceled"
/>
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..b4276d69ed6 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,14 @@ 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 FETCH_PACKAGE_VERSIONS_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Failed to load version data',
+);
+
export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting packages.',
);
@@ -126,7 +126,23 @@ 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_PACKAGES_MODAL_DESCRIPTION = s__(
+ 'PackageRegistry|You are about to delete %{count} packages. This operation is irreversible.',
+);
+export const DELETE_PACKAGE_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__(
+ 'PackageRegistry|Yes, delete package',
+);
+export const DELETE_PACKAGES_WITH_REQUEST_FORWARDING_PRIMARY_ACTION = s__(
+ 'PackageRegistry|Yes, delete selected packages',
+);
+export const DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT = s__(
+ 'PackageRegistry|Deleting this package while request forwarding is enabled for the project can pose a security risk. Do you want to delete %{name} version %{version} anyway? %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
+export const DELETE_PACKAGES_REQUEST_FORWARDING_MODAL_CONTENT = s__(
+ 'PackageRegistry|Some of the selected package formats allow request forwarding. Deleting a package while request forwarding is enabled for the project can pose a security risk. Do you want to proceed with deleting the selected packages? %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
+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 +158,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 +165,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';
@@ -214,5 +226,9 @@ export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/inde
export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index');
export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index');
export const PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens');
+export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/package_registry/supported_functionality',
+ { anchor: 'deleting-packages' },
+);
export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
index 2d405f3e9cc..bcd90b7bee5 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
@@ -12,7 +12,7 @@ fragment PackageData on Package {
name
}
}
- pipelines(last: 1) {
+ pipelines(first: 1) {
nodes {
id
sha
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql
new file mode 100644
index 00000000000..db05f497b7f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql
@@ -0,0 +1,8 @@
+fragment GroupPackageSettings on Group {
+ id
+ packageSettings {
+ mavenPackageRequestsForwarding
+ npmPackageRequestsForwarding
+ pypiPackageRequestsForwarding
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index 56f95fa2c1f..39e5da54509 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -4,6 +4,27 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
+export const mergeVariables = (existing, incoming) => {
+ if (!incoming) return existing;
+ return incoming;
+};
+
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ typePolicies: {
+ PackageDetailsType: {
+ fields: {
+ versions: {
+ keyArgs: false,
+ merge: mergeVariables,
+ },
+ },
+ },
+ },
+ },
+ },
+ ),
});
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..984996b829a 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -1,10 +1,6 @@
-query getPackageDetails(
- $id: PackagesPackageID!
- $first: Int
- $last: Int
- $after: String
- $before: String
-) {
+#import "~/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql"
+
+query getPackageDetails($id: PackagesPackageID!) {
package(id: $id) {
id
name
@@ -15,6 +11,7 @@ query getPackageDetails(
updatedAt
status
canDestroy
+ publicPackage
npmUrl
mavenUrl
conanUrl
@@ -28,6 +25,9 @@ query getPackageDetails(
path
name
fullPath
+ group {
+ ...GroupPackageSettings
+ }
}
tags(first: 10) {
nodes {
@@ -61,31 +61,8 @@ query getPackageDetails(
downloadPath
}
}
- versions(after: $after, before: $before, first: $first, last: $last) {
+ versions {
count
- nodes {
- id
- name
- canDestroy
- createdAt
- version
- status
- _links {
- webPath
- }
- tags(first: 1) {
- nodes {
- id
- name
- }
- }
- }
- pageInfo {
- hasNextPage
- hasPreviousPage
- endCursor
- startCursor
- }
}
dependencyLinks {
nodes {
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql
new file mode 100644
index 00000000000..a4119ac5821
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql
@@ -0,0 +1,38 @@
+query getPackageVersions(
+ $id: PackagesPackageID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ package(id: $id) {
+ id
+ versions(after: $after, before: $before, first: $first, last: $last) {
+ count
+ nodes {
+ id
+ name
+ canDestroy
+ createdAt
+ packageType
+ version
+ status
+ _links {
+ webPath
+ }
+ tags(first: 1) {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
index 5bde5f08e56..f25f24cbc5f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
@@ -1,4 +1,5 @@
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
+#import "~/packages_and_registries/package_registry/graphql/fragments/package_group_settings.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getPackages(
@@ -32,6 +33,9 @@ query getPackages(
...PageInfo
}
}
+ group {
+ ...GroupPackageSettings
+ }
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
id
@@ -52,5 +56,6 @@ query getPackages(
...PageInfo
}
}
+ ...GroupPackageSettings
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js
index 15ed98122a0..e2f8d239bae 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js
@@ -19,6 +19,7 @@ export default () => {
npmInstanceUrl,
projectListUrl,
groupListUrl,
+ settingsPath,
} = el.dataset;
const isGroupPage = pageType === 'groups';
@@ -48,6 +49,7 @@ export default () => {
projectListUrl,
groupListUrl,
breadCrumbState,
+ settingsPath,
},
render(createElement) {
return createElement(PackageRegistry);
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..6d4979ac785 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
@@ -2,6 +2,7 @@
import {
GlBadge,
GlButton,
+ GlLink,
GlModal,
GlModalDirective,
GlTooltipDirective,
@@ -10,7 +11,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';
@@ -37,6 +38,7 @@ import {
DELETE_PACKAGE_FILE_TRACKING_ACTION,
DELETE_PACKAGE_FILES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
@@ -44,6 +46,7 @@ import {
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
DELETE_MODAL_TITLE,
DELETE_MODAL_CONTENT,
@@ -54,6 +57,7 @@ import {
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
+import getPackageVersionsQuery from '~/packages_and_registries/package_registry/graphql/queries/get_package_versions.query.graphql';
import Tracking from '~/tracking';
export default {
@@ -63,6 +67,7 @@ export default {
GlButton,
GlEmptyState,
GlModal,
+ GlLink,
GlTab,
GlTabs,
GlSprintf,
@@ -123,6 +128,11 @@ export default {
},
},
computed: {
+ deleteModalContent() {
+ return this.isRequestForwardingEnabled
+ ? DELETE_PACKAGE_REQUEST_FORWARDING_MODAL_CONTENT
+ : this.deletePackageModalContent;
+ },
projectName() {
return this.packageEntity.project?.name;
},
@@ -135,7 +145,6 @@ export default {
queryVariables() {
return {
id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId),
- first: GRAPHQL_PAGE_SIZE,
};
},
packageFiles() {
@@ -147,9 +156,6 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
- isVersionsLoading() {
- return this.isLoading || this.versionsMutationLoading;
- },
packageFilesLoading() {
return this.isLoading || this.mutationLoading;
},
@@ -161,18 +167,24 @@ export default {
category: packageTypeToTrackCategory(this.packageType),
};
},
- versionPageInfo() {
- return this.packageEntity?.versions?.pageInfo ?? {};
- },
packageDependencies() {
return this.packageEntity.dependencyLinks?.nodes || [];
},
+ packageVersionsCount() {
+ return this.packageEntity.versions?.count ?? 0;
+ },
showDependencies() {
return this.packageType === PACKAGE_TYPE_NUGET;
},
showFiles() {
return this.packageType !== PACKAGE_TYPE_COMPOSER;
},
+ groupSettings() {
+ return this.packageEntity.project?.group?.packageSettings ?? {};
+ },
+ isRequestForwardingEnabled() {
+ return this.groupSettings[`${this.packageType.toLowerCase()}PackageRequestsForwarding`];
+ },
showMetadata() {
return [
PACKAGE_TYPE_COMPOSER,
@@ -190,6 +202,17 @@ export default {
},
];
},
+ refetchVersionsQueryData() {
+ return [
+ {
+ query: getPackageVersionsQuery,
+ variables: {
+ id: this.queryVariables.id,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ },
+ ];
+ },
},
methods: {
formatSize(size) {
@@ -274,34 +297,6 @@ export default {
resetDeleteModalContent() {
this.deletePackageModalContent = DELETE_MODAL_CONTENT;
},
- updateQuery(_, { fetchMoreResult }) {
- return fetchMoreResult;
- },
- fetchPreviousVersionsPage() {
- const variables = {
- ...this.queryVariables,
- first: null,
- last: GRAPHQL_PAGE_SIZE,
- before: this.versionPageInfo?.startCursor,
- };
- this.$apollo.queries.packageEntity.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- },
- fetchNextVersionsPage() {
- const variables = {
- ...this.queryVariables,
- first: GRAPHQL_PAGE_SIZE,
- last: null,
- after: this.versionPageInfo?.endCursor,
- };
-
- this.$apollo.queries.packageEntity.fetchMore({
- variables,
- updateQuery: this.updateQuery,
- });
- },
},
i18n: {
DELETE_MODAL_TITLE,
@@ -311,22 +306,25 @@ export default {
),
otherVersionsTabTitle: s__('PackageRegistry|Other versions'),
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
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'),
@@ -403,12 +401,12 @@ export default {
<template #title>
<span>{{ $options.i18n.otherVersionsTabTitle }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge" data-testid="other-versions-badge">{{
- packageEntity.versions.count
+ packageVersionsCount
}}</gl-badge>
</template>
<delete-packages
- :refetch-queries="refetchQueriesData"
+ :refetch-queries="refetchVersionsQueryData"
show-success-alert
@start="versionsMutationLoading = true"
@end="versionsMutationLoading = false"
@@ -416,12 +414,11 @@ export default {
<template #default="{ deletePackages }">
<package-versions-list
:can-destroy="packageEntity.canDestroy"
- :is-loading="isVersionsLoading"
- :page-info="versionPageInfo"
- :versions="packageEntity.versions.nodes"
+ :count="packageVersionsCount"
+ :is-mutation-loading="versionsMutationLoading"
+ :is-request-forwarding-enabled="isRequestForwardingEnabled"
+ :package-id="packageEntity.id"
@delete="deletePackages"
- @prev-page="fetchPreviousVersionsPage"
- @next-page="fetchNextVersionsPage"
>
<template #empty-state>
<p class="gl-mt-3" data-testid="no-versions-message">
@@ -451,15 +448,23 @@ export default {
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
>
<template #modal-title>{{ $options.i18n.DELETE_MODAL_TITLE }}</template>
- <gl-sprintf :message="deletePackageModalContent">
- <template #version>
- <strong>{{ packageEntity.version }}</strong>
- </template>
+ <p>
+ <gl-sprintf :message="deleteModalContent">
+ <template v-if="isRequestForwardingEnabled" #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{
+ content
+ }}</gl-link>
+ </template>
- <template #name>
- <strong>{{ packageEntity.name }}</strong>
- </template>
- </gl-sprintf>
+ <template #version>
+ <strong>{{ packageEntity.version }}</strong>
+ </template>
+
+ <template #name>
+ <strong>{{ packageEntity.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
</gl-modal>
</template>
</delete-packages>
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..044ce4e6413 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 { GlButton, GlEmptyState, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+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,
@@ -20,6 +19,7 @@ import PackageList from '~/packages_and_registries/package_registry/components/l
export default {
components: {
+ GlButton,
GlEmptyState,
GlLink,
GlSprintf,
@@ -28,23 +28,26 @@ export default {
PackageSearch,
DeletePackages,
},
- inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['emptyListIllustration', 'isGroupPage', 'fullPath', 'settingsPath'],
data() {
return {
- packages: {},
+ packagesResource: {},
sort: '',
filters: {},
mutationLoading: false,
};
},
apollo: {
- packages: {
+ packagesResource: {
query: getPackagesQuery,
variables() {
return this.queryVariables;
},
update(data) {
- return data[this.graphqlResource].packages;
+ return data[this.graphqlResource] ?? {};
},
skip() {
return !this.sort;
@@ -52,6 +55,14 @@ export default {
},
},
computed: {
+ packages() {
+ return this.packagesResource?.packages ?? {};
+ },
+ groupSettings() {
+ return this.isGroupPage
+ ? this.packagesResource?.packageSettings ?? {}
+ : this.packagesResource?.group?.packageSettings ?? {};
+ },
queryVariables() {
return {
isGroupPage: this.isGroupPage,
@@ -64,7 +75,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 ?? {};
@@ -84,7 +95,7 @@ export default {
: this.$options.i18n.noResultsTitle;
},
isLoading() {
- return this.$apollo.queries.packages.loading || this.mutationLoading;
+ return this.$apollo.queries.packagesResource.loading || this.mutationLoading;
},
refetchQueriesData() {
return [
@@ -124,7 +135,7 @@ export default {
after: this.pageInfo?.endCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -137,7 +148,7 @@ export default {
before: this.pageInfo?.startCursor,
};
- this.$apollo.queries.packages.fetchMore({
+ this.$apollo.queries.packagesResource.fetchMore({
variables,
updateQuery: this.updateQuery,
});
@@ -150,6 +161,7 @@ export default {
noResultsText: s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
+ settingsText: s__('PackageRegistry|Configure in settings'),
},
links: {
EMPTY_LIST_HELP_URL,
@@ -160,7 +172,16 @@ export default {
<template>
<div>
- <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" />
+ <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount">
+ <template v-if="settingsPath" #settings-link>
+ <gl-button
+ v-gl-tooltip="$options.i18n.settingsText"
+ icon="settings"
+ :href="settingsPath"
+ :aria-label="$options.i18n.settingsText"
+ />
+ </template>
+ </package-title>
<package-search class="gl-mb-5" @update="handleSearchUpdate" />
<delete-packages
@@ -171,6 +192,7 @@ export default {
>
<template #default="{ deletePackages }">
<package-list
+ :group-settings="groupSettings"
:list="packages.nodes"
:is-loading="isLoading"
:page-info="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/components/packages_forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
index b7d7f0aaca7..ab88d9e8936 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue
@@ -1,12 +1,14 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { isEqual } from 'lodash';
import {
+ PACKAGE_FORWARDING_SECURITY_DESCRIPTION,
PACKAGE_FORWARDING_SETTINGS_HEADER,
PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
PACKAGE_FORWARDING_FORM_BUTTON,
PACKAGE_FORWARDING_FIELDS,
MAVEN_FORWARDING_FIELDS,
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
} from '~/packages_and_registries/settings/group/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
@@ -20,12 +22,15 @@ export default {
name: 'PackageForwardingSettings',
i18n: {
PACKAGE_FORWARDING_FORM_BUTTON,
+ PACKAGE_FORWARDING_SECURITY_DESCRIPTION,
PACKAGE_FORWARDING_SETTINGS_HEADER,
PACKAGE_FORWARDING_SETTINGS_DESCRIPTION,
},
components: {
ForwardingSettings,
GlButton,
+ GlLink,
+ GlSprintf,
SettingsBlock,
},
mixins: [glFeatureFlagsMixin()],
@@ -150,6 +155,9 @@ export default {
this.$set(this.workingCopy, type, value);
},
},
+ links: {
+ REQUEST_FORWARDING_HELP_PAGE_PATH,
+ },
};
</script>
@@ -157,9 +165,14 @@ export default {
<settings-block>
<template #title> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_HEADER }}</template>
<template #description>
- <span data-testid="description">
+ <span class="gl-display-block gl-mb-2" data-testid="description">
{{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_DESCRIPTION }}
</span>
+ <gl-sprintf :message="$options.i18n.PACKAGE_FORWARDING_SECURITY_DESCRIPTION">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.REQUEST_FORWARDING_HELP_PAGE_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
<template #default>
<form @submit.prevent="submit">
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..fa73c01c5c4 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -17,6 +17,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
);
+export const PACKAGE_FORWARDING_SECURITY_DESCRIPTION = s__(
+ 'PackageRegistry|There are security risks if packages are deleted while request forwarding is enabled. %{docLinkStart}What are the risks?%{docLinkEnd}',
+);
export const PACKAGE_FORWARDING_SETTINGS_HEADER = s__('PackageRegistry|Package forwarding');
export const PACKAGE_FORWARDING_SETTINGS_DESCRIPTION = s__(
'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.',
@@ -78,8 +81,8 @@ 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');
+export const REQUEST_FORWARDING_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/package_registry/supported_functionality',
+ { anchor: 'deleting-packages' },
+);
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..c13d49b5379 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
@@ -225,9 +225,6 @@ export default {
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
- <template #secondStrong="{ content }">
- <strong>{{ content }}</strong>
- </template>
</gl-sprintf>
</p>
<expiration-dropdown
@@ -264,9 +261,6 @@ export default {
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
- <template #secondStrong="{ content }">
- <strong>{{ content }}</strong>
- </template>
</gl-sprintf>
</p>
<expiration-dropdown
@@ -312,7 +306,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/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
index 7a9ea7c0bf7..35fc0910a16 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
@@ -8,7 +8,7 @@ import {
export default {
i18n: {
- toggleLabel: s__('ContainerRegistry|Enable expiration policy'),
+ toggleLabel: s__('ContainerRegistry|Enable cleanup policy'),
},
components: {
GlFormGroup,
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/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 731fb3e4c45..05616a0a4f6 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -1,6 +1,6 @@
import { s__, __ } from '~/locale';
-export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up image tags`);
+export const CONTAINER_CLEANUP_POLICY_TITLE = s__('ContainerRegistry|Cleanup policies');
export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__(
`ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`,
);
@@ -29,7 +29,7 @@ export const TEXT_AREA_INVALID_FEEDBACK = s__(
export const KEEP_HEADER_TEXT = s__('ContainerRegistry|Keep these tags');
export const KEEP_INFO_TEXT = s__(
- 'ContainerRegistry|Tags that match these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag is always kept.',
+ 'ContainerRegistry|Tags that match %{strongStart}any of%{strongEnd} these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{strongStart}latest%{strongEnd} tag is always kept.',
);
export const KEEP_N_LABEL = s__('ContainerRegistry|Keep the most recent:');
export const NAME_REGEX_KEEP_LABEL = s__('ContainerRegistry|Keep tags matching:');
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue
deleted file mode 100644
index 105f7bbe132..00000000000
--- a/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue
+++ /dev/null
@@ -1,17 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-
-export default {
- name: 'PackageIconAndName',
- components: {
- GlIcon,
- },
-};
-</script>
-
-<template>
- <div class="gl-display-flex gl-align-items-center">
- <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
- <span><slot></slot></span>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
index 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/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js
index 76623377d90..adffab277cc 100644
--- a/app/assets/javascripts/packages_and_registries/shared/utils.js
+++ b/app/assets/javascripts/packages_and_registries/shared/utils.js
@@ -55,15 +55,6 @@ export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) =>
RegistryBreadcrumb,
},
render(createElement) {
- // FIXME(@tnir): this is a workaround until the MR gets merged:
- // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115
- const parentEl = breadCrumbEl.parentElement.parentElement;
- if (parentEl) {
- parentEl.classList.remove('breadcrumbs-container');
- parentEl.classList.add('gl-display-flex');
- parentEl.classList.add('w-100');
- }
- // End of FIXME(@tnir)
return createElement('registry-breadcrumb', {
class: breadCrumbEl.className,
props: {
diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js
index feceeb0b10a..ea7c9042e6d 100644
--- a/app/assets/javascripts/pages/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/abuse_reports/index.js
@@ -1,3 +1,5 @@
import { initLinkToSpam } from '~/abuse_reports';
+import initFilePickers from '~/file_pickers';
initLinkToSpam();
+initFilePickers();
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/abuse_reports/show/index.js b/app/assets/javascripts/pages/admin/abuse_reports/show/index.js
new file mode 100644
index 00000000000..13475f9560f
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/abuse_reports/show/index.js
@@ -0,0 +1,3 @@
+import { initAbuseReportApp } from '~/admin/abuse_report';
+
+initAbuseReportApp();
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..cde46c3da50 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
@@ -58,8 +58,6 @@ export default {
'emailRestrictions',
'afterSignUpText',
'pendingUserCount',
- 'projectSharingHelpLink',
- 'groupSharingHelpLink',
],
data() {
return {
@@ -83,8 +81,6 @@ export default {
supportedSyntaxLinkUrl: this.supportedSyntaxLinkUrl,
emailRestrictions: this.emailRestrictions,
afterSignUpText: this.afterSignUpText,
- projectSharingHelpLink: this.projectSharingHelpLink,
- groupSharingHelpLink: this.groupSharingHelpLink,
},
};
},
@@ -207,6 +203,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.',
@@ -220,7 +220,7 @@ export default {
),
userCapLabel: s__('ApplicationSettings|User cap'),
userCapDescription: s__(
- 'ApplicationSettings|After the instance reaches the user cap, any user who is added or requests access must be approved by an administrator. Leave blank for an unlimited user cap. If you change the user cap to unlimited, you must re-enable %{projectSharingLinkStart}project sharing%{projectSharingLinkEnd} and %{groupSharingLinkStart}group sharing%{groupSharingLinkEnd}.',
+ 'ApplicationSettings|After the instance reaches the user cap, any user who is added or requests access must be approved by an administrator. Leave blank for unlimited.',
),
domainDenyListGroupLabel: s__('ApplicationSettings|Domain denylist'),
domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign-ups'),
@@ -286,23 +286,29 @@ 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>
<gl-form-group
:label="$options.i18n.userCapLabel"
:description="$options.i18n.userCapDescription"
- data-testid="user-cap-form-group"
>
<gl-form-input
v-model="form.userCap"
@@ -310,17 +316,6 @@ export default {
name="application_setting[new_user_signups_cap]"
data-testid="user-cap-input"
/>
-
- <template #description>
- <gl-sprintf :message="$options.i18n.userCapDescription">
- <template #projectSharingLink="{ content }">
- <gl-link :href="projectSharingHelpLink" target="_blank">{{ content }}</gl-link>
- </template>
- <template #groupSharingLink="{ content }">
- <gl-link :href="groupSharingHelpLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
</gl-form-group>
<gl-form-group :label="$options.i18n.minimumPasswordLengthLabel">
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index 366be334e87..6c38615c16c 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -1,10 +1,8 @@
import initVariableList from '~/ci/ci_variable_list';
import initSearchSettings from '~/search_settings';
-import selfMonitor from '~/self_monitor';
import initSettingsPanels from '~/settings_panels';
initVariableList('js-instance-variables');
-selfMonitor();
// Initialize expandable settings panels
initSettingsPanels();
initSearchSettings();
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/applications/index.js b/app/assets/javascripts/pages/admin/applications/index.js
index 3397b02aeba..df9e38431b0 100644
--- a/app/assets/javascripts/pages/admin/applications/index.js
+++ b/app/assets/javascripts/pages/admin/applications/index.js
@@ -1,3 +1,5 @@
import initApplicationDeleteButtons from '~/admin/applications';
+import { initOAuthApplicationSecret } from '~/oauth_application';
initApplicationDeleteButtons();
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue
index 72cfc005782..72cfc005782 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue
+++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs.vue
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue
index d5857294617..b2c5326fefd 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/components/cancel_jobs_modal.vue
@@ -1,8 +1,8 @@
<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 { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import {
CANCEL_TEXT,
CANCEL_JOBS_FAILED_TEXT,
@@ -31,7 +31,7 @@ export default {
.post(this.url)
.then((response) => {
// follow the rediect to refresh the page
- redirectTo(response.request.responseURL);
+ redirectTo(response.request.responseURL); // eslint-disable-line import/no-deprecated
})
.catch((error) => {
createAlert({
@@ -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/jobs/components/constants.js b/app/assets/javascripts/pages/admin/jobs/components/constants.js
new file mode 100644
index 00000000000..8c4ea2cde92
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/constants.js
@@ -0,0 +1,29 @@
+import { s__, __ } from '~/locale';
+import { DEFAULT_FIELDS, RAW_TEXT_WARNING } from '~/jobs/components/table/constants';
+
+export const JOBS_COUNT_ERROR_MESSAGE = __('There was an error fetching the number of jobs.');
+export const JOBS_FETCH_ERROR_MSG = __('There was an error fetching the jobs.');
+export const LOADING_ARIA_LABEL = __('Loading');
+export const CANCELABLE_JOBS_ERROR_MSG = __('There was an error fetching the cancelable jobs.');
+export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal';
+export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?');
+export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs');
+export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs');
+export const CANCEL_TEXT = __('Cancel');
+export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed');
+export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed');
+export const CANCEL_JOBS_WARNING = s__(
+ "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?",
+);
+export const RUNNER_EMPTY_TEXT = __('None');
+export const RUNNER_NO_DESCRIPTION = s__('Runners|No description');
+
+/* Admin Table constants */
+export const DEFAULT_FIELDS_ADMIN = [
+ ...DEFAULT_FIELDS.slice(0, 2),
+ { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' },
+ { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' },
+ ...DEFAULT_FIELDS.slice(2),
+];
+
+export const RAW_TEXT_WARNING_ADMIN = RAW_TEXT_WARNING;
diff --git a/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue b/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue
new file mode 100644
index 00000000000..c305e09af0d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/jobs_skeleton_loader.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <gl-skeleton-loader :width="1248" :height="73">
+ <circle cx="748.031" cy="37.7193" r="15.0307" />
+ <circle cx="787.241" cy="37.7193" r="15.0307" />
+ <circle cx="827.759" cy="37.7193" r="15.0307" />
+ <circle cx="866.969" cy="37.7193" r="15.0307" />
+ <circle cx="380" cy="37" r="18" />
+ <rect x="432" y="19" width="126.587" height="15" />
+ <rect x="432" y="41" width="247" height="15" />
+ <rect x="158" y="19" width="86.1" height="15" />
+ <rect x="158" y="41" width="168" height="15" />
+ <rect x="22" y="19" width="96" height="36" />
+ <rect x="924" y="30" width="96" height="15" />
+ <rect x="1057" y="20" width="166" height="35" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue
new file mode 100644
index 00000000000..daa4119f44d
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/admin_jobs_table_app.vue
@@ -0,0 +1,259 @@
+<script>
+import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
+import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility';
+import { validateQueryString } from '~/jobs/components/filtered_search/utils';
+import JobsTable from '~/jobs/components/table/jobs_table.vue';
+import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
+import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
+import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue';
+import { createAlert } from '~/alert';
+import JobsSkeletonLoader from '../jobs_skeleton_loader.vue';
+import {
+ DEFAULT_FIELDS_ADMIN,
+ RAW_TEXT_WARNING_ADMIN,
+ JOBS_COUNT_ERROR_MESSAGE,
+ JOBS_FETCH_ERROR_MSG,
+ LOADING_ARIA_LABEL,
+ CANCELABLE_JOBS_ERROR_MSG,
+} from '../constants';
+import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql';
+import GetAllJobsCount from './graphql/queries/get_all_jobs_count.query.graphql';
+import CancelableJobs from './graphql/queries/get_cancelable_jobs_count.query.graphql';
+
+export default {
+ i18n: {
+ jobsCountErrorMsg: JOBS_COUNT_ERROR_MESSAGE,
+ jobsFetchErrorMsg: JOBS_FETCH_ERROR_MSG,
+ loadingAriaLabel: LOADING_ARIA_LABEL,
+ cancelableJobsErrorMsg: CANCELABLE_JOBS_ERROR_MSG,
+ },
+ filterSearchBoxStyles:
+ 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100',
+ components: {
+ JobsSkeletonLoader,
+ JobsTableEmptyState,
+ GlAlert,
+ JobsFilteredSearch,
+ JobsTable,
+ JobsTableTabs,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ },
+ inject: {
+ jobStatuses: {
+ default: null,
+ required: false,
+ },
+ url: {
+ default: '',
+ required: false,
+ },
+ emptyStateSvgPath: {
+ default: '',
+ required: false,
+ },
+ },
+ apollo: {
+ jobs: {
+ query: GetAllJobs,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data || {};
+ return {
+ list,
+ pageInfo,
+ };
+ },
+ error() {
+ this.error = this.$options.i18n.jobsFetchErrorMsg;
+ },
+ },
+ jobsCount: {
+ query: GetAllJobsCount,
+ update(data) {
+ return data?.jobs?.count || 0;
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ error() {
+ this.error = this.$options.i18n.jobsCountErrorMsg;
+ },
+ },
+ cancelable: {
+ query: CancelableJobs,
+ update(data) {
+ this.isCancelable = data.cancelable.count !== 0;
+ },
+ error() {
+ this.error = this.$options.i18n.cancelableJobsErrorMsg;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: {
+ list: [],
+ },
+ error: '',
+ count: 0,
+ scope: null,
+ infiniteScrollingTriggered: false,
+ filterSearchTriggered: false,
+ DEFAULT_FIELDS_ADMIN,
+ isCancelable: false,
+ jobsCount: null,
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.jobs.loading;
+ },
+ // 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
+ showEmptyState() {
+ return (
+ this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered
+ );
+ },
+ hasNextPage() {
+ return this.jobs?.pageInfo?.hasNextPage;
+ },
+ variables() {
+ return { ...this.validatedQueryString };
+ },
+ validatedQueryString() {
+ const queryStringObject = queryToObject(window.location.search);
+
+ return validateQueryString(queryStringObject);
+ },
+ showFilteredSearch() {
+ return !this.scope;
+ },
+ showLoadingSpinner() {
+ return this.loading && this.infiniteScrollingTriggered;
+ },
+ showSkeletonLoader() {
+ return this.loading && !this.showLoadingSpinner;
+ },
+ },
+ watch: {
+ // this watcher ensures that the count on the all tab
+ // is not updated when switching to the finished tab
+ jobsCount(newCount) {
+ if (this.scope) return;
+
+ this.count = newCount;
+ },
+ },
+ methods: {
+ updateHistoryAndFetchCount(status = null) {
+ this.$apollo.queries.jobsCount.refetch({ statuses: status });
+
+ updateHistory({
+ url: setUrlParams({ statuses: status }, window.location.href, true),
+ });
+ },
+ fetchJobsByStatus(scope) {
+ this.infiniteScrollingTriggered = false;
+
+ if (this.scope === scope) return;
+
+ this.scope = scope;
+
+ if (!this.scope) this.updateHistoryAndFetchCount();
+
+ this.$apollo.queries.jobs.refetch({ statuses: scope });
+ },
+ fetchMoreJobs() {
+ if (!this.loading) {
+ this.infiniteScrollingTriggered = true;
+
+ const parameters = this.variables;
+ parameters.after = this.jobs?.pageInfo?.endCursor;
+
+ this.$apollo.queries.jobs.fetchMore({
+ variables: parameters,
+ });
+ }
+ },
+ filterJobsBySearch(filters) {
+ this.infiniteScrollingTriggered = false;
+ this.filterSearchTriggered = true;
+
+ // all filters have been cleared reset query param
+ // and refetch jobs/count with defaults
+ if (!filters.length) {
+ this.updateHistoryAndFetchCount();
+ this.$apollo.queries.jobs.refetch({ statuses: null });
+
+ return;
+ }
+
+ // Eventually there will be more tokens available
+ // this code is written to scale for those tokens
+ filters.forEach((filter) => {
+ // Raw text input in filtered search does not have a type
+ // when a user enters raw text we alert them that it is
+ // not supported and we do not make an additional API call
+ if (!filter.type) {
+ createAlert({
+ message: RAW_TEXT_WARNING_ADMIN,
+ type: 'warning',
+ });
+ }
+
+ if (filter.type === 'status') {
+ this.updateHistoryAndFetchCount(filter.value.data);
+ this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
+ }
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="error" class="gl-mt-2" variant="danger" dismissible @dismiss="error = ''">
+ {{ error }}
+ </gl-alert>
+
+ <jobs-table-tabs
+ :all-jobs-count="count"
+ :loading="loading"
+ :show-cancel-all-jobs-button="isCancelable"
+ @fetchJobsByStatus="fetchJobsByStatus"
+ />
+
+ <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles">
+ <jobs-filtered-search
+ :query-string="validatedQueryString"
+ @filterJobsBySearch="filterJobsBySearch"
+ />
+ </div>
+
+ <jobs-skeleton-loader v-if="showSkeletonLoader" class="gl-mt-5" />
+
+ <jobs-table-empty-state v-else-if="showEmptyState" />
+
+ <jobs-table
+ v-else
+ :jobs="jobs.list"
+ :table-fields="DEFAULT_FIELDS_ADMIN"
+ admin
+ class="gl-table-no-top-border"
+ />
+
+ <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
+ <gl-loading-icon
+ v-if="showLoadingSpinner"
+ size="lg"
+ :aria-label="$options.i18n.loadingAriaLabel"
+ />
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue b/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue
new file mode 100644
index 00000000000..cbb80a5175f
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/cell/project_cell.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ projectName() {
+ return this.job.pipeline?.project?.fullPath;
+ },
+ projectUrl() {
+ return this.job.pipeline?.project?.webUrl;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-text-truncate">
+ <gl-link :href="projectUrl"> {{ projectName }}</gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue b/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue
new file mode 100644
index 00000000000..33bcee5b34b
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/cells/runner_cell.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { RUNNER_EMPTY_TEXT, RUNNER_NO_DESCRIPTION } from '~/pages/admin/jobs/components/constants';
+
+export default {
+ i18n: {
+ emptyRunnerText: RUNNER_EMPTY_TEXT,
+ noRunnerDescription: RUNNER_NO_DESCRIPTION,
+ },
+ components: {
+ GlLink,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ adminUrl() {
+ return this.job.runner?.adminUrl;
+ },
+ description() {
+ return this.job.runner?.description
+ ? this.job.runner.description
+ : this.$options.i18n.noRunnerDescription;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-text-truncate">
+ <gl-link v-if="adminUrl" :href="adminUrl">
+ {{ description }}
+ </gl-link>
+ <span v-else data-testid="empty-runner-text"> {{ $options.i18n.emptyRunnerText }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js
new file mode 100644
index 00000000000..fd7ee2a6f8c
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/cache_config.js
@@ -0,0 +1,62 @@
+import { isEqual } from 'lodash';
+
+export default {
+ typePolicies: {
+ Query: {
+ fields: {
+ jobs: {
+ keyArgs: ['statuses'],
+ },
+ },
+ },
+ CiJobConnection: {
+ merge(existing = {}, incoming, { args = {} }) {
+ 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;
+
+ 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 {
+ if (!existing.pageInfo?.hasNextPage) {
+ nodes = [...incoming.nodes];
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ count: incoming.count,
+ };
+ }
+
+ nodes = [...existing.nodes, ...incoming.nodes];
+ }
+ } else {
+ nodes = [...incoming.nodes];
+ }
+
+ return {
+ nodes,
+ statuses,
+ pageInfo,
+ count: incoming.count,
+ };
+ }
+
+ return {
+ nodes: existing.nodes,
+ pageInfo: existing.pageInfo,
+ statuses: args.statuses,
+ };
+ },
+ },
+ },
+};
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql
new file mode 100644
index 00000000000..9e2795966e0
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql
@@ -0,0 +1,85 @@
+query getAllJobs($after: String, $first: Int = 50, $statuses: [CiJobStatus!]) {
+ jobs(after: $after, first: $first, statuses: $statuses) {
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ }
+ nodes {
+ runner {
+ id
+ description
+ adminUrl
+ }
+ artifacts {
+ nodes {
+ id
+ downloadPath
+ fileType
+ }
+ }
+ allowFailure
+ status
+ scheduledAt
+ manualJob
+ triggered
+ createdByTag
+ detailedStatus {
+ id
+ detailsPath
+ group
+ icon
+ label
+ text
+ tooltip
+ action {
+ id
+ buttonTitle
+ icon
+ method
+ path
+ title
+ }
+ }
+ id
+ refName
+ refPath
+ tags
+ shortSha
+ commitPath
+ pipeline {
+ id
+ project {
+ id
+ fullPath
+ webUrl
+ }
+ path
+ user {
+ id
+ webPath
+ avatarUrl
+ }
+ }
+ stage {
+ id
+ name
+ }
+ name
+ duration
+ finishedAt
+ coverage
+ retryable
+ playable
+ cancelable
+ active
+ stuck
+ userPermissions {
+ readBuild
+ readJobArtifacts
+ updateBuild
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql
new file mode 100644
index 00000000000..8c59230b2b8
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql
@@ -0,0 +1,5 @@
+query getAllJobsCount($statuses: [CiJobStatus!]) {
+ jobs(statuses: $statuses) {
+ count
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql
new file mode 100644
index 00000000000..9b90abebbf7
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql
@@ -0,0 +1,5 @@
+query canelableJobs {
+ cancelable: jobs(statuses: [PENDING, RUNNING]) {
+ count
+ }
+}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
deleted file mode 100644
index cfde1fc0a2b..00000000000
--- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { s__, __ } from '~/locale';
-
-export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal';
-export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?');
-export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs');
-export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs');
-export const CANCEL_TEXT = __('Cancel');
-export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed');
-export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed');
-export const CANCEL_JOBS_WARNING = s__(
- "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?",
-);
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
deleted file mode 100644
index c5a0509b625..00000000000
--- a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<script>
-export default {
- inject: {
- jobStatuses: {
- default: null,
- },
- url: {
- default: '',
- },
- emptyStateSvgPath: {
- default: '',
- },
- },
-};
-</script>
-
-<template>
- <div>{{ __('Jobs') }}</div>
-</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index 9df52557212..9c2a255a1a3 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -1,11 +1,21 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import { CANCEL_JOBS_MODAL_ID } from './components/constants';
-import CancelJobsModal from './components/cancel_jobs_modal.vue';
-import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue';
+import createDefaultClient from '~/lib/graphql';
+import { CANCEL_JOBS_MODAL_ID } from '../components/constants';
+import CancelJobsModal from '../components/cancel_jobs_modal.vue';
+import AdminJobsTableApp from '../components/table/admin_jobs_table_app.vue';
+import cacheConfig from '../components/table/graphql/cache_config';
Vue.use(Translate);
+Vue.use(VueApollo);
+
+const client = createDefaultClient({}, { cacheConfig });
+
+const apolloProvider = new VueApollo({
+ defaultClient: client,
+});
function initJobs() {
const buttonId = 'js-stop-jobs-button';
@@ -44,6 +54,7 @@ export function initAdminJobsApp() {
return new Vue({
el: containerEl,
+ apolloProvider,
provide: {
url,
emptyStateSvgPath,
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/achievements/index.js b/app/assets/javascripts/pages/groups/achievements/index.js
new file mode 100644
index 00000000000..d964b0feb96
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/achievements/index.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
+import createDefaultClient from '~/lib/graphql';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AchievementsApp from '~/achievements/components/achievements_app.vue';
+import routes from '~/achievements/routes';
+
+Vue.use(VueApollo);
+Vue.use(VueRouter);
+
+const init = () => {
+ const el = document.getElementById('js-achievements-app');
+
+ if (!el) {
+ return false;
+ }
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ const { basePath, viewModel } = el.dataset;
+ const provide = JSON.parse(viewModel);
+
+ const router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ provide: convertObjectPropsToCamelCase(provide),
+ render(createElement) {
+ return createElement(AchievementsApp);
+ },
+ });
+};
+
+init();
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index dec06fe6f4d..721168f6140 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -9,6 +9,7 @@ import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import initConfirmDanger from '~/init_confirm_danger';
+import { initGroupSettingsReadme } from '~/groups/settings/init_group_settings_readme';
initFilePickers();
initConfirmDanger();
@@ -27,3 +28,5 @@ initProjectSelects();
initSearchSettings();
initCascadingSettingsLockPopovers();
+
+initGroupSettingsReadme();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 1b3c7ba5a52..2e71eced66f 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -23,7 +23,7 @@ const APP_OPTIONS = {
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
show: true,
- tokens: ['two_factor', 'with_inherited_permissions', 'enterprise'],
+ tokens: ['two_factor', 'with_inherited_permissions', 'enterprise', 'user_type'],
searchParam: 'search',
placeholder: s__('Members|Filter members'),
recentSearchesStorageKey: 'group_members',
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index f01e5e595a3..513f4968dbd 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,19 @@ export default {
NewNamespacePage,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ groupsUrl: {
+ type: String,
+ required: true,
+ },
+ parentGroupUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
parentGroupName: {
type: String,
required: false,
@@ -28,8 +41,17 @@ 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__('Navigation|Your work'), href: this.rootPath },
+ { text: s__('GroupsNew|Groups'), href: this.groupsUrl },
+ { text: s__('GroupsNew|New group'), href: '#' },
+ ];
},
panels() {
return [
@@ -68,7 +90,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..2e53324717c 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -22,14 +22,17 @@ initFilePickers();
function initNewGroupCreation(el) {
const {
hasErrors,
+ rootPath,
+ groupsUrl,
+ parentGroupUrl,
parentGroupName,
importExistingGroupPath,
- verificationRequired,
- verificationFormUrl,
- subscriptionsUrl,
} = el.dataset;
const props = {
+ groupsUrl,
+ rootPath,
+ parentGroupUrl,
parentGroupName,
importExistingGroupPath,
hasErrors: parseBoolean(hasErrors),
@@ -42,11 +45,6 @@ function initNewGroupCreation(el) {
return new Vue({
el,
apolloProvider,
- provide: {
- verificationRequired: parseBoolean(verificationRequired),
- verificationFormUrl,
- subscriptionsUrl,
- },
render(h) {
return h(NewGroupCreationApp, { props });
},
diff --git a/app/assets/javascripts/pages/groups/runners/new/index.js b/app/assets/javascripts/pages/groups/runners/new/index.js
new file mode 100644
index 00000000000..318643d95a4
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/new/index.js
@@ -0,0 +1,3 @@
+import { initGroupNewRunner } from '~/ci/runner/group_new_runner';
+
+initGroupNewRunner();
diff --git a/app/assets/javascripts/pages/groups/runners/register/index.js b/app/assets/javascripts/pages/groups/runners/register/index.js
new file mode 100644
index 00000000000..b02e33e21f2
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/register/index.js
@@ -0,0 +1,3 @@
+import { initGroupRegisterRunner } from '~/ci/runner/group_register_runner';
+
+initGroupRegisterRunner();
diff --git a/app/assets/javascripts/pages/groups/settings/applications/index.js b/app/assets/javascripts/pages/groups/settings/applications/index.js
new file mode 100644
index 00000000000..4dee5433ec9
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/applications/index.js
@@ -0,0 +1,3 @@
+import { initOAuthApplicationSecret } from '~/oauth_application';
+
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 52124865bcc..dba65c7e791 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-new */
-
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
@@ -7,11 +5,11 @@ import initNotificationsDropdown from '~/notifications';
import ProjectsList from '~/projects_list';
export default function initGroupDetails() {
- new ShortcutsNavigation();
+ new ShortcutsNavigation(); // eslint-disable-line no-new
initNotificationsDropdown();
- new ProjectsList();
+ new ProjectsList(); // eslint-disable-line no-new
initInviteMembersBanner();
initInviteMembersModal();
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 53bceb3a6f0..f6a4ca0f360 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,5 +1,6 @@
import leaveByUrl from '~/namespaces/leave_by_url';
import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
+import { initGroupReadme } from '~/groups/init_group_readme';
import initReadMore from '~/read_more';
import initGroupDetails from '../shared/group_details';
@@ -7,3 +8,4 @@ leaveByUrl('group');
initGroupDetails();
initGroupOverviewTabs();
initReadMore();
+initGroupReadme();
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/github/details/index.js b/app/assets/javascripts/pages/import/github/details/index.js
new file mode 100644
index 00000000000..44a85589c9d
--- /dev/null
+++ b/app/assets/javascripts/pages/import/github/details/index.js
@@ -0,0 +1,3 @@
+import initImportDetails from '~/import/details';
+
+initImportDetails();
diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js
index 30ee468734d..e9c98d0adb5 100644
--- a/app/assets/javascripts/pages/import/github/status/index.js
+++ b/app/assets/javascripts/pages/import/github/status/index.js
@@ -1,5 +1,12 @@
import mountImportProjectsTable from '~/import_entities/import_projects';
+import GithubStatusTable from '~/import_entities/import_projects/components/github_status_table.vue';
const mountElement = document.getElementById('import-projects-mount-element');
-mountImportProjectsTable({ mountElement });
+mountImportProjectsTable({
+ mountElement,
+ Component: GithubStatusTable,
+ extraProvide: (dataset) => ({
+ statusImportGithubGroupPath: dataset.statusImportGithubGroupPath,
+ }),
+});
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/import/phabricator/new/index.js b/app/assets/javascripts/pages/import/phabricator/new/index.js
deleted file mode 100644
index 0bb70a7364e..00000000000
--- a/app/assets/javascripts/pages/import/phabricator/new/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { initNewProjectUrlSelect } from '~/projects/new';
-
-initNewProjectUrlSelect();
diff --git a/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
index 3fe238dcb35..6e92f136e4d 100644
--- a/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
+++ b/app/assets/javascripts/pages/jira_connect/oauth_callbacks/index.js
@@ -1,3 +1,5 @@
+import { OAUTH_CALLBACK_MESSAGE_TYPE } from '~/jira_connect/subscriptions/constants';
+
function getOriginURL() {
const origin = new URL(window.opener.location);
origin.hash = '';
@@ -7,7 +9,10 @@ function getOriginURL() {
}
function postMessageToJiraConnectApp(data) {
- window.opener.postMessage(data, getOriginURL().toString());
+ window.opener.postMessage(
+ { ...data, type: OAUTH_CALLBACK_MESSAGE_TYPE },
+ getOriginURL().toString(),
+ );
}
function initOAuthCallbacks() {
diff --git a/app/assets/javascripts/pages/oauth/applications/index.js b/app/assets/javascripts/pages/oauth/applications/index.js
new file mode 100644
index 00000000000..4dee5433ec9
--- /dev/null
+++ b/app/assets/javascripts/pages/oauth/applications/index.js
@@ -0,0 +1,3 @@
+import { initOAuthApplicationSecret } from '~/oauth_application';
+
+initOAuthApplicationSecret();
diff --git a/app/assets/javascripts/pages/profiles/comment_templates/index.js b/app/assets/javascripts/pages/profiles/comment_templates/index.js
new file mode 100644
index 00000000000..413816c29cc
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/comment_templates/index.js
@@ -0,0 +1,3 @@
+import { initCommentTemplates } from '~/comment_templates';
+
+initCommentTemplates();
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/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js
deleted file mode 100644
index ef227b82172..00000000000
--- a/app/assets/javascripts/pages/profiles/saved_replies/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { initSavedReplies } from '~/saved_replies';
-
-initSavedReplies();
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/artifacts/index.js b/app/assets/javascripts/pages/projects/artifacts/index.js
index 4aa9b225790..df8f110a60d 100644
--- a/app/assets/javascripts/pages/projects/artifacts/index.js
+++ b/app/assets/javascripts/pages/projects/artifacts/index.js
@@ -1,3 +1,3 @@
-import { initArtifactsTable } from '~/artifacts/index';
+import { initArtifactsTable } from '~/ci/artifacts/index';
initArtifactsTable();
diff --git a/app/assets/javascripts/pages/projects/blame/streaming/index.js b/app/assets/javascripts/pages/projects/blame/streaming/index.js
new file mode 100644
index 00000000000..82cc09c1e76
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/blame/streaming/index.js
@@ -0,0 +1,5 @@
+import initBlob from '~/pages/projects/init_blob';
+import { renderBlamePageStreams } from '~/blame/streaming';
+
+renderBlamePageStreams(window.blamePageStream);
+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..f8dcf1a5c9c 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -8,11 +8,16 @@ import { BlobViewer, initAuxiliaryViewer } from '~/blob/viewer/index';
import GpgBadges from '~/gpg_badges';
import createDefaultClient from '~/lib/graphql';
import initBlob from '~/pages/projects/init_blob';
+import ForkInfo from '~/repository/components/fork_info.vue';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import 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 { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Vuex);
Vue.use(VueApollo);
@@ -26,8 +31,45 @@ 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, refType } = refSwitcherEl.dataset;
+
+ return new Vue({
+ el: refSwitcherEl,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: refType ? joinPaths('refs', refType, ref) : ref,
+ useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
+ },
+ on: {
+ input(selectedRef) {
+ visitUrl(generateRefDestinationPath(projectRootPath, ref, selectedRef));
+ },
+ },
+ });
+ },
+ });
+};
+
+initRefSwitcher();
+
if (viewBlobEl) {
- const { blobPath, projectPath, targetBranch, originalBranch } = viewBlobEl.dataset;
+ const {
+ blobPath,
+ projectPath,
+ targetBranch,
+ originalBranch,
+ resourceId,
+ userId,
+ explainCodeAvailable,
+ } = viewBlobEl.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -38,6 +80,9 @@ if (viewBlobEl) {
provide: {
targetBranch,
originalBranch,
+ resourceId,
+ userId,
+ explainCodeAvailable: parseBoolean(explainCodeAvailable),
},
render(createElement) {
return createElement(BlobContentViewer, {
@@ -56,6 +101,47 @@ if (viewBlobEl) {
initBlob();
}
+const initForkInfo = () => {
+ const forkEl = document.getElementById('js-fork-info');
+ if (!forkEl) {
+ return null;
+ }
+ const {
+ projectPath,
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ canSyncBranch,
+ aheadComparePath,
+ behindComparePath,
+ createMrPath,
+ viewMrPath,
+ } = forkEl.dataset;
+ return new Vue({
+ el: forkEl,
+ apolloProvider,
+ render(h) {
+ return h(ForkInfo, {
+ props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
+ projectPath,
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ aheadComparePath,
+ behindComparePath,
+ createMrPath,
+ viewMrPath,
+ },
+ });
+ },
+ });
+};
+
+initForkInfo();
+
const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
const statusLink = document.querySelector('.commit-actions .ci-status-link');
if (statusLink) {
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..c9f5895c7a3 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';
@@ -18,9 +18,7 @@ import '~/sourcegraph/load';
import DiffStats from '~/diffs/components/diff_stats.vue';
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();
new ZenMode();
new ShortcutsNavigation();
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index 760bf3f7131..328b5596e1a 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -7,8 +7,7 @@ import syntaxHighlight from '~/syntax_highlight';
initCompareSelector();
new Diff(); // eslint-disable-line no-new
-const paddingTop = 16;
-initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
+initDiffStatsDropdown();
GpgBadges.fetch();
syntaxHighlight([document.querySelector('.files')]);
diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
index 05a1bbc69ed..fe9f0c7e69f 100644
--- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
+++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js
@@ -1,3 +1,3 @@
-import initCycleAnalytics from '~/analytics/cycle_analytics';
+import cycleAnalyticsAppBundle from 'ee_else_ce/analytics/cycle_analytics/bundle';
-initCycleAnalytics();
+cycleAnalyticsAppBundle();
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..9f7a7b436df 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,10 +12,10 @@ 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';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__, __ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
import {
@@ -261,7 +261,7 @@ export default {
try {
const { data } = await axios.post(url, postParams);
- redirectTo(data.web_url);
+ redirectTo(data.web_url); // eslint-disable-line import/no-deprecated
return;
} catch (error) {
createAlert({
@@ -332,7 +332,7 @@ export default {
</div>
</div>
- <p class="gl-mt-n5 gl-text-gray-500">
+ <p class="gl-mt-n3 gl-text-gray-500">
{{ s__('ForkProject|Want to organize several dependent projects under the same namespace?') }}
<gl-link :href="newGroupPath" target="_blank">
{{ s__('ForkProject|Create a group') }}
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..84796954cf1 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';
@@ -97,7 +97,7 @@ export default {
:no-results-text="__('No matches found')"
:searchable="true"
:searching="loading"
- toggle-class="gl-flex-direction-column gl-align-items-stretch!"
+ toggle-class="gl-flex-direction-column gl-align-items-stretch! gl-rounded-top-left-none! gl-rounded-bottom-left-none! gl-w-full!"
:toggle-text="dropdownText"
@search="searchNamespaces"
@select="setNamespace"
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/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
index 097b2f33aa9..244d1d5590e 100644
--- a/app/assets/javascripts/pages/projects/init_blob.js
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -22,7 +22,6 @@ export default () => {
// eslint-disable-next-line no-new
new ShortcutsBlob({
- skipResetBindings: true,
fileBlobPermalinkUrl,
fileBlobPermalinkUrlElement,
});
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
index 06dcd2c2d94..c5b63b74c35 100644
--- a/app/assets/javascripts/pages/projects/issues/edit/index.js
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -1,3 +1,8 @@
import { initForm } from 'ee_else_ce/issues';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
initForm();
+
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index 06dcd2c2d94..c5b63b74c35 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,3 +1,8 @@
import { initForm } from 'ee_else_ce/issues';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
initForm();
+
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
index 2718765ee23..3d81e77f879 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -3,6 +3,8 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import MergeRequest from '~/merge_request';
import CompareApp from '~/merge_requests/components/compare_app.vue';
import { __ } from '~/locale';
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
if (mrNewCompareNode) {
@@ -82,4 +84,6 @@ if (mrNewCompareNode) {
action: mrNewSubmitNode.dataset.mrSubmitAction,
});
initPipelines();
+ // eslint-disable-next-line no-new
+ new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
}
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..6127adc3584 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,10 +1,11 @@
-import { createAlert } from '~/flash';
+import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-
+import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown';
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
+import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import initCheckFormState from './check_form_state';
import initFormUpdate from './update_form';
@@ -72,3 +73,5 @@ initMergeRequest();
initFormUpdate();
initCheckFormState();
initTargetBranchSelector();
+// eslint-disable-next-line no-new
+new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() });
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/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index d4734b8842d..599fd225de9 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -4,19 +4,13 @@ import $ from 'jquery';
import IssuableForm from 'ee_else_ce/issuable/issuable_form';
import IssuableLabelSelector from '~/issuable/issuable_label_selector';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import GLForm from '~/gl_form';
import LabelsSelect from '~/labels/labels_select';
-import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors';
import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar';
export default () => {
new ShortcutsNavigation();
- new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form'));
IssuableLabelSelector();
new LabelsSelect();
- new IssuableTemplateSelectors({
- warnTemplateOverride: true,
- });
mountMilestoneDropdown('[name="merge_request[milestone_id]"]');
};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js
index a8699b350f8..552e75da9b8 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/page.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/page.js
@@ -1,7 +1,10 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import initMrNotes from 'ee_else_ce/mr_notes';
import StickyHeader from '~/merge_requests/components/sticky_header.vue';
import { initIssuableHeaderWarnings } from '~/issuable';
-import initMrNotes from '~/mr_notes';
+import { start as startCodeReviewMessaging } from '~/code_review/signals';
+import diffsEventHub from '~/diffs/event_hub';
import store from '~/mr_notes/stores';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import { apolloProvider } from '~/graphql_shared/issuable_client';
@@ -9,9 +12,12 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import initShow from './init_merge_request_show';
import getStateQuery from './queries/get_state.query.graphql';
+Vue.use(VueApollo);
+
export function initMrPage() {
initMrNotes();
initShow();
+ startCodeReviewMessaging({ signalBus: diffsEventHub });
}
requestIdleCallback(() => {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 91394755367..38cc4337047 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,7 +1,7 @@
-import initNotesApp from '~/mr_notes/init_notes';
+import mountNotesApp from 'ee_else_ce/mr_notes/mount_app';
import { initReportAbuse } from '~/projects/report_abuse';
import { initMrPage } from '../page';
initMrPage();
-initNotesApp();
+mountNotesApp();
initReportAbuse();
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/index/index.js b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
index e9ffd4b528b..b054022b6d6 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
@@ -8,9 +8,11 @@ const initIndexMlExperiments = () => {
return undefined;
}
+ const { experiments, pageInfo, emptyStateSvgPath } = element.dataset;
const props = {
- experiments: JSON.parse(element.dataset.experiments),
- pageInfo: convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)),
+ experiments: JSON.parse(experiments),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
+ emptyStateSvgPath,
};
return new Vue({
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..b3f15a9f65e 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,28 @@
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 { experiment, candidates, metrics, params, pageInfo, emptyStateSvgPath } = element.dataset;
- 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));
+ const props = {
+ experiment: JSON.parse(experiment),
+ candidates: JSON.parse(candidates),
+ metricNames: JSON.parse(metrics),
+ paramNames: JSON.parse(params),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
+ emptyStateSvgPath,
+ };
- // 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/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js
index 414636f0a74..a669ea5baaf 100644
--- a/app/assets/javascripts/pages/projects/network/show/index.js
+++ b/app/assets/javascripts/pages/projects/network/show/index.js
@@ -24,7 +24,7 @@ const initRefSwitcher = () => {
},
on: {
input(selectedRef) {
- visitUrl(joinPaths(networkRootPath, selectedRef));
+ visitUrl(joinPaths(networkRootPath, encodeURIComponent(selectedRef)));
},
},
});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 242c5a1a97b..eab4be4dcf1 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -176,7 +176,7 @@ export default {
<gl-icon
v-if="showDailyLimitMessage(option)"
v-gl-tooltip.hover
- name="question"
+ name="question-o"
:title="scheduleDailyLimitMsg"
/>
</gl-form-radio>
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index a4b3b83a855..c566490dcb5 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -3,28 +3,10 @@
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 axios from '~/lib/utils/axios_utils';
-import { serializeForm } from '~/lib/utils/forms';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-
-const BRANCH_REF_TYPE = 'heads';
-const TAG_REF_TYPE = 'tags';
-const BRANCH_GROUP_NAME = __('Branches');
-const TAG_GROUP_NAME = __('Tags');
export default class Project {
constructor() {
initClonePanel();
- // Ref switcher
- if (document.querySelector('.js-project-refs-dropdown')) {
- Project.initRefSwitcher();
- $('.project-refs-select').on('change', function () {
- return $(this).parents('form').trigger('submit');
- });
- }
$('.js-hide-no-ssh-message').on('click', function (e) {
setCookie('hide_no_ssh_message', 'false');
@@ -43,131 +25,16 @@ export default class Project {
$(this).parents('.auto-devops-implicitly-enabled-banner').remove();
return e.preventDefault();
});
+ $('.hide-mobile-devops-promo').on('click', function (e) {
+ const projectId = $(this).data('project-id');
+ const cookieKey = `hide_mobile_devops_promo_${projectId}`;
+ setCookie(cookieKey, 'false');
+ $(this).parents('#mobile-devops-promo-banner').remove();
+ return e.preventDefault();
+ });
}
static changeProject(url) {
return (window.location = url);
}
-
- static initRefSwitcher() {
- const refListItem = document.createElement('li');
- const refLink = document.createElement('a');
-
- refLink.href = '#';
-
- return $('.js-project-refs-dropdown').each(function () {
- const $dropdown = $(this);
- const selected = $dropdown.data('selected');
- const refType = $dropdown.data('refType');
- const fieldName = $dropdown.data('fieldName');
- const shouldVisit = Boolean($dropdown.data('visit'));
- const $form = $dropdown.closest('form');
- const path = $form.find('#path').val();
- const action = $form.attr('action');
- const linkTarget = mergeUrlParams(serializeForm($form[0]), action);
-
- return initDeprecatedJQueryDropdown($dropdown, {
- data(term, callback) {
- axios
- .get($dropdown.data('refsUrl'), {
- params: {
- ref: $dropdown.data('ref'),
- search: term,
- },
- })
- .then(({ data }) => callback(data))
- .catch(() =>
- createAlert({
- message: __('An error occurred while getting projects'),
- }),
- );
- },
- selectable: true,
- filterable: true,
- filterRemote: true,
- filterByText: true,
- inputFieldName: $dropdown.data('inputFieldName'),
- fieldName,
- renderRow(ref, _, params) {
- const li = refListItem.cloneNode(false);
-
- const link = refLink.cloneNode(false);
-
- if (ref === selected) {
- // Check group and current ref type to avoid adding a class when tags and branches share the same name
- if (
- (refType === BRANCH_REF_TYPE && params.group === BRANCH_GROUP_NAME) ||
- (refType === TAG_REF_TYPE && params.group === TAG_GROUP_NAME) ||
- !refType
- ) {
- link.className = 'is-active';
- }
- }
-
- link.textContent = ref;
- link.dataset.ref = ref;
- if (ref.length > 0 && shouldVisit) {
- const urlParams = { [fieldName]: ref };
- if (params.group === BRANCH_GROUP_NAME) {
- urlParams.ref_type = BRANCH_REF_TYPE;
- } else if (params.group === TAG_GROUP_NAME) {
- urlParams.ref_type = TAG_REF_TYPE;
- }
-
- link.href = mergeUrlParams(urlParams, linkTarget);
- }
-
- li.appendChild(link);
-
- return li;
- },
- id(obj, $el) {
- return $el.attr('data-ref');
- },
- toggleLabel(obj, $el) {
- return $el.text().trim();
- },
- clicked(options) {
- const { e } = options;
-
- if (!shouldVisit) {
- e.preventDefault();
- }
-
- // Some pages need to dynamically get the current path
- // so they can opt-in to JS getting the path from the
- // current URL by not setting a path in the dropdown form
- if (shouldVisit && path === undefined) {
- e.preventDefault();
-
- const selectedUrl = new URL(e.target.href);
- const loc = window.location.href;
-
- if (loc.includes('/-/')) {
- const currentRef = $dropdown.data('ref');
- // The split and startWith is to ensure an exact word match
- // and avoid partial match ie. currentRef is "dev" and loc is "development"
- const splitPathAfterRefPortion = loc.split('/-/')[1].split(currentRef)[1];
- const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/');
-
- if (doesPathContainRef) {
- // We are ignoring the url containing the ref portion
- // and plucking the thereafter portion to reconstructure the url that is correct
- const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0];
- selectedUrl.searchParams.set('path', targetPath);
- selectedUrl.hash = window.location.hash;
- }
- }
-
- // Open in new window if "meta" key is pressed
- if (e.metaKey) {
- window.open(selectedUrl.href, '_blank');
- } else {
- window.location.href = selectedUrl.href;
- }
- }
- },
- });
- });
- }
}
diff --git a/app/assets/javascripts/pages/projects/runners/new/index.js b/app/assets/javascripts/pages/projects/runners/new/index.js
new file mode 100644
index 00000000000..e67de424496
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/runners/new/index.js
@@ -0,0 +1,3 @@
+import { initProjectNewRunner } from '~/ci/runner/project_new_runner';
+
+initProjectNewRunner();
diff --git a/app/assets/javascripts/pages/projects/runners/register/index.js b/app/assets/javascripts/pages/projects/runners/register/index.js
new file mode 100644
index 00000000000..a55ff95f84d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/runners/register/index.js
@@ -0,0 +1,3 @@
+import { initProjectRegisterRunner } from '~/ci/runner/project_register_runner';
+
+initProjectRegisterRunner();
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..731b1373987 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,11 +6,13 @@ 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';
import initDeployTokens from '~/deploy_tokens';
import { initProjectRunners } from '~/ci/runner/project_runners';
+import { initProjectRunnersRegistrationDropdown } from '~/ci/runner/project_runners/register';
// Initialize expandable settings panels
initSettingsPanels();
@@ -41,7 +43,9 @@ initSettingsPipelinesTriggers();
initArtifactsSettings();
initProjectRunners();
+initProjectRunnersRegistrationDropdown();
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/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index d2263fa815d..087808c33da 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,3 +1,4 @@
+import 'bootstrap/js/dist/collapse';
import MirrorRepos from '~/mirrors/mirror_repos';
import mountBranchRules from '~/projects/settings/repository/branch_rules/mount_branch_rules';
import mountDefaultBranchSelector from '~/projects/settings/mount_default_branch_selector';
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..64c363dd721 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"
@@ -930,7 +930,10 @@ export default {
name="project[project_feature_attributes][monitor_access_level]"
/>
</project-setting-row>
- <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5">
+ <div
+ v-if="!glFeatures.removeMonitorMetrics"
+ class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"
+ >
<project-setting-row
ref="metrics-visibility-settings"
:label="__('Metrics Dashboard')"
diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
index 84ff802c268..43ff617dabe 100644
--- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
+++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js
@@ -1,15 +1,7 @@
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
import WebIdeButton from '~/vue_shared/components/web_ide_link.vue';
-import createDefaultClient from '~/lib/graphql';
-
-Vue.use(VueApollo);
-
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
export default ({ el, router }) => {
if (!el) return;
@@ -23,7 +15,6 @@ export default ({ el, router }) => {
new Vue({
el,
router,
- apolloProvider,
render(h) {
return h(WebIdeButton, {
props: {
diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js
index 885b8ca8e12..d907b8a470d 100644
--- a/app/assets/javascripts/pages/projects/usage_quotas/index.js
+++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js
@@ -1,9 +1,21 @@
import initProjectStorage from '~/usage_quotas/storage/init_project_storage';
import initSearchSettings from '~/search_settings';
+import { GlTabsBehavior, HISTORY_TYPE_HASH } from '~/tabs';
+
+const initGlTabs = () => {
+ const tabsEl = document.getElementById('js-project-usage-quotas-tabs');
+ if (!tabsEl) {
+ return;
+ }
+
+ // eslint-disable-next-line no-new
+ new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH });
+};
const initVueApp = () => {
initProjectStorage('js-project-storage-count-app');
};
+initGlTabs();
initVueApp();
initSearchSettings();
diff --git a/app/assets/javascripts/pages/projects/wikis/diff/index.js b/app/assets/javascripts/pages/projects/wikis/diff/index.js
index 73440db761f..067ffb3dca9 100644
--- a/app/assets/javascripts/pages/projects/wikis/diff/index.js
+++ b/app/assets/javascripts/pages/projects/wikis/diff/index.js
@@ -1,3 +1,7 @@
+import syntaxHighlight from '~/syntax_highlight';
import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
+import Diff from '~/diff';
+new Diff(); // eslint-disable-line no-new
initDiffStatsDropdown();
+syntaxHighlight([document.querySelector('.files')]);
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index eaafc0235a8..84050c3cb0f 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -1,19 +1,17 @@
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';
+import { initPasswordInput } from '~/authentication/password';
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();
@@ -22,3 +20,4 @@ Tracking.enableFormTracking({
});
initLanguageSwitcher();
+initPasswordInput();
diff --git a/app/assets/javascripts/pages/sessions/index.js b/app/assets/javascripts/pages/sessions/index.js
index 8d8534ec556..fdd846a9476 100644
--- a/app/assets/javascripts/pages/sessions/index.js
+++ b/app/assets/javascripts/pages/sessions/index.js
@@ -1,3 +1,5 @@
import { mount2faAuthentication } from '~/authentication/mount_2fa';
+import { initPasswordInput } from '~/authentication/password';
mount2faAuthentication();
+initPasswordInput();
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/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index ee48543f0d2..bad8a7cedc6 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -14,7 +14,7 @@ export default class OAuthRememberMe {
}
bindEvents() {
- $('#remember_me', this.container).on('click', this.toggleRememberMe);
+ $('#remember_me_omniauth', this.container).on('click', this.toggleRememberMe);
}
toggleRememberMe(event) {
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..3b38d715ea5 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -128,6 +128,9 @@ export default {
};
},
computed: {
+ autocompleteDataSources() {
+ return gl.GfmAutoComplete?.dataSources;
+ },
noContent() {
return !this.content.trim();
},
@@ -351,6 +354,9 @@ export default {
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
:autofocus="pageInfo.persisted"
+ :enable-autocomplete="true"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :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/time_tracking/timelogs/index.js b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
new file mode 100644
index 00000000000..41c78fbe3a6
--- /dev/null
+++ b/app/assets/javascripts/pages/time_tracking/timelogs/index.js
@@ -0,0 +1,3 @@
+import initTimelogsApp from '~/time_tracking';
+
+initTimelogsApp();
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/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue
index 9ac6b0e6403..6702c49030b 100644
--- a/app/assets/javascripts/performance_bar/components/add_request.vue
+++ b/app/assets/javascripts/performance_bar/components/add_request.vue
@@ -1,7 +1,12 @@
<script>
import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
+ i18n: {
+ buttonLabel: __('Add request manually'),
+ inputLabel: __('URL or request ID'),
+ },
components: {
GlForm,
GlButton,
@@ -37,14 +42,16 @@ export default {
variant="link"
icon="plus"
size="small"
- :title="__('Add request manually')"
+ :title="$options.i18n.buttonLabel"
+ :aria-label="$options.i18n.buttonLabel"
@click="toggleInput"
/>
<gl-form-input
v-if="inputEnabled"
v-model="urlOrRequestId"
type="text"
- :placeholder="__(`URL or request ID`)"
+ :placeholder="$options.i18n.inputLabel"
+ :aria-label="$options.i18n.inputLabel"
class="gl-ml-2"
@keyup.enter="addRequest"
@keyup.esc="clearForm"
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index dbca8bc9be7..fac070d6e47 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink, GlPopover } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { glEmojiTag } from '~/emoji';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@@ -10,8 +11,10 @@ import RequestSelector from './request_selector.vue';
export default {
components: {
+ GlPopover,
AddRequest,
DetailedMetric,
+ GlLink,
RequestSelector,
},
directives: {
@@ -30,6 +33,10 @@ export default {
type: String,
required: true,
},
+ requestMethod: {
+ type: String,
+ required: true,
+ },
peekUrl: {
type: String,
required: true,
@@ -72,6 +79,11 @@ export default {
keys: ['request', 'body'],
},
{
+ metric: 'zkt',
+ header: s__('PerformanceBar|Zoekt calls'),
+ keys: ['request', 'body'],
+ },
+ {
metric: 'external-http',
title: 'external',
header: s__('PerformanceBar|External Http calls'),
@@ -103,9 +115,6 @@ export default {
this.currentRequestId = requestId;
},
},
- initialRequest() {
- return this.currentRequestId === this.requestId;
- },
hasHost() {
return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host;
},
@@ -124,24 +133,47 @@ export default {
const fileName = this.requests[0].displayName;
return `${fileName}_perf_bar_${Date.now()}.json`;
},
+ showZoekt() {
+ return document.body.dataset.page === 'search:show';
+ },
+ showFlamegraphButtons() {
+ return this.isGetRequest(this.currentRequestId);
+ },
+ showMemoryReportButton() {
+ return this.isGetRequest(this.currentRequestId) && this.env === 'development';
+ },
memoryReportPath() {
- return mergeUrlParams({ performance_bar: 'memory' }, window.location.href);
+ return mergeUrlParams(
+ { performance_bar: 'memory' },
+ this.store.findRequest(this.currentRequestId).fullUrl,
+ );
},
},
+ created() {
+ if (!this.showZoekt) {
+ this.$options.detailedMetrics = this.$options.detailedMetrics.filter(
+ (item) => item.metric !== 'zkt',
+ );
+ }
+ },
mounted() {
this.currentRequest = this.requestId;
},
methods: {
+ glEmojiTag,
changeCurrentRequest(newRequestId) {
this.currentRequest = newRequestId;
this.$emit('change-request', newRequestId);
},
- flamegraphPath(mode) {
+ flamegraphPath(mode, requestId) {
return mergeUrlParams(
{ performance_bar: 'flamegraph', stackprof_mode: mode },
- window.location.href,
+ this.store.findRequest(requestId).fullUrl,
);
},
+ isGetRequest(requestId) {
+ return this.store.findRequest(requestId)?.method?.toUpperCase() === 'GET';
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
@@ -159,8 +191,17 @@ export default {
class="current-host"
:class="{ canary: currentRequest.details.host.canary }"
>
- <span v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span>
- {{ currentRequest.details.host.hostname }}
+ <span id="canary-emoji" v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span>
+ <gl-popover placement="bottom" target="canary-emoji" content="Canary" />
+ <span
+ id="host-emoji"
+ v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('computer')"
+ ></span>
+ <gl-popover
+ placement="bottom"
+ target="host-emoji"
+ :content="currentRequest.details.host.hostname"
+ />
</span>
</div>
<detailed-metric
@@ -177,41 +218,45 @@ export default {
id="peek-view-trace"
class="view"
>
- <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
+ <gl-link class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
s__('PerformanceBar|Trace')
- }}</a>
+ }}</gl-link>
</div>
<div v-if="currentRequest.details" id="peek-download" class="view">
- <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{
- s__('PerformanceBar|Download')
- }}</a>
+ <gl-link
+ class="gl-text-blue-200"
+ is-unsafe-link
+ :download="downloadName"
+ :href="downloadPath"
+ >{{ s__('PerformanceBar|Download') }}</gl-link
+ >
</div>
- <div
- v-if="currentRequest.details && env === 'development'"
- id="peek-memory-report"
- class="view"
- >
- <a class="gl-text-blue-200" :href="memoryReportPath">{{
+ <div v-if="showMemoryReportButton" id="peek-memory-report" class="view">
+ <gl-link class="gl-text-blue-200" :href="memoryReportPath">{{
s__('PerformanceBar|Memory report')
- }}</a>
+ }}</gl-link>
</div>
- <div v-if="currentRequest.details" id="peek-flamegraph" class="view">
- <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span>
- <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{
+ <div v-if="showFlamegraphButtons" id="peek-flamegraph" class="view">
+ <span id="flamegraph-emoji" class="gl-text-white-200">
+ <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('fire')"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('bar_chart')"></span>
+ </span>
+ <gl-popover placement="bottom" target="flamegraph-emoji" content="Flamegraph" />
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('wall', currentRequestId)">{{
s__('PerformanceBar|wall')
- }}</a>
+ }}</gl-link>
/
- <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('cpu', currentRequestId)">{{
s__('PerformanceBar|cpu')
- }}</a>
+ }}</gl-link>
/
- <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{
+ <gl-link class="gl-text-blue-200" :href="flamegraphPath('object', currentRequestId)">{{
s__('PerformanceBar|object')
- }}</a>
+ }}</gl-link>
</div>
- <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
+ <gl-link v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
s__('PerformanceBar|Stats')
- }}</a>
+ }}</gl-link>
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 84fe14fe056..e3e48e61393 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -32,15 +32,21 @@ const initPerformanceBar = (el) => {
store,
env: performanceBarData.env,
requestId: performanceBarData.requestId,
+ requestMethod: performanceBarData.requestMethod,
peekUrl: performanceBarData.peekUrl,
- profileUrl: performanceBarData.profileUrl,
statsUrl: performanceBarData.statsUrl,
};
},
mounted() {
PerformanceBarService.registerInterceptor(this.peekUrl, this.addRequest);
- this.addRequest(this.requestId, window.location.href);
+ this.addRequest(
+ this.requestId,
+ window.location.href,
+ undefined,
+ undefined,
+ this.requestMethod,
+ );
this.loadRequestDetails(this.requestId);
},
beforeDestroy() {
@@ -56,12 +62,12 @@ const initPerformanceBar = (el) => {
this.addRequest(urlOrRequestId, urlOrRequestId);
}
},
- addRequest(requestId, requestUrl, operationName) {
+ addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) {
if (!this.store.canTrackRequest(requestUrl)) {
return;
}
- this.store.addRequest(requestId, requestUrl, operationName);
+ this.store.addRequest(requestId, requestUrl, operationName, requestParams, methodVerb);
},
loadRequestDetails(requestId) {
const request = this.store.findRequest(requestId);
@@ -145,8 +151,8 @@ const initPerformanceBar = (el) => {
store: this.store,
env: this.env,
requestId: this.requestId,
+ requestMethod: this.requestMethod,
peekUrl: this.peekUrl,
- profileUrl: this.profileUrl,
statsUrl: this.statsUrl,
},
on: {
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
index e67143f3ede..3a9788d8ab6 100644
--- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -14,11 +14,13 @@ export default class PerformanceBarService {
fireCallback,
requestId,
requestUrl,
+ requestParams,
operationName,
+ methodVerb,
] = PerformanceBarService.callbackParams(response, peekUrl);
if (fireCallback) {
- callback(requestId, requestUrl, operationName);
+ callback(requestId, requestUrl, operationName, requestParams, methodVerb);
}
return response;
@@ -35,11 +37,14 @@ export default class PerformanceBarService {
static callbackParams(response, peekUrl) {
const requestId = response.headers && response.headers['x-request-id'];
const requestUrl = response.config?.url;
+ const requestParams = response.config?.params;
+ const methodVerb = response.config?.method;
+
const cachedResponse =
response.headers && parseBoolean(response.headers['x-gitlab-from-cache']);
const fireCallback = requestUrl !== peekUrl && Boolean(requestId) && !cachedResponse;
const operationName = response.config?.operationName;
- return [fireCallback, requestId, requestUrl, operationName];
+ return [fireCallback, requestId, requestUrl, requestParams, operationName, methodVerb];
}
}
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 2011604534c..34e2763a478 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -1,11 +1,22 @@
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+
export default class PerformanceBarStore {
constructor() {
this.requests = [];
}
- addRequest(requestId, requestUrl, operationName) {
- if (!this.findRequest(requestId)) {
- let displayName = PerformanceBarStore.truncateUrl(requestUrl);
+ addRequest(requestId, requestUrl, operationName, requestParams, methodVerb) {
+ if (this.findRequest(requestId)) {
+ this.updateRequestBatchedQueriesCount(requestId);
+ } else {
+ let displayName = '';
+
+ if (methodVerb) {
+ displayName += `${methodVerb.toUpperCase()} `;
+ }
+
+ displayName += PerformanceBarStore.truncateUrl(requestUrl);
if (operationName) {
displayName += ` (${operationName})`;
@@ -14,13 +25,31 @@ export default class PerformanceBarStore {
this.requests.push({
id: requestId,
url: requestUrl,
+ fullUrl: mergeUrlParams(requestParams, requestUrl),
+ method: methodVerb,
details: {},
+ queriesInBatch: 1, // only for GraphQL
displayName,
});
}
return this.requests;
}
+ updateRequestBatchedQueriesCount(requestId) {
+ const existingRequest = this.findRequest(requestId);
+ existingRequest.queriesInBatch += 1;
+
+ const oldDisplayName = existingRequest.displayName;
+ const regex = /\d+ queries batched/;
+ if (regex.test(oldDisplayName)) {
+ existingRequest.displayName = oldDisplayName.replace(
+ regex,
+ `${existingRequest.queriesInBatch} queries batched`,
+ );
+ } else {
+ existingRequest.displayName += __(` [${existingRequest.queriesInBatch} queries batched]`);
+ }
+ }
findRequest(requestId) {
return this.requests.find((request) => request.id === requestId);
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/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 8f76d7535f1..83cd64c17ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -255,7 +255,7 @@ export default {
this.canRefetchHeaderPipeline = true;
this.$apollo.queries.headerPipeline.refetch();
},
- /* eslint-disable @gitlab/require-i18n-strings */
+ // eslint-disable-next-line @gitlab/require-i18n-strings
reportFailure({ type, err = 'No error string passed.', skipSentry = false }) {
this.showAlert = true;
this.alertType = type;
@@ -263,7 +263,6 @@ export default {
reportToSentry(this.$options.name, `type: ${type}, info: ${err}`);
}
},
- /* eslint-enable @gitlab/require-i18n-strings */
updateShowLinksState(val) {
this.showLinks = val;
},
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
index 6d8c35f4482..73143c981ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -62,6 +62,7 @@ export default {
},
showTip() {
return (
+ this.showLinksToggle &&
this.showLinks &&
this.showLinksActive &&
!this.tipPreviouslyDismissed &&
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/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 3da792cb9df..c888c8a5537 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,11 +1,12 @@
import { isEmpty } from 'lodash';
-import Visibility from 'visibilityjs';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getIdFromGraphQLId, etagQueryHeaders } from '~/graphql_shared/utils';
import { reportToSentry } from '../../utils';
import { listByLayers } from '../parsing_utils';
import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils';
import { beginPerfMeasure, finishPerfMeasureAndSend } from './perf_utils';
+export { toggleQueryPollingByVisibility } from '~/graphql_shared/utils';
+
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
return {
...linkedPipeline,
@@ -35,23 +36,13 @@ const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => {
return layers;
};
-/* eslint-disable @gitlab/require-i18n-strings */
-const getQueryHeaders = (etagResource) => {
- return {
- fetchOptions: {
- method: 'GET',
- },
- headers: {
- 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
- 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
- 'X-Requested-With': 'XMLHttpRequest',
- },
- };
-};
+const getQueryHeaders = (etagResource) =>
+ etagQueryHeaders('verify/ci/pipeline-graph', etagResource);
const serializeGqlErr = (gqlError) => {
const { locations = [], message = '', path = [] } = gqlError;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
return `
${message}.
Locations: ${locations
@@ -74,27 +65,12 @@ const serializeLoadErrors = (errors) => {
}
if (!isEmpty(networkError)) {
- return `Network error: ${networkError.message}`;
+ return `Network error: ${networkError.message}`; // eslint-disable-line @gitlab/require-i18n-strings
}
return message;
};
-/* eslint-enable @gitlab/require-i18n-strings */
-
-const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
- const stopStartQuery = (query) => {
- if (!Visibility.hidden()) {
- query.startPolling(interval);
- } else {
- query.stopPolling();
- }
- };
-
- stopStartQuery(queryRef);
- Visibility.change(stopStartQuery.bind(null, queryRef));
-};
-
const transformId = (linkedPipeline) => {
return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
};
@@ -135,7 +111,6 @@ export {
getQueryHeaders,
serializeGqlErr,
serializeLoadErrors,
- toggleQueryPollingByVisibility,
unwrapPipelineData,
validateConfigPaths,
};
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index a36d5d9b58f..27119419060 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -7,7 +7,7 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
+import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __ } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import {
@@ -16,6 +16,7 @@ import {
DELETE_FAILURE,
DEFAULT,
BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
} from '../constants';
import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
@@ -29,6 +30,7 @@ const POLL_INTERVAL = 10000;
export default {
name: 'PipelineHeaderSection',
BUTTON_TOOLTIP_RETRY,
+ BUTTON_TOOLTIP_CANCEL,
pipelineCancel: 'pipelineCancel',
pipelineRetry: 'pipelineRetry',
finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
@@ -231,7 +233,7 @@ export default {
this.reportFailure(DELETE_FAILURE, errors);
this.isDeleting = false;
} else {
- redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success'));
+ redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated
}
} catch {
this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL);
@@ -275,6 +277,9 @@ export default {
<gl-button
v-if="canCancelPipeline"
+ v-gl-tooltip
+ :aria-label="$options.BUTTON_TOOLTIP_CANCEL"
+ :title="$options.BUTTON_TOOLTIP_CANCEL"
:loading="isCanceling"
:disabled="isCanceling"
class="gl-ml-3"
@@ -282,7 +287,7 @@ export default {
data-testid="cancelPipeline"
@click="cancelPipeline()"
>
- {{ __('Cancel running') }}
+ {{ __('Cancel pipeline') }}
</gl-button>
<gl-button
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..c24862f828b 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue
@@ -1,10 +1,8 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { createAlert } from '~/alert';
import GetFailedJobsQuery from '../../graphql/queries/get_failed_jobs.query.graphql';
-import { prepareFailedJobs } from './utils';
import FailedJobsTable from './failed_jobs_table.vue';
export default {
@@ -13,38 +11,33 @@ export default {
FailedJobsTable,
},
inject: {
- fullPath: {
+ projectPath: {
default: '',
},
pipelineIid: {
default: '',
},
},
- props: {
- failedJobsSummary: {
- type: Array,
- required: true,
- },
- },
apollo: {
failedJobs: {
query: GetFailedJobsQuery,
variables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
pipelineIid: this.pipelineIid,
};
},
update({ project }) {
- if (project?.pipeline?.jobs?.nodes) {
- return project.pipeline.jobs.nodes.map((job) => {
- return { normalizedId: getIdFromGraphQLId(job.id), ...job };
- });
- }
- return [];
- },
- result() {
- this.preparedFailedJobs = prepareFailedJobs(this.failedJobs, this.failedJobsSummary);
+ const jobNodes = project?.pipeline?.jobs?.nodes || [];
+
+ return jobNodes.map((job) => {
+ return {
+ ...job,
+ // this field is needed for the slot row-details
+ // on the failed_jobs_table.vue component
+ _showDetails: true,
+ };
+ });
},
error() {
createAlert({ message: s__('Jobs|There was a problem fetching the failed jobs.') });
@@ -54,7 +47,6 @@ export default {
data() {
return {
failedJobs: [],
- preparedFailedJobs: [],
};
},
computed: {
@@ -68,6 +60,6 @@ export default {
<template>
<div>
<gl-loading-icon v-if="loading" size="lg" class="gl-mt-4" />
- <failed-jobs-table v-else :failed-jobs="preparedFailedJobs" />
+ <failed-jobs-table v-else :failed-jobs="failedJobs" />
</div>
</template>
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..ec7000120f1 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -2,8 +2,8 @@
import { GlButton, GlLink, GlTableLite } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql';
import { DEFAULT_FIELDS } from '../../constants';
@@ -40,7 +40,7 @@ export default {
if (errors.length > 0) {
this.showErrorMessage();
} else {
- redirectTo(job.detailedStatus.detailsPath);
+ redirectTo(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated
}
} catch {
this.showErrorMessage();
@@ -52,6 +52,9 @@ export default {
showErrorMessage() {
createAlert({ message: s__('Job|There was a problem retrying the failed job.') });
},
+ failureSummary(trace) {
+ return trace ? trace.htmlSummary : s__('Job|No job log');
+ },
},
};
</script>
@@ -90,8 +93,8 @@ export default {
</div>
</template>
- <template #cell(failure)="{ item }">
- <span>{{ item.failure }}</span>
+ <template #cell(failureMessage)="{ item }">
+ <span data-testid="job-failure-message">{{ item.failureMessage }}</span>
</template>
<template #cell(actions)="{ item }">
@@ -110,7 +113,7 @@ export default {
class="gl-w-full gl-text-left gl-border-none"
data-testid="job-log"
>
- <code v-safe-html="item.failureSummary" class="gl-reset-bg gl-p-0" >
+ <code v-safe-html="failureSummary(item.trace)" class="gl-reset-bg gl-p-0" data-testid="job-trace-summary">
</code>
</pre>
</template>
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index f1ad312dcaa..61748860983 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';
@@ -17,7 +17,7 @@ export default {
JobsTable,
},
inject: {
- fullPath: {
+ projectPath: {
default: '',
},
pipelineIid: {
@@ -56,7 +56,7 @@ export default {
computed: {
queryVariables() {
return {
- fullPath: this.fullPath,
+ fullPath: this.projectPath,
iid: this.pipelineIid,
};
},
diff --git a/app/assets/javascripts/pipelines/components/jobs/utils.js b/app/assets/javascripts/pipelines/components/jobs/utils.js
deleted file mode 100644
index c8414d44d14..00000000000
--- a/app/assets/javascripts/pipelines/components/jobs/utils.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- We get the failure and failure summary from Rails which has
- a summary failure log. Here we combine that data with the data
- from GraphQL to display the log.
-
- failedJobs is from GraphQL
- failedJobsSummary is from Rails
- */
-
-export const prepareFailedJobs = (failedJobs = [], failedJobsSummary = []) => {
- const combinedJobs = [];
-
- if (failedJobs.length > 0 && failedJobsSummary.length > 0) {
- failedJobs.forEach((failedJob) => {
- const foundJob = failedJobsSummary.find(
- (failedJobSummary) => failedJob.normalizedId === failedJobSummary.id,
- );
-
- if (foundJob) {
- combinedJobs.push({
- ...failedJob,
- failure: foundJob?.failure,
- failureSummary: foundJob?.failure_summary,
- // this field is needed for the slot row-details
- // on the failed_jobs_table.vue component
- _showDetails: true,
- });
- }
- });
- }
-
- return combinedJobs;
-};
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/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
index 3798863ae60..d2ec3c352fe 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -31,13 +31,7 @@ export default {
GlTab,
GlTabs,
},
- inject: [
- 'defaultTabValue',
- 'failedJobsCount',
- 'failedJobsSummary',
- 'totalJobCount',
- 'testsCount',
- ],
+ inject: ['defaultTabValue', 'failedJobsCount', 'totalJobCount', 'testsCount'],
data() {
return {
activeTab: this.defaultTabValue,
@@ -110,7 +104,7 @@ export default {
<span class="gl-mr-2">{{ $options.i18n.tabs.failedJobsTitle }}</span>
<gl-badge size="sm" data-testid="failed-builds-counter">{{ failedJobsCount }}</gl-badge>
</template>
- <router-view :failed-jobs-summary="failedJobsSummary" />
+ <router-view />
</gl-tab>
<gl-tab
:active="isActive($options.tabNames.tests)"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
index 03a2eac89e4..a6297213402 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue
@@ -1,19 +1,8 @@
<script>
-import { GlButton, GlCard, GlSprintf, GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import {
- STARTER_TEMPLATE_NAME,
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
- I18N,
-} from '~/ci/pipeline_editor/constants';
+import { STARTER_TEMPLATE_NAME, I18N } from '~/ci/pipeline_editor/constants';
import Tracking from '~/tracking';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { isExperimentVariant } from '~/experimentation/utils';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import CiTemplates from './ci_templates.vue';
export default {
@@ -21,19 +10,12 @@ export default {
GlButton,
GlCard,
GlSprintf,
- GlIcon,
- GlLink,
- GitlabExperiment,
CiTemplates,
},
mixins: [Tracking.mixin()],
STARTER_TEMPLATE_NAME,
- RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
- RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
- RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT,
- RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT,
I18N,
- inject: ['anyRunnersAvailable', 'pipelineEditorPath', 'ciRunnerSettingsPath'],
+ inject: ['pipelineEditorPath'],
data() {
return {
gettingStartedTemplateUrl: mergeUrlParams(
@@ -43,26 +25,12 @@ export default {
tracker: null,
};
},
- computed: {
- sharedRunnersHelpPagePath() {
- return helpPagePath('ci/runners/runners_scope', { anchor: 'shared-runners' });
- },
- runnersAvailabilitySectionExperimentEnabled() {
- return isExperimentVariant(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
- },
- },
- created() {
- this.tracker = new ExperimentTracking(RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME);
- },
methods: {
trackEvent(template) {
this.track('template_clicked', {
label: template,
});
},
- trackExperimentEvent(action) {
- this.tracker.event(action);
- },
},
};
</script>
@@ -70,92 +38,42 @@ export default {
<div>
<h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2>
- <gitlab-experiment :name="$options.RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME">
- <template #candidate>
- <div v-if="anyRunnersAvailable">
- <h2 class="gl-font-base gl-text-gray-900">
- <gl-icon name="check-circle-filled" class="gl-text-green-500 gl-mr-2" :size="12" />
- {{ $options.I18N.runners.title }}
- </h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.I18N.runners.subtitle">
- <template #settingsLink="{ content }">
- <gl-link
- data-testid="settings-link"
- :href="ciRunnerSettingsPath"
- @click="trackExperimentEvent($options.RUNNERS_SETTINGS_LINK_CLICKED_EVENT)"
- >{{ content }}</gl-link
- >
- </template>
- <template #docsLink="{ content }">
- <gl-link
- data-testid="documentation-link"
- :href="sharedRunnersHelpPagePath"
- @click="trackExperimentEvent($options.RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT)"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </p>
- </div>
-
- <div v-else>
- <h2 class="gl-font-base gl-text-gray-900">
- <gl-icon name="warning-solid" class="gl-text-red-600 gl-mr-2" :size="14" />
- {{ $options.I18N.noRunners.title }}
- </h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.noRunners.subtitle }}</p>
- <gl-button
- data-testid="settings-button"
- category="primary"
- variant="confirm"
- :href="ciRunnerSettingsPath"
- @click="trackExperimentEvent($options.RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT)"
- >
- {{ $options.I18N.noRunners.cta }}
- </gl-button>
- </div>
- </template>
- </gitlab-experiment>
-
- <template v-if="!runnersAvailabilitySectionExperimentEnabled || anyRunnersAvailable">
- <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">
- <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.I18N.learnBasics.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
- <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
- <gl-card>
- <div class="gl-flex-direction-row">
- <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
- <div class="gl-mb-3">
- <strong class="gl-text-gray-800 gl-mb-2">
- {{ $options.I18N.learnBasics.gettingStarted.title }}
- </strong>
- </div>
- <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
+ <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8">
+ <gl-card>
+ <div class="gl-flex-direction-row">
+ <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800 gl-mb-2">
+ {{ $options.I18N.learnBasics.gettingStarted.title }}
+ </strong>
</div>
+ <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p>
+ </div>
- <gl-button
- category="primary"
- variant="confirm"
- :href="gettingStartedTemplateUrl"
- data-testid="test-template-link"
- @click="trackEvent($options.STARTER_TEMPLATE_NAME)"
- >
- {{ $options.I18N.learnBasics.gettingStarted.cta }}
- </gl-button>
- </gl-card>
- </div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="gettingStartedTemplateUrl"
+ data-testid="test-template-link"
+ @click="trackEvent($options.STARTER_TEMPLATE_NAME)"
+ >
+ {{ $options.I18N.learnBasics.gettingStarted.cta }}
+ </gl-button>
+ </gl-card>
+ </div>
- <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
- <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
+ <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p>
- <ci-templates />
- </template>
+ <ci-templates />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
index dd62ffb27f7..caeee7edefe 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -36,12 +36,10 @@ export default {
};
},
computed: {
- actions() {
- if (!this.pipeline || !this.pipeline.details) {
- return [];
- }
- const { details } = this.pipeline;
- return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
+ hasActions() {
+ return (
+ this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions
+ );
},
isCancelling() {
return this.cancelingPipeline === this.pipeline.id;
@@ -75,7 +73,7 @@ export default {
<template>
<div class="gl-text-right">
<div class="btn-group">
- <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" />
+ <pipelines-manual-actions v-if="hasActions" :iid="pipeline.iid" />
<gl-button
v-if="pipeline.flags.retryable"
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/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index fe2ef2c2d71..7ad12d397e5 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -144,14 +144,16 @@ export default {
<tooltip-on-truncate :title="commitTitle" class="gl-flex-grow-1 gl-text-truncate">
<gl-link
:href="commitUrl"
- class="commit-row-message gl-text-gray-900"
+ class="commit-row-message gl-font-weight-bold gl-text-gray-900"
data-testid="commit-title"
@click="trackClick('click_commit_title')"
>{{ commitTitle }}</gl-link
>
</tooltip-on-truncate>
</span>
- <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
+ <span v-else class="gl-text-gray-500">{{
+ __("Can't find HEAD commit for this branch")
+ }}</span>
</div>
<div class="gl-mb-2">
<gl-link
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 4111823e0bb..bcbf655a737 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';
@@ -189,6 +189,10 @@ export default {
);
},
+ shouldRenderPagination() {
+ return !this.isLoading && !this.hasError;
+ },
+
emptyTabMessage() {
if (this.scope === this.$options.scopes.finished) {
return s__('Pipelines|There are currently no finished pipelines.');
@@ -346,7 +350,7 @@ export default {
</div>
<div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex">
- <div class="row-content-block gl-display-flex gl-flex-grow-1">
+ <div class="row-content-block gl-display-flex gl-flex-grow-1 gl-border-b-0">
<pipelines-filtered-search
class="gl-display-flex gl-flex-grow-1 gl-mr-4"
:project-id="projectId"
@@ -381,10 +385,8 @@ export default {
<gl-empty-state
v-else-if="stateToRender === $options.stateMap.error"
:svg-path="errorStateSvgPath"
- :title="
- s__(`Pipelines|There was an error fetching the pipelines.
- Try again in a few moments or contact your support team.`)
- "
+ :title="s__('Pipelines|There was an error fetching the pipelines.')"
+ :description="s__('Pipelines|Try again in a few moments or contact your support team.')"
/>
<gl-empty-state
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..262e82677a7 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 { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+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';
@@ -8,8 +8,10 @@ import Tracking from '~/tracking';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import eventHub from '../../event_hub';
import { TRACKING_CATEGORIES } from '../../constants';
+import getPipelineActionsQuery from '../../graphql/queries/get_pipeline_actions.query.graphql';
export default {
+ name: 'PipelinesManualActions',
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -18,22 +20,52 @@ export default {
GlDropdown,
GlDropdownItem,
GlIcon,
+ GlLoadingIcon,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath', 'manualActionsLimit'],
props: {
- actions: {
- type: Array,
+ iid: {
+ type: Number,
required: true,
},
},
+ apollo: {
+ actions: {
+ query: getPipelineActionsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ limit: this.manualActionsLimit,
+ };
+ },
+ skip() {
+ return !this.hasDropdownBeenShown;
+ },
+ update({ project }) {
+ return project?.pipeline?.jobs?.nodes || [];
+ },
+ },
+ },
data() {
return {
isLoading: false,
+ actions: [],
+ hasDropdownBeenShown: false,
};
},
+ computed: {
+ isActionsLoading() {
+ return this.$apollo.queries.actions.loading;
+ },
+ isDropdownLimitReached() {
+ return this.actions.length === this.manualActionsLimit;
+ },
+ },
methods: {
async onClickAction(action) {
- if (action.scheduled_at) {
+ if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.',
@@ -54,12 +86,12 @@ export default {
* Ideally, the component would not make an api call directly.
* However, in order to use the eventhub and know when to
* toggle back the `isLoading` property we'd need an ID
- * to track the request with a wacther - since this component
+ * to track the request with a watcher - since this component
* is rendered at least 20 times in the same page, moving the
* api call directly here is the most performant solution
*/
axios
- .post(`${action.path}.json`)
+ .post(`${action.playPath}.json`)
.then(() => {
this.isLoading = false;
eventHub.$emit('updateTable');
@@ -69,12 +101,12 @@ export default {
createAlert({ message: __('An error occurred while making the request.') });
});
},
- isActionDisabled(action) {
- if (action.playable === undefined) {
- return false;
- }
+ fetchActions() {
+ this.hasDropdownBeenShown = true;
+
+ this.$apollo.queries.actions.refetch();
- return !action.playable;
+ this.trackClick();
},
trackClick() {
this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table });
@@ -91,21 +123,37 @@ export default {
right
lazy
icon="play"
- @shown="trackClick"
+ @shown="fetchActions"
>
+ <gl-dropdown-item v-if="isActionsLoading">
+ <div class="gl-display-flex">
+ <gl-loading-icon class="mr-2" />
+ <span>{{ __('Loading...') }}</span>
+ </div>
+ </gl-dropdown-item>
+
<gl-dropdown-item
v-for="action in actions"
- :key="action.path"
- :disabled="isActionDisabled(action)"
+ v-else
+ :key="action.id"
+ :disabled="!action.canPlayJob"
@click="onClickAction(action)"
>
<div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
{{ action.name }}
- <span v-if="action.scheduled_at">
+ <span v-if="action.scheduledAt">
<gl-icon name="clock" />
- <gl-countdown :end-date-string="action.scheduled_at" />
+ <gl-countdown :end-date-string="action.scheduledAt" />
</span>
</div>
</gl-dropdown-item>
+
+ <template #footer>
+ <gl-dropdown-item v-if="isDropdownLimitReached">
+ <span class="gl-font-sm gl-text-gray-300!" data-testid="limit-reached-msg">
+ {{ __('Showing first 50 actions.') }}
+ </span>
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 365572f194b..b2da0df17c0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -15,7 +15,7 @@ import PipelinesStatusBadge from './pipelines_status_badge.vue';
const DEFAULT_TD_CLASS = 'gl-p-5!';
const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!';
const DEFAULT_TH_CLASSES =
- 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!';
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 960af030421..d068eb16ed4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { durationTimeFormatted } from '~/lib/utils/datetime_utility';
+import { formatTime } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -20,7 +20,7 @@ export default {
return this.pipeline?.details?.duration;
},
durationFormatted() {
- return durationTimeFormatted(this.duration);
+ return formatTime(this.duration * 1000);
},
finishedTime() {
return this.pipeline?.details?.finished_at;
@@ -41,7 +41,7 @@ export default {
};
</script>
<template>
- <div class="gl-display-flex gl-flex-direction-column time-ago">
+ <div class="gl-display-flex gl-flex-direction-column gl-font-sm time-ago">
<span
v-if="showInProgress"
class="gl-display-inline-flex gl-align-items-center"
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..d092c3ca630 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';
@@ -82,7 +79,7 @@ export const PipelineKeyOptions = [
export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs');
-export const BUTTON_TOOLTIP_CANCEL = __('Cancel');
+export const BUTTON_TOOLTIP_CANCEL = __('Cancel the running pipeline');
export const DEFAULT_FIELDS = [
{
@@ -96,7 +93,7 @@ export const DEFAULT_FIELDS = [
columnClass: 'gl-w-20p',
},
{
- key: 'failure',
+ key: 'failureMessage',
label: __('Failure'),
columnClass: 'gl-w-40p',
},
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
index 14e9a838f4b..5bdafa15f72 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_failed_jobs.query.graphql
@@ -3,7 +3,7 @@ query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
id
pipeline(iid: $pipelineIid) {
id
- jobs(statuses: FAILED) {
+ jobs(statuses: FAILED, retried: false) {
nodes {
status
detailedStatus {
@@ -34,6 +34,10 @@ query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) {
readBuild
updateBuild
}
+ trace {
+ htmlSummary
+ }
+ failureMessage
}
}
}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql
new file mode 100644
index 00000000000..d1878c01e91
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql
@@ -0,0 +1,24 @@
+query getPipelineActions($fullPath: ID!, $iid: ID!, $limit: Int) {
+ project(fullPath: $fullPath) {
+ id
+ pipeline(iid: $iid) {
+ id
+ jobs(
+ first: $limit
+ whenExecuted: ["manual", "delayed"]
+ retried: false
+ statuses: [MANUAL, SCHEDULED, SUCCESS, FAILED, SKIPPED, CANCELED]
+ ) {
+ nodes {
+ id
+ name
+ canPlayJob
+ manualJob
+ scheduledAt
+ scheduled
+ playPath
+ }
+ }
+ }
+ }
+}
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..33bdedee764 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);
@@ -26,14 +28,12 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeSecurityDashboard,
exposeLicenseScanningData,
failedJobsCount,
- failedJobsSummary,
- fullPath,
+ projectPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
totalJobCount,
licenseManagementApiUrl,
- licenseManagementSettingsPath,
licenseScanCount,
licensesApiPath,
canManageLicenses,
@@ -45,11 +45,10 @@ export const createAppOptions = (selector, apolloProvider, router) => {
emptyStateImagePath,
artifactsExpiredImagePath,
isFullCodequalityReportAvailable,
+ securityPoliciesPath,
testsCount,
} = dataset;
- // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved
- const projectPath = fullPath;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
return {
@@ -80,14 +79,11 @@ export const createAppOptions = (selector, apolloProvider, router) => {
exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard),
exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData),
failedJobsCount,
- failedJobsSummary: JSON.parse(failedJobsSummary),
- fullPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
totalJobCount,
licenseManagementApiUrl,
- licenseManagementSettingsPath,
licenseScanCount,
licensesApiPath,
canManageLicenses: parseBoolean(canManageLicenses),
@@ -98,6 +94,7 @@ export const createAppOptions = (selector, apolloProvider, router) => {
emptyDagSvgPath,
emptyStateImagePath,
artifactsExpiredImagePath,
+ securityPoliciesPath,
testsCount,
},
errorCaptured(err, _vm, info) {
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 6dccdb1a3e6..49e2e1644e2 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -1,5 +1,7 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import {
parseBoolean,
historyReplaceState,
@@ -13,6 +15,11 @@ import PipelinesStore from './stores/pipelines_store';
Vue.use(Translate);
Vue.use(GlToast);
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
const el = document.querySelector(selector);
@@ -38,22 +45,22 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
projectId,
defaultBranchName,
params,
- ciRunnerSettingsPath,
- anyRunnersAvailable,
iosRunnersAvailable,
registrationToken,
+ fullPath,
} = el.dataset;
return new Vue({
el,
+ apolloProvider,
provide: {
pipelineEditorPath,
artifactsEndpoint,
artifactsEndpointPlaceholder,
suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
- ciRunnerSettingsPath,
- anyRunnersAvailable: parseBoolean(anyRunnersAvailable),
iosRunnersAvailable: parseBoolean(iosRunnersAvailable),
+ fullPath,
+ manualActionsLimit: 50,
},
data() {
return {
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..21f8a2d3500 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -1,17 +1,44 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import ActivityCalendar from './activity_calendar.vue';
export default {
i18n: {
title: s__('UserProfile|Overview'),
+ personalProjects: s__('UserProfile|Personal projects'),
+ viewAll: s__('UserProfile|View all'),
+ },
+ components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList },
+ props: {
+ personalProjects: {
+ type: Array,
+ required: true,
+ },
+ personalProjectsLoading: {
+ type: Boolean,
+ required: true,
+ },
},
- components: { GlTab },
};
</script>
<template>
<gl-tab :title="$options.i18n.title">
- <!-- placeholder -->
+ <activity-calendar />
+ <div class="gl-mx-n3 gl-display-flex gl-flex-wrap">
+ <div class="gl-px-3 gl-w-full gl-lg-w-half"></div>
+ <div class="gl-px-3 gl-w-full gl-lg-w-half" data-testid="personal-projects-section">
+ <div
+ class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
+ >
+ <h4 class="gl-flex-grow-1">{{ $options.i18n.personalProjects }}</h4>
+ <gl-link href="">{{ $options.i18n.viewAll }}</gl-link>
+ </div>
+ <gl-loading-icon v-if="personalProjectsLoading" class="gl-mt-5" size="md" />
+ <projects-list v-else :projects="personalProjects" />
+ </div>
+ </div>
</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..8e52a98803d 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -1,6 +1,10 @@
<script>
import { GlTabs } from '@gitlab/ui';
+import { getUserProjects } from '~/rest_api';
+import { s__ } from '~/locale';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createAlert } from '~/alert';
import OverviewTab from './overview_tab.vue';
import ActivityTab from './activity_tab.vue';
import GroupsTab from './groups_tab.vue';
@@ -12,6 +16,11 @@ import FollowersTab from './followers_tab.vue';
import FollowingTab from './following_tab.vue';
export default {
+ i18n: {
+ personalProjectsErrorMessage: s__(
+ 'UserProfile|An error occurred loading the personal projects. Please refresh the page to try again.',
+ ),
+ },
components: {
GlTabs,
OverviewTab,
@@ -62,11 +71,34 @@ export default {
component: FollowingTab,
},
],
+ inject: ['userId'],
+ data() {
+ return {
+ personalProjectsLoading: true,
+ personalProjects: [],
+ };
+ },
+ async mounted() {
+ try {
+ const response = await getUserProjects(this.userId, { per_page: 10 });
+ this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true });
+ this.personalProjectsLoading = false;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.personalProjectsErrorMessage });
+ }
+ },
};
</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-text-left"
+ :personal-projects="personalProjects"
+ :personal-projects-loading="personalProjectsLoading"
+ />
</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..fd42b64f4c5
--- /dev/null
+++ b/app/assets/javascripts/profile/components/user_achievements.vue
@@ -0,0 +1,98 @@
+<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 } }) => {
+ return {
+ id: `user-achievement-${getIdFromGraphQLId(achievement.id)}`,
+ name: achievement.name,
+ timeAgo: this.timeFormatted(createdAt),
+ avatarUrl: achievement.avatarUrl || gon.gitlab_logo,
+ description: achievement.description,
+ namespace: namespace && {
+ fullPath: namespace.fullPath,
+ webUrl: this.rootUrl + namespace.fullPath,
+ },
+ };
+ });
+ },
+ achievementAwardedMessage(userAchievement) {
+ return userAchievement.namespace
+ ? this.$options.i18n.awardedBy
+ : this.$options.i18n.awardedByUnknownNamespace;
+ },
+ },
+ i18n: {
+ awardedBy: s__('Achievements|Awarded %{timeAgo} by %{namespace}'),
+ awardedByUnknownNamespace: s__('Achievements|Awarded %{timeAgo} by a private 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="achievementAwardedMessage(userAchievement)">
+ <template #timeAgo>
+ <span>{{ userAchievement.timeAgo }}</span>
+ </template>
+ <template v-if="userAchievement.namespace" #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/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 050b004f657..107bfd159dd 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -3,6 +3,8 @@
import $ from 'jquery';
import 'cropper';
import { isString } from 'lodash';
+import { s__ } from '~/locale';
+import { createAlert } from '~/alert';
import { loadCSSFile } from '../lib/utils/css_utils';
(() => {
@@ -139,11 +141,20 @@ import { loadCSSFile } from '../lib/utils/css_utils';
}
readFile(input) {
- const _this = this;
const reader = new FileReader();
reader.onload = () => {
- _this.modalCropImg.attr('src', reader.result);
- return _this.modalCrop.modal('show');
+ this.modalCropImg.attr('src', reader.result);
+ import(/* webpackChunkName: 'bootstrapModal' */ 'bootstrap/js/dist/modal')
+ .then(() => {
+ this.modalCrop.modal('show');
+ })
+ .catch(() => {
+ createAlert({
+ message: s__(
+ 'UserProfile|Failed to set avatar. Please reload the page to try again.',
+ ),
+ });
+ });
};
return reader.readAsDataURL(input.files[0]);
}
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
index 5378ed3d743..101e52c873e 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -1,16 +1,54 @@
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, userId } = el.dataset;
+
return new Vue({
el,
+ name: 'ProfileRoot',
+ provide: {
+ followees: parseInt(followers, 10),
+ followers: parseInt(followees, 10),
+ userCalendarPath,
+ utcOffset,
+ userId,
+ },
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/diffs_colors.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
index 1992819ab82..5a87ed7996e 100644
--- a/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
+++ b/app/assets/javascripts/profile/preferences/components/diffs_colors.vue
@@ -1,7 +1,9 @@
<script>
-import { validateHexColor, hexToRgb } from '~/lib/utils/color_utils';
+import { hexToRgba } from '@gitlab/ui/dist/utils/utils';
+
import { s__ } from '~/locale';
import { getCssVariable } from '~/lib/utils/css_utils';
+import { validateHexColor } from '~/lib/utils/color_utils';
import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
import DiffsColorsPreview from './diffs_colors_preview.vue';
@@ -48,17 +50,15 @@ export default {
previewStyle() {
let style = {};
if (this.isValidColor(this.deletionColor)) {
- const colorRgb = hexToRgb(this.deletionColor).join();
style = {
...style,
- '--diff-deletion-color': `rgba(${colorRgb},0.2)`,
+ '--diff-deletion-color': hexToRgba(this.deletionColor, 0.2),
};
}
if (this.isValidColor(this.additionColor)) {
- const colorRgb = hexToRgb(this.additionColor).join();
style = {
...style,
- '--diff-addition-color': `rgba(${colorRgb},0.2)`,
+ '--diff-addition-color': hexToRgba(this.additionColor, 0.2),
};
}
return style;
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index a33a20b49f6..164ec46cdb9 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';
@@ -131,6 +131,10 @@ export default {
:message-url="view.message_url"
:config="$options.integrationViewConfigs[view.name]"
/>
+ </div>
+
+ <div class="col-lg-4"></div>
+ <div class="col-lg-8">
<hr />
</div>
<div class="col-sm-12 js-hide-when-nothing-matches-search">
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/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
index 0fd31381ba6..a4edc988d67 100644
--- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
@@ -93,7 +93,7 @@ export default {
data-testid="email-patches-link"
data-qa-selector="email_patches"
>
- {{ s__('DownloadCommit|Email Patches') }}
+ {{ __('Patches') }}
</gl-dropdown-item>
<gl-dropdown-item
:href="plainDiffPath"
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/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 7500c152b6a..7c4b76fd62f 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,6 +1,5 @@
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
-import { initDetailsButton } from './init_details_button';
import { loadBranches } from './load_branches';
import initCommitPipelineStatus from './init_commit_pipeline_status';
@@ -14,7 +13,5 @@ export const initCommitBoxInfo = () => {
// Display pipeline mini graph for this commit
initCommitPipelineMiniGraph();
- initDetailsButton();
-
initCommitPipelineStatus();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/init_details_button.js b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
index bc2c16b9e83..520b20fcb86 100644
--- a/app/assets/javascripts/projects/commit_box/info/init_details_button.js
+++ b/app/assets/javascripts/projects/commit_box/info/init_details_button.js
@@ -1,7 +1,17 @@
export const initDetailsButton = () => {
- document.querySelector('.commit-info').addEventListener('click', function expand(e) {
- e.preventDefault();
- this.querySelector('.js-details-content').classList.remove('hide');
- this.querySelector('.js-details-expand').classList.add('gl-display-none');
+ const expandButton = document.querySelector('.js-details-expand');
+
+ if (!expandButton) {
+ return;
+ }
+
+ expandButton.addEventListener('click', (event) => {
+ const btn = event.currentTarget;
+ const contentEl = btn.parentElement.querySelector('.js-details-content');
+
+ if (contentEl) {
+ contentEl.classList.remove('hide');
+ btn.classList.add('gl-display-none');
+ }
});
};
diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js
index d1136817cb3..8333e70b951 100644
--- a/app/assets/javascripts/projects/commit_box/info/load_branches.js
+++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js
@@ -1,6 +1,7 @@
import axios from 'axios';
import { sanitize } from '~/lib/dompurify';
import { __ } from '~/locale';
+import { initDetailsButton } from './init_details_button';
export const loadBranches = (containerSelector = '.js-commit-box-info') => {
const containerEl = document.querySelector(containerSelector);
@@ -14,6 +15,8 @@ export const loadBranches = (containerSelector = '.js-commit-box-info') => {
.get(commitPath)
.then(({ data }) => {
branchesEl.innerHTML = sanitize(data);
+
+ initDetailsButton();
})
.catch(() => {
branchesEl.textContent = __('Failed to load branches. Please try again.');
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index f85be67d4b3..2966214e051 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex';
-import { redirectTo, queryToObject } from '~/lib/utils/url_utility';
+import { redirectTo, queryToObject } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __ } from '~/locale';
const tooltipMessage = __('Searching by both author and message is currently not supported.');
@@ -89,10 +89,10 @@ export default {
commitListElement.style.transition = 'opacity 200ms';
if (!user) {
- return redirectTo(this.commitsPath);
+ return redirectTo(this.commitsPath); // eslint-disable-line import/no-deprecated
}
- return redirectTo(`${this.commitsPath}?author=${user}`);
+ return redirectTo(`${this.commitsPath}?author=${user}`); // eslint-disable-line import/no-deprecated
},
searchAuthors() {
this.fetchAuthors(this.authorInput);
diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js
index f56884f605f..d37c1800718 100644
--- a/app/assets/javascripts/projects/commits/index.js
+++ b/app/assets/javascripts/projects/commits/index.js
@@ -35,6 +35,10 @@ export const initCommitsRefSwitcher = () => {
const { projectId, ref, commitsPath, refType } = el.dataset;
const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0];
+ const generateRefDestinationUrl = (selectedRef, selectedRefType) => {
+ const commitsPathSuffix = selectedRefType ? `?ref_type=${selectedRefType}` : '';
+ return `${commitsPathPrefix}/${encodeURIComponent(selectedRef)}${commitsPathSuffix}`;
+ };
const useSymbolicRefNames = Boolean(refType);
return new Vue({
el,
@@ -42,21 +46,17 @@ export const initCommitsRefSwitcher = () => {
return createElement(RefSelector, {
props: {
projectId,
+ queryParams: { sort: 'updated_desc' },
value: useSymbolicRefNames ? `refs/${refType}/${ref}` : ref,
useSymbolicRefNames,
- refType,
},
on: {
input(selected) {
- if (useSymbolicRefNames) {
- const matches = selected.match(/refs\/(heads|tags)\/(.+)/);
- if (matches) {
- visitUrl(`${commitsPathPrefix}/${matches[2]}?ref_type=${matches[1]}`);
- } else {
- visitUrl(`${commitsPathPrefix}/${selected}`);
- }
+ const matches = selected.match(/refs\/(heads|tags)\/(.+)/);
+ if (useSymbolicRefNames && matches) {
+ visitUrl(generateRefDestinationUrl(matches[2], matches[1]));
} else {
- visitUrl(`${commitsPathPrefix}/${selected}`);
+ visitUrl(generateRefDestinationUrl(selected));
}
},
},
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/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index a44855c14d5..5bbc881952f 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -53,10 +53,6 @@ export default {
text: s__('ProjectTemplates|Pages/Plain HTML'),
icon: '.template-option .icon-plainhtml',
},
- gitbook: {
- text: s__('ProjectTemplates|Pages/GitBook'),
- icon: '.template-option .icon-gitbook',
- },
hexo: {
text: s__('ProjectTemplates|Pages/Hexo'),
icon: '.template-option .icon-hexo',
@@ -121,4 +117,8 @@ export default {
text: s__('ProjectTemplates|TYPO3 Distribution'),
icon: '.template-option .icon-typo3',
},
+ laravel: {
+ text: s__('ProjectTemplates|Laravel Framework'),
+ icon: '.template-option .icon-laravel',
+ },
};
diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 3100029eb31..2f58d4468be 100644
--- a/app/assets/javascripts/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
@@ -9,6 +9,7 @@ import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'
import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
const CI_CD_PANEL = 'cicd_for_external_repo';
+const IMPORT_PROJECT_PANEL = 'import_project';
const PANELS = [
{
key: 'blank',
@@ -32,7 +33,7 @@ const PANELS = [
},
{
key: 'import',
- name: 'import_project',
+ name: IMPORT_PROJECT_PANEL,
selector: '#import-project-pane',
title: s__('ProjectsNew|Import project'),
description: s__(
@@ -59,6 +60,24 @@ export default {
SafeHtml,
},
props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ projectsUrl: {
+ type: String,
+ required: true,
+ },
+ parentGroupUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ parentGroupName: {
+ type: String,
+ required: false,
+ default: '',
+ },
hasErrors: {
type: Boolean,
required: false,
@@ -74,11 +93,40 @@ export default {
required: false,
default: '',
},
+ canImportProjects: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
+ initialBreadcrumbs() {
+ const breadcrumbs = this.parentGroupUrl
+ ? [{ text: this.parentGroupName, href: this.parentGroupUrl }]
+ : [
+ { text: s__('Navigation|Your work'), href: this.rootPath },
+ { text: s__('ProjectsNew|Projects'), href: this.projectsUrl },
+ ];
+ breadcrumbs.push({ text: s__('ProjectsNew|New project'), href: '#' });
+ return breadcrumbs;
+ },
availablePanels() {
- return this.isCiCdAvailable ? PANELS : PANELS.filter((p) => p.name !== CI_CD_PANEL);
+ if (this.isCiCdAvailable && this.canImportProjects) {
+ return PANELS;
+ }
+
+ return PANELS.filter((panel) => {
+ if (!this.canImportProjects && panel.name === IMPORT_PROJECT_PANEL) {
+ return false;
+ }
+
+ if (!this.isCiCdAvailable && panel.name === CI_CD_PANEL) {
+ return false;
+ }
+
+ return true;
+ });
},
},
@@ -95,7 +143,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..a5a833dc73b 100644
--- a/app/assets/javascripts/projects/new/index.js
+++ b/app/assets/javascripts/projects/new/index.js
@@ -15,12 +15,22 @@ export function initNewProjectCreation() {
newProjectGuidelines,
hasErrors,
isCiCdAvailable,
+ parentGroupUrl,
+ parentGroupName,
+ projectsUrl,
+ rootPath,
+ canImportProjects,
} = el.dataset;
const props = {
hasErrors: parseBoolean(hasErrors),
isCiCdAvailable: parseBoolean(isCiCdAvailable),
newProjectGuidelines,
+ parentGroupUrl,
+ parentGroupName,
+ projectsUrl,
+ rootPath,
+ canImportProjects: parseBoolean(canImportProjects),
};
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..d8675a851ea 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';
@@ -408,14 +408,16 @@ export default class AccessDropdown {
// Has to be checked against server response
// because the selected item can be in filter results
- usersResponse.forEach((response) => {
- // Add is it has not been added
- if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) {
- const user = { ...response };
- user.type = LEVEL_TYPES.USER;
- users.push(user);
- }
- });
+ if (gon.current_project_id) {
+ usersResponse.forEach((response) => {
+ // Add is it has not been added
+ if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) {
+ const user = { ...response };
+ user.type = LEVEL_TYPES.USER;
+ users.push(user);
+ }
+ });
+ }
if (groups.length) {
if (roles.length) {
@@ -469,6 +471,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 +516,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..dbcb77b67f3 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;
@@ -162,12 +187,9 @@ export default {
:roles="pushAccessLevels.roles"
:users="pushAccessLevels.users"
:groups="pushAccessLevels.groups"
+ data-qa-selector="allowed_to_push_content"
/>
- <!-- Force push -->
- <strong>{{ $options.i18n.forcePushTitle }}</strong>
- <p>{{ forcePushDescription }}</p>
-
<!-- Allowed to merge -->
<protection
:header="allowedToMergeHeader"
@@ -176,11 +198,40 @@ export default {
:roles="mergeAccessLevels.roles"
:users="mergeAccessLevels.users"
:groups="mergeAccessLevels.groups"
+ data-qa-selector="allowed_to_merge_content"
/>
+ <!-- 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 +251,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..3a5b3409596 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
@@ -105,7 +105,8 @@ export default {
v-for="(item, index) in accessLevels"
:key="index"
data-testid="access-level"
- class="gl-w-quarter"
+ data-qa-selector="access_level_content"
+ :data-qa-role="item.accessLevelDescription"
>
<span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span>
{{ item.accessLevelDescription }}
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/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
index aa3235b1515..b8e7e9e15db 100644
--- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
+import { GlAlert, GlToggle } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
import { CC_VALIDATION_REQUIRED_ERROR } from '../constants';
@@ -16,7 +16,6 @@ export default {
components: {
GlAlert,
GlToggle,
- GlTooltip,
CcValidationRequiredAlert: () =>
import('ee_component/billings/components/cc_validation_required_alert.vue'),
},
@@ -94,10 +93,26 @@ export default {
@dismiss="ccAlertDismissed = true"
/>
- <gl-alert v-if="genericError" class="gl-mb-3" variant="danger" :dismissible="false">
+ <gl-alert
+ v-if="genericError"
+ data-testid="error-alert"
+ variant="danger"
+ :dismissible="false"
+ class="gl-mb-5"
+ >
{{ errorMessage }}
</gl-alert>
+ <gl-alert
+ v-if="isDisabledAndUnoverridable"
+ data-testid="unoverridable-alert"
+ variant="warning"
+ :dismissible="false"
+ class="gl-mb-5"
+ >
+ {{ s__('Runners|Shared runners are disabled in the group settings') }}
+ </gl-alert>
+
<gl-toggle
ref="sharedRunnersToggle"
:disabled="isDisabledAndUnoverridable"
@@ -107,10 +122,6 @@ export default {
data-testid="toggle-shared-runners"
@change="toggleSharedRunners"
/>
-
- <gl-tooltip v-if="isDisabledAndUnoverridable" :target="() => $refs.sharedRunnersToggle">
- {{ __('Shared runners are disabled on group level') }}
- </gl-tooltip>
</section>
</div>
</template>
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..dcf5155644d 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';
@@ -69,9 +69,14 @@ export default {
<div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div>
- <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{
- $options.i18n.addBranchRule
- }}</gl-button>
+ <gl-button
+ v-gl-modal="$options.modalId"
+ class="gl-mt-5"
+ data-qa-selector="add_branch_rule_button"
+ category="secondary"
+ variant="info"
+ >{{ $options.i18n.addBranchRule }}</gl-button
+ >
<gl-modal
:ref="$options.modalId"
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..a5ff478a826 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) {
@@ -150,7 +153,11 @@ export default {
</script>
<template>
- <div class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between">
+ <div
+ class="gl-border-b gl-pt-5 gl-pb-5 gl-display-flex gl-justify-content-space-between"
+ data-qa-selector="branch_content"
+ :data-qa-branch-name="name"
+ >
<div>
<strong class="gl-font-monospace">{{ name }}</strong>
@@ -166,7 +173,7 @@ export default {
<li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li>
</ul>
</div>
- <gl-button class="gl-align-self-start" :href="detailsPath">
+ <gl-button class="gl-align-self-start" data-qa-selector="details_button" :href="detailsPath">
{{ $options.i18n.detailsButtonLabel }}</gl-button
>
</div>
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/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index b79b3fa4573..79ece99e6ec 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -8,7 +8,7 @@ import ServiceDeskSetting from './service_desk_setting.vue';
export default {
customEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
- anchor: 'using-a-custom-email-address',
+ anchor: 'use-a-custom-email-address',
}),
components: {
GlAlert,
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 85550e262e6..5a3930b5df4 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -102,12 +102,12 @@ export default {
},
emailSuffixHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'configuring-a-custom-email-address-suffix',
+ anchor: 'configure-a-custom-email-address-suffix',
});
},
customEmailAddressHelpUrl() {
return helpPagePath('user/project/service_desk.html', {
- anchor: 'using-a-custom-email-address',
+ anchor: 'use-a-custom-email-address',
});
},
},
@@ -155,7 +155,7 @@ export default {
<div v-if="isEnabled" class="row mt-3">
<div class="col-md-9 mb-0">
<gl-form-group
- :label="__('Email address to use for Support Desk')"
+ :label="__('Email address to use for Service Desk')"
label-for="incoming-email"
data-testid="incoming-email-label"
>
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..5342874250c 100644
--- a/app/assets/javascripts/protected_branches/constants.js
+++ b/app/assets/javascripts/protected_branches/constants.js
@@ -10,11 +10,8 @@ export const LEVEL_TYPES = {
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 BRANCH_RULES_ANCHOR = '#branch-rules';
+
+export const IS_PROTECTED_BRANCH_CREATED = 'is_protected_branch_created';
-export const ACCESS_LEVEL_NONE = 0;
+export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings';
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 120f75d4f0c..cdbe39fd5e0 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -1,17 +1,24 @@
import $ from 'jquery';
import CreateItemDropdown from '~/create_item_dropdown';
-import { createAlert } from '~/flash';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { initToggle } from '~/toggles';
-import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
+import { expandSection } from '~/settings_panels';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import {
+ BRANCH_RULES_ANCHOR,
+ PROTECTED_BRANCHES_ANCHOR,
+ IS_PROTECTED_BRANCH_CREATED,
+ ACCESS_LEVELS,
+ LEVEL_TYPES,
+} from './constants';
export default class ProtectedBranchCreate {
constructor(options) {
this.hasLicense = options.hasLicense;
-
this.$form = $('.js-new-protected-branch');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
this.currentProjectUserDefaults = {};
@@ -22,7 +29,7 @@ export default class ProtectedBranchCreate {
if (this.hasLicense) {
this.codeOwnerToggle = initToggle(document.querySelector('.js-code-owner-toggle'));
}
-
+ this.showSuccessAlertIfNeeded();
this.bindEvents();
}
@@ -81,6 +88,49 @@ export default class ProtectedBranchCreate {
callback(gon.open_branches);
}
+ // eslint-disable-next-line class-methods-use-this
+ expandAndScroll(anchor) {
+ expandSection(anchor);
+ scrollToElement(anchor);
+ }
+
+ hasProtectedBranchSuccessAlert() {
+ return (
+ window.gon?.features?.branchRules &&
+ this.isLocalStorageAvailable &&
+ localStorage.getItem(IS_PROTECTED_BRANCH_CREATED)
+ );
+ }
+
+ createSuccessAlert() {
+ this.alert = createAlert({
+ variant: VARIANT_SUCCESS,
+ containerSelector: '.js-alert-protected-branch-created-container',
+ title: s__('ProtectedBranch|View protected branches as branch rules'),
+ message: s__('ProtectedBranch|Manage branch related settings in one area with branch rules.'),
+ primaryButton: {
+ text: s__('ProtectedBranch|View branch rule'),
+ clickHandler: () => {
+ this.expandAndScroll(BRANCH_RULES_ANCHOR);
+ },
+ },
+ secondaryButton: {
+ text: __('Dismiss'),
+ clickHandler: () => this.alert.dismiss(),
+ },
+ });
+ }
+
+ showSuccessAlertIfNeeded() {
+ if (!this.hasProtectedBranchSuccessAlert()) {
+ return;
+ }
+ this.expandAndScroll(PROTECTED_BRANCHES_ANCHOR);
+
+ this.createSuccessAlert();
+ localStorage.removeItem(IS_PROTECTED_BRANCH_CREATED);
+ }
+
getFormData() {
const formData = {
authenticity_token: this.$form.find('input[name="authenticity_token"]').val(),
@@ -127,6 +177,9 @@ export default class ProtectedBranchCreate {
axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData())
.then(() => {
+ if (this.isLocalStorageAvailable) {
+ localStorage.setItem(IS_PROTECTED_BRANCH_CREATED, 'true');
+ }
window.location.reload();
})
.catch(() =>
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/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 9826124912b..7f58b394547 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -42,10 +42,10 @@ export default {
required: false,
default: '',
},
- refType: {
- type: String,
+ queryParams: {
+ type: Object,
required: false,
- default: null,
+ default: () => {},
},
projectId: {
type: String,
@@ -93,6 +93,7 @@ export default {
matches: (state) => state.matches,
lastQuery: (state) => state.query,
selectedRef: (state) => state.selectedRef,
+ params: (state) => state.params,
}),
...mapGetters(['isLoading', 'isQueryPossiblyASha']),
i18n() {
@@ -186,6 +187,7 @@ export default {
this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
+ this.setParams(this.queryParams);
this.$watch(
'enabledRefTypes',
@@ -206,6 +208,7 @@ export default {
...mapActions([
'setEnabledRefTypes',
'setUseSymbolicRefNames',
+ 'setParams',
'setProjectId',
'setSelectedRef',
]),
diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js
index a6019f21e73..3d6b46abf52 100644
--- a/app/assets/javascripts/ref/stores/actions.js
+++ b/app/assets/javascripts/ref/stores/actions.js
@@ -5,6 +5,8 @@ import * as types from './mutation_types';
export const setEnabledRefTypes = ({ commit }, refTypes) =>
commit(types.SET_ENABLED_REF_TYPES, refTypes);
+export const setParams = ({ commit }, params) => commit(types.SET_PARAMS, params);
+
export const setUseSymbolicRefNames = ({ commit }, useSymbolicRefNames) =>
commit(types.SET_USE_SYMBOLIC_REF_NAMES, useSymbolicRefNames);
@@ -29,7 +31,7 @@ export const search = ({ state, dispatch, commit }, query) => {
export const searchBranches = ({ commit, state }) => {
commit(types.REQUEST_START);
- Api.branches(state.projectId, state.query)
+ Api.branches(state.projectId, state.query, state.params)
.then((response) => {
commit(types.RECEIVE_BRANCHES_SUCCESS, response);
})
diff --git a/app/assets/javascripts/ref/stores/index.js b/app/assets/javascripts/ref/stores/index.js
index 2bebffc19ab..fb2196fa1d0 100644
--- a/app/assets/javascripts/ref/stores/index.js
+++ b/app/assets/javascripts/ref/stores/index.js
@@ -14,3 +14,11 @@ export default () =>
mutations,
state: createState(),
});
+
+export const createRefModule = () => ({
+ namespaced: true,
+ actions,
+ getters,
+ mutations,
+ state: createState(),
+});
diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js
index 4c602908cae..6178106fe00 100644
--- a/app/assets/javascripts/ref/stores/mutation_types.js
+++ b/app/assets/javascripts/ref/stores/mutation_types.js
@@ -1,5 +1,6 @@
export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES';
export const SET_USE_SYMBOLIC_REF_NAMES = 'SET_USE_SYMBOLIC_REF_NAMES';
+export const SET_PARAMS = 'SET_PARAMS';
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_SELECTED_REF = 'SET_SELECTED_REF';
diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js
index 9846ac0adb7..43c4318ad6c 100644
--- a/app/assets/javascripts/ref/stores/mutations.js
+++ b/app/assets/javascripts/ref/stores/mutations.js
@@ -10,6 +10,9 @@ export default {
[types.SET_USE_SYMBOLIC_REF_NAMES](state, useSymbolicRefNames) {
state.useSymbolicRefNames = useSymbolicRefNames;
},
+ [types.SET_PARAMS](state, params) {
+ state.params = params;
+ },
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js
index 3affa8f8d03..1619b43c02e 100644
--- a/app/assets/javascripts/ref/stores/state.js
+++ b/app/assets/javascripts/ref/stores/state.js
@@ -15,5 +15,6 @@ export default () => ({
commits: createRefTypeState(),
},
selectedRef: null,
+ params: null,
requestCount: 0,
});
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..24b350c7f18 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,71 @@ 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-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-relative gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1 gl-line-height-24"
+ >
+ <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 +270,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 +282,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 +305,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..63452f3eace 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,10 +123,11 @@ 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"
+ :iid="issue.iid"
:display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title"
@@ -144,6 +147,7 @@ export default {
:work-item-type="issue.type"
event-namespace="relatedIssue"
data-qa-selector="related_issuable_content"
+ class="gl-mx-n2"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
/>
</li>
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/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index cc00ef10dda..23620432feb 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -19,6 +19,7 @@ export function initRelatedIssues(issueType = TYPE_ISSUE) {
fullPath: el.dataset.fullPath,
hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(el.dataset.hasIterationsFeature),
+ reportAbusePath: el.dataset.reportAbusePath,
},
render: (createElement) =>
createElement(RelatedIssuesRoot, {
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 9f200856db3..eebaeeea286 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,12 +1,13 @@
<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';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
-import { convertAllReleasesGraphQLResponse, deleteReleaseSessionKey } from '~/releases/util';
+import { convertAllReleasesGraphQLResponse } from '~/releases/util';
+import { popDeleteReleaseNotification } from '~/releases/release_notification_service';
import allReleasesQuery from '../graphql/queries/all_releases.query.graphql';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
@@ -173,18 +174,7 @@ export default {
},
},
mounted() {
- const key = deleteReleaseSessionKey(this.projectPath);
- const deletedRelease = window.sessionStorage.getItem(key);
-
- if (deletedRelease) {
- this.$toast.show(
- sprintf(__('Release %{deletedRelease} has been successfully deleted.'), {
- deletedRelease,
- }),
- );
- }
-
- window.sessionStorage.removeItem(key);
+ popDeleteReleaseNotification(this.projectPath);
},
created() {
this.updateQueryParamsFromUrl();
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/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index cc28980a6bf..dd45a2b1762 100644
--- a/app/assets/javascripts/releases/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -83,8 +83,17 @@ export default {
linksForType(type) {
return this.assets.links.filter((l) => l.linkType === type);
},
+ getTooltipTitle(section) {
+ return section.title
+ ? this.$options.externalLinkTooltipText
+ : this.$options.downloadTooltipText;
+ },
+ getIconName(section) {
+ return section.title ? 'external-link' : 'download';
+ },
},
externalLinkTooltipText: __('This link points to external content'),
+ downloadTooltipText: __('Download'),
};
</script>
@@ -121,13 +130,12 @@ export default {
<gl-icon :name="section.iconName" class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" />
{{ link.name }}
<gl-icon
- v-if="section.title"
v-gl-tooltip
- name="external-link"
- :aria-label="$options.externalLinkTooltipText"
- :title="$options.externalLinkTooltipText"
+ :name="getIconName(section)"
+ :aria-label="getTooltipTitle(section)"
+ :title="getTooltipTitle(section)"
data-testid="external-link-indicator"
- class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400"
+ class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0"
/>
</gl-link>
</li>
diff --git a/app/assets/javascripts/releases/components/tag_create.vue b/app/assets/javascripts/releases/components/tag_create.vue
new file mode 100644
index 00000000000..44269bccec9
--- /dev/null
+++ b/app/assets/javascripts/releases/components/tag_create.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { uniqueId } from 'lodash';
+import { __, s__ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ RefSelector,
+ },
+ model: {
+ prop: 'value',
+ event: 'change',
+ },
+ props: {
+ value: { type: String, required: true },
+ },
+ data() {
+ return {
+ nameId: uniqueId('tag-name-'),
+ refId: uniqueId('ref-'),
+ messageId: uniqueId('message-'),
+ };
+ },
+ computed: {
+ ...mapState('editNew', ['projectId', 'release', 'createFrom']),
+ },
+ methods: {
+ ...mapActions('editNew', ['updateReleaseTagMessage', 'updateCreateFrom']),
+ },
+ i18n: {
+ tagNameLabel: __('Tag name'),
+ refLabel: __('Create from'),
+ messageLabel: s__('CreateGitTag|Set tag message'),
+ messagePlaceholder: s__(
+ 'CreateGitTag|Add a message to the tag. Leaving this blank creates a lightweight tag.',
+ ),
+ create: __('Save'),
+ cancel: s__('Release|Select another tag'),
+ refSelector: {
+ noRefSelected: __('No source selected'),
+ searchPlaceholder: __('Search branches, tags, and commits'),
+ dropdownHeader: __('Select source'),
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-p-3" data-testid="create-from-field">
+ <gl-form-group
+ class="gl-mb-3"
+ :label="$options.i18n.tagNameLabel"
+ :label-for="nameId"
+ label-sr-only
+ >
+ <gl-form-input :id="nameId" :value="value" autofocus @input="$emit('change', $event)" />
+ </gl-form-group>
+ <gl-form-group class="gl-mb-3" :label="$options.i18n.refLabel" :label-for="refId" label-sr-only>
+ <ref-selector
+ :id="refId"
+ :project-id="projectId"
+ :value="createFrom"
+ :translations="$options.i18n.refSelector"
+ @input="updateCreateFrom"
+ />
+ </gl-form-group>
+ <gl-form-group
+ class="gl-mb-3"
+ :label="$options.i18n.messageLabel"
+ :label-for="messageId"
+ label-sr-only
+ >
+ <gl-form-textarea
+ :id="messageId"
+ :placeholder="$options.i18n.messagePlaceholder"
+ :no-resize="false"
+ :value="release.tagMessage"
+ @input="updateReleaseTagMessage"
+ />
+ </gl-form-group>
+ <gl-button class="gl-mr-3" variant="confirm" @click="$emit('create')">
+ {{ $options.i18n.create }}
+ </gl-button>
+ <gl-button @click="$emit('cancel')">{{ $options.i18n.cancel }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 2ddab5dddea..ec058cc3603 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -1,230 +1,133 @@
<script>
-import {
- GlCollapse,
- GlLink,
- GlFormGroup,
- GlFormTextarea,
- GlDropdownItem,
- GlSprintf,
-} from '@gitlab/ui';
-import { uniqueId } from 'lodash';
+import { GlDropdown, GlFormGroup, GlPopover } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __, s__ } from '~/locale';
-import RefSelector from '~/ref/components/ref_selector.vue';
-import { REF_TYPE_TAGS } from '~/ref/constants';
-import FormFieldContainer from './form_field_container.vue';
+
+import TagSearch from './tag_search.vue';
+import TagCreate from './tag_create.vue';
export default {
- name: 'TagFieldNew',
components: {
- GlCollapse,
+ GlDropdown,
GlFormGroup,
- GlFormTextarea,
- GlLink,
- RefSelector,
- FormFieldContainer,
- GlDropdownItem,
- GlSprintf,
+ GlPopover,
+ TagSearch,
+ TagCreate,
},
data() {
- return {
- // Keeps track of whether or not the user has interacted with
- // the input field. This is used to avoid showing validation
- // errors immediately when the page loads.
- isInputDirty: false,
- };
+ return { id: 'release-tag-name', newTagName: '', show: false, isInputDirty: false };
},
computed: {
- ...mapState('editNew', ['projectId', 'release', 'createFrom', 'showCreateFrom']),
- ...mapGetters('editNew', ['validationErrors']),
- tagName: {
- get() {
- return this.release.tagName;
- },
- set(tagName) {
- this.updateReleaseTagName(tagName);
-
- // This setter is used by the `v-model` on the `RefSelector`.
- // When this is called, the selection originated from the
- // dropdown list of existing tag names, so we know the tag
- // already exists and don't need to show the "create from" input
- this.updateShowCreateFrom(false);
- },
- },
- tagMessage: {
- get() {
- return this.release.tagMessage;
- },
- set(tagMessage) {
- this.updateReleaseTagMessage(tagMessage);
- },
- },
- createFromModel: {
- get() {
- return this.createFrom;
- },
- set(createFrom) {
- this.updateCreateFrom(createFrom);
- },
+ ...mapState('editNew', ['release', 'showCreateFrom']),
+ ...mapGetters('editNew', ['validationErrors', 'isSearching', 'isCreating']),
+ title() {
+ return this.isCreating ? this.$options.i18n.createTitle : this.$options.i18n.selectTitle;
},
showTagNameValidationError() {
- return (
- this.isInputDirty &&
- (this.validationErrors.isTagNameEmpty || this.validationErrors.existingRelease)
- );
+ return this.isInputDirty && !this.validationErrors.tagNameValidation.isValid;
},
- tagNameInputId() {
- return uniqueId('tag-name-input-');
+ tagFeedback() {
+ return this.validationErrors.tagNameValidation.validationErrors[0];
},
- createFromSelectorId() {
- return uniqueId('create-from-selector-');
+ buttonText() {
+ return this.release?.tagName || s__('Release|Search or create tag name');
},
- tagFeedback() {
- return this.validationErrors.existingRelease
- ? __('Selected tag is already in use. Choose another option.')
- : __('Tag name is required.');
+ buttonVariant() {
+ return this.showTagNameValidationError ? 'danger' : 'default';
+ },
+ createText() {
+ return this.newTagName ? this.$options.i18n.createTag : this.$options.i18n.typeNew;
},
},
methods: {
...mapActions('editNew', [
+ 'setSearching',
+ 'setCreating',
+ 'setNewTag',
+ 'setExistingTag',
'updateReleaseTagName',
- 'updateReleaseTagMessage',
- 'updateCreateFrom',
'fetchTagNotes',
- 'updateShowCreateFrom',
]),
- markInputAsDirty() {
- this.isInputDirty = true;
+ startCreate(query) {
+ this.newTagName = query;
+ this.setCreating();
},
- createTagClicked(newTagName) {
- this.updateReleaseTagName(newTagName);
+ selected(tag) {
+ this.updateReleaseTagName(tag);
- // This method is called when the user selects the "create tag"
- // option, so the tag does not already exist. Because of this,
- // we need to show the "create from" input.
- this.updateShowCreateFrom(true);
- },
- shouldShowCreateTagOption(isLoading, matches, query) {
- // Show the "create tag" option if:
- return (
- // we're not currently loading any results, and
- !isLoading &&
- // the search query isn't just whitespace, and
- query.trim() &&
- // the `matches` object is non-null, and
- matches &&
- // the tag name doesn't already exist
- !matches.tags.list.some(
- (tagInfo) => tagInfo.name.toUpperCase() === query.toUpperCase().trim(),
- )
- );
+ if (this.isSearching) {
+ this.fetchTagNotes(tag);
+ this.setExistingTag();
+ this.newTagName = '';
+ } else {
+ this.setNewTag();
+ }
+
+ this.hidePopover();
},
- },
- translations: {
- tagName: {
- noRefSelected: __('No tag selected'),
- dropdownHeader: __('Tag name'),
- searchPlaceholder: __('Search or create tag'),
- label: __('Tag name'),
- labelDescription: __('*Required'),
+ markInputAsDirty() {
+ this.isInputDirty = true;
},
- createFrom: {
- noRefSelected: __('No source selected'),
- searchPlaceholder: __('Search branches, tags, and commits'),
- dropdownHeader: __('Select source'),
- label: __('Create from'),
- description: __('Existing branch name, tag, or commit SHA'),
+ showPopover() {
+ this.show = true;
},
- annotatedTag: {
- label: s__('CreateGitTag|Set tag message'),
- description: s__(
- 'CreateGitTag|Add a message to the tag. Leaving this blank creates a %{linkStart}lightweight tag%{linkEnd}.',
- ),
+ hidePopover() {
+ this.show = false;
},
},
- tagMessageId: uniqueId('tag-message-'),
-
- tagNameEnabledRefTypes: [REF_TYPE_TAGS],
- gitTagDocsLink: 'https://git-scm.com/book/en/v2/Git-Basics-Tagging/',
+ i18n: {
+ selectTitle: __('Tags'),
+ createTitle: s__('Release|Create tag'),
+ label: __('Tag name'),
+ required: __('(required)'),
+ create: __('Create'),
+ cancel: __('Cancel'),
+ },
};
</script>
<template>
- <div>
+ <div class="row">
<gl-form-group
- data-testid="tag-name-field"
+ class="col-md-4 col-sm-10"
+ :label="$options.i18n.label"
+ :label-for="id"
+ :optional-text="$options.i18n.required"
:state="!showTagNameValidationError"
:invalid-feedback="tagFeedback"
- :label="$options.translations.tagName.label"
- :label-for="tagNameInputId"
- :label-description="$options.translations.tagName.labelDescription"
+ optional
+ data-testid="tag-name-field"
>
- <form-field-container>
- <ref-selector
- :id="tagNameInputId"
- v-model="tagName"
- :project-id="projectId"
- :translations="$options.translations.tagName"
- :enabled-ref-types="$options.tagNameEnabledRefTypes"
- :state="!showTagNameValidationError"
- @input="fetchTagNotes"
- @hide.once="markInputAsDirty"
- >
- <template #footer="{ isLoading, matches, query }">
- <gl-dropdown-item
- v-if="shouldShowCreateTagOption(isLoading, matches, query)"
- is-check-item
- :is-checked="tagName === query"
- @click="createTagClicked(query)"
- >
- <gl-sprintf :message="__('Create tag %{tagName}')">
- <template #tagName>
- <b>{{ query }}</b>
- </template>
- </gl-sprintf>
- </gl-dropdown-item>
- </template>
- </ref-selector>
- </form-field-container>
+ <gl-dropdown
+ :id="id"
+ :variant="buttonVariant"
+ :text="buttonText"
+ :toggle-class="['gl-text-gray-900!']"
+ category="secondary"
+ class="gl-w-30"
+ @show.prevent="showPopover"
+ />
+ <gl-popover
+ :show="show"
+ :target="id"
+ :title="title"
+ :css-classes="['gl-z-index-200', 'release-tag-selector']"
+ placement="bottom"
+ triggers="manual"
+ container="content-body"
+ show-close-button
+ @close-button-clicked="hidePopover"
+ @hide.once="markInputAsDirty"
+ >
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200">
+ <tag-create
+ v-if="isCreating"
+ v-model="newTagName"
+ @create="selected(newTagName)"
+ @cancel="setSearching"
+ />
+ <tag-search v-else v-model="newTagName" @create="startCreate" @select="selected" />
+ </div>
+ </gl-popover>
</gl-form-group>
- <gl-collapse :visible="showCreateFrom">
- <div class="gl-pl-6 gl-border-l-1 gl-border-l-solid gl-border-gray-300">
- <gl-form-group
- v-if="showCreateFrom"
- :label="$options.translations.createFrom.label"
- :label-for="createFromSelectorId"
- data-testid="create-from-field"
- >
- <form-field-container>
- <ref-selector
- :id="createFromSelectorId"
- v-model="createFromModel"
- :project-id="projectId"
- :translations="$options.translations.createFrom"
- />
- </form-field-container>
- <template #description>{{ $options.translations.createFrom.description }}</template>
- </gl-form-group>
- <gl-form-group
- v-if="showCreateFrom"
- :label="$options.translations.annotatedTag.label"
- :label-for="$options.tagMessageId"
- data-testid="annotated-tag-message-field"
- >
- <gl-form-textarea :id="$options.tagMessageId" v-model="tagMessage" />
- <template #description>
- <gl-sprintf :message="$options.translations.annotatedTag.description">
- <template #link="{ content }">
- <gl-link
- :href="$options.gitTagDocsLink"
- rel="noopener noreferrer"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </template>
- </gl-form-group>
- </div>
- </gl-collapse>
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/tag_search.vue b/app/assets/javascripts/releases/components/tag_search.vue
new file mode 100644
index 00000000000..33b44c90e1f
--- /dev/null
+++ b/app/assets/javascripts/releases/components/tag_search.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlButton, GlDropdownItem, GlSearchBoxByType, GlSprintf } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { debounce } from 'lodash';
+import { REF_TYPE_TAGS, SEARCH_DEBOUNCE_MS } from '~/ref/constants';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlSprintf,
+ },
+ model: {
+ prop: 'query',
+ event: 'change',
+ },
+ props: {
+ query: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return { tagName: '' };
+ },
+ computed: {
+ ...mapState('ref', ['matches']),
+ ...mapState('editNew', ['projectId', 'release']),
+ tags() {
+ return this.matches?.tags?.list || [];
+ },
+ createText() {
+ return this.query ? this.$options.i18n.createTag : this.$options.i18n.typeNew;
+ },
+ selectedNotShown() {
+ return this.release.tagName && !this.tags.some((tag) => tag.name === this.release.tagName);
+ },
+ },
+ created() {
+ this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
+ },
+ mounted() {
+ this.setProjectId(this.projectId);
+ this.setEnabledRefTypes([REF_TYPE_TAGS]);
+ this.search(this.query);
+ },
+ methods: {
+ ...mapActions('ref', ['setEnabledRefTypes', 'setProjectId', 'search']),
+ onSearchBoxInput(searchQuery = '') {
+ const query = searchQuery.trim();
+ this.$emit('change', query);
+ this.debouncedSearch(query);
+ },
+ selected(tagName) {
+ return (this.release?.tagName ?? '') === tagName;
+ },
+ },
+ i18n: {
+ noResults: __('No results found'),
+ createTag: s__('Release|Create tag %{tag}'),
+ typeNew: s__('Release|Or type a new tag name'),
+ },
+};
+</script>
+<template>
+ <div data-testid="tag-name-search">
+ <gl-search-box-by-type
+ :value="query"
+ class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"
+ borderless
+ autofocus
+ @input="onSearchBoxInput"
+ />
+ <div class="gl-overflow-y-auto release-tag-list">
+ <div v-if="tags.length || release.tagName">
+ <gl-dropdown-item
+ v-if="selectedNotShown"
+ is-checked
+ is-check-item
+ class="gl-list-style-none"
+ >
+ {{ release.tagName }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-for="tag in tags"
+ :key="tag.name"
+ :is-checked="selected(tag.name)"
+ is-check-item
+ class="gl-list-style-none"
+ @click="$emit('select', tag.name)"
+ >
+ {{ tag.name }}
+ </gl-dropdown-item>
+ </div>
+ <div
+ v-else
+ class="gl-my-5 gl-text-gray-500 gl-display-flex gl-font-base gl-justify-content-center"
+ >
+ {{ $options.i18n.noResults }}
+ </div>
+ </div>
+ <div class="gl-border-t-solid gl-border-t-1 gl-border-gray-200 gl-py-3">
+ <gl-button
+ category="tertiary"
+ class="gl-justify-content-start! gl-rounded-0!"
+ block
+ :disabled="!query"
+ @click="$emit('create', query)"
+ >
+ <gl-sprintf :message="createText">
+ <template #tag>
+ <span class="gl-font-weight-bold">{{ query }}</span>
+ </template>
+ </gl-sprintf>
+ </gl-button>
+ </div>
+ </div>
+</template>
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/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index e22726f27a7..e6d981600b9 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
@@ -23,7 +23,6 @@ fragment Release on Release {
url
directAssetUrl
linkType
- external
}
}
}
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index 0a3f8b5e63b..efd82edcdf0 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import { createRefModule } from '../ref/stores';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
import createEditNewModule from './stores/modules/edit_new';
@@ -12,6 +13,7 @@ export default () => {
const store = createStore({
modules: {
editNew: createEditNewModule({ ...el.dataset, isExistingRelease: false }),
+ ref: createRefModule(),
},
});
diff --git a/app/assets/javascripts/releases/release_notification_service.js b/app/assets/javascripts/releases/release_notification_service.js
index a4f926d7561..20fbab3241e 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 { s__, __, sprintf } from '~/locale';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
const createReleaseSessionKey = (projectPath) => `createRelease:${projectPath}`;
@@ -21,3 +21,24 @@ export const popCreateReleaseNotification = (projectPath) => {
window.sessionStorage.removeItem(key);
}
};
+
+export const deleteReleaseSessionKey = (projectPath) => `deleteRelease:${projectPath}`;
+
+export const putDeleteReleaseNotification = (projectPath, releaseName) => {
+ window.sessionStorage.setItem(deleteReleaseSessionKey(projectPath), releaseName);
+};
+
+export const popDeleteReleaseNotification = (projectPath) => {
+ const key = deleteReleaseSessionKey(projectPath);
+ const deletedRelease = window.sessionStorage.getItem(key);
+
+ if (deletedRelease) {
+ createAlert({
+ message: sprintf(__('Release %{deletedRelease} has been successfully deleted.'), {
+ deletedRelease,
+ }),
+ variant: VARIANT_SUCCESS,
+ });
+ window.sessionStorage.removeItem(key);
+ }
+};
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..2e3cf3bf9b8 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -1,6 +1,6 @@
import { getTag } from '~/rest_api';
-import { createAlert } from '~/flash';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { s__ } from '~/locale';
import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql';
import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql';
@@ -8,11 +8,8 @@ import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_
import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql';
import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql';
import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql';
-import {
- gqClient,
- convertOneReleaseGraphQLResponse,
- deleteReleaseSessionKey,
-} from '~/releases/util';
+import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
+import { putDeleteReleaseNotification } from '~/releases/release_notification_service';
import * as types from './mutation_types';
@@ -98,7 +95,7 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => {
export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
- redirectTo(urlToRedirectTo);
+ redirectTo(urlToRedirectTo); // eslint-disable-line import/no-deprecated
};
export const saveRelease = ({ commit, dispatch, state }) => {
@@ -261,10 +258,7 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => {
})
.then((response) => checkForErrorsAsData(response, 'releaseDelete'))
.then(() => {
- window.sessionStorage.setItem(
- deleteReleaseSessionKey(state.projectPath),
- state.originalRelease.name,
- );
+ putDeleteReleaseNotification(state.projectPath, state.originalRelease.name);
return dispatch('receiveSaveReleaseSuccess', state.releasesPagePath);
})
.catch((error) => {
@@ -274,3 +268,9 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => {
});
});
};
+
+export const setSearching = ({ commit }) => commit(types.SET_SEARCHING);
+export const setCreating = ({ commit }) => commit(types.SET_CREATING);
+
+export const setExistingTag = ({ commit }) => commit(types.SET_EXISTING_TAG);
+export const setNewTag = ({ commit }) => commit(types.SET_NEW_TAG);
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/constants.js b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js
new file mode 100644
index 00000000000..0f12f150525
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/constants.js
@@ -0,0 +1,4 @@
+export const SEARCH = 'SEARCH';
+export const CREATE = 'CREATE';
+export const EXISTING_TAG = 'EXISTING_TAG';
+export const NEW_TAG = 'NEW_TAG';
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..edf6c81c9e9 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,9 @@ 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';
+import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants';
/**
* @param {Object} link The link to test
@@ -35,18 +38,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
@@ -164,10 +170,23 @@ export const releaseDeleteMutationVariables = (state) => ({
},
});
-export const formattedReleaseNotes = ({ includeTagNotes, release: { description }, tagNotes }) =>
- includeTagNotes && tagNotes
- ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`
+export const formattedReleaseNotes = ({
+ includeTagNotes,
+ release: { description, tagMessage },
+ tagNotes,
+ showCreateFrom,
+}) => {
+ const notes = showCreateFrom ? tagMessage : tagNotes;
+ return includeTagNotes && notes
+ ? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${notes}\n`
: description;
+};
export const releasedAtChanged = ({ originalReleasedAt, release }) =>
originalReleasedAt !== release.releasedAt;
+
+export const isSearching = ({ step }) => step === SEARCH;
+export const isCreating = ({ step }) => step === CREATE;
+
+export const isExistingTag = ({ tagStep }) => tagStep === EXISTING_TAG;
+export const isNewTag = ({ tagStep }) => tagStep === NEW_TAG;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
index e52eccd6a21..fc450970cde 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
@@ -29,3 +29,9 @@ export const RECEIVE_TAG_NOTES_ERROR = 'RECEIVE_TAG_NOTES_ERROR';
export const UPDATE_INCLUDE_TAG_NOTES = 'UPDATE_INCLUDE_TAG_NOTES';
export const UPDATE_RELEASED_AT = 'UPDATE_RELEASED_AT';
+
+export const SET_SEARCHING = 'SET_SEARCHING';
+export const SET_CREATING = 'SET_CREATING';
+
+export const SET_EXISTING_TAG = 'SET_EXISTING_TAG';
+export const SET_NEW_TAG = 'SET_NEW_TAG';
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index ccd168aafc9..7ff18245a80 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -1,6 +1,7 @@
import { uniqueId, cloneDeep } from 'lodash';
import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants';
import * as types from './mutation_types';
+import { SEARCH, CREATE, EXISTING_TAG, NEW_TAG } from './constants';
const findReleaseLink = (release, id) => {
return release.assets.links.find((l) => l.id === id);
@@ -127,4 +128,17 @@ export default {
[types.UPDATE_RELEASED_AT](state, releasedAt) {
state.release.releasedAt = releasedAt;
},
+
+ [types.SET_SEARCHING](state) {
+ state.step = SEARCH;
+ },
+ [types.SET_CREATING](state) {
+ state.step = CREATE;
+ },
+ [types.SET_EXISTING_TAG](state) {
+ state.tagStep = EXISTING_TAG;
+ },
+ [types.SET_NEW_TAG](state) {
+ state.tagStep = NEW_TAG;
+ },
};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index 3112becfa9e..7bd3968dd93 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -1,3 +1,5 @@
+import { SEARCH, EXISTING_TAG } from './constants';
+
export default ({
isExistingRelease,
projectId,
@@ -62,4 +64,6 @@ export default ({
includeTagNotes: false,
existingRelease: null,
originalReleasedAt: new Date(),
+ step: SEARCH,
+ tagStep: EXISTING_TAG,
});
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index 10d7887c0b1..018a9352beb 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -135,5 +135,3 @@ export const convertOneReleaseGraphQLResponse = (response) => {
return { data: release };
};
-
-export const deleteReleaseSessionKey = (projectPath) => `deleteRelease:${projectPath}`;
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..e056a822c8b 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -4,11 +4,11 @@ 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';
-import { redirectTo, getLocationHash } from '~/lib/utils/url_utility';
+import { redirectTo, getLocationHash } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import CodeIntelligence from '~/code_navigation/components/app.vue';
@@ -34,12 +34,14 @@ export default {
ForkSuggestion,
WebIdeLink,
CodeIntelligence,
+ AiGenie: () => import('ee_component/ai/components/ai_genie.vue'),
},
mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
originalBranch: {
default: '',
},
+ explainCodeAvailable: { default: false },
},
apollo: {
projectInfo: {
@@ -303,7 +305,7 @@ export default {
}
const { ideEditPath, editBlobPath } = this.blobInfo;
- redirectTo(target === 'ide' ? ideEditPath : editBlobPath);
+ redirectTo(target === 'ide' ? ideEditPath : editBlobPath); // eslint-disable-line import/no-deprecated
},
setForkTarget(target) {
this.forkTarget = target;
@@ -316,9 +318,9 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-relative">
<gl-loading-icon v-if="isLoading" size="sm" />
- <div v-if="blobInfo && !isLoading" class="file-holder">
+ <div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder">
<blob-header
:blob="blobInfo"
:hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs"
@@ -393,5 +395,11 @@ export default {
:wrap-text-nodes="glFeatures.highlightJs"
/>
</div>
+ <ai-genie
+ v-if="explainCodeAvailable"
+ container-id="fileHolder"
+ :file-path="path"
+ class="gl-ml-7"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue
index 29c2c3762fc..460db0fe2ae 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';
@@ -101,7 +101,6 @@ export default {
fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
// eslint-disable-next-line no-new
new ShortcutsBlob({
- skipResetBindings: true,
fileBlobPermalinkUrl,
fileBlobPermalinkUrlElement,
});
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..1da445a7906 100644
--- a/app/assets/javascripts/repository/components/fork_info.vue
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -1,8 +1,18 @@
<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, VARIANT_INFO } from '~/alert';
+import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import eventHub from '../event_hub';
+import {
+ POLLING_INTERVAL_DEFAULT,
+ POLLING_INTERVAL_BACKOFF,
+ FIVE_MINUTES_IN_MS,
+ FORK_UPDATED_EVENT,
+} 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 +22,14 @@ 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.'),
+ updateFork: s__('ForksDivergence|Update fork'),
+ createMergeRequest: s__('ForksDivergence|Create merge request'),
+ viewMergeRequest: s__('ForksDivergence|View merge request'),
+ successMessage: s__(
+ 'ForksDivergence|Successfully fetched and merged from the upstream repository.',
+ ),
};
export default {
@@ -20,17 +37,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 +61,21 @@ export default {
error,
});
},
+ result({ loading }) {
+ if (!loading && this.isSyncing) {
+ this.increasePollInterval();
+ }
+ if (this.isForkUpdated) {
+ createAlert({
+ message: this.$options.i18n.successMessage,
+ variant: VARIANT_INFO,
+ });
+ eventHub.$emit(FORK_UPDATED_EVENT);
+ }
+ },
+ pollInterval() {
+ return this.pollInterval;
+ },
},
},
props: {
@@ -53,6 +87,11 @@ export default {
type: String,
required: true,
},
+ sourceDefaultBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
sourceName: {
type: String,
required: false,
@@ -63,6 +102,11 @@ export default {
required: false,
default: '',
},
+ canSyncBranch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
aheadComparePath: {
type: String,
required: false,
@@ -73,21 +117,48 @@ export default {
required: false,
default: '',
},
+ createMrPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ viewMrPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- project: {
- forkDetails: {
- ahead: null,
- behind: null,
- },
- },
+ project: {},
+ currentPollInterval: null,
};
},
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;
+ },
+ isForkUpdated() {
+ return this.isUpToDate && this.currentPollInterval;
+ },
ahead() {
return this.project?.forkDetails?.ahead;
},
@@ -107,7 +178,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 +196,23 @@ export default {
hasBehindAheadMessage() {
return this.behindAheadMessage.length > 0;
},
+ hasUpdateButton() {
+ return (
+ this.glFeatures.synchronizeFork &&
+ this.canSyncBranch &&
+ ((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence)
+ );
+ },
+ hasCreateMrButton() {
+ return this.ahead && this.createMrPath;
+ },
+ hasViewMrButton() {
+ return this.viewMrPath;
+ },
forkDivergenceMessage() {
+ if (!this.forkDetails) {
+ return this.$options.i18n.limitedVisibility;
+ }
if (this.isUnknownDivergence) {
return this.$options.i18n.unknown;
}
@@ -134,6 +224,64 @@ export default {
return this.$options.i18n.upToDate;
},
},
+ watch: {
+ hasConflicts(newVal) {
+ if (newVal && this.currentPollInterval) {
+ this.showConflictsModal();
+ }
+ },
+ },
+ 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.syncForkWithPolling();
+ },
+ checkIfSyncIsPossible() {
+ if (this.hasConflicts) {
+ this.showConflictsModal();
+ } else {
+ this.startSyncing();
+ }
+ },
+ increasePollInterval() {
+ 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;
+ },
+ },
};
</script>
@@ -141,23 +289,66 @@ 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>
+ <div class="gl-display-flex gl-xs-display-none!">
+ <gl-button
+ v-if="hasCreateMrButton"
+ class="gl-ml-4"
+ :href="createMrPath"
+ data-testid="create-mr-button"
+ >
+ <span>{{ $options.i18n.createMergeRequest }}</span>
+ </gl-button>
+ <gl-button
+ v-if="hasViewMrButton"
+ class="gl-ml-4"
+ :href="viewMrPath"
+ data-testid="view-mr-button"
+ >
+ <span>{{ $options.i18n.viewMergeRequest }}</span>
+ </gl-button>
+ <gl-button
+ v-if="hasUpdateButton"
+ class="gl-ml-4"
+ :disabled="forkDetails.isSyncing"
+ data-testid="update-fork-button"
+ @click="checkIfSyncIsPossible"
+ >
+ <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" />
+ <span>{{ $options.i18n.updateFork }}</span>
+ </gl-button>
+ </div>
+ <conflicts-modal
+ ref="modal"
+ :selected-branch="selectedBranch"
+ :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..ffe4fd4cd38
--- /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 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:'),
+ 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: '',
+ },
+ selectedBranch: {
+ type: String,
+ required: true,
+ default: '',
+ },
+ },
+ computed: {
+ instructionsStep1() {
+ const baseUrl = getBaseURL();
+ return `git fetch ${baseUrl}${this.sourcePath} ${this.sourceDefaultBranch}`;
+ },
+ instructionsStep2() {
+ return `git checkout ${this.selectedBranch}\ngit merge FETCH_HEAD`;
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.modal.show();
+ },
+ hide() {
+ this.$refs.modal.hide();
+ },
+ },
+ i18n,
+ instructionsStep3: 'git 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">{{
+ instructionsStep2
+ }}</pre>
+ <modal-copy-button
+ modal-id="fork-sync-conflicts-modal"
+ :text="instructionsStep2"
+ :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>
+ <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..82dd1fda2a0 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -9,8 +9,11 @@ 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';
+import eventHub from '../event_hub';
+import { FORK_UPDATED_EVENT } from '../constants';
export default {
components: {
@@ -23,6 +26,7 @@ export default {
GlLink,
GlLoadingIcon,
UserAvatarImage,
+ SignatureBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -95,10 +99,19 @@ export default {
this.commit = null;
},
},
+ mounted() {
+ eventHub.$on(FORK_UPDATED_EVENT, this.refetchLastCommit);
+ },
+ beforeDestroy() {
+ eventHub.$off(FORK_UPDATED_EVENT, this.refetchLastCommit);
+ },
methods: {
toggleShowDescription() {
this.showDescription = !this.showDescription;
},
+ refetchLastCommit() {
+ this.$apollo.queries.commit.refetch();
+ },
},
defaultAvatarUrl,
safeHtmlConfig: {
@@ -170,10 +183,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..b711f671850 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,12 @@ 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';
+
+export const FORK_UPDATED_EVENT = 'fork:updated';
diff --git a/app/assets/javascripts/repository/event_hub.js b/app/assets/javascripts/repository/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/repository/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 95e0c94527b..befd731a61b 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -32,7 +32,16 @@ Vue.use(PerformancePlugin, {
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
- const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
+ const {
+ projectPath,
+ projectShortPath,
+ ref,
+ escapedRef,
+ fullName,
+ resourceId,
+ userId,
+ explainCodeAvailable,
+ } = dataset;
const router = createRouter(projectPath, escapedRef);
apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -69,19 +78,33 @@ export default function setupVueRepositoryList() {
if (!forkEl) {
return null;
}
- const { sourceName, sourcePath, aheadComparePath, behindComparePath } = forkEl.dataset;
+ const {
+ selectedBranch,
+ sourceName,
+ sourcePath,
+ sourceDefaultBranch,
+ createMrPath,
+ viewMrPath,
+ canSyncBranch,
+ aheadComparePath,
+ behindComparePath,
+ } = forkEl.dataset;
return new Vue({
el: forkEl,
apolloProvider,
render(h) {
return h(ForkInfo, {
props: {
+ canSyncBranch: parseBoolean(canSyncBranch),
projectPath,
- selectedBranch: ref,
+ selectedBranch,
sourceName,
sourcePath,
+ sourceDefaultBranch,
aheadComparePath,
behindComparePath,
+ createMrPath,
+ viewMrPath,
},
});
},
@@ -131,6 +154,7 @@ export default function setupVueRepositoryList() {
projectId,
value: refType ? joinPaths('refs', refType, ref) : ref,
useSymbolicRefNames: true,
+ queryParams: { sort: 'updated_desc' },
},
on: {
input(selectedRef) {
@@ -144,8 +168,8 @@ export default function setupVueRepositoryList() {
initLastCommitApp();
initBlobControlsApp();
- initForkInfo();
initRefSwitcher();
+ initForkInfo();
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
@@ -266,6 +290,7 @@ export default function setupVueRepositoryList() {
store: createStore(),
router,
apolloProvider,
+ provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) },
render(h) {
return h(App);
},
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..58e4553d00d 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -56,8 +56,10 @@ Sidebar.prototype.addEventListeners = function () {
const layoutPage = document.querySelector('.layout-page');
const rightSidebar = document.querySelector('.js-right-sidebar');
- updateSidebarClasses(layoutPage, rightSidebar);
- window.addEventListener('resize', () => updateSidebarClasses(layoutPage, rightSidebar));
+ if (rightSidebar.classList.contains('right-sidebar-merge-requests')) {
+ updateSidebarClasses(layoutPage, rightSidebar);
+ window.addEventListener('resize', () => updateSidebarClasses(layoutPage, rightSidebar));
+ }
}
};
@@ -70,7 +72,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/list.vue b/app/assets/javascripts/saved_replies/components/list.vue
deleted file mode 100644
index 30089cfa53f..00000000000
--- a/app/assets/javascripts/saved_replies/components/list.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<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: {},
- };
- },
-};
-</script>
-
-<template>
- <div>
- <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="lg" />
- <template v-else>
- <h5 class="gl-font-lg" data-testid="title">
- <gl-sprintf :message="__('My saved replies (%{count})')">
- <template #count>{{ count }}</template>
- </gl-sprintf>
- </h5>
- <ul class="gl-list-style-none gl-p-0 gl-m-0">
- <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" />
- </ul>
- <gl-keyset-pagination
- v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
- v-bind="pageInfo"
- class="gl-mt-4"
- />
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue
deleted file mode 100644
index dfa9a405dee..00000000000
--- a/app/assets/javascripts/saved_replies/components/list_item.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<script>
-export default {
- props: {
- reply: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <li class="gl-mb-5">
- <div class="gl-display-flex gl-align-items-center">
- <strong>{{ reply.name }}</strong>
- </div>
- <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div>
- </li>
-</template>
diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue
deleted file mode 100644
index 38f51dbc365..00000000000
--- a/app/assets/javascripts/saved_replies/pages/index.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-<script>
-import List from '../components/list.vue';
-
-export default {
- components: {
- List,
- },
-};
-</script>
-
-<template>
- <div>
- <list />
- </div>
-</template>
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index d71785d7fac..1e4b1e36514 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -15,6 +15,7 @@ export const initSearchApp = () => {
const store = createStore({
query,
navigation,
+ useNewNavigation: gon.use_new_navigation,
});
initTopbar(store);
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 2efc80fef75..317145d4cd1 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -1,33 +1,49 @@
<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 ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue';
+import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue';
import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants';
import ResultsFilters from './results_filters.vue';
-import LanguageFilter from './language_filter.vue';
+import LanguageFilter from './language_filter/index.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
ResultsFilters,
ScopeNavigation,
+ ScopeNewNavigation,
LanguageFilter,
+ SidebarPortal,
},
- mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapState(['urlQuery']),
+ ...mapState(['urlQuery', 'useNewNavigation']),
+ ...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;
+ },
+ showOldNavigation() {
+ return Boolean(this.currentScope);
},
},
};
</script>
<template>
- <section class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5">
+ <section v-if="useNewNavigation">
+ <sidebar-portal>
+ <scope-new-navigation />
+ <results-filters v-if="showIssueAndMergeFilters" />
+ <language-filter v-if="showBlobFilter" />
+ </sidebar-portal>
+ </section>
+ <section
+ v-else
+ 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..feff3f77dd2 100644
--- a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
@@ -1,10 +1,15 @@
<script>
+import Vue from 'vue';
import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import { intersection } from 'lodash';
+import Tracking from '~/tracking';
import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants';
import { formatSearchResultCount } from '../../store/utils';
+export const TRACKING_LABEL_SET = 'set';
+export const TRACKING_LABEL_CHECKBOX = 'checkbox';
+
export default {
name: 'CheckboxFilter',
components: {
@@ -12,31 +17,33 @@ export default {
GlFormCheckbox,
},
props: {
- filterData: {
+ filtersData: {
type: Object,
required: true,
},
+ trackingNamespace: {
+ type: String,
+ required: true,
+ },
},
computed: {
- ...mapState(['query']),
- scope() {
- return this.query.scope;
- },
- queryFilters() {
- return this.query[this.filterData?.filterParam] || [];
- },
+ ...mapState(['query', 'useNewNavigation']),
+ ...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 });
+ async set(value) {
+ this.setQuery({ key: this.filtersData?.filterParam, value });
+
+ await Vue.nextTick();
+ this.trackSelectCheckbox();
},
},
labelCountClasses() {
@@ -45,9 +52,15 @@ export default {
},
methods: {
...mapActions(['setQuery']),
- getFormatedCount(count) {
+ getFormattedCount(count) {
return formatSearchResultCount(count);
},
+ trackSelectCheckbox() {
+ Tracking.event(this.trackingNamespace, TRACKING_LABEL_CHECKBOX, {
+ label: TRACKING_LABEL_SET,
+ property: this.selectedFilter,
+ });
+ },
},
NAV_LINK_COUNT_DEFAULT_CLASSES,
LABEL_DEFAULT_CLASSES,
@@ -56,7 +69,7 @@ export default {
<template>
<div class="gl-mx-5">
- <h5 class="gl-mt-0">{{ filterData.header }}</h5>
+ <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filtersData.header }}</h5>
<gl-form-checkbox-group v-model="selectedFilter">
<gl-form-checkbox
v-for="f in dataFilters"
@@ -72,7 +85,7 @@ export default {
{{ f.label }}
</span>
<span v-if="f.count" :class="labelCountClasses" data-testid="labelCount">
- {{ getFormatedCount(f.count) }}
+ {{ getFormattedCount(f.count) }}
</span>
</span>
</gl-form-checkbox>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
index e7aa3d61409..56e44d454a1 100644
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -1,5 +1,7 @@
<script>
+import { mapState } from 'vuex';
import { confidentialFilterData } from '../constants/confidential_filter_data';
+import { HR_DEFAULT_CLASSES } from '../constants';
import RadioFilter from './radio_filter.vue';
export default {
@@ -7,13 +9,17 @@ export default {
components: {
RadioFilter,
},
+ computed: {
+ ...mapState(['useNewNavigation']),
+ },
confidentialFilterData,
+ HR_DEFAULT_CLASSES,
};
</script>
<template>
<div>
<radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" />
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/components/language_filter/data.js
index df44a58a14b..df44a58a14b 100644
--- a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/data.js
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
index 26ce204cb5c..40b50f657f0 100644
--- a/app/assets/javascripts/search/sidebar/components/language_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/index.vue
@@ -3,10 +3,18 @@ import { GlButton, GlAlert, GlForm } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __, s__, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data';
-import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants';
-import { convertFiltersData } from '../utils';
-import CheckboxFilter from './checkbox_filter.vue';
+import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../../constants';
+import { convertFiltersData } from '../../utils';
+import CheckboxFilter from '../checkbox_filter.vue';
+import {
+ trackShowMore,
+ trackShowHasOverMax,
+ trackSubmitQuery,
+ trackResetQuery,
+ TRACKING_ACTION_SELECT,
+} from './tracking';
+
+import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from './data';
export default {
name: 'LanguageFilter',
@@ -27,19 +35,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']),
+ ...mapState(['aggregations', 'sidebarDirty', 'useNewNavigation']),
+ ...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,28 +60,54 @@ 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;
+ trackShowMore();
+
+ if (this.hasOverMax) {
+ trackShowHasOverMax();
+ }
+ },
+ submitQuery() {
+ trackSubmitQuery();
+ this.applyQuery();
},
trimBuckets(length) {
- return this.langugageAggregationBuckets.slice(0, length);
+ return this.languageAggregationBuckets.slice(0, length);
+ },
+ cleanResetFilters() {
+ trackResetQuery();
+ if (this.currentUrlQueryHasLanguageFilters) {
+ return this.resetLanguageQueryWithRedirect();
+ }
+ this.showAll = false;
+ return this.resetLanguageQuery();
},
},
HR_DEFAULT_CLASSES,
+ TRACKING_ACTION_SELECT,
};
</script>
@@ -76,15 +115,18 @@ export default {
<gl-form
v-if="hasBuckets"
class="gl-pt-5 gl-md-pt-0 language-filter-checkbox"
- @submit.prevent="applyQuery"
+ @submit.prevent="submitQuery"
>
- <hr :class="dividerClasses" />
+ <hr v-if="!useNewNavigation" :class="dividerClasses" />
<div
v-if="!aggregations.error"
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"
+ :tracking-namespace="$options.TRACKING_ACTION_SELECT"
+ />
<span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
$options.i18n.showingMax
}}</span>
@@ -105,8 +147,10 @@ export default {
</gl-button>
</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">
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
+ <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 +160,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/language_filter/tracking.js b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
new file mode 100644
index 00000000000..db107830329
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/language_filter/tracking.js
@@ -0,0 +1,39 @@
+import Tracking from '~/tracking';
+import { MAX_ITEM_LENGTH } from './data';
+
+export const TRACKING_CATEGORY = 'Language filters';
+export const TRACKING_LABEL_FILTERS = 'Filters';
+
+export const TRACKING_LABEL_MAX = 'Max Shown';
+export const TRACKING_LABEL_SHOW_MORE = 'Show More';
+export const TRACKING_LABEL_APPLY = 'Apply Filters';
+export const TRACKING_LABEL_RESET = 'Reset Filters';
+export const TRACKING_LABEL_ALL = 'All Filters';
+export const TRACKING_PROPERTY_MAX = `More than ${MAX_ITEM_LENGTH} filters to show`;
+
+export const TRACKING_ACTION_CLICK = 'search:agreggations:language:click';
+export const TRACKING_ACTION_SHOW = 'search:agreggations:language:show';
+
+// select is imported and used in checkbox_filter.vue
+export const TRACKING_ACTION_SELECT = 'search:agreggations:language:select';
+
+export const trackShowMore = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_SHOW_MORE, {
+ label: TRACKING_LABEL_ALL,
+ });
+
+export const trackShowHasOverMax = () =>
+ Tracking.event(TRACKING_ACTION_SHOW, TRACKING_LABEL_FILTERS, {
+ label: TRACKING_LABEL_MAX,
+ property: TRACKING_PROPERTY_MAX,
+ });
+
+export const trackSubmitQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_APPLY, {
+ label: TRACKING_CATEGORY,
+ });
+
+export const trackResetQuery = () =>
+ Tracking.event(TRACKING_ACTION_CLICK, TRACKING_LABEL_RESET, {
+ label: TRACKING_CATEGORY,
+ });
diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue
index aa7c26b8044..477ba37dab7 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 {
@@ -16,13 +16,11 @@ export default {
},
},
computed: {
- ...mapState(['query']),
+ ...mapState(['query', 'useNewNavigation']),
+ ...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() {
@@ -58,7 +56,7 @@ export default {
<template>
<div>
- <h5 class="gl-mt-0">{{ filterData.header }}</h5>
+ <h5 class="gl-mt-0" :class="{ 'gl-font-sm': useNewNavigation }">{{ filterData.header }}</h5>
<gl-form-radio-group v-model="selectedFilter">
<gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value">
{{ radioLabel(f) }}
diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
index 4d9cc9d6450..24804baef44 100644
--- a/app/assets/javascripts/search/sidebar/components/results_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { HR_DEFAULT_CLASSES } from '../constants/index';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import { stateFilterData } from '../constants/state_filter_data';
import ConfidentialityFilter from './confidentiality_filter.vue';
@@ -15,15 +16,19 @@ export default {
ConfidentialityFilter,
},
computed: {
- ...mapState(['urlQuery', 'sidebarDirty']),
+ ...mapState(['urlQuery', 'sidebarDirty', 'useNewNavigation']),
+ ...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);
+ },
+ hrClasses() {
+ return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block'];
},
},
methods: {
@@ -34,7 +39,7 @@ export default {
<template>
<form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery">
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" />
+ <hr v-if="!useNewNavigation" :class="hrClasses" />
<status-filter v-if="showStatusFilter" />
<confidentiality-filter v-if="showConfidentialityFilter" />
<div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5">
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
index 5863381e2ef..fc41baee831 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
@@ -3,8 +3,8 @@ import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
-import { formatSearchResultCount } from '../../store/utils';
import { slugifyWithUnderscore } from '../../../lib/utils/text_utility';
export default {
@@ -22,15 +22,17 @@ export default {
...mapState(['navigation', 'urlQuery']),
},
created() {
- this.fetchSidebarCount();
+ if (this.urlQuery?.search) {
+ this.fetchSidebarCount();
+ }
},
methods: {
...mapActions(['fetchSidebarCount']),
- showFormatedCount(count) {
- return formatSearchResultCount(count);
+ showFormatedCount(countString) {
+ return formatSearchResultCount(countString);
},
- isCountOverLimit(count) {
- return count.includes('+');
+ isCountOverLimit(countString) {
+ return Boolean(addCountOverLimit(countString));
},
handleClick(scope) {
this.track('click_menu_item', { label: `vertical_navigation_${scope}` });
@@ -44,9 +46,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 +59,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/components/scope_new_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue
new file mode 100644
index 00000000000..86b7cc577a6
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/scope_new_navigation.vue
@@ -0,0 +1,40 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
+
+export default {
+ name: 'ScopeNewNavigation',
+ i18n: {
+ countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'),
+ },
+ components: {
+ NavItem,
+ },
+ mixins: [Tracking.mixin()],
+ computed: {
+ ...mapState(['navigation', 'urlQuery']),
+ ...mapGetters(['navigationItems']),
+ },
+ created() {
+ if (this.urlQuery?.search) {
+ this.fetchSidebarCount();
+ }
+ },
+ methods: {
+ ...mapActions(['fetchSidebarCount']),
+ },
+ NAV_LINK_DEFAULT_CLASSES,
+ NAV_LINK_COUNT_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <nav data-testid="search-filter" class="gl-py-2 gl-relative">
+ <ul class="gl-px-2 gl-list-style-none">
+ <nav-item v-for="item in navigationItems" :key="`menu-${item.title}`" :item="item" />
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
index c3deabfcc26..44d6b537b7b 100644
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -1,5 +1,7 @@
<script>
+import { mapState } from 'vuex';
import { stateFilterData } from '../constants/state_filter_data';
+import { HR_DEFAULT_CLASSES } from '../constants';
import RadioFilter from './radio_filter.vue';
export default {
@@ -7,13 +9,17 @@ export default {
components: {
RadioFilter,
},
+ computed: {
+ ...mapState(['useNewNavigation']),
+ },
stateFilterData,
+ HR_DEFAULT_CLASSES,
};
</script>
<template>
<div>
<radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" />
- <hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
+ <hr v-if="!useNewNavigation" :class="$options.HR_DEFAULT_CLASSES" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js
index 19b1ad0905b..9519154a571 100644
--- a/app/assets/javascripts/search/sidebar/constants/index.js
+++ b/app/assets/javascripts/search/sidebar/constants/index.js
@@ -4,7 +4,7 @@ export const SCOPE_BLOB = 'blobs';
export const LABEL_DEFAULT_CLASSES = [
'gl-display-flex',
'gl-flex-direction-row',
- 'gl-flex-wrap-nowrap',
+ 'gl-flex-nowrap',
'gl-text-gray-900',
];
export const NAV_LINK_DEFAULT_CLASSES = [
@@ -14,3 +14,5 @@ export const NAV_LINK_DEFAULT_CLASSES = [
export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal'];
export const HR_DEFAULT_CLASSES = ['gl-my-5', 'gl-mx-5', 'gl-border-gray-100'];
export const ONLY_SHOW_MD = ['gl-display-none', 'gl-md-display-block'];
+
+export const TRACKING_LABEL_CHECKBOX = 'Checkbox';
diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js
index 5c08ad2f959..78e03fcdeee 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';
+import { languageFilterData } from '~/search/sidebar/components/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..3d6ca2a6eee 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/components/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,12 @@ 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);
+ commit(types.RECEIVE_AGGREGATIONS_SUCCESS, prepareSearchAggregations(state, data));
})
.catch((e) => {
logError(e);
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index ba4fe85db9d..c8ee0a3f9d9 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -1,6 +1,6 @@
import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
export const MAX_FREQUENT_ITEMS = 5;
@@ -17,3 +17,15 @@ export const SIDEBAR_PARAMS = [
];
export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
+
+export const ICON_MAP = {
+ blobs: 'code',
+ issues: 'issues',
+ merge_requests: 'merge-request',
+ commits: 'commit',
+ notes: 'comments',
+ milestones: 'tag',
+ users: 'users',
+ projects: 'project',
+ wiki_blobs: 'overview',
+};
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
index 0278239c144..135c9a3d67c 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,5 +1,8 @@
-import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
-import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
+import { findKey, has } from 'lodash';
+import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
+import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils';
+
+import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants';
export const frequentGroups = (state) => {
return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY];
@@ -9,10 +12,28 @@ 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;
+
+export const navigationItems = (state) =>
+ Object.values(state.navigation).map((item) => ({
+ title: item.label,
+ icon: ICON_MAP[item.scope] || '',
+ link: item.link,
+ is_active: Boolean(item?.active),
+ pill_count: `${formatSearchResultCount(item?.count)}${addCountOverLimit(item?.count)}` || '',
+ items: [],
+ }));
diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js
index e20a43808cf..634f8f7a7fa 100644
--- a/app/assets/javascripts/search/store/index.js
+++ b/app/assets/javascripts/search/store/index.js
@@ -7,11 +7,11 @@ import createState from './state';
Vue.use(Vuex);
-export const getStoreConfig = ({ query, navigation }) => ({
+export const getStoreConfig = ({ query, navigation, useNewNavigation }) => ({
actions,
getters,
mutations,
- state: createState({ query, navigation }),
+ state: createState({ query, navigation, useNewNavigation }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
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/state.js b/app/assets/javascripts/search/store/state.js
index d85a135bb4e..a62b6728819 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -1,7 +1,7 @@
import { cloneDeep } from 'lodash';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
-const createState = ({ query, navigation }) => ({
+const createState = ({ query, navigation, useNewNavigation }) => ({
urlQuery: cloneDeep(query),
query,
groups: [],
@@ -14,6 +14,7 @@ const createState = ({ query, navigation }) => ({
},
sidebarDirty: false,
navigation,
+ useNewNavigation,
aggregations: {
error: false,
fetching: false,
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index acb99c60426..2f02ef3475c 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/components/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,31 @@ 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;
+ });
+
+export const addCountOverLimit = (count = '') => {
+ return count.includes('+') ? '+' : '';
+};
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/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
deleted file mode 100644
index 94244eeb12e..00000000000
--- a/app/assets/javascripts/search_autocomplete.js
+++ /dev/null
@@ -1,520 +0,0 @@
-/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
-
-import $ from 'jquery';
-import { escape, throttle } from 'lodash';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { s__, __, sprintf } from '~/locale';
-import Tracking from '~/tracking';
-import axios from './lib/utils/axios_utils';
-import { spriteIcon } from './lib/utils/common_utils';
-import {
- isInGroupsPage,
- isInProjectPage,
- getGroupSlug,
- getProjectSlug,
-} from './search_autocomplete_utils';
-
-/**
- * Search input in top navigation bar.
- * On click, opens a dropdown
- * As the user types it filters the results
- * When the user clicks `x` button it cleans the input and closes the dropdown.
- */
-
-const KEYCODE = {
- ESCAPE: 27,
- BACKSPACE: 8,
- ENTER: 13,
- UP: 38,
- DOWN: 40,
-};
-
-function setSearchOptions() {
- const $projectOptionsDataEl = $('.js-search-project-options');
- const $groupOptionsDataEl = $('.js-search-group-options');
- const $dashboardOptionsDataEl = $('.js-search-dashboard-options');
-
- if ($projectOptionsDataEl.length) {
- gl.projectOptions = gl.projectOptions || {};
-
- const projectPath = $projectOptionsDataEl.data('projectPath');
-
- gl.projectOptions[projectPath] = {
- name: $projectOptionsDataEl.data('name'),
- issuesPath: $projectOptionsDataEl.data('issuesPath'),
- issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'),
- mrPath: $projectOptionsDataEl.data('mrPath'),
- };
- }
-
- if ($groupOptionsDataEl.length) {
- gl.groupOptions = gl.groupOptions || {};
-
- const groupPath = $groupOptionsDataEl.data('groupPath');
-
- gl.groupOptions[groupPath] = {
- name: $groupOptionsDataEl.data('name'),
- issuesPath: $groupOptionsDataEl.data('issuesPath'),
- mrPath: $groupOptionsDataEl.data('mrPath'),
- };
- }
-
- if ($dashboardOptionsDataEl.length) {
- gl.dashboardOptions = {
- name: s__('SearchAutocomplete|All GitLab'),
- issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
- mrPath: $dashboardOptionsDataEl.data('mrPath'),
- };
- }
-}
-
-export class SearchAutocomplete {
- constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
- setSearchOptions();
- this.bindEventContext();
- this.wrap = wrap || $('.search');
- this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
- this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
- this.projectId = projectId || this.optsEl.data('autocompleteProjectId') || '';
- this.projectRef = projectRef || this.optsEl.data('autocompleteProjectRef') || '';
- this.dropdown = this.wrap.find('.dropdown');
- this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
- this.dropdownMenu = this.dropdown.find('.dropdown-menu');
- this.dropdownContent = this.dropdown.find('.dropdown-content');
- this.scopeInputEl = this.getElement('#scope');
- this.searchInput = this.getElement('.search-input');
- this.projectInputEl = this.getElement('#search_project_id');
- this.groupInputEl = this.getElement('#group_id');
- this.searchCodeInputEl = this.getElement('#search_code');
- this.repositoryInputEl = this.getElement('#repository_ref');
- this.clearInput = this.getElement('.js-clear-input');
- this.scrollFadeInitialized = false;
- this.saveOriginalState();
-
- // Only when user is logged in
- if (gon.current_user_id) {
- this.createAutocomplete();
- }
-
- this.bindEvents();
- this.dropdownToggle.dropdown();
- this.searchInput.addClass('js-autocomplete-disabled');
- }
-
- // Finds an element inside wrapper element
- bindEventContext() {
- this.onSearchInputBlur = this.onSearchInputBlur.bind(this);
- this.onClearInputClick = this.onClearInputClick.bind(this);
- this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
- this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
- this.onSearchInputChange = this.onSearchInputChange.bind(this);
- this.setScrollFade = this.setScrollFade.bind(this);
- }
- getElement(selector) {
- return this.wrap.find(selector);
- }
-
- saveOriginalState() {
- return (this.originalState = this.serializeState());
- }
-
- createAutocomplete() {
- return initDeprecatedJQueryDropdown(this.searchInput, {
- filterInputBlur: false,
- filterable: true,
- filterRemote: true,
- highlight: true,
- icon: true,
- enterCallback: false,
- filterInput: 'input#search',
- search: {
- fields: ['text'],
- },
- id: this.getSearchText,
- data: this.getData.bind(this),
- selectable: true,
- clicked: this.onClick.bind(this),
- trackSuggestionClickedLabel: 'search_autocomplete_suggestion',
- });
- }
-
- getSearchText(selectedObject) {
- return selectedObject.id ? selectedObject.text : '';
- }
-
- getData(term, callback) {
- if (!term) {
- const contents = this.getCategoryContents();
- if (contents) {
- const deprecatedJQueryDropdownInstance = this.searchInput.data('deprecatedJQueryDropdown');
-
- if (deprecatedJQueryDropdownInstance) {
- deprecatedJQueryDropdownInstance.filter.options.callback(contents);
- }
- this.enableAutocomplete();
- }
- return;
- }
-
- // Prevent multiple ajax calls
- if (this.loadingSuggestions) {
- return;
- }
-
- this.loadingSuggestions = true;
-
- return axios
- .get(this.autocompletePath, {
- params: {
- project_id: this.projectId,
- project_ref: this.projectRef,
- term,
- },
- })
- .then((response) => {
- const options = this.scopedSearchOptions(term);
-
- // List results
- let lastCategory = null;
- for (let i = 0, len = response.data.length; i < len; i += 1) {
- const suggestion = response.data[i];
- // Add group header before list each group
- if (lastCategory !== suggestion.category) {
- options.push({ type: 'separator' });
- options.push({
- type: 'header',
- content: suggestion.category,
- });
- lastCategory = suggestion.category;
- }
-
- // Add the suggestion
- options.push({
- id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
- icon: this.getAvatar(suggestion),
- category: suggestion.category,
- text: suggestion.label,
- url: suggestion.url,
- });
- }
-
- callback(options);
-
- this.loadingSuggestions = false;
- this.highlightFirstRow();
- this.setScrollFade();
- })
- .catch(() => {
- this.loadingSuggestions = false;
- });
- }
-
- getCategoryContents() {
- const userName = gon.current_username;
- const { projectOptions, groupOptions, dashboardOptions } = gl;
-
- // Get options
- let options;
- if (isInProjectPage() && projectOptions) {
- options = projectOptions[getProjectSlug()];
- } else if (isInGroupsPage() && groupOptions) {
- options = groupOptions[getGroupSlug()];
- } else if (dashboardOptions) {
- options = dashboardOptions;
- }
-
- const { issuesPath, mrPath, name, issuesDisabled } = options;
- const baseItems = [];
-
- if (name) {
- baseItems.push({
- type: 'header',
- content: `${name}`,
- });
- }
-
- const issueItems = [
- {
- text: s__('SearchAutocomplete|Issues assigned to me'),
- url: `${issuesPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Issues I've created"),
- url: `${issuesPath}/?author_username=${userName}`,
- },
- ];
- const mergeRequestItems = [
- {
- text: s__('SearchAutocomplete|Merge requests assigned to me'),
- url: `${mrPath}/?assignee_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"),
- url: `${mrPath}/?reviewer_username=${userName}`,
- },
- {
- text: s__("SearchAutocomplete|Merge requests I've created"),
- url: `${mrPath}/?author_username=${userName}`,
- },
- ];
-
- let items;
- if (issuesDisabled) {
- items = baseItems.concat(mergeRequestItems);
- } else {
- items = baseItems.concat(...issueItems, ...mergeRequestItems);
- }
- return items;
- }
-
- // Add option to proceed with the search for each
- // scope that is currently available, namely:
- //
- // - Search in this project
- // - Search in this group (or project's group)
- // - Search in all GitLab
- scopedSearchOptions(term) {
- const icon = spriteIcon('search', 's16 inline-search-icon');
- const projectId = this.projectInputEl.val();
- const groupId = this.groupInputEl.val();
- const options = [];
-
- if (projectId) {
- const projectOptions = gl.projectOptions[getProjectSlug()];
- const url = groupId
- ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}&nav_source=navbar`
- : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&nav_source=navbar`;
-
- options.push({
- icon,
- text: term,
- template: sprintf(
- s__(`SearchAutocomplete|in project %{projectName}`),
- {
- projectName: `<i>${projectOptions.name}</i>`,
- },
- false,
- ),
- url,
- });
- }
-
- if (groupId) {
- const groupOptions = gl.groupOptions[getGroupSlug()];
- options.push({
- icon,
- text: term,
- template: sprintf(
- s__(`SearchAutocomplete|in group %{groupName}`),
- {
- groupName: `<i>${groupOptions.name}</i>`,
- },
- false,
- ),
- url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}&nav_source=navbar`,
- });
- }
-
- options.push({
- icon,
- text: term,
- template: s__('SearchAutocomplete|in all GitLab'),
- url: `${gon.relative_url_root}/search?search=${term}&nav_source=navbar`,
- });
-
- return options;
- }
-
- serializeState() {
- return {
- // Search Criteria
- search_project_id: this.projectInputEl.val(),
- group_id: this.groupInputEl.val(),
- search_code: this.searchCodeInputEl.val(),
- repository_ref: this.repositoryInputEl.val(),
- scope: this.scopeInputEl.val(),
- };
- }
-
- bindEvents() {
- this.searchInput.on('input', this.onSearchInputChange);
- this.searchInput.on('keyup', this.onSearchInputKeyUp);
- this.searchInput.on('focus', this.onSearchInputFocus);
- this.searchInput.on('blur', this.onSearchInputBlur);
- this.clearInput.on('click', this.onClearInputClick);
- this.dropdownContent.on(
- 'scroll',
- throttle(this.setScrollFade, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- );
-
- this.searchInput.on('click', (e) => {
- e.stopPropagation();
- });
- }
-
- enableAutocomplete() {
- this.setScrollFade();
-
- // No need to enable anything if user is not logged in
- if (!gon.current_user_id) {
- return;
- }
-
- // If the dropdown is closed, we'll open it
- if (!this.dropdown.hasClass('show')) {
- this.loadingSuggestions = false;
- this.dropdownToggle.dropdown('toggle');
-
- const trackEvent = 'click_search_bar';
- const trackCategory = undefined; // will be default set in event method
-
- Tracking.event(trackCategory, trackEvent, {
- label: 'main_navigation',
- property: 'navigation',
- });
-
- return this.searchInput.removeClass('js-autocomplete-disabled');
- }
- }
-
- onSearchInputChange() {
- this.enableAutocomplete();
- }
-
- onSearchInputKeyUp(e) {
- switch (e.keyCode) {
- case KEYCODE.ESCAPE:
- this.restoreOriginalState();
- break;
- case KEYCODE.ENTER:
- this.disableAutocomplete();
- break;
- default:
- }
- this.wrap.toggleClass('has-value', Boolean(e.target.value));
- }
-
- onSearchInputFocus() {
- this.isFocused = true;
- this.wrap.addClass('search-active');
- if (this.getValue() === '') {
- return this.getData();
- }
- }
-
- getValue() {
- return this.searchInput.val();
- }
-
- onClearInputClick(e) {
- e.preventDefault();
- this.wrap.toggleClass('has-value', Boolean(e.target.value));
- return this.searchInput.val('').focus();
- }
-
- onSearchInputBlur() {
- this.isFocused = false;
- this.wrap.removeClass('search-active');
- // If input is blank then restore state
- if (this.searchInput.val() === '') {
- this.restoreOriginalState();
- }
- this.dropdownMenu.removeClass('show');
- }
-
- restoreOriginalState() {
- const inputs = Object.keys(this.originalState);
- for (let i = 0, len = inputs.length; i < len; i += 1) {
- const input = inputs[i];
- this.getElement(`#${input}`).val(this.originalState[input]);
- }
- }
-
- resetSearchState() {
- const inputs = Object.keys(this.originalState);
- const results = [];
- for (let i = 0, len = inputs.length; i < len; i += 1) {
- const input = inputs[i];
- results.push(this.getElement(`#${input}`).val(''));
- }
- return results;
- }
-
- disableAutocomplete() {
- if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
- this.searchInput.addClass('js-autocomplete-disabled');
- this.dropdownToggle.dropdown('toggle');
- this.restoreMenu();
- }
- }
-
- restoreMenu() {
- const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
- return this.dropdownContent.html(html);
- }
-
- onClick(item, $el, e) {
- if (window.location.pathname.indexOf(item.url) !== -1) {
- if (!e.metaKey) e.preventDefault();
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- if (item.category === 'Projects') {
- this.projectInputEl.val(item.id);
- }
- // eslint-disable-next-line @gitlab/require-i18n-strings
- if (item.category === 'Groups') {
- this.groupInputEl.val(item.id);
- }
- $el.removeClass('is-active');
- this.disableAutocomplete();
- return this.searchInput.val('').focus();
- }
- }
-
- highlightFirstRow() {
- this.searchInput.data('deprecatedJQueryDropdown').highlightRowAtIndex(null, 0);
- }
-
- getAvatar(item) {
- if (!Object.prototype.hasOwnProperty.call(item, 'avatar_url')) {
- return false;
- }
-
- const { label, id } = item;
- const avatarUrl = item.avatar_url;
- const avatar = avatarUrl
- ? `<img class="search-item-avatar" src="${avatarUrl}" />`
- : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
- escape(label),
- )}</div>`;
-
- return avatar;
- }
-
- isScrolledUp() {
- const el = this.dropdownContent[0];
- const currentPosition = this.contentClientHeight + el.scrollTop;
-
- return currentPosition < this.maxPosition;
- }
-
- initScrollFade() {
- const el = this.dropdownContent[0];
- this.scrollFadeInitialized = true;
-
- this.contentClientHeight = el.clientHeight;
- this.maxPosition = el.scrollHeight;
- this.dropdownMenu.addClass('dropdown-content-faded-mask');
- }
-
- setScrollFade() {
- this.initScrollFade();
-
- this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp());
- }
-}
-
-export default function initSearchAutocomplete(opts) {
- return new SearchAutocomplete(opts);
-}
diff --git a/app/assets/javascripts/search_autocomplete_utils.js b/app/assets/javascripts/search_autocomplete_utils.js
deleted file mode 100644
index a9a0f941e93..00000000000
--- a/app/assets/javascripts/search_autocomplete_utils.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { getPagePath } from './lib/utils/common_utils';
-
-export const isInGroupsPage = () => getPagePath() === 'groups';
-
-export const isInProjectPage = () => getPagePath() === 'projects';
-
-export const getProjectSlug = () => {
- if (isInProjectPage()) {
- return document?.body?.dataset?.project;
- }
- return null;
-};
-
-export const getGroupSlug = () => {
- if (isInProjectPage() || isInGroupsPage()) {
- return document?.body?.dataset?.group;
- }
- return null;
-};
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index 3ebd21609a6..d57b3fda342 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -10,10 +10,8 @@ import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue';
import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants';
import FeatureCard from './feature_card.vue';
import TrainingProviderList from './training_provider_list.vue';
-import UpgradeBanner from './upgrade_banner.vue';
export const i18n = {
- compliance: s__('SecurityConfiguration|Compliance'),
configurationHistory: s__('SecurityConfiguration|Configuration history'),
securityTesting: s__('SecurityConfiguration|Security testing'),
latestPipelineDescription: s__(
@@ -26,7 +24,7 @@ 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__(
@@ -48,7 +46,8 @@ export default {
GlTabs,
LocalStorageSync,
SectionLayout,
- UpgradeBanner,
+ UpgradeBanner: () =>
+ import('ee_component/security_configuration/components/upgrade_banner.vue'),
UserCalloutDismisser,
TrainingProviderList,
},
@@ -59,10 +58,6 @@ export default {
type: Array,
required: true,
},
- augmentedComplianceFeatures: {
- type: Array,
- required: true,
- },
gitlabCiPresent: {
type: Boolean,
required: false,
@@ -101,9 +96,7 @@ export default {
},
computed: {
canUpgrade() {
- return [...this.augmentedSecurityFeatures, ...this.augmentedComplianceFeatures].some(
- ({ available }) => !available,
- );
+ return [...this.augmentedSecurityFeatures].some(({ available }) => !available);
},
canViewCiHistory() {
return Boolean(this.gitlabCiPresent && this.gitlabCiHistoryPath);
@@ -226,44 +219,6 @@ export default {
</section-layout>
</gl-tab>
<gl-tab
- data-testid="compliance-testing-tab"
- :title="$options.i18n.compliance"
- query-param-value="compliance-testing"
- >
- <section-layout :heading="$options.i18n.compliance">
- <template #description>
- <p>
- <span data-testid="latest-pipeline-info-compliance">
- <gl-sprintf
- v-if="latestPipelinePath"
- :message="$options.i18n.latestPipelineDescription"
- >
- <template #link="{ content }">
- <gl-link :href="latestPipelinePath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
-
- {{ $options.i18n.description }}
- </p>
- <p v-if="canViewCiHistory">
- <gl-link data-testid="compliance-view-history-link" :href="gitlabCiHistoryPath">{{
- $options.i18n.configurationHistory
- }}</gl-link>
- </p>
- </template>
- <template #features>
- <feature-card
- v-for="feature in augmentedComplianceFeatures"
- :key="feature.type"
- :feature="feature"
- class="gl-mb-6"
- @error="onError"
- />
- </template>
- </section-layout>
- </gl-tab>
- <gl-tab
data-testid="vulnerability-management-tab"
:title="$options.i18n.vulnerabilityManagement"
query-param-value="vulnerability-management"
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index c87dcef6a93..1b86d7d0a2b 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -6,17 +6,18 @@ import {
REPORT_TYPE_SAST_IAC,
REPORT_TYPE_DAST,
REPORT_TYPE_DAST_PROFILES,
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
REPORT_TYPE_SECRET_DETECTION,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
REPORT_TYPE_CORPUS_MANAGEMENT,
REPORT_TYPE_API_FUZZING,
- REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
import kontraLogo from 'images/vulnerability/kontra-logo.svg';
import scwLogo from 'images/vulnerability/scw-logo.svg';
+import secureflagLogo from 'images/vulnerability/secureflag-logo.svg';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql';
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
@@ -35,7 +36,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,9 +66,32 @@ 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 BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature');
+export const BAS_BADGE_TOOLTIP = s__(
+ 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.',
+);
+export const BAS_DESCRIPTION = s__(
+ 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.',
+);
+export const BAS_HELP_PATH = helpPagePath(
+ 'user/application_security/breach_and_attack_simulation/index',
+);
+export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)');
+export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS');
+
+export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__(
+ 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.',
+);
+export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath(
+ 'user/application_security/breach_and_attack_simulation/index',
+ { anchor: 'extend-dynamic-application-security-testing-dast' },
+);
+export const BAS_DAST_FEATURE_FLAG_NAME = s__(
+ 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)',
+);
+
export const SECRET_DETECTION_NAME = __('Secret Detection');
export const SECRET_DETECTION_DESCRIPTION = __(
'Analyze your source code and git history for secrets.',
@@ -126,13 +150,7 @@ export const API_FUZZING_NAME = __('API Fuzzing');
export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.');
export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index');
-export const LICENSE_COMPLIANCE_NAME = __('License Compliance');
-export const LICENSE_COMPLIANCE_DESCRIPTION = __(
- 'Search your project dependencies for their licenses and apply policies.',
-);
-export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath(
- 'user/compliance/license_compliance/index',
-);
+export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning');
export const SCANNER_NAMES_MAP = {
SAST: SAST_SHORT_NAME,
@@ -143,6 +161,8 @@ export const SCANNER_NAMES_MAP = {
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
+ BREACH_AND_ATTACK_SIMULATION: BAS_NAME,
+ CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME,
GENERIC: s__('ciReport|Manually added'),
};
@@ -224,14 +244,24 @@ export const securityFeatures = [
configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
},
},
-];
-
-export const complianceFeatures = [
{
- name: LICENSE_COMPLIANCE_NAME,
- description: LICENSE_COMPLIANCE_DESCRIPTION,
- helpPath: LICENSE_COMPLIANCE_HELP_PATH,
- type: REPORT_TYPE_LICENSE_COMPLIANCE,
+ anchor: 'bas',
+ badge: {
+ alwaysDisplay: true,
+ text: BAS_BADGE_TEXT,
+ tooltipText: BAS_BADGE_TOOLTIP,
+ variant: 'info',
+ },
+ description: BAS_DESCRIPTION,
+ name: BAS_NAME,
+ helpPath: BAS_HELP_PATH,
+ secondary: {
+ configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH,
+ description: BAS_DAST_FEATURE_FLAG_DESCRIPTION,
+ name: BAS_DAST_FEATURE_FLAG_NAME,
+ },
+ shortName: BAS_SHORT_NAME,
+ type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
},
];
@@ -284,6 +314,9 @@ export const TEMP_PROVIDER_LOGOS = {
[__('Secure Code Warrior')]: {
svg: scwLogo,
},
+ SecureFlag: {
+ svg: secureflagLogo,
+ },
};
// Use the `url` field from the GraphQL query once this issue is resolved
@@ -291,4 +324,5 @@ export const TEMP_PROVIDER_LOGOS = {
export const TEMP_PROVIDER_URLS = {
Kontra: 'https://application.security/',
[__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/',
+ SecureFlag: 'https://www.secureflag.com/',
};
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 19b412d66ca..d1b705fe2fc 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -1,7 +1,10 @@
<script>
import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
-import { REPORT_TYPE_SAST_IAC } from '~/vue_shared/security_reports/constants';
+import {
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ REPORT_TYPE_SAST_IAC,
+} from '~/vue_shared/security_reports/constants';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import FeatureCardBadge from './feature_card_badge.vue';
@@ -68,8 +71,7 @@ export default {
};
},
hasSecondary() {
- const { name, description, configurationText } = this.feature.secondary ?? {};
- return Boolean(name && description && configurationText);
+ return Boolean(this.feature.secondary);
},
// This condition is a temporary hack to not display any wrong information
// until this BE Bug is fixed: https://gitlab.com/gitlab-org/gitlab/-/issues/350307.
@@ -78,7 +80,17 @@ export default {
return this.feature.type !== REPORT_TYPE_SAST_IAC;
},
hasBadge() {
- return Boolean(this.available && this.feature.badge?.text);
+ const shouldDisplay = this.available || this.feature.badge?.alwaysDisplay;
+ return Boolean(shouldDisplay && this.feature.badge?.text);
+ },
+ hasEnabledStatus() {
+ return (
+ this.isNotSastIACTemporaryHack &&
+ this.feature.type !== REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION
+ );
+ },
+ showSecondaryConfigurationHelpPath() {
+ return Boolean(this.available && this.feature.secondary?.configurationHelpPath);
},
},
methods: {
@@ -118,19 +130,25 @@ export default {
:badge-href="feature.badge.badgeHref"
/>
- <template v-if="enabled">
- <span>
- <gl-icon name="check-circle-filled" />
- <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
- </span>
- </template>
-
- <template v-else-if="available">
- <span>{{ $options.i18n.notEnabled }}</span>
+ <template v-if="hasEnabledStatus">
+ <template v-if="enabled">
+ <span>
+ <gl-icon name="check-circle-filled" />
+ <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
+ </span>
+ </template>
+
+ <template v-else-if="available">
+ <span>{{ $options.i18n.notEnabled }}</span>
+ </template>
+
+ <template v-else>
+ {{ $options.i18n.availableWith }}
+ </template>
</template>
- <template v-else>
- {{ $options.i18n.availableWith }}
+ <template v-else-if="!available">
+ <span>{{ $options.i18n.availableWith }}</span>
</template>
</div>
</div>
@@ -186,6 +204,16 @@ export default {
>
{{ feature.secondary.configurationText }}
</gl-button>
+
+ <gl-button
+ v-else-if="showSecondaryConfigurationHelpPath"
+ icon="external-link"
+ :href="feature.secondary.configurationHelpPath"
+ category="secondary"
+ class="gl-mt-5"
+ >
+ {{ $options.i18n.configurationGuide }}
+ </gl-button>
</div>
</gl-card>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue b/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
deleted file mode 100644
index eaff1ce6055..00000000000
--- a/app/assets/javascripts/security_configuration/components/upgrade_banner.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<script>
-import { GlBanner } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import Tracking from '~/tracking';
-
-export const SECURITY_UPGRADE_BANNER = 'security_upgrade_banner';
-export const UPGRADE_OR_FREE_TRIAL = 'upgrade_or_free_trial';
-
-export default {
- components: {
- GlBanner,
- },
- mixins: [Tracking.mixin({ property: SECURITY_UPGRADE_BANNER })],
- inject: ['upgradePath'],
- i18n: {
- title: s__('SecurityConfiguration|Secure your project'),
- bodyStart: s__(
- `SecurityConfiguration|Immediately begin risk analysis and remediation with application security features. Start with SAST and Secret Detection, available to all plans. Upgrade to Ultimate to get all features, including:`,
- ),
- bodyListItems: [
- s__('SecurityConfiguration|Vulnerability details and statistics in the merge request'),
- s__('SecurityConfiguration|High-level vulnerability statistics across projects and groups'),
- s__('SecurityConfiguration|Runtime security metrics for application environments'),
- s__(
- 'SecurityConfiguration|More scan types, including DAST, Dependency Scanning, Fuzzing, and Licence Compliance',
- ),
- ],
- buttonText: s__('SecurityConfiguration|Upgrade or start a free trial'),
- },
- mounted() {
- this.track('render', { label: SECURITY_UPGRADE_BANNER });
- },
- methods: {
- bannerClosed() {
- this.track('dismiss_banner', { label: SECURITY_UPGRADE_BANNER });
- },
- bannerButtonClicked() {
- this.track('click_button', { label: UPGRADE_OR_FREE_TRIAL });
- },
- },
-};
-</script>
-
-<template>
- <gl-banner
- :title="$options.i18n.title"
- :button-text="$options.i18n.buttonText"
- :button-link="upgradePath"
- variant="introduction"
- @close="bannerClosed"
- @primary="bannerButtonClicked"
- v-on="$listeners"
- >
- <p>{{ $options.i18n.bodyStart }}</p>
- <ul class="gl-pl-6">
- <li v-for="bodyListItem in $options.i18n.bodyListItems" :key="bodyListItem">
- {{ bodyListItem }}
- </li>
- </ul>
- </gl-banner>
-</template>
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index 637d510e684..aa3c9c87622 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils';
import SecurityConfigurationApp from './components/app.vue';
-import { securityFeatures, complianceFeatures } from './components/constants';
+import { securityFeatures } from './components/constants';
import { augmentFeatures } from './utils';
export const initSecurityConfiguration = (el) => {
@@ -28,9 +28,8 @@ export const initSecurityConfiguration = (el) => {
vulnerabilityTrainingDocsPath,
} = el.dataset;
- const { augmentedSecurityFeatures, augmentedComplianceFeatures } = augmentFeatures(
+ const { augmentedSecurityFeatures } = augmentFeatures(
securityFeatures,
- complianceFeatures,
features ? JSON.parse(features) : [],
);
@@ -48,7 +47,6 @@ export const initSecurityConfiguration = (el) => {
render(createElement) {
return createElement(SecurityConfigurationApp, {
props: {
- augmentedComplianceFeatures,
augmentedSecurityFeatures,
latestPipelinePath,
gitlabCiHistoryPath,
diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js
index df23698ba7e..72e6d870e13 100644
--- a/app/assets/javascripts/security_configuration/utils.js
+++ b/app/assets/javascripts/security_configuration/utils.js
@@ -2,19 +2,18 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
/**
- * This function takes in 3 arrays of objects, securityFeatures, complianceFeatures and features.
- * securityFeatures and complianceFeatures are static arrays living in the constants.
+ * This function takes in 3 arrays of objects, securityFeatures and features.
+ * securityFeatures are static arrays living in the constants.
* features is dynamic and coming from the backend.
* This function builds a superset of those arrays.
* It looks for matching keys within the dynamic and the static arrays
* and will enrich the objects with the available static data.
* @param [{}] securityFeatures
- * @param [{}] complianceFeatures
* @param [{}] features
* @returns {Object} Object with enriched features from constants divided into Security and Compliance Features
*/
-export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => {
+export const augmentFeatures = (securityFeatures, features = []) => {
const featuresByType = features.reduce((acc, feature) => {
acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true });
return acc;
@@ -39,7 +38,6 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features =
return {
augmentedSecurityFeatures: securityFeatures.map((feature) => augmentFeature(feature)),
- augmentedComplianceFeatures: complianceFeatures.map((feature) => augmentFeature(feature)),
};
};
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
deleted file mode 100644
index d9e969e2278..00000000000
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ /dev/null
@@ -1,174 +0,0 @@
-<script>
-import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle, GlLink } from '@gitlab/ui';
-import Vue from 'vue';
-import { mapState, mapActions } from 'vuex';
-import SafeHtml from '~/vue_shared/directives/safe_html';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { visitUrl, getBaseURL } from '~/lib/utils/url_utility';
-import { __, s__, sprintf } from '~/locale';
-
-Vue.use(GlToast);
-
-export default {
- components: {
- GlFormGroup,
- GlButton,
- GlModal,
- GlToggle,
- GlLink,
- },
- directives: {
- SafeHtml,
- },
- formLabels: {
- createProject: __('Self-monitoring'),
- },
- data() {
- return {
- modalId: 'delete-self-monitor-modal',
- };
- },
- computed: {
- ...mapState('selfMonitoring', [
- 'projectEnabled',
- 'projectCreated',
- 'showAlert',
- 'projectPath',
- 'loading',
- 'alertContent',
- ]),
- selfMonitorEnabled: {
- get() {
- return this.projectEnabled;
- },
- set(projectEnabled) {
- this.setSelfMonitor(projectEnabled);
- },
- },
- selfMonitorProjectFullUrl() {
- return `${getBaseURL()}/${this.projectPath}`;
- },
- selfMonitoringFormText() {
- if (this.projectCreated) {
- return sprintf(
- s__(
- 'SelfMonitoring|Self-monitoring is active. Use the %{projectLinkStart}self-monitoring project%{projectLinkEnd} to monitor the health of your instance.',
- ),
- {
- projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`,
- projectLinkEnd: '</a>',
- },
- false,
- );
- }
-
- return s__(
- 'SelfMonitoring|Activate self-monitoring to create a project to use to monitor the health of your instance.',
- );
- },
- helpDocsPath() {
- return helpPagePath('administration/monitoring/gitlab_self_monitoring_project/index');
- },
- },
- watch: {
- selfMonitorEnabled() {
- this.saveChangesSelfMonitorProject();
- },
- showAlert() {
- let toastOptions = {
- onComplete: () => {
- this.resetAlert();
- },
- };
-
- if (this.showAlert) {
- if (this.alertContent.actionName && this.alertContent.actionName.length > 0) {
- toastOptions = {
- ...toastOptions,
- action: {
- text: this.alertContent.actionText,
- onClick: (_, toastObject) => {
- this[this.alertContent.actionName]();
- toastObject.hide();
- },
- },
- };
- }
- this.$toast.show(this.alertContent.message, toastOptions);
- }
- },
- },
- methods: {
- ...mapActions('selfMonitoring', [
- 'setSelfMonitor',
- 'createProject',
- 'deleteProject',
- 'resetAlert',
- ]),
- hideSelfMonitorModal() {
- this.$root.$emit(BV_HIDE_MODAL, this.modalId);
- this.setSelfMonitor(true);
- },
- showSelfMonitorModal() {
- this.$root.$emit(BV_SHOW_MODAL, this.modalId);
- },
- saveChangesSelfMonitorProject() {
- if (this.projectCreated && !this.projectEnabled) {
- this.showSelfMonitorModal();
- } else if (!this.projectCreated && !this.loading) {
- this.createProject();
- }
- },
- viewSelfMonitorProject() {
- visitUrl(this.selfMonitorProjectFullUrl);
- },
- },
-};
-</script>
-<template>
- <section class="settings no-animate js-self-monitoring-settings">
- <div class="settings-header">
- <h4
- class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
- >
- {{ s__('SelfMonitoring|Self-monitoring') }}
- </h4>
- <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
- <p class="js-section-sub-header">
- {{ s__('SelfMonitoring|Activate or deactivate instance self-monitoring.') }}
- <gl-link :href="helpDocsPath">{{ __('Learn more.') }}</gl-link>
- </p>
- </div>
- <div class="settings-content">
- <form name="self-monitoring-form">
- <p ref="selfMonitoringFormText" v-safe-html="selfMonitoringFormText"></p>
- <gl-form-group>
- <gl-toggle
- v-model="selfMonitorEnabled"
- :is-loading="loading"
- :label="$options.formLabels.createProject"
- />
- </gl-form-group>
- </form>
- </div>
- <gl-modal
- :title="s__('SelfMonitoring|Deactivate self-monitoring?')"
- :modal-id="modalId"
- :ok-title="__('Delete self-monitoring project')"
- :cancel-title="__('Cancel')"
- ok-variant="danger"
- category="primary"
- @ok="deleteProject"
- @cancel="hideSelfMonitorModal"
- >
- <div>
- {{
- s__(
- 'SelfMonitoring|Deactivating self-monitoring deletes the self-monitoring project. Are you sure you want to deactivate self-monitoring and delete the project?',
- )
- }}
- </div>
- </gl-modal>
- </section>
-</template>
diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js
deleted file mode 100644
index 384b73e528e..00000000000
--- a/app/assets/javascripts/self_monitor/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import SelfMonitorForm from './components/self_monitor_form.vue';
-import store from './store';
-
-export default () => {
- const el = document.querySelector('.js-self-monitoring-settings');
-
- if (el) {
- // eslint-disable-next-line no-new
- new Vue({
- el,
- store: store({
- ...el.dataset,
- }),
- render(createElement) {
- return createElement(SelfMonitorForm);
- },
- });
- }
-};
diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js
deleted file mode 100644
index d94fd77dd42..00000000000
--- a/app/assets/javascripts/self_monitor/store/actions.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-import { backOff } from '~/lib/utils/common_utils';
-import { HTTP_STATUS_ACCEPTED, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { __, s__ } from '~/locale';
-import * as types from './mutation_types';
-
-const TWO_MINUTES = 120000;
-
-function backOffRequest(makeRequestCallback) {
- return backOff((next, stop) => {
- makeRequestCallback()
- .then((resp) => {
- if (resp.status === HTTP_STATUS_ACCEPTED) {
- next();
- } else {
- stop(resp);
- }
- })
- .catch(stop);
- }, TWO_MINUTES);
-}
-
-export const setSelfMonitor = ({ commit }, enabled) => commit(types.SET_ENABLED, enabled);
-
-export const createProject = ({ dispatch }) => dispatch('requestCreateProject');
-
-export const resetAlert = ({ commit }) => commit(types.SET_SHOW_ALERT, false);
-
-export const requestCreateProject = ({ dispatch, state, commit }) => {
- commit(types.SET_LOADING, true);
- axios
- .post(state.createProjectEndpoint)
- .then((resp) => {
- if (resp.status === HTTP_STATUS_ACCEPTED) {
- dispatch('requestCreateProjectStatus', resp.data.job_id);
- }
- })
- .catch((error) => {
- dispatch('requestCreateProjectError', error);
- });
-};
-
-export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => {
- backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } }))
- .then((resp) => {
- if (resp.status === HTTP_STATUS_OK) {
- dispatch('requestCreateProjectSuccess', resp.data);
- }
- })
- .catch((error) => {
- dispatch('requestCreateProjectError', error);
- });
-};
-
-export const requestCreateProjectSuccess = ({ commit, dispatch }, selfMonitorData) => {
- commit(types.SET_LOADING, false);
- commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path);
- commit(types.SET_ALERT_CONTENT, {
- message: s__('SelfMonitoring|Self-monitoring project successfully created.'),
- actionText: __('View project'),
- actionName: 'viewSelfMonitorProject',
- });
- commit(types.SET_SHOW_ALERT, true);
- commit(types.SET_PROJECT_CREATED, true);
- dispatch('setSelfMonitor', true);
-};
-
-export const requestCreateProjectError = ({ commit }, error) => {
- const { response } = error;
- const message = response.data && response.data.message ? response.data.message : '';
-
- commit(types.SET_ALERT_CONTENT, {
- message: `${__('There was an error saving your changes.')} ${message}`,
- });
- commit(types.SET_SHOW_ALERT, true);
- commit(types.SET_LOADING, false);
-};
-
-export const deleteProject = ({ dispatch }) => dispatch('requestDeleteProject');
-
-export const requestDeleteProject = ({ dispatch, state, commit }) => {
- commit(types.SET_LOADING, true);
- axios
- .delete(state.deleteProjectEndpoint)
- .then((resp) => {
- if (resp.status === HTTP_STATUS_ACCEPTED) {
- dispatch('requestDeleteProjectStatus', resp.data.job_id);
- }
- })
- .catch((error) => {
- dispatch('requestDeleteProjectError', error);
- });
-};
-
-export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => {
- backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } }))
- .then((resp) => {
- if (resp.status === HTTP_STATUS_OK) {
- dispatch('requestDeleteProjectSuccess', resp.data);
- }
- })
- .catch((error) => {
- dispatch('requestDeleteProjectError', error);
- });
-};
-
-export const requestDeleteProjectSuccess = ({ commit }) => {
- commit(types.SET_PROJECT_URL, '');
- commit(types.SET_PROJECT_CREATED, false);
- commit(types.SET_ALERT_CONTENT, {
- message: s__('SelfMonitoring|Self-monitoring project successfully deleted.'),
- actionText: __('Undo'),
- actionName: 'createProject',
- });
- commit(types.SET_SHOW_ALERT, true);
- commit(types.SET_LOADING, false);
-};
-
-export const requestDeleteProjectError = ({ commit }, error) => {
- const { response } = error;
- const message = response.data && response.data.message ? response.data.message : '';
-
- commit(types.SET_ALERT_CONTENT, {
- message: `${__('There was an error saving your changes.')} ${message}`,
- });
- commit(types.SET_LOADING, false);
-};
diff --git a/app/assets/javascripts/self_monitor/store/index.js b/app/assets/javascripts/self_monitor/store/index.js
deleted file mode 100644
index 187c3bdd803..00000000000
--- a/app/assets/javascripts/self_monitor/store/index.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import mutations from './mutations';
-import createState from './state';
-
-Vue.use(Vuex);
-
-export const createStore = (initialState) =>
- new Vuex.Store({
- modules: {
- selfMonitoring: {
- namespaced: true,
- state: createState(initialState),
- actions,
- mutations,
- },
- },
- });
-
-export default createStore;
diff --git a/app/assets/javascripts/self_monitor/store/mutation_types.js b/app/assets/javascripts/self_monitor/store/mutation_types.js
deleted file mode 100644
index c5952b66144..00000000000
--- a/app/assets/javascripts/self_monitor/store/mutation_types.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export const SET_ENABLED = 'SET_ENABLED';
-export const SET_PROJECT_CREATED = 'SET_PROJECT_CREATED';
-export const SET_SHOW_ALERT = 'SET_SHOW_ALERT';
-export const SET_PROJECT_URL = 'SET_PROJECT_URL';
-export const SET_LOADING = 'SET_LOADING';
-export const SET_ALERT_CONTENT = 'SET_ALERT_CONTENT';
diff --git a/app/assets/javascripts/self_monitor/store/mutations.js b/app/assets/javascripts/self_monitor/store/mutations.js
deleted file mode 100644
index 7dca8bcdc4d..00000000000
--- a/app/assets/javascripts/self_monitor/store/mutations.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.SET_ENABLED](state, enabled) {
- state.projectEnabled = enabled;
- },
- [types.SET_PROJECT_CREATED](state, created) {
- state.projectCreated = created;
- },
- [types.SET_SHOW_ALERT](state, show) {
- state.showAlert = show;
- },
- [types.SET_PROJECT_URL](state, url) {
- state.projectPath = url;
- },
- [types.SET_LOADING](state, loading) {
- state.loading = loading;
- },
- [types.SET_ALERT_CONTENT](state, content) {
- state.alertContent = content;
- },
-};
diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js
deleted file mode 100644
index 582ce5576f1..00000000000
--- a/app/assets/javascripts/self_monitor/store/state.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-export default (initialState = {}) => ({
- projectEnabled: parseBoolean(initialState.selfMonitoringProjectExists) || false,
- projectCreated: parseBoolean(initialState.selfMonitoringProjectExists) || false,
- createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '',
- deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '',
- createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '',
- deleteProjectStatusEndpoint: initialState.statusDeleteSelfMonitoringProjectPath || '',
- selfMonitorProjectPath: initialState.selfMonitoringProjectFullPath || '',
- showAlert: false,
- projectPath: initialState.selfMonitoringProjectFullPath || '',
- loading: false,
- alertContent: {},
-});
diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js
index 5539a061726..ea835945aa9 100644
--- a/app/assets/javascripts/sentry/index.js
+++ b/app/assets/javascripts/sentry/index.js
@@ -15,8 +15,9 @@ const index = function index() {
: [gon.gitlab_url, 'webpack-internal://'],
release: gon.revision,
tags: {
- revision: gon.revision,
- feature_category: gon.feature_category,
+ revision: gon?.revision,
+ feature_category: gon?.feature_category,
+ page: document?.body?.dataset?.page,
},
});
};
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..c61c02c8b3a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,7 +1,7 @@
<script>
-import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
-import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
-import { s__, sprintf } from '~/locale';
+import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants';
+import { __ } from '~/locale';
const AVAILABILITY_STATUS = {
NOT_SET: 'NOT_SET',
@@ -11,6 +11,7 @@ const AVAILABILITY_STATUS = {
export default {
components: {
GlAvatarLabeled,
+ GlBadge,
GlIcon,
},
props: {
@@ -25,30 +26,23 @@ export default {
},
},
computed: {
- userLabel() {
- const { name, status } = this.user;
- if (!status || status?.availability !== AVAILABILITY_STATUS.BUSY) {
- return name;
- }
- return sprintf(
- s__('UserAvailability|%{author} (Busy)'),
- {
- author: name,
- },
- false,
- );
+ isBusy() {
+ return this.user?.status?.availability === AVAILABILITY_STATUS.BUSY;
},
hasCannotMergeIcon() {
- return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge;
+ return this.issuableType === TYPE_MERGE_REQUEST && !this.user.canMerge;
},
},
+ i18n: {
+ busy: __('Busy'),
+ },
};
</script>
<template>
<gl-avatar-labeled
:size="32"
- :label="userLabel"
+ :label="user.name"
:sub-label="`@${user.username}`"
:src="user.avatarUrl || user.avatar || user.avatar_url"
class="gl-align-items-center gl-relative sidebar-participant"
@@ -61,6 +55,9 @@ export default {
class="merge-icon"
:size="12"
/>
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2">
+ {{ $options.i18n.busy }}
+ </gl-badge>
</template>
</gl-avatar-labeled>
</template>
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/assignees/user_name_with_status.vue b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
index bed84dc5706..72084fdafb1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/user_name_with_status.vue
@@ -1,10 +1,11 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlBadge, GlSprintf } from '@gitlab/ui';
import { isUserBusy } from '~/set_status_modal/utils';
export default {
name: 'UserNameWithStatus',
components: {
+ GlBadge,
GlSprintf,
},
props: {
@@ -40,17 +41,17 @@ export default {
</script>
<template>
<span :class="containerClasses">
- <gl-sprintf :message="s__('UserAvailability|%{author} %{spanStart}(Busy)%{spanEnd}')">
+ <gl-sprintf :message="s__('UserAvailability|%{author}%{badgeStart}Busy%{badgeEnd}')">
<template #author
- >{{ name }}
- <span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal"
+ ><span>{{ name }}</span
+ ><span v-if="hasPronouns" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-ml-1"
>({{ pronouns }})</span
></template
>
- <template #span="{ content }"
- ><span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal">{{
- content
- }}</span>
+ <template #badge="{ content }">
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-2">
+ {{ content }}
+ </gl-badge>
</template>
</gl-sprintf>
</span>
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..5a9545f3460 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 {
@@ -96,6 +106,19 @@ export default {
),
});
},
+ subscribeToMore: {
+ document() {
+ return this.dateQueries[this.issuableType].subscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return this.skipIssueDueDateSubscription;
+ },
+ },
},
},
computed: {
@@ -153,6 +176,12 @@ export default {
dataTestId() {
return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date';
},
+ issuableId() {
+ return this.issuable.id;
+ },
+ skipIssueDueDateSubscription() {
+ return this.issuableType !== TYPE_ISSUE || !this.issuableId || this.isLoading;
+ },
},
methods: {
epicDatePopoverEl() {
@@ -292,6 +321,9 @@ export default {
v-if="!isLoading"
ref="datePicker"
class="gl-relative"
+ :value="parsedDate"
+ :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/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js
deleted file mode 100644
index 00c54313292..00000000000
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export const DropdownVariant = {
- Sidebar: 'sidebar',
- Standalone: 'standalone',
- Embedded: 'embedded',
-};
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..8535398decf 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
@@ -78,6 +78,7 @@ export default {
v-model.trim="labelTitle"
:placeholder="__('Name new label')"
:autofocus="true"
+ data-testid="label-title"
/>
</div>
<div class="dropdown-content px-2">
@@ -92,10 +93,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"
@@ -109,6 +114,7 @@ export default {
category="primary"
variant="confirm"
class="float-left d-flex align-items-center"
+ data-testid="create-click"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" size="sm" :inline="true" class="mr-1" />
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
index ee6b531c1ca..3db962c7fe8 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue
@@ -80,9 +80,6 @@ export default {
'updateSelectedLabels',
'toggleDropdownContents',
]),
- isLabelSelected(label) {
- return this.selectedLabelsList.includes(label.id);
- },
/**
* This method scrolls item from dropdown into
* the view if it is off the viewable area of the
@@ -160,7 +157,11 @@ export default {
<template>
<gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
- <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
+ <div
+ class="labels-select-contents-list js-labels-list"
+ data-testid="labels-list"
+ @keydown="handleKeyDown"
+ >
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
@@ -200,7 +201,11 @@ export default {
:highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)"
/>
- <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
+ <li
+ v-show="showNoMatchingResultsMessage"
+ class="gl-p-3 gl-text-center"
+ data-testid="no-matching-results"
+ >
{{ __('No matching results') }}
</li>
</ul>
@@ -210,6 +215,7 @@ export default {
<li v-if="allowLabelCreate">
<gl-link
class="gl-display-flex w-100 flex-row text-break-word label-item"
+ data-testid="create-label-link"
@click="handleCreateLabelClick"
>
{{ footerCreateLabelTitle }}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
index 135fa9f6228..9df03a4d7f8 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue
@@ -31,7 +31,7 @@ export default {
const { label, highlight, isLabelSet, isLabelIndeterminate } = props;
const labelColorBox = h('span', {
- class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
+ class: 'dropdown-label-box gl-mr-2',
style: {
backgroundColor: label.color,
},
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
index 04c62c99a11..74e47b333ef 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue
@@ -3,8 +3,8 @@ import Vue from 'vue';
import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import { VARIANT_SIDEBAR } from '~/sidebar/components/labels/labels_select_widget/constants';
-import { DropdownVariant } from './constants';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
@@ -60,7 +60,7 @@ export default {
variant: {
type: String,
required: false,
- default: DropdownVariant.Sidebar,
+ default: VARIANT_SIDEBAR,
},
selectedLabels: {
type: Array,
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_vue/store/getters.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
index ef3eedd9bb2..03ace6286e0 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js
@@ -1,5 +1,9 @@
import { __, s__, sprintf } from '~/locale';
-import { DropdownVariant } from '../constants';
+import {
+ VARIANT_EMBEDDED,
+ VARIANT_SIDEBAR,
+ VARIANT_STANDALONE,
+} from '~/sidebar/components/labels/labels_select_widget/constants';
/**
* Returns string representing current labels
@@ -36,18 +40,18 @@ export const selectedLabelsList = (state) => state.selectedLabels.map((label) =>
* is `sidebar`
* @param {object} state
*/
-export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar;
+export const isDropdownVariantSidebar = (state) => state.variant === VARIANT_SIDEBAR;
/**
* Returns boolean representing whether dropdown variant
* is `standalone`
* @param {object} state
*/
-export const isDropdownVariantStandalone = (state) => state.variant === DropdownVariant.Standalone;
+export const isDropdownVariantStandalone = (state) => state.variant === VARIANT_STANDALONE;
/**
* Returns boolean representing whether dropdown variant
* is `embedded`
* @param {object} state
*/
-export const isDropdownVariantEmbedded = (state) => state.variant === DropdownVariant.Embedded;
+export const isDropdownVariantEmbedded = (state) => state.variant === VARIANT_EMBEDDED;
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
index c85d9befcbb..dba48a721ac 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js
@@ -1,5 +1,5 @@
import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
-import { DropdownVariant } from '../constants';
+import { VARIANT_SIDEBAR } from '~/sidebar/components/labels/labels_select_widget/constants';
import * as types from './mutation_types';
const transformLabels = (labels, selectedLabels) =>
@@ -43,7 +43,7 @@ export default {
},
[types.TOGGLE_DROPDOWN_CONTENTS](state) {
- if (state.variant === DropdownVariant.Sidebar) {
+ if (state.variant === VARIANT_SIDEBAR) {
state.showDropdownButton = !state.showDropdownButton;
}
state.showDropdownContents = !state.showDropdownContents;
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..57f08ab59e2 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
@@ -1,13 +1,7 @@
export const SCOPED_LABEL_DELIMITER = '::';
export const DEBOUNCE_DROPDOWN_DELAY = 200;
+export const DEFAULT_LABEL_COLOR = '#6699cc';
-export const DropdownVariant = {
- Sidebar: 'sidebar',
- Standalone: 'standalone',
- Embedded: 'embedded',
-};
-
-export const LabelType = {
- group: 'group',
- project: 'project',
-};
+export const VARIANT_EMBEDDED = 'embedded';
+export const VARIANT_SIDEBAR = 'sidebar';
+export const VARIANT_STANDALONE = 'standalone';
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
index 83df9056af2..b44096c7743 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue
@@ -188,6 +188,11 @@ export default {
selectFirstItem() {
this.$refs.dropdownContentsView.selectFirstItem();
},
+ handleNewLabel(label) {
+ this.localSelectedLabels = [...this.localSelectedLabels, label];
+ this.toggleDropdownContent();
+ this.clearSearch();
+ },
},
};
</script>
@@ -229,6 +234,7 @@ export default {
:attr-workspace-path="attrWorkspacePath"
:label-create-type="labelCreateType"
@hideCreateView="toggleDropdownContent"
+ @labelCreated="handleNewLabel"
@input="clearSearch"
/>
</template>
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..45778640957 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,12 @@ 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';
+import { DEFAULT_LABEL_COLOR } from './constants';
const errorMessage = __('Error creating label.');
@@ -44,11 +45,16 @@ export default {
type: String,
required: true,
},
+ searchKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
- labelTitle: '',
- selectedColor: '',
+ labelTitle: this.searchKey,
+ selectedColor: DEFAULT_LABEL_COLOR,
labelCreateInProgress: false,
error: undefined,
};
@@ -62,7 +68,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,
@@ -126,7 +132,7 @@ export default {
if (labelCreate.errors.length) {
[this.error] = labelCreate.errors;
} else {
- this.$emit('hideCreateView');
+ this.$emit('labelCreated', labelCreate.label);
}
} catch {
createAlert({ message: errorMessage });
@@ -163,11 +169,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..1d8b21700c3 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';
@@ -147,14 +147,13 @@ export default {
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3"
- size="lg"
+ size="sm"
/>
<template v-else>
<gl-dropdown-item
v-for="(label, index) in visibleLabels"
:key="label.id"
:is-checked="isLabelSelected(label)"
- is-check-centered
is-check-item
:active="shouldHighlightFirstItem && index === 0"
active-class="is-focused"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
index 3a93fc7f3b2..a3bacc4a674 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue
@@ -53,7 +53,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-mt-3" data-testid="embedded-labels-list">
<gl-label
v-for="label in sortedSelectedLabels"
:key="label.id"
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
index 314ffbaf84c..4ce2644262a 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue
@@ -10,7 +10,7 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center gl-word-break-word">
+ <div class="gl-display-flex gl-word-break-word">
<span
class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3"
:style="{ 'background-color': label.color }"
diff --git a/app/assets/javascripts/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..72567b7d4a4 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,14 +2,14 @@
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';
import SidebarEditableItem from '../../sidebar_editable_item.vue';
-import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants';
+import { DEBOUNCE_DROPDOWN_DELAY, VARIANT_SIDEBAR } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
import EmbeddedLabelsList from './embedded_labels_list.vue';
@@ -60,7 +60,7 @@ export default {
variant: {
type: String,
required: false,
- default: DropdownVariant.Sidebar,
+ default: VARIANT_SIDEBAR,
},
labelsFilterBasePath: {
type: String,
@@ -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/labels/labels_select_widget/utils.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
index b5cd946a189..d752accf14a 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js
@@ -1,22 +1,22 @@
-import { DropdownVariant } from './constants';
+import { VARIANT_EMBEDDED, VARIANT_SIDEBAR, VARIANT_STANDALONE } from './constants';
/**
* Returns boolean representing whether dropdown variant
* is `sidebar`
* @param {string} variant
*/
-export const isDropdownVariantSidebar = (variant) => variant === DropdownVariant.Sidebar;
+export const isDropdownVariantSidebar = (variant) => variant === VARIANT_SIDEBAR;
/**
* Returns boolean representing whether dropdown variant
* is `standalone`
* @param {string} variant
*/
-export const isDropdownVariantStandalone = (variant) => variant === DropdownVariant.Standalone;
+export const isDropdownVariantStandalone = (variant) => variant === VARIANT_STANDALONE;
/**
* Returns boolean representing whether dropdown variant
* is `embedded`
* @param {string} variant
*/
-export const isDropdownVariantEmbedded = (variant) => variant === DropdownVariant.Embedded;
+export const isDropdownVariantEmbedded = (variant) => variant === VARIANT_EMBEDDED;
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..06876546fa4 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -3,8 +3,9 @@ import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitl
import { mapGetters, mapActions } from 'vuex';
import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
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';
@@ -45,8 +46,8 @@ export default {
},
computed: {
...mapGetters(['getNoteableData']),
- isMergeRequest() {
- return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.movedMrSidebar;
+ isMovedMrSidebar() {
+ return this.glFeatures.movedMrSidebar;
},
issuableDisplayName() {
const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE;
@@ -58,7 +59,6 @@ export default {
lockStatus() {
return this.isLocked ? this.$options.locked : this.$options.unlocked;
},
-
tooltipLabel() {
return this.isLocked ? __('Locked') : __('Unlocked');
},
@@ -87,16 +87,21 @@ export default {
fullPath: this.fullPath,
})
.then(() => {
- if (this.isMergeRequest) {
- toast(this.isLocked ? __('Merge request locked.') : __('Merge request unlocked.'));
+ if (this.isMovedMrSidebar) {
+ toast(
+ sprintf(__('%{issuableDisplayName} %{lockStatus}.'), {
+ issuableDisplayName: capitalizeFirstCharacter(this.issuableDisplayName),
+ lockStatus: this.isLocked ? __('locked') : __('unlocked'),
+ }),
+ );
}
})
.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(() => {
@@ -111,14 +116,14 @@ export default {
</script>
<template>
- <li v-if="isMergeRequest" class="gl-dropdown-item">
- <button type="button" class="dropdown-item" @click="toggleLocked">
+ <li v-if="isMovedMrSidebar" class="gl-dropdown-item">
+ <button type="button" class="dropdown-item" data-testid="issuable-lock" @click="toggleLocked">
<span class="gl-dropdown-item-text-wrapper">
<template v-if="isLocked">
- {{ __('Unlock merge request') }}
+ {{ sprintf(__('Unlock %{issuableType}'), { issuableType: issuableDisplayName }) }}
</template>
<template v-else>
- {{ __('Lock merge request') }}
+ {{ sprintf(__('Lock %{issuableType}'), { issuableType: issuableDisplayName }) }}
</template>
</span>
</button>
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/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
index 9f64ddc8721..34a4da946d6 100644
--- a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
@@ -194,7 +194,11 @@ export default {
<div v-if="hasNoSearchResults" class="gl-text-center gl-p-3">
{{ __('No matching results') }}
</div>
- <div v-if="failedToLoadResults" class="gl-text-center gl-p-3">
+ <div
+ v-if="failedToLoadResults"
+ data-testid="failed-load-results"
+ class="gl-text-center gl-p-3"
+ >
{{ __('Failed to load projects') }}
</div>
</div>
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..581537264db 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';
@@ -26,10 +26,11 @@ export default {
},
},
methods: {
- moveIssue(targetProject) {
+ async moveIssue(targetProject) {
this.moveInProgress = true;
- return this.$apollo
- .mutate({
+
+ try {
+ const { data } = await this.$apollo.mutate({
mutation: moveIssueMutation,
variables: {
moveIssueInput: {
@@ -38,24 +39,25 @@ export default {
targetProjectPath: targetProject.full_path,
},
},
- })
- .then(({ data = {} }) => {
- if (!data.issueMove) return;
+ });
+
+ if (!data.issueMove) return;
+
+ const { errors } = data.issueMove;
+ if (errors?.length > 0) {
+ throw new Error(`Error moving the issue. Error message: ${errors[0].message}`);
+ }
- const { errors } = data.issueMove;
- if (errors?.length > 0) {
- throw new Error(`Error moving the issue. Error message: ${errors[0].message}`);
- }
- visitUrl(data.issueMove?.issue.webUrl);
- })
- .catch((error) => {
- this.moveInProgress = false;
- createAlert({
- message: this.$options.i18n.moveErrorMessage,
- captureError: true,
- error,
- });
+ visitUrl(data.issueMove?.issue.webUrl);
+ } catch (error) {
+ createAlert({
+ message: this.$options.i18n.moveErrorMessage,
+ captureError: true,
+ error,
});
+ } finally {
+ this.moveInProgress = false;
+ }
},
},
};
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..bbd3cda0ad3 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -69,7 +69,7 @@ export default {
},
participantLabel() {
return sprintf(
- n__('%{count} participant', '%{count} participants', this.participants.length),
+ n__('%{count} Participant', '%{count} Participants', this.participants.length),
{ count: this.loading ? '' : this.participantCount },
);
},
@@ -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/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index a3710d9534e..99f9d5e872c 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -6,6 +6,7 @@ import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
const SUCCESS_STATE = 'success';
+const JUST_APPROVED = 'approved';
export default {
i18n: {
@@ -42,7 +43,7 @@ export default {
},
watch: {
users: {
- handler(users) {
+ handler(users, previousUsers) {
this.loadingStates = users.reduce(
(acc, user) => ({
...acc,
@@ -50,14 +51,41 @@ export default {
}),
this.loadingStates,
);
+ if (previousUsers) {
+ users.forEach((user) => {
+ const userPreviousState = previousUsers.find(({ id }) => id === user.id);
+ if (
+ userPreviousState &&
+ user.mergeRequestInteraction.approved &&
+ !userPreviousState.mergeRequestInteraction.approved
+ ) {
+ this.showApprovalAnimation(user.id);
+ }
+ });
+ }
},
immediate: true,
},
},
methods: {
+ showApprovalAnimation(userId) {
+ this.loadingStates[userId] = JUST_APPROVED;
+
+ setTimeout(() => {
+ this.loadingStates[userId] = null;
+ }, 1500);
+ },
+ approveAnimation(userId) {
+ return {
+ 'merge-request-approved-icon': this.loadingStates[userId] === JUST_APPROVED,
+ };
+ },
approvedByTooltipTitle(user) {
return sprintf(s__('MergeRequest|Approved by @%{username}'), user);
},
+ reviewedButNotApprovedTooltip(user) {
+ return sprintf(s__('MergeRequest|Reviewed by @%{username} but not yet approved'), user);
+ },
toggleShowLess() {
this.showLess = !this.showLess;
},
@@ -105,35 +133,38 @@ export default {
{{ user.name }}
</div>
</reviewer-avatar-link>
- <gl-icon
- v-if="user.mergeRequestInteraction.approved"
- v-gl-tooltip.left
- :size="16"
- :title="approvedByTooltipTitle(user)"
- name="status-success"
- class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0"
- data-testid="re-approved"
- />
- <gl-icon
- v-if="loadingStates[user.id] === $options.SUCCESS_STATE"
- :size="24"
- name="check"
- class="float-right gl-py-2 gl-mr-2 gl-text-green-500"
- data-testid="re-request-success"
- />
<gl-button
- v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
+ v-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
v-gl-tooltip.left
:title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview"
:loading="loadingStates[user.id] === $options.LOADING_STATE"
- class="float-right gl-text-gray-500!"
+ class="float-right gl-text-gray-500! gl-mr-2"
size="small"
icon="redo"
variant="link"
data-testid="re-request-button"
@click="reRequestReview(user.id)"
/>
+ <gl-icon
+ v-if="user.mergeRequestInteraction.approved"
+ v-gl-tooltip.left
+ :size="16"
+ :title="approvedByTooltipTitle(user)"
+ name="status-success"
+ class="float-right gl-my-2 gl-ml-auto gl-text-green-500 gl-flex-shrink-0"
+ :class="approveAnimation(user.id)"
+ data-testid="approved"
+ />
+ <gl-icon
+ v-else-if="user.mergeRequestInteraction.reviewed"
+ v-gl-tooltip.left
+ :size="16"
+ :title="reviewedButNotApprovedTooltip(user)"
+ name="dotted-circle"
+ class="float-right gl-my-2 gl-ml-auto gl-text-gray-400 gl-flex-shrink-0"
+ data-testid="reviewed-not-approved"
+ />
</div>
</div>
</template>
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..4721c6fee61 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: {
@@ -81,7 +81,7 @@ export default {
},
},
apollo: {
- currentAttribute: {
+ issuable: {
query() {
const { current } = this.issuableAttributeQuery;
const { query } = current[this.issuableType];
@@ -95,11 +95,12 @@ export default {
};
},
update(data) {
+ return data.workspace?.issuable || {};
+ },
+ result({ data }) {
if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) {
this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic;
}
-
- return data?.workspace?.issuable.attribute;
},
error(error) {
createAlert({
@@ -108,13 +109,26 @@ export default {
error,
});
},
+ subscribeToMore: {
+ document() {
+ return issuableAttributesQueries[this.issuableAttribute].subscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return this.shouldSkipRealTimeEpicLinkUpdates;
+ },
+ },
},
},
data() {
return {
updating: false,
selectedTitle: null,
- currentAttribute: null,
+ issuable: {},
hasCurrentAttribute: false,
editConfirmation: false,
tracking: {
@@ -125,6 +139,12 @@ export default {
};
},
computed: {
+ currentAttribute() {
+ return this.issuable.attribute;
+ },
+ issuableId() {
+ return this.issuable.id;
+ },
issuableAttributeQuery() {
return this.issuableAttributesQueries[this.issuableAttribute];
},
@@ -135,7 +155,7 @@ export default {
return this.currentAttribute?.webUrl;
},
loading() {
- return this.$apollo.queries.currentAttribute.loading;
+ return this.$apollo.queries.issuable.loading;
},
attributeTypeTitle() {
return this.widgetTitleText[this.issuableAttribute];
@@ -170,6 +190,9 @@ export default {
? !this.editConfirmation
: false;
},
+ shouldSkipRealTimeEpicLinkUpdates() {
+ return !this.issuableId || this.issuableAttribute !== IssuableAttributeType.Epic;
+ },
},
methods: {
updateAttribute({ id }) {
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..f2b960ed02c 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,7 @@
<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, 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';
@@ -86,8 +86,8 @@ export default {
},
},
computed: {
- isMergeRequest() {
- return this.issuableType === IssuableType.MergeRequest && this.glFeatures.movedMrSidebar;
+ isMovedMrSidebar() {
+ return this.glFeatures.movedMrSidebar;
},
isLoading() {
return this.$apollo.queries?.subscribed?.loading || this.loading;
@@ -109,7 +109,7 @@ export default {
},
subscribeDisabledDescription() {
return sprintf(__('Disabled by %{parent} owner'), {
- parent: this.parentIsGroup ? 'group' : 'project',
+ parent: this.parentIsGroup ? WORKSPACE_GROUP : WORKSPACE_PROJECT,
});
},
isLoggedIn() {
@@ -143,7 +143,7 @@ export default {
});
}
- if (this.isMergeRequest) {
+ if (this.isMovedMrSidebar) {
toast(subscribed ? __('Notifications turned on.') : __('Notifications turned off.'));
}
},
@@ -182,7 +182,7 @@ export default {
</script>
<template>
- <gl-dropdown-form v-if="isMergeRequest" class="gl-dropdown-item">
+ <gl-dropdown-form v-if="isMovedMrSidebar" class="gl-dropdown-item">
<div class="gl-px-5 gl-pb-2 gl-pt-1">
<gl-toggle
:value="subscribed"
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/components/toggle/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
index 6dacf4e10d3..ba0bf783315 100644
--- a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue
@@ -32,9 +32,22 @@ export default {
return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }];
},
},
+ watch: {
+ collapsed(value) {
+ this.updateLayout(value);
+ },
+ },
+ mounted() {
+ this.page = document.querySelector('.layout-page');
+ },
methods: {
toggle() {
this.$emit('toggle');
+ this.updateLayout();
+ },
+ updateLayout(collapsed) {
+ this.page?.classList.remove(collapsed ? 'right-sidebar-expanded' : 'right-sidebar-collapsed');
+ this.page?.classList.add(collapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded');
},
},
};
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 14491226b15..0f82182c6e2 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -3,8 +3,17 @@ 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 issuableDatesUpdatedSubscription from '../graphql_shared/subscriptions/work_item_dates.subscription.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';
import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
@@ -69,11 +78,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 +92,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 +108,7 @@ export const userSearchQueries = {
[TYPE_ISSUE]: {
query: userSearchQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: userSearchWithMRPermissionsQuery,
},
};
@@ -119,7 +128,7 @@ export const referenceQueries = {
[TYPE_ISSUE]: {
query: issueReferenceQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestReferenceQuery,
},
[TYPE_EPIC]: {
@@ -128,10 +137,10 @@ export const referenceQueries = {
};
export const workspaceLabelsQueries = {
- [WorkspaceType.project]: {
+ [WORKSPACE_PROJECT]: {
query: projectLabelsQuery,
},
- [WorkspaceType.group]: {
+ [WORKSPACE_GROUP]: {
query: groupLabelsQuery,
},
};
@@ -142,7 +151,7 @@ export const issuableLabelsQueries = {
mutation: updateIssueLabelsMutation,
mutationName: 'updateIssue',
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
issuableQuery: mergeRequestLabelsQuery,
mutation: updateMergeRequestLabelsMutation,
mutationName: 'mergeRequestSetLabels',
@@ -152,7 +161,7 @@ export const issuableLabelsQueries = {
mutation: updateEpicLabelsMutation,
mutationName: 'updateEpic',
},
- [IssuableType.TestCase]: {
+ [TYPE_TEST_CASE]: {
issuableQuery: issueLabelsQuery,
mutation: updateTestCaseLabelsMutation,
mutationName: 'updateTestCaseLabels',
@@ -186,7 +195,7 @@ export const subscribedQueries = {
query: epicSubscribedQuery,
mutation: updateEpicSubscriptionMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestSubscribed,
mutation: updateMergeRequestSubscriptionMutation,
},
@@ -201,7 +210,7 @@ export const timeTrackingQueries = {
[TYPE_ISSUE]: {
query: issueTimeTrackingQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestTimeTrackingQuery,
},
};
@@ -210,6 +219,7 @@ export const dueDateQueries = {
[TYPE_ISSUE]: {
query: issueDueDateQuery,
mutation: updateIssueDueDateMutation,
+ subscription: issuableDatesUpdatedSubscription,
},
[TYPE_EPIC]: {
query: epicDueDateQuery,
@@ -228,7 +238,7 @@ export const timelogQueries = {
[TYPE_ISSUE]: {
query: getIssueTimelogsQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: getMrTimelogsQuery,
},
};
@@ -240,7 +250,7 @@ export const issuableMilestoneQueries = {
query: projectIssueMilestoneQuery,
mutation: projectIssueMilestoneMutation,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestMilestone,
mutation: mergeRequestMilestoneMutation,
},
@@ -249,14 +259,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 +299,7 @@ export const todoQueries = {
[TYPE_ISSUE]: {
query: issueTodoQuery,
},
- [IssuableType.MergeRequest]: {
+ [TYPE_MERGE_REQUEST]: {
query: mergeRequestTodoQuery,
},
};
@@ -407,10 +417,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..74843bcc006 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,
@@ -17,6 +17,7 @@ import { __ } from '~/locale';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import Translate from '~/vue_shared/translate';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
+import NewHeaderActionsPopover from '~/issues/show/components/new_header_actions_popover.vue';
import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue';
@@ -24,8 +25,6 @@ import SidebarConfidentialityWidget from './components/confidential/sidebar_conf
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,14 +141,14 @@ 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,
name: 'SidebarAssigneesRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
directlyInviteMembers: Object.prototype.hasOwnProperty.call(
el.dataset,
'directlyInviteMembers',
@@ -163,7 +162,7 @@ function mountSidebarAssigneesWidget() {
issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees || el.dataset.maxAssignees > 1,
- editable,
+ editable: parseBoolean(editable),
},
scopedSlots: {
collapsed: ({ users }) =>
@@ -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,
@@ -354,14 +351,13 @@ export function mountSidebarLabelsWidget() {
footerManageLabelTitle: __('Manage project labels'),
labelsCreateTitle: __('Create project label'),
labelsFilterBasePath: el.dataset.projectIssuesPath,
- variant: DropdownVariant.Sidebar,
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 +394,7 @@ function mountSidebarConfidentialityWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -418,7 +414,7 @@ function mountSidebarDueDateWidget() {
name: 'SidebarDueDateWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarDueDateWidget, {
@@ -454,7 +450,7 @@ function mountSidebarReferenceWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -479,7 +475,7 @@ function mountIssuableLockForm(store) {
render: (createElement) =>
createElement(IssuableLockForm, {
props: {
- isEditable: editable,
+ isEditable: parseBoolean(editable),
},
}),
});
@@ -506,7 +502,7 @@ function mountSidebarParticipantsWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -526,7 +522,7 @@ function mountSidebarSubscriptionsWidget() {
name: 'SidebarSubscriptionsWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSubscriptionsWidget, {
@@ -536,7 +532,7 @@ function mountSidebarSubscriptionsWidget() {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? TYPE_ISSUE
- : IssuableType.MergeRequest,
+ : TYPE_MERGE_REQUEST,
},
}),
});
@@ -590,7 +586,7 @@ function mountSidebarSeverityWidget() {
name: 'SidebarSeverityWidgetRoot',
apolloProvider,
provide: {
- canUpdate: editable,
+ canUpdate: parseBoolean(editable),
},
render: (createElement) =>
createElement(SidebarSeverityWidget, {
@@ -648,7 +644,7 @@ function mountCopyEmailToClipboard() {
});
}
-export function mountMoveIssuesButton() {
+export async function mountMoveIssuesButton() {
const el = document.querySelector('.js-move-issues');
if (!el) {
@@ -661,7 +657,7 @@ export function mountMoveIssuesButton() {
el,
name: 'MoveIssuesRoot',
apolloProvider: new VueApollo({
- defaultClient: gqlClient,
+ defaultClient: await gqlClient(),
}),
render: (createElement) =>
createElement(MoveIssuesButton, {
@@ -790,6 +786,21 @@ export function mountAssigneesDropdown() {
});
}
+function mountNewIssuePopover() {
+ const el = document.querySelector('.js-sidebar-header-popover');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
+ name: 'NewHeaderActionsPopover',
+ render: (createElement) =>
+ createElement(NewHeaderActionsPopover, { props: { issueType: TYPE_MERGE_REQUEST } }),
+ });
+}
+
const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
@@ -817,6 +828,7 @@ export function mountSidebar(mediator, store) {
mountSidebarSeverityWidget();
mountSidebarEscalationStatus();
mountMoveIssueButton();
+ mountNewIssuePopover();
}
export { getSidebarOptions };
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/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index baf906bb96c..ea3b3633ea7 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -1,3 +1,5 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
export default class SidebarStore {
constructor(options) {
if (!SidebarStore.singleton) {
@@ -12,7 +14,7 @@ export default class SidebarStore {
const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options;
this.currentUser = currentUser;
this.rootPath = rootPath;
- this.editable = editable;
+ this.editable = parseBoolean(editable);
this.timeEstimate = 0;
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js
index 6b90fb80abf..a61b4e4f066 100644
--- a/app/assets/javascripts/sidebar/utils.js
+++ b/app/assets/javascripts/sidebar/utils.js
@@ -12,7 +12,7 @@ export const updateGlobalTodoCount = (additionalTodoCount) => {
if (countContainer === null) return;
- const currentCount = parseInt(countContainer.innerText, 10);
+ const currentCount = parseInt(countContainer.innerText, 10) || 0;
const todoToggleEvent = new CustomEvent('todo:toggle', {
detail: {
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 6e5b2ce4dbe..bab167bb7e4 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,7 +1,5 @@
-/* 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';
@@ -66,7 +64,7 @@ export default class SingleFileDiff {
} else {
this.$chevronDownIcon.removeClass('gl-display-none');
this.$chevronRightIcon.addClass('gl-display-none');
- return this.getContentHTML(cb);
+ return this.getContentHTML(cb); // eslint-disable-line consistent-return
}
}
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 4a7528d9c8e..5e2f194e133 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -2,8 +2,8 @@
import { GlButton, GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui';
import eventHub from '~/blob/components/eventhub';
-import { createAlert } from '~/flash';
-import { redirectTo, joinPaths } from '~/lib/utils/url_utility';
+import { createAlert } from '~/alert';
+import { redirectTo, joinPaths } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, sprintf } from '~/locale';
import {
SNIPPET_MARK_EDIT_APP_START,
@@ -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,16 +190,16 @@ export default {
const errors = baseObj?.errors;
if (errors?.length) {
- this.flashAPIFailure(errors[0]);
+ this.alertAPIFailure(errors[0]);
} else {
- redirectTo(baseObj.snippet.webUrl);
+ redirectTo(baseObj.snippet.webUrl); // eslint-disable-line import/no-deprecated
}
})
.catch((e) => {
// 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) {
@@ -256,7 +256,7 @@ export default {
<form-footer-actions>
<template #prepend>
<gl-button
- class="js-no-auto-disable"
+ class="js-no-auto-disable gl-mr-2"
category="primary"
type="submit"
variant="confirm"
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_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index bac423f6838..3ce7ea231ff 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -35,11 +35,7 @@ export default {
<div class="js-collapsed" :class="{ 'd-none': value }">
<gl-form-input
class="form-control"
- :placeholder="
- s__(
- 'Snippets|Optionally add a description about what your snippet does or how to use it…',
- )
- "
+ :placeholder="s__('Snippets|Describe what your snippet does or how to use it…')"
data-qa-selector="description_placeholder"
/>
</div>
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/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
index e6aa3be0371..24dd978585c 100644
--- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue
@@ -42,7 +42,7 @@ export default {
<label>
{{ __('Visibility level') }}
<gl-link v-if="helpLink" :href="helpLink" target="_blank"
- ><gl-icon :size="12" name="question"
+ ><gl-icon :size="12" name="question-o"
/></gl-link>
</label>
<gl-form-group id="visibility-level-setting" class="gl-mb-0">
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..ad2111140a1 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -1,83 +1,216 @@
<script>
-import { GlAvatar, GlSearchBoxByType } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { contextSwitcherItems } from '../mock_data';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql';
+import { trackContextAccess, formatContextSwitcherItems } from '../utils';
+import { maxSize, applyMaxSize } from '../popper_max_size_modifier';
import NavItem from './nav_item.vue';
+import ProjectsList from './projects_list.vue';
+import GroupsList from './groups_list.vue';
+import ContextSwitcherToggle from './context_switcher_toggle.vue';
export default {
+ i18n: {
+ contextNavigation: s__('Navigation|Context navigation'),
+ switchTo: s__('Navigation|Switch context'),
+ searchPlaceholder: s__('Navigation|Search your projects or groups'),
+ searchingLabel: s__('Navigation|Retrieving search results'),
+ searchError: s__('Navigation|There was an error fetching search results.'),
+ },
+ apollo: {
+ groupsAndProjects: {
+ query: searchUserProjectsAndGroups,
+ manual: true,
+ variables() {
+ return {
+ username: this.username,
+ search: this.searchString,
+ };
+ },
+ result(response) {
+ this.hasError = false;
+ try {
+ const {
+ data: {
+ projects: { nodes: projects },
+ user: {
+ groups: { nodes: groups },
+ },
+ },
+ } = response;
+
+ this.projects = formatContextSwitcherItems(projects);
+ this.groups = formatContextSwitcherItems(groups);
+ } catch (e) {
+ this.handleError(e);
+ }
+ },
+ error(e) {
+ this.handleError(e);
+ },
+ skip() {
+ return !this.searchString;
+ },
+ },
+ },
components: {
- GlAvatar,
+ GlDisclosureDropdown,
+ ContextSwitcherToggle,
GlSearchBoxByType,
+ GlLoadingIcon,
+ GlAlert,
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: {
+ persistentLinks: {
+ type: Array,
+ required: true,
+ },
+ username: {
+ type: String,
+ required: true,
+ },
+ projectsPath: {
+ type: String,
+ required: true,
+ },
+ groupsPath: {
+ type: String,
+ required: true,
+ },
+ currentContext: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ contextHeader: {
+ type: Object,
+ required: true,
+ },
},
- contextSwitcherItems,
- viewAllProjectsItem: {
- title: s__('Navigation|View all projects'),
- link: '/projects',
- icon: 'project',
+ data() {
+ return {
+ searchString: '',
+ projects: [],
+ groups: [],
+ hasError: false,
+ isOpen: false,
+ };
},
- viewAllGroupsItem: {
- title: s__('Navigation|View all groups'),
- link: '/groups',
- icon: 'group',
+ computed: {
+ isSearch() {
+ return Boolean(this.searchString);
+ },
+ isSearching() {
+ return this.$apollo.queries.groupsAndProjects.loading;
+ },
+ },
+ watch: {
+ isOpen(isOpen) {
+ this.$emit('toggle', isOpen);
+
+ if (isOpen) {
+ this.focusInput();
+ }
+ },
+ },
+ created() {
+ if (this.currentContext.namespace) {
+ trackContextAccess(this.username, this.currentContext);
+ }
+ },
+ methods: {
+ close() {
+ this.$refs['disclosure-dropdown'].close();
+ },
+ focusInput() {
+ this.$refs['search-box'].focusInput();
+ },
+ handleError(e) {
+ Sentry.captureException(e);
+ this.hasError = true;
+ },
+ onDisclosureDropdownShown() {
+ this.isOpen = true;
+ },
+ onDisclosureDropdownHidden() {
+ this.isOpen = false;
+ },
+ },
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ popperOptions: {
+ modifiers: [maxSize, applyMaxSize],
},
};
</script>
<template>
- <div>
- <gl-search-box-by-type />
- <nav :aria-label="$options.i18n.contextNavigation">
- <ul class="gl-p-0 gl-list-style-none">
- <li>
- <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
- {{ $options.i18n.switchTo }}
- </div>
- <ul :aria-label="$options.i18n.switchTo" class="gl-p-0">
- <nav-item :item="$options.contextSwitcherItems.yourWork" />
- </ul>
- </li>
- <li>
- <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
- {{ $options.i18n.recentProjects }}
- </div>
- <ul :aria-label="$options.i18n.recentProjects" class="gl-p-0">
- <nav-item
- v-for="project in $options.contextSwitcherItems.recentProjects"
- :key="project.title"
- :item="project"
- >
- <template #icon>
- <gl-avatar shape="rect" :size="32" :src="project.avatar" />
- </template>
- </nav-item>
- <nav-item :item="$options.viewAllProjectsItem" />
- </ul>
- </li>
- <li>
- <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3">
- {{ $options.i18n.recentGroups }}
- </div>
- <ul :aria-label="$options.i18n.recentGroups" class="gl-p-0">
+ <gl-disclosure-dropdown
+ ref="disclosure-dropdown"
+ class="context-switcher gl-w-full"
+ placement="center"
+ :popper-options="$options.popperOptions"
+ @shown="onDisclosureDropdownShown"
+ @hidden="onDisclosureDropdownHidden"
+ >
+ <template #toggle>
+ <context-switcher-toggle :context="contextHeader" :expanded="isOpen" />
+ </template>
+ <div aria-hidden="true" class="gl-font-sm gl-font-weight-bold gl-px-4 gl-pt-3 gl-pb-4">
+ {{ $options.i18n.switchTo }}
+ </div>
+ <div class="gl-p-1 gl-border-t gl-border-b gl-border-gray-50 gl-bg-white">
+ <gl-search-box-by-type
+ ref="search-box"
+ v-model="searchString"
+ class="context-switcher-search-box"
+ :placeholder="$options.i18n.searchPlaceholder"
+ :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS"
+ borderless
+ />
+ </div>
+ <gl-loading-icon
+ v-if="isSearching"
+ class="gl-mt-5"
+ size="md"
+ :label="$options.i18n.searchingLabel"
+ />
+ <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2">
+ {{ $options.i18n.searchError }}
+ </gl-alert>
+ <nav v-else :aria-label="$options.i18n.contextNavigation" data-qa-selector="context_navigation">
+ <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+ <li v-if="!isSearch">
+ <ul
+ :aria-label="$options.i18n.switchTo"
+ class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2"
+ >
<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" />
+ v-for="item in persistentLinks"
+ :key="item.link"
+ :item="item"
+ :link-classes="{ [item.link_classes]: item.link_classes }"
+ />
</ul>
</li>
+ <projects-list
+ :username="username"
+ :view-all-link="projectsPath"
+ :is-search="isSearch"
+ :search-results="projects"
+ />
+ <groups-list
+ class="gl-border-t gl-border-gray-50"
+ :username="username"
+ :view-all-link="groupsPath"
+ :is-search="isSearch"
+ :search-results="groups"
+ />
</ul>
</nav>
- </div>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
index b6f058f7aee..cfb7e7732e9 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTruncate, GlAvatar, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui';
+import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui';
export default {
components: {
@@ -7,10 +7,10 @@ export default {
GlAvatar,
GlIcon,
},
- directives: {
- CollapseToggle: GlCollapseToggleDirective,
- },
props: {
+ /*
+ * Contains metadata about the current view, e.g. `id`, `title` and `avatar`
+ */
context: {
type: Object,
required: true,
@@ -24,22 +24,39 @@ export default {
collapseIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
+ avatarShape() {
+ return this.context.avatar_shape || 'rect';
+ },
},
};
</script>
<template>
<button
- v-collapse-toggle.context-switcher
type="button"
- class="context-switcher-toggle gl-bg-transparent gl-border-0 border-top border-bottom gl-border-gray-a-08 gl-box-shadow-none gl-display-flex gl-align-items-center gl-font-weight-bold gl-w-full gl-pl-3 gl-pr-5 gl-h-8"
+ class="context-switcher-toggle gl-p-0 gl-bg-transparent gl-hover-bg-t-gray-a-08 gl-focus-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-flex-shrink-0"
+ data-qa-selector="context_switcher"
>
- <gl-avatar :size="32" shape="rect" :src="context.avatar" class="gl-mr-3" />
- <div class="gl-overflow-auto">
+ <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 class="gl-text-gray-700" :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-text-gray-900">
<gl-truncate :text="context.title" />
</div>
- <span class="gl-flex-grow-1 gl-text-right">
- <gl-icon :name="collapseIcon" />
+ <span class="gl-flex-grow-1 gl-text-right gl-mr-4">
+ <gl-icon class="gl-text-gray-400" :name="collapseIcon" />
</span>
</button>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
index 62a1e5a6b20..a6f19ff95f3 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { highCountTrim } from '~/lib/utils/text_utility';
export default {
components: {
@@ -7,7 +8,7 @@ export default {
},
props: {
count: {
- type: Number,
+ type: [Number, String],
required: true,
},
href: {
@@ -31,6 +32,12 @@ export default {
component() {
return this.href ? 'a' : 'button';
},
+ formattedCount() {
+ if (Number.isFinite(this.count)) {
+ return highCountTrim(this.count);
+ }
+ return this.count;
+ },
},
};
</script>
@@ -40,9 +47,9 @@ 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-focus--focus"
>
<gl-icon aria-hidden="true" :name="icon" />
- <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span>
+ <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ formattedCount }}</span>
</component>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
index e92a6cbf5f5..fa6056aff5e 100644
--- a/app/assets/javascripts/super_sidebar/components/create_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -1,11 +1,28 @@
<script>
-import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui';
+import {
+ GlDisclosureDropdown,
+ GlTooltip,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { __ } from '~/locale';
+import {
+ TOP_NAV_INVITE_MEMBERS_COMPONENT,
+ TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
+} from '~/invite_members/constants';
+import { DROPDOWN_Y_OFFSET } from '../constants';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -147;
export default {
components: {
GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
GlTooltip,
+ InviteMembersTrigger,
},
i18n: {
createNew: __('Create new...'),
@@ -16,22 +33,74 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ dropdownOpen: false,
+ };
+ },
+ methods: {
+ isInvitedMembers(groupItem) {
+ return groupItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT;
+ },
+ closeAndFocus() {
+ this.$refs.dropdown.closeAndFocus();
+ },
+ },
toggleId: 'create-menu-toggle',
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
+ },
+ TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
};
</script>
<template>
<div>
<gl-disclosure-dropdown
+ ref="dropdown"
category="tertiary"
icon="plus"
- :items="groups"
no-caret
text-sr-only
:toggle-text="$options.i18n.createNew"
:toggle-id="$options.toggleId"
- />
- <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar">
+ :popper-options="$options.popperOptions"
+ data-qa-selector="new_menu_toggle"
+ data-testid="new-menu-toggle"
+ @shown="dropdownOpen = true"
+ @hidden="dropdownOpen = false"
+ >
+ <gl-disclosure-dropdown-group
+ v-for="(group, index) in groups"
+ :key="group.name"
+ :bordered="index !== 0"
+ :group="group"
+ >
+ <template v-for="groupItem in group.items">
+ <invite-members-trigger
+ v-if="isInvitedMembers(groupItem)"
+ :key="`${groupItem.text}-trigger`"
+ trigger-source="top-nav"
+ :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN"
+ @modal-opened="closeAndFocus"
+ />
+ <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" />
+ </template>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
+ <gl-tooltip
+ v-if="!dropdownOpen"
+ :target="`#${$options.toggleId}`"
+ placement="bottom"
+ container="#super-sidebar"
+ >
{{ $options.i18n.createNew }}
</gl-tooltip>
</div>
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..11bf2ddbd30
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -0,0 +1,96 @@
+<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);
+ }
+ },
+ handleItemRemove(item) {
+ try {
+ // Remove item from local storage
+ const parsedCachedFrequentItems = JSON.parse(localStorage.getItem(this.storageKey));
+ localStorage.setItem(
+ this.storageKey,
+ JSON.stringify(parsedCachedFrequentItems.filter((i) => i.id !== item.id)),
+ );
+
+ // Update the list
+ this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-3">
+ <div
+ data-testid="list-title"
+ aria-hidden="true"
+ class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-semibold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ >
+ <span class="gl-flex-grow-1 gl-px-3">{{ title }}</span>
+ </div>
+ <div
+ v-if="isEmpty"
+ data-testid="empty-text"
+ class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3"
+ >
+ {{ pristineText }}
+ </div>
+ <items-list :aria-label="title" :items="cachedFrequentItems" @remove-item="handleItemRemove">
+ <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..55c28661440
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue
@@ -0,0 +1,316 @@
+<script>
+import {
+ GlSearchBoxByType,
+ GlOutsideDirective as Outside,
+ GlIcon,
+ GlToken,
+ GlTooltipDirective,
+ GlResizeObserverDirective,
+ GlModal,
+} from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { debounce, clamp } from 'lodash';
+import { truncate } from '~/lib/utils/text_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { sprintf } from '~/locale';
+import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys';
+import {
+ MIN_SEARCH_TERM,
+ SEARCH_GITLAB,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+} from '~/vue_shared/global_search/constants';
+import {
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ SCOPE_TOKEN_MAX_LENGTH,
+ INPUT_FIELD_PADDING,
+ IS_SEARCHING,
+ SEARCH_MODAL_ID,
+ SEARCH_INPUT_SELECTOR,
+ SEARCH_RESULTS_ITEM_SELECTOR,
+} from '../constants';
+import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue';
+import GlobalSearchDefaultItems from './global_search_default_items.vue';
+import GlobalSearchScopedItems from './global_search_scoped_items.vue';
+
+export default {
+ name: 'GlobalSearchModal',
+ SEARCH_MODAL_ID,
+ i18n: {
+ SEARCH_GITLAB,
+ SEARCH_DESCRIBED_BY_WITH_RESULTS,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ MIN_SEARCH_TERM,
+ },
+ directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
+ components: {
+ GlSearchBoxByType,
+ GlobalSearchDefaultItems,
+ GlobalSearchScopedItems,
+ GlobalSearchAutocompleteItems,
+ GlIcon,
+ GlToken,
+ GlModal,
+ },
+ computed: {
+ ...mapState(['search', 'loading', 'searchContext']),
+ ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']),
+ searchText: {
+ get() {
+ return this.search;
+ },
+ set(value) {
+ this.setSearch(value);
+ },
+ },
+ showDefaultItems() {
+ return !this.searchText;
+ },
+ searchTermOverMin() {
+ return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
+ },
+ showScopedSearchItems() {
+ return this.searchTermOverMin && this.scopedSearchOptions.length > 1;
+ },
+ searchResultsDescription() {
+ if (this.showDefaultItems) {
+ return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
+ count: this.searchOptions.length,
+ });
+ }
+
+ if (!this.searchTermOverMin) {
+ return this.$options.i18n.MIN_SEARCH_TERM;
+ }
+
+ 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,
+ };
+ },
+ showScopeHelp() {
+ return this.searchTermOverMin;
+ },
+ 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']),
+ getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
+ 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`;
+ },
+ getFocusableOptions() {
+ return Array.from(
+ this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [],
+ );
+ },
+ onKeydown(event) {
+ const { code, target } = event;
+
+ let stop = true;
+
+ const elements = this.getFocusableOptions();
+ if (elements.length < 1) return;
+
+ const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR);
+
+ if (code === HOME_KEY) {
+ this.focusItem(0, elements);
+ } else if (code === END_KEY) {
+ this.focusItem(elements.length - 1, elements);
+ } else if (code === ARROW_UP_KEY) {
+ if (isSearchInput) return;
+
+ if (elements.indexOf(target) === 0) {
+ this.focusSearchInput();
+ return;
+ }
+ this.focusNextItem(event, elements, -1);
+ } else if (code === ARROW_DOWN_KEY) {
+ this.focusNextItem(event, elements, 1);
+ } else if (code === ESC_KEY) {
+ this.$refs.searchModal.close();
+ } else {
+ stop = false;
+ }
+
+ if (stop) {
+ event.preventDefault();
+ }
+ },
+ focusSearchInput() {
+ this.$refs.searchInputBox.$el.querySelector('input').focus();
+ },
+ focusNextItem(event, elements, offset) {
+ const { target } = event;
+ const currentIndex = elements.indexOf(target);
+ const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1);
+
+ this.focusItem(nextIndex, elements);
+ },
+ focusItem(index, elements) {
+ this.nextFocusedItemIndex = index;
+
+ elements[index]?.focus();
+ },
+ submitSearch() {
+ if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
+ return;
+ }
+ visitUrl(this.searchQuery);
+ },
+ },
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="searchModal"
+ :modal-id="$options.SEARCH_MODAL_ID"
+ hide-header
+ hide-footer
+ hide-header-close
+ scrollable
+ body-class="gl-p-0!"
+ modal-class="global-search-modal"
+ :centered="false"
+ @hidden="$emit('hidden')"
+ @shown="$emit('shown')"
+ >
+ <form
+ role="search"
+ :aria-label="$options.i18n.SEARCH_GITLAB"
+ class="gl-relative gl-rounded-base gl-w-full"
+ :class="searchBarClasses"
+ data-testid="global-search-form"
+ >
+ <div class="gl-p-1">
+ <gl-search-box-by-type
+ id="search"
+ ref="searchInputBox"
+ v-model="searchText"
+ role="searchbox"
+ data-testid="global-search-input"
+ data-qa-selector="global_search_input"
+ autocomplete="off"
+ :placeholder="$options.i18n.SEARCH_GITLAB"
+ :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
+ borderless
+ @input="getAutocompleteOptions"
+ @keydown.enter.stop.prevent="submitSearch"
+ @keydown="onKeydown"
+ />
+ <gl-token
+ v-if="showScopeHelp"
+ v-gl-resize-observer-directive="observeTokenWidth"
+ class="in-search-scope-help gl-sm-display-block gl-display-none"
+ view-only
+ :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>
+ <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">
+ {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }}
+ </span>
+ </div>
+ <span
+ role="region"
+ :data-testid="$options.SEARCH_RESULTS_DESCRIPTION"
+ class="gl-sr-only"
+ aria-live="polite"
+ aria-atomic="true"
+ >
+ {{ searchResultsDescription }}
+ </span>
+ <div
+ ref="resultsList"
+ data-testid="global-search-results"
+ class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2"
+ @keydown="onKeydown"
+ >
+ <global-search-default-items v-if="showDefaultItems" />
+ <template v-else>
+ <global-search-scoped-items v-if="showScopedSearchItems" />
+ <global-search-autocomplete-items />
+ </template>
+ </div>
+
+ <template v-if="searchContext">
+ <input
+ v-if="searchContext.group"
+ type="hidden"
+ name="group_id"
+ :value="searchContext.group.id"
+ />
+ <input
+ v-if="searchContext.project"
+ type="hidden"
+ name="project_id"
+ :value="searchContext.project.id"
+ />
+
+ <template v-if="searchContext.group || searchContext.project">
+ <input type="hidden" name="scope" :value="searchContext.scope" />
+ <input type="hidden" name="search_code" :value="searchContext.code_search" />
+ </template>
+
+ <input type="hidden" name="snippets" :value="searchContext.for_snippets" />
+ <input type="hidden" name="repository_ref" :value="searchContext.ref" />
+ </template>
+ </form>
+ </gl-modal>
+</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..cd623200b03
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } 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 { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'GlobalSearchAutocompleteItems',
+ i18n: {
+ AUTOCOMPLETE_ERROR_MESSAGE,
+ },
+ components: {
+ GlAvatar,
+ GlAlert,
+ GlLoadingIcon,
+ GlDisclosureDropdownGroup,
+ },
+ directives: {
+ SafeHtml,
+ },
+ computed: {
+ ...mapState(['search', 'loading', 'autocompleteError']),
+ ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']),
+ isPrecededByScopedOptions() {
+ return this.scopedSearchOptions.length > 1;
+ },
+ },
+ methods: {
+ highlightedName(val) {
+ return highlight(val, this.search);
+ },
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+};
+</script>
+
+<template>
+ <div>
+ <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none">
+ <gl-disclosure-dropdown-group
+ v-for="group in autocompleteGroupedSearchOptions"
+ :key="group.name"
+ :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }"
+ :group="group"
+ bordered
+ >
+ <template #list-item="{ item }">
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-avatar
+ v-if="item.avatar_url !== undefined"
+ class="gl-mr-3"
+ :src="item.avatar_url"
+ :entity-id="item.entity_id"
+ :entity-name="item.entity_name"
+ :size="item.avatar_size"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ aria-hidden="true"
+ />
+ <span class="gl-display-flex gl-flex-direction-column">
+ <span
+ v-safe-html="highlightedName(item.text)"
+ class="gl-text-gray-900"
+ data-testid="autocomplete-item-name"
+ ></span>
+ <span
+ v-if="item.value"
+ v-safe-html="item.namespace"
+ class="gl-font-sm gl-text-gray-500"
+ data-testid="autocomplete-item-namespace"
+ ></span>
+ </span>
+ </div>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </ul>
+
+ <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..239c61fd750
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlDisclosureDropdownGroup } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
+
+export default {
+ name: 'GlobalSearchDefaultItems',
+ i18n: {
+ ALL_GITLAB,
+ },
+ components: {
+ GlDisclosureDropdownGroup,
+ },
+ computed: {
+ ...mapState(['searchContext']),
+ ...mapGetters(['defaultSearchOptions']),
+ sectionHeader() {
+ return (
+ this.searchContext?.project?.name ||
+ this.searchContext?.group?.name ||
+ this.$options.i18n.ALL_GITLAB
+ );
+ },
+ defaultItemsGroup() {
+ return {
+ name: this.sectionHeader,
+ items: this.defaultSearchOptions,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-m-0 gl-list-style-none">
+ <gl-disclosure-dropdown-group :group="defaultItemsGroup" bordered class="gl-mt-0!" />
+ </ul>
+</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..76600f829f6
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { s__, sprintf } from '~/locale';
+import { truncate } from '~/lib/utils/text_utility';
+import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
+
+export default {
+ name: 'GlobalSearchScopedItems',
+ components: {
+ GlIcon,
+ GlToken,
+ GlDisclosureDropdownGroup,
+ },
+ computed: {
+ ...mapState(['search']),
+ ...mapGetters(['scopedSearchGroup']),
+ },
+ methods: {
+ titleLabel(item) {
+ return sprintf(s__('GlobalSearch|in %{scope}'), {
+ search: this.search,
+ scope: item.scope || item.description,
+ });
+ },
+ getTruncatedScope(scope) {
+ return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none">
+ <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!">
+ <template #list-item="{ item }">
+ <span
+ class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full"
+ >
+ <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" />
+ <span class="gl-flex-grow-1">
+ <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only>
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" />
+ <span>{{ getTruncatedScope(titleLabel(item)) }}</span>
+ </gl-token>
+ {{ search }}
+ </span>
+ </span>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </ul>
+ </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..cb267df6122
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js
@@ -0,0 +1,28 @@
+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 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 = 84;
+
+export const IS_SEARCHING = 'is-searching';
+
+export const FETCH_TYPES = ['generic', 'search'];
+export const SEARCH_MODAL_ID = 'super-sidebar-search-modal';
+
+export const SEARCH_INPUT_SELECTOR = '.gl-search-box-by-type-input-borderless';
+
+export const SEARCH_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item';
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..4a42f416206
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js
@@ -0,0 +1,222 @@
+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,
+ SEARCH_RESULTS_ORDER,
+} from '~/vue_shared/global_search/constants';
+import { getFormattedItem } from '../utils';
+
+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 = [
+ {
+ text: MSG_ISSUES_ASSIGNED_TO_ME,
+ href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: MSG_ISSUES_IVE_CREATED,
+ href: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+ },
+ ];
+
+ const mergeRequests = [
+ {
+ text: MSG_MR_ASSIGNED_TO_ME,
+ href: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+ },
+ {
+ text: MSG_MR_IM_REVIEWER,
+ href: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+ },
+ {
+ text: MSG_MR_IVE_CREATED,
+ href: `${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 items = [];
+
+ if (state.searchContext?.project) {
+ items.push({
+ text: 'scoped-in-project',
+ scope: state.searchContext.project?.name || '',
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ href: getters.projectUrl,
+ });
+ }
+
+ if (state.searchContext?.group) {
+ items.push({
+ text: 'scoped-in-group',
+ scope: state.searchContext.group?.name || '',
+ scopeCategory: GROUPS_CATEGORY,
+ icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
+ href: getters.groupUrl,
+ });
+ }
+
+ items.push({
+ text: 'scoped-in-all',
+ description: MSG_IN_ALL_GITLAB,
+ href: getters.allUrl,
+ });
+
+ return items;
+};
+
+export const scopedSearchGroup = (state, getters) => {
+ const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : [];
+ return { items };
+};
+
+export const autocompleteGroupedSearchOptions = (state) => {
+ const groupedOptions = {};
+ const results = [];
+
+ state.autocompleteOptions.forEach((item) => {
+ const group = groupedOptions[item.category];
+ const formattedItem = getFormattedItem(item, state.searchContext);
+
+ if (group) {
+ group.items.push(formattedItem);
+ } else {
+ groupedOptions[item.category] = {
+ name: formattedItem.category,
+ items: [formattedItem],
+ };
+
+ results.push(groupedOptions[formattedItem.category]);
+ }
+ });
+
+ return results.sort(
+ (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name),
+ );
+};
+
+export const searchOptions = (state, getters) => {
+ if (!state.search) {
+ return getters.defaultSearchOptions;
+ }
+
+ const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
+ (items, group) => {
+ return [...items, ...group.items];
+ },
+ [],
+ );
+
+ 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..d7d9ebecd16
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js
@@ -0,0 +1,5 @@
+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..9936c3f59d8
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js
@@ -0,0 +1,26 @@
+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);
+ 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/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js
new file mode 100644
index 00000000000..11d1fa1ab95
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js
@@ -0,0 +1,81 @@
+import { pickBy } from 'lodash';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+import {
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+} from '~/vue_shared/global_search/constants';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants';
+
+const getTruncatedNamespace = (string) => {
+ if (string.split(' / ').length > 2) {
+ return truncateNamespace(string);
+ }
+
+ return string;
+};
+const getAvatarSize = (category) => {
+ if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) {
+ return LARGE_AVATAR_PX;
+ }
+
+ return SMALL_AVATAR_PX;
+};
+
+const getEntityId = (item, searchContext) => {
+ switch (item.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return item.group_id || item.id || searchContext?.group?.id;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return item.project_id || item.id || searchContext?.project?.id;
+ default:
+ return item.id;
+ }
+};
+const getEntityName = (item, searchContext) => {
+ switch (item.category) {
+ case GROUPS_CATEGORY:
+ case RECENT_EPICS_CATEGORY:
+ return item.group_name || item.value || item.label || searchContext?.group?.name;
+ case PROJECTS_CATEGORY:
+ case ISSUES_CATEGORY:
+ case MERGE_REQUEST_CATEGORY:
+ return item.project_name || item.value || item.label || searchContext?.project?.name;
+ default:
+ return item.label;
+ }
+};
+
+export const getFormattedItem = (item, searchContext) => {
+ const { id, category, value, label, url: href, avatar_url } = item;
+ let namespace;
+ const text = value || label;
+ if (value) {
+ namespace = getTruncatedNamespace(label);
+ }
+ const avatarSize = getAvatarSize(category);
+ const entityId = getEntityId(item, searchContext);
+ const entityName = getEntityName(item, searchContext);
+
+ return pickBy(
+ {
+ id,
+ category,
+ value,
+ label,
+ text,
+ href,
+ avatar_url,
+ avatar_size: avatarSize,
+ namespace,
+ entity_id: entityId,
+ entity_name: entityName,
+ },
+ (val) => val !== undefined,
+ );
+};
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..4fa15f1cd76
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue
@@ -0,0 +1,81 @@
+<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`;
+ },
+ viewAllProps() {
+ return {
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your groups'),
+ icon: 'group',
+ },
+ linkClasses: { 'dashboard-shortcuts-groups': true },
+ };
+ },
+ },
+ i18n: {
+ title: s__('Navigation|Frequently visited 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 v-bind="viewAllProps" />
+ </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 v-bind="viewAllProps" />
+ </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..1fffbb05d03 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -1,19 +1,32 @@
<script>
-import { GlBadge, GlButton, GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
+import {
+ GlBadge,
+ GlButton,
+ GlIcon,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+} from '@gitlab/ui';
import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
import { STORAGE_KEY } from '~/whats_new/utils/notification';
+import Tracking from '~/tracking';
+import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS, helpCenterState } from '../constants';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -4;
export default {
components: {
GlBadge,
GlButton,
+ GlIcon,
GlDisclosureDropdown,
GlDisclosureDropdownGroup,
GitlabVersionCheckBadge,
},
+ mixins: [Tracking.mixin({ property: 'nav_help_menu' })],
i18n: {
help: __('Help'),
support: __('Support'),
@@ -25,6 +38,7 @@ export default {
shortcuts: __('Keyboard shortcuts'),
version: __('Your GitLab version'),
whatsnew: __("What's new"),
+ chat: s__('TanukiBot|Ask GitLab Chat'),
},
props: {
sidebarData: {
@@ -35,6 +49,7 @@ export default {
data() {
return {
showWhatsNewNotification: this.shouldShowWhatsNewNotification(),
+ helpCenterState,
};
},
computed: {
@@ -46,28 +61,84 @@ export default {
text: this.$options.i18n.version,
href: helpPagePath('update/index'),
version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`,
+ extraAttrs: {
+ ...this.trackingAttrs('version_help_dropdown'),
+ },
},
],
},
helpLinks: {
items: [
- { text: this.$options.i18n.help, href: helpPagePath() },
- { text: this.$options.i18n.support, href: this.sidebarData.support_path },
- { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' },
- { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` },
- { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' },
+ this.sidebarData.show_tanuki_bot && {
+ icon: 'tanuki',
+ text: this.$options.i18n.chat,
+ action: this.showTanukiBotChat,
+ extraAttrs: {
+ ...this.trackingAttrs('tanuki_bot_help_dropdown'),
+ },
+ },
+ {
+ text: this.$options.i18n.help,
+ href: helpPagePath(),
+ extraAttrs: {
+ ...this.trackingAttrs('help'),
+ },
+ },
+ {
+ text: this.$options.i18n.support,
+ href: this.sidebarData.support_path,
+ extraAttrs: {
+ ...this.trackingAttrs('support'),
+ },
+ },
+ {
+ text: this.$options.i18n.docs,
+ href: `https://docs.${DOMAIN}`,
+ extraAttrs: {
+ ...this.trackingAttrs('gitlab_documentation'),
+ },
+ },
+ {
+ text: this.$options.i18n.plans,
+ href: `${PROMO_URL}/pricing`,
+ extraAttrs: {
+ ...this.trackingAttrs('compare_gitlab_plans'),
+ },
+ },
+ {
+ text: this.$options.i18n.forum,
+ href: `https://forum.${DOMAIN}/`,
+ extraAttrs: {
+ ...this.trackingAttrs('community_forum'),
+ },
+ },
{
text: this.$options.i18n.contribute,
href: helpPagePath('', { anchor: 'contributing-to-gitlab' }),
+ extraAttrs: {
+ ...this.trackingAttrs('contribute_to_gitlab'),
+ },
},
- { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' },
- ],
+ {
+ text: this.$options.i18n.feedback,
+ href: `${PROMO_URL}/submit-feedback`,
+ extraAttrs: {
+ ...this.trackingAttrs('submit_feedback'),
+ },
+ },
+ ].filter(Boolean),
},
helpActions: {
items: [
{
text: this.$options.i18n.shortcuts,
action: this.showKeyboardShortcuts,
+ extraAttrs: {
+ class: 'js-shortcuts-modal-trigger',
+ 'data-track-action': 'click_button',
+ 'data-track-label': 'keyboard_shortcuts_help',
+ 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'],
+ },
shortcut: '?',
},
this.sidebarData.display_whats_new && {
@@ -76,6 +147,11 @@ export default {
count:
this.showWhatsNewNotification &&
this.sidebarData.whats_new_most_recent_release_items_count,
+ extraAttrs: {
+ 'data-track-action': 'click_button',
+ 'data-track-label': 'whats_new',
+ 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'],
+ },
},
].filter(Boolean),
},
@@ -96,15 +172,14 @@ export default {
return true;
},
- handleAction({ action }) {
- if (action) {
- action();
- }
+ showKeyboardShortcuts() {
+ this.$refs.dropdown.close();
},
- showKeyboardShortcuts() {
+ showTanukiBotChat() {
this.$refs.dropdown.close();
- window?.toggleShortcutsHelp();
+
+ this.helpCenterState.showTanukiBotChatDrawer = true;
},
async showWhatsNew() {
@@ -122,15 +197,43 @@ export default {
this.toggleWhatsNewDrawer();
}
},
+
+ trackingAttrs(label) {
+ return {
+ ...HELP_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': label,
+ };
+ },
+
+ trackDropdownToggle(show) {
+ this.track('click_toggle', {
+ label: show ? 'show_help_dropdown' : 'hide_help_dropdown',
+ });
+ },
+ },
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
},
};
</script>
<template>
- <gl-disclosure-dropdown ref="dropdown">
+ <gl-disclosure-dropdown
+ ref="dropdown"
+ :popper-options="$options.popperOptions"
+ @shown="trackDropdownToggle(true)"
+ @hidden="trackDropdownToggle(false)"
+ >
<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 +243,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,25 +252,31 @@ 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>
<gl-disclosure-dropdown-group
:group="itemGroups.helpLinks"
:bordered="sidebarData.show_version_check"
- />
+ >
+ <template #list-item="{ item }">
+ <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ {{ item.text }}
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-orange-500" />
+ </span>
+ </template>
+ </gl-disclosure-dropdown-group>
- <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..ef27251dc6c
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ components: {
+ GlButton,
+ ProjectAvatar,
+ NavItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ 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>
+ <template #actions>
+ <gl-button
+ v-gl-tooltip.right.viewport
+ size="small"
+ category="tertiary"
+ icon="dash"
+ :aria-label="__('Remove')"
+ :title="__('Remove')"
+ class="gl-align-self-center gl-p-1! gl-absolute gl-right-4"
+ data-testid="item-remove"
+ @click.stop.prevent="$emit('remove-item', item)"
+ />
+ </template>
+ </nav-item>
+ <slot name="view-all-items"></slot>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue
new file mode 100644
index 00000000000..93c249dffeb
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue
@@ -0,0 +1,122 @@
+<script>
+import { kebabCase } from 'lodash';
+import { GlCollapse, GlIcon } from '@gitlab/ui';
+import NavItem from './nav_item.vue';
+
+export default {
+ name: 'MenuSection',
+ components: {
+ GlCollapse,
+ GlIcon,
+ NavItem,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ separated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ tag: {
+ type: String,
+ required: false,
+ default: 'div',
+ },
+ },
+ data() {
+ return {
+ isExpanded: Boolean(this.expanded || this.item.is_active),
+ };
+ },
+ computed: {
+ buttonProps() {
+ return {
+ 'aria-controls': this.itemId,
+ 'aria-expanded': String(this.isExpanded),
+ 'data-qa-menu-item': this.item.title,
+ };
+ },
+ collapseIcon() {
+ return this.isExpanded ? 'chevron-up' : 'chevron-down';
+ },
+ computedLinkClasses() {
+ return {
+ 'gl-bg-t-gray-a-08': this.isActive,
+ };
+ },
+ isActive() {
+ return !this.isExpanded && this.item.is_active;
+ },
+ itemId() {
+ return kebabCase(this.item.title);
+ },
+ },
+ watch: {
+ isExpanded(newIsExpanded) {
+ this.$emit('collapse-toggle', newIsExpanded);
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="tag">
+ <hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" />
+ <button
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-appearance-none gl-border-0 gl-bg-transparent gl-text-left gl-w-full gl-focus--focus"
+ :class="computedLinkClasses"
+ data-qa-selector="menu_section_button"
+ :data-qa-section-name="item.title"
+ v-bind="buttonProps"
+ @click="isExpanded = !isExpanded"
+ >
+ <span
+ :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"
+ ></span>
+ <span class="gl-flex-shrink-0 gl-w-6 gl-mx-3">
+ <slot name="icon">
+ <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" />
+ </slot>
+ </span>
+
+ <span class="gl-pr-3 gl-text-gray-900 gl-truncate-end">
+ {{ item.title }}
+ </span>
+
+ <span class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-text-gray-400">
+ <gl-icon :name="collapseIcon" />
+ </span>
+ </button>
+
+ <gl-collapse
+ :id="itemId"
+ v-model="isExpanded"
+ :aria-label="item.title"
+ class="gl-list-style-none gl-p-0 gl-m-0 gl-transition-duration-medium gl-transition-timing-function-ease"
+ data-qa-selector="menu_section"
+ :data-qa-section-name="item.title"
+ tag="ul"
+ >
+ <slot>
+ <nav-item
+ v-for="subItem of item.items"
+ :key="`${item.title}-${subItem.title}`"
+ :item="subItem"
+ @pin-add="(itemId) => $emit('pin-add', itemId)"
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </slot>
+ </gl-collapse>
+ </component>
+</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..260c3906b93 100644
--- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
@@ -1,5 +1,6 @@
<script>
import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui';
+import { userCounts } from '~/super_sidebar/user_counts_manager';
export default {
components: {
@@ -13,28 +14,28 @@ export default {
},
},
methods: {
- navigate() {
- this.$refs.link.click();
+ getCount(item) {
+ return userCounts[item.userCount] ?? item.count ?? 0;
},
},
};
</script>
<template>
- <gl-disclosure-dropdown :items="items" placement="center" @action="navigate">
+ <gl-disclosure-dropdown
+ :items="items"
+ placement="center"
+ @shown="$emit('shown')"
+ @hidden="$emit('hidden')"
+ >
<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>
+ <gl-badge pill size="sm" variant="neutral">{{ getCount(item) }}</gl-badge>
+ </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..ec1c4069b1a 100644
--- a/app/assets/javascripts/super_sidebar/components/nav_item.vue
+++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue
@@ -1,37 +1,178 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import {
+ CLICK_MENU_ITEM_ACTION,
+ CLICK_PINNED_MENU_ITEM_ACTION,
+ TRACKING_UNKNOWN_ID,
+ TRACKING_UNKNOWN_PANEL,
+} from '~/super_sidebar/constants';
+import NavItemLink from './nav_item_link.vue';
+import NavItemRouterLink from './nav_item_router_link.vue';
export default {
+ i18n: {
+ pinItem: s__('Navigation|Pin item'),
+ unpinItem: s__('Navigation|Unpin item'),
+ },
name: 'NavItem',
components: {
+ GlButton,
GlIcon,
+ GlBadge,
+ NavItemLink,
+ NavItemRouterLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ pinnedItemIds: { default: { ids: [] } },
+ panelSupportsPins: { default: false },
+ panelType: { default: '' },
},
props: {
+ isInPinnedSection: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isStatic: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
item: {
type: Object,
required: true,
},
+ linkClasses: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ pillData() {
+ return this.item.pill_count;
+ },
+ hasPill() {
+ return (
+ Number.isFinite(this.pillData) ||
+ (typeof this.pillData === 'string' && this.pillData !== '')
+ );
+ },
+ isPinnable() {
+ return this.panelSupportsPins && !this.isStatic;
+ },
+ isPinned() {
+ return this.pinnedItemIds.ids.includes(this.item.id);
+ },
+ trackingProps() {
+ // Set extra event data to debug missing IDs / Panel Types
+ const extraData =
+ !this.item.id || !this.panelType
+ ? { 'data-track-extra': JSON.stringify({ title: this.item.title }) }
+ : {};
+
+ return {
+ 'data-track-action': this.isInPinnedSection
+ ? CLICK_PINNED_MENU_ITEM_ACTION
+ : CLICK_MENU_ITEM_ACTION,
+ 'data-track-label': this.item.id ?? TRACKING_UNKNOWN_ID,
+ 'data-track-property': this.panelType
+ ? `nav_panel_${this.panelType}`
+ : TRACKING_UNKNOWN_PANEL,
+ ...extraData,
+ };
+ },
+ linkProps() {
+ return {
+ ...this.$attrs,
+ ...this.trackingProps,
+ item: this.item,
+ 'data-qa-submenu-item': this.item.title,
+ 'data-method': this.item.data_method ?? null,
+ };
+ },
+ computedLinkClasses() {
+ return {
+ 'gl-py-2': this.isPinnable,
+ 'gl-py-3': !this.isPinnable,
+ [this.item.link_classes]: this.item.link_classes,
+ ...this.linkClasses,
+ };
+ },
+ navItemLinkComponent() {
+ return this.item.to ? NavItemRouterLink : NavItemLink;
+ },
},
};
</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="navItemLinkComponent"
+ #default="{ isActive }"
+ v-bind="linkProps"
+ class="nav-item-link gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus"
+ :class="computedLinkClasses"
+ data-qa-selector="nav_item_link"
+ data-testid="nav-item-link"
>
- <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"
+ data-testid="active-indicator"
+ ></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 item-icon" />
+ <gl-icon
+ v-else-if="isInPinnedSection"
+ name="grip"
+ class="gl-text-gray-400 gl-ml-2 draggable-icon"
+ />
</slot>
</div>
- <div class="gl-pr-3">
+ <div class="gl-pr-8 gl-text-gray-900 gl-truncate-end">
{{ 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 gl-truncate-end">
{{ item.subtitle }}
</div>
</div>
- </a>
+ <slot name="actions"></slot>
+ <span v-if="hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-relative">
+ <gl-badge
+ v-if="hasPill"
+ size="sm"
+ variant="neutral"
+ :class="{ 'nav-item-badge gl-absolute gl-right-0 gl-top-2': isPinnable }"
+ >
+ {{ pillData }}
+ </gl-badge>
+ <gl-button
+ v-if="isPinnable && !isPinned"
+ v-gl-tooltip.right.viewport="$options.i18n.pinItem"
+ size="small"
+ category="tertiary"
+ icon="thumbtack"
+ :aria-label="$options.i18n.pinItem"
+ @click.prevent="$emit('pin-add', item.id)"
+ />
+ <gl-button
+ v-else-if="isPinnable && isPinned"
+ v-gl-tooltip.right.viewport="$options.i18n.unpinItem"
+ size="small"
+ category="tertiary"
+ :aria-label="$options.i18n.unpinItem"
+ icon="thumbtack-solid"
+ @click.prevent="$emit('pin-remove', item.id)"
+ />
+ </span>
+ </component>
</li>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item_link.vue b/app/assets/javascripts/super_sidebar/components/nav_item_link.vue
new file mode 100644
index 00000000000..8358e96db94
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/nav_item_link.vue
@@ -0,0 +1,35 @@
+<script>
+import { NAV_ITEM_LINK_ACTIVE_CLASS } from '../constants';
+import { ariaCurrent } from '../utils';
+
+export default {
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isActive() {
+ return this.item.is_active;
+ },
+ linkProps() {
+ return {
+ href: this.item.link,
+ 'aria-current': ariaCurrent(this.isActive),
+ };
+ },
+ computedLinkClasses() {
+ return {
+ [NAV_ITEM_LINK_ACTIVE_CLASS]: this.isActive,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <a v-bind="linkProps" :class="computedLinkClasses">
+ <slot :is-active="isActive"></slot>
+ </a>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue b/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue
new file mode 100644
index 00000000000..78aca24d9a6
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue
@@ -0,0 +1,37 @@
+<script>
+import { NAV_ITEM_LINK_ACTIVE_CLASS } from '../constants';
+import { ariaCurrent } from '../utils';
+
+export default {
+ NAV_ITEM_LINK_ACTIVE_CLASS,
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ linkProps() {
+ return {
+ to: this.item.to,
+ };
+ },
+ },
+ methods: {
+ ariaCurrent,
+ },
+};
+</script>
+
+<template>
+ <router-link
+ #default="{ href, navigate, isActive }"
+ v-bind="linkProps"
+ :active-class="$options.NAV_ITEM_LINK_ACTIVE_CLASS"
+ custom
+ >
+ <a :href="href" :aria-current="ariaCurrent(isActive)" @click="navigate">
+ <slot :is-active="isActive"></slot>
+ </a>
+ </router-link>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
new file mode 100644
index 00000000000..ccd739c8bb1
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue
@@ -0,0 +1,101 @@
+<script>
+import Draggable from 'vuedraggable';
+import { s__ } from '~/locale';
+import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../constants';
+import MenuSection from './menu_section.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ i18n: {
+ pinned: s__('Navigation|Pinned'),
+ emptyHint: s__('Navigation|Your pinned items appear here.'),
+ },
+ name: 'PinnedSection',
+ components: {
+ Draggable,
+ MenuSection,
+ NavItem,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ separated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false',
+ draggableItems: this.items,
+ };
+ },
+ computed: {
+ isActive() {
+ return this.items.some((item) => item.is_active);
+ },
+ sectionItem() {
+ return { title: this.$options.i18n.pinned, icon: 'thumbtack', is_active: this.isActive };
+ },
+ itemIds() {
+ return this.draggableItems.map((item) => item.id);
+ },
+ },
+ watch: {
+ expanded(newExpanded) {
+ setCookie(SIDEBAR_PINS_EXPANDED_COOKIE, newExpanded, {
+ expires: SIDEBAR_COOKIE_EXPIRATION,
+ });
+ },
+ items(newItems) {
+ this.draggableItems = newItems;
+ },
+ },
+ methods: {
+ handleDrag(event) {
+ if (event.oldIndex === event.newIndex) return;
+ this.$emit(
+ 'pin-reorder',
+ this.items[event.oldIndex].id,
+ this.items[event.newIndex].id,
+ event.oldIndex < event.newIndex,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <menu-section
+ :item="sectionItem"
+ :expanded="expanded"
+ :separated="separated"
+ @collapse-toggle="expanded = !expanded"
+ >
+ <draggable
+ v-if="items.length > 0"
+ v-model="draggableItems"
+ class="gl-p-0 gl-m-0"
+ data-testid="pinned-nav-items"
+ handle=".draggable-icon"
+ tag="ul"
+ @end="handleDrag"
+ >
+ <nav-item
+ v-for="item of draggableItems"
+ :key="item.id"
+ :item="item"
+ is-in-pinned-section
+ @pin-remove="(itemId) => $emit('pin-remove', itemId)"
+ />
+ </draggable>
+ <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem">
+ {{ $options.i18n.emptyHint }}
+ </li>
+ </menu-section>
+</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..78860e35eb1
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue
@@ -0,0 +1,82 @@
+<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`;
+ },
+ viewAllProps() {
+ return {
+ item: {
+ link: this.viewAllLink,
+ title: s__('Navigation|View all your projects'),
+ icon: 'project',
+ },
+ linkClasses: { 'dashboard-shortcuts-projects': true },
+ };
+ },
+ },
+ i18n: {
+ title: s__('Navigation|Frequently visited 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 v-bind="viewAllProps" />
+ </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 v-bind="viewAllProps" />
+ </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..ff933f341af
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/search_results.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui';
+import uniqueId from 'lodash/uniqueId';
+import ItemsList from './items_list.vue';
+
+export default {
+ components: {
+ GlCollapse,
+ GlIcon,
+ ItemsList,
+ },
+ directives: {
+ CollapseToggle: GlCollapseToggleDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ noResultsText: {
+ type: String,
+ required: true,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ expanded: true,
+ };
+ },
+ computed: {
+ isEmpty() {
+ return !this.searchResults.length;
+ },
+ collapseIcon() {
+ return this.expanded ? 'chevron-up' : 'chevron-down';
+ },
+ },
+ created() {
+ this.collapseId = uniqueId('expandable-section-');
+ },
+ buttonClasses: [
+ // Reset user agent styles
+ 'gl-appearance-none',
+ 'gl-border-0',
+ 'gl-bg-transparent',
+ // Text styles
+ 'gl-text-left',
+ 'gl-text-transform-uppercase',
+ 'gl-text-secondary',
+ 'gl-font-weight-semibold',
+ 'gl-font-xs',
+ 'gl-line-height-12',
+ 'gl-letter-spacing-06em',
+ // Border
+ 'gl-border-t',
+ 'gl-border-gray-50',
+ // Spacing
+ 'gl-my-3',
+ 'gl-pt-2',
+ // Layout
+ 'gl-display-flex',
+ 'gl-justify-content-space-between',
+ 'gl-align-items-center',
+ ],
+};
+</script>
+
+<template>
+ <li class="gl-border-t gl-border-gray-50">
+ <button
+ v-collapse-toggle="collapseId"
+ :class="$options.buttonClasses"
+ class="gl-mx-3"
+ data-testid="search-results-toggle"
+ >
+ {{ title }}
+ <gl-icon :name="collapseIcon" :size="16" />
+ </button>
+ <gl-collapse :id="collapseId" v-model="expanded">
+ <div
+ v-if="isEmpty"
+ data-testid="empty-text"
+ class="gl-text-gray-500 gl-font-sm gl-mb-3 gl-mx-4"
+ >
+ {{ noResultsText }}
+ </div>
+ <items-list :aria-label="title" :items="searchResults">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
+ </gl-collapse>
+ </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..08af9232107
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue
@@ -0,0 +1,178 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import axios from '~/lib/utils/axios_utils';
+import { PANELS_WITH_PINS } from '../constants';
+import NavItem from './nav_item.vue';
+import PinnedSection from './pinned_section.vue';
+import MenuSection from './menu_section.vue';
+
+export default {
+ name: 'SidebarMenu',
+ components: {
+ MenuSection,
+ NavItem,
+ PinnedSection,
+ },
+
+ provide() {
+ return {
+ pinnedItemIds: this.changedPinnedItemIds,
+ panelSupportsPins: this.supportsPins,
+ panelType: this.panelType,
+ };
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ pinnedItemIds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ panelType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatePinsUrl: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ // This is used as a provide and injected into the nav items.
+ // Note: It has to be an object to be reactive.
+ changedPinnedItemIds: { ids: this.pinnedItemIds },
+ };
+ },
+
+ computed: {
+ // Returns the list of items that we want to have static at the top.
+ // Only sidebars that support pins also support a static section.
+ staticItems() {
+ if (!this.supportsPins) return [];
+ return this.items.filter((item) => !item.items || item.items.length === 0);
+ },
+
+ // Returns only the items that aren't static at the top and makes sure no
+ // section shows as active (and expanded) when one of its items is pinned.
+ nonStaticItems() {
+ if (!this.supportsPins) return this.items;
+
+ return this.items
+ .filter((item) => item.items && item.items.length > 0)
+ .map((item) => {
+ const hasActivePinnedChild = item.items.some((childItem) => {
+ return childItem.is_active && this.changedPinnedItemIds.ids.includes(childItem.id);
+ });
+ const showAsActive = item.is_active && !hasActivePinnedChild;
+
+ return { ...item, is_active: showAsActive };
+ });
+ },
+
+ // Returns a flat list of all items that are in sections, but not the sections.
+ // Only items from sections (item.items) can be pinned.
+ flatPinnableItems() {
+ return this.nonStaticItems.flatMap((item) => item.items).filter(Boolean);
+ },
+
+ pinnedItems() {
+ return this.changedPinnedItemIds.ids
+ .map((id) => this.flatPinnableItems.find((item) => item.id === id))
+ .filter(Boolean);
+ },
+ supportsPins() {
+ return PANELS_WITH_PINS.includes(this.panelType);
+ },
+ hasStaticItems() {
+ return this.staticItems.length > 0;
+ },
+ },
+ methods: {
+ createPin(itemId) {
+ this.changedPinnedItemIds.ids.push(itemId);
+ this.updatePins();
+ },
+ destroyPin(itemId) {
+ this.changedPinnedItemIds.ids = this.changedPinnedItemIds.ids.filter((id) => id !== itemId);
+ this.updatePins();
+ },
+ movePin(fromId, toId, isDownwards) {
+ const fromIndex = this.changedPinnedItemIds.ids.indexOf(fromId);
+ this.changedPinnedItemIds.ids.splice(fromIndex, 1);
+
+ let toIndex = this.changedPinnedItemIds.ids.indexOf(toId);
+
+ // If the item was moved downwards, we insert it *after* the item it was dragged on to.
+ // This matches how vuedraggable previews the change while still dragging.
+ if (isDownwards) toIndex += 1;
+
+ this.changedPinnedItemIds.ids.splice(toIndex, 0, fromId);
+
+ this.updatePins();
+ },
+ updatePins() {
+ axios
+ .put(this.updatePinsUrl, {
+ panel: this.panelType,
+ menu_item_ids: this.changedPinnedItemIds.ids,
+ })
+ .then((response) => {
+ this.changedPinnedItemIds.ids = response.data;
+ })
+ .catch((e) => {
+ Sentry.captureException(e);
+ });
+ },
+ isSection(navItem) {
+ return navItem.items?.length;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav class="gl-p-2 gl-relative">
+ <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0">
+ <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static />
+ </ul>
+ <pinned-section
+ v-if="supportsPins"
+ separated
+ :items="pinnedItems"
+ @pin-remove="destroyPin"
+ @pin-reorder="movePin"
+ />
+ <hr
+ v-if="supportsPins"
+ aria-hidden="true"
+ class="gl-my-2 gl-mx-4"
+ data-testid="main-menu-separator"
+ />
+ <ul class="gl-p-0 gl-list-style-none">
+ <template v-for="item in nonStaticItems">
+ <menu-section
+ v-if="isSection(item)"
+ :key="item.id"
+ :item="item"
+ :separated="item.separated"
+ @pin-add="createPin"
+ @pin-remove="destroyPin"
+ />
+ <nav-item
+ v-else
+ :key="item.id"
+ :item="item"
+ tag="li"
+ @pin-add="createPin"
+ @pin-remove="destroyPin"
+ />
+ </template>
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
new file mode 100644
index 00000000000..9d2836e9dfa
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
@@ -0,0 +1,122 @@
+<script>
+import { getCssClassDimensions } from '~/lib/utils/css_utils';
+import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants';
+
+export const STATE_CLOSED = 'closed';
+export const STATE_WILL_OPEN = 'will-open';
+export const STATE_OPEN = 'open';
+export const STATE_WILL_CLOSE = 'will-close';
+
+export default {
+ name: 'SidebarPeek',
+ created() {
+ // Nothing needs to observe these properties, so they are not reactive.
+ this.state = null;
+ this.openTimer = null;
+ this.closeTimer = null;
+ this.xNearWindowEdge = null;
+ this.xSidebarEdge = null;
+ this.xAwayFromSidebar = null;
+ },
+ mounted() {
+ this.xNearWindowEdge = getCssClassDimensions('gl-w-3').width;
+ this.xSidebarEdge = getCssClassDimensions('super-sidebar').width;
+ this.xAwayFromSidebar = 2 * this.xSidebarEdge;
+ document.addEventListener('mousemove', this.onMouseMove);
+ document.documentElement.addEventListener('mouseleave', this.onDocumentLeave);
+ this.changeState(STATE_CLOSED);
+ },
+ beforeDestroy() {
+ document.removeEventListener('mousemove', this.onMouseMove);
+ document.documentElement.removeEventListener('mouseleave', this.onDocumentLeave);
+ this.clearTimers();
+ },
+ methods: {
+ /**
+ * Callback for document-wide mousemove events.
+ *
+ * Since mousemove events can fire frequently, it's important for this to
+ * do as little work as possible.
+ *
+ * When mousemove events fire within one of the defined regions, this ends
+ * up being a no-op. Only when the cursor moves from one region to another
+ * does this do any work: it sets a non-reactive instance property, maybe
+ * cancels/starts timers, and emits an event.
+ *
+ * @params {MouseEvent} event
+ */
+ onMouseMove({ clientX }) {
+ if (this.state === STATE_CLOSED) {
+ if (clientX < this.xNearWindowEdge) {
+ this.willOpen();
+ }
+ } else if (this.state === STATE_WILL_OPEN) {
+ if (clientX >= this.xNearWindowEdge) {
+ this.close();
+ }
+ } else if (this.state === STATE_OPEN) {
+ if (clientX >= this.xAwayFromSidebar) {
+ this.close();
+ } else if (clientX >= this.xSidebarEdge) {
+ this.willClose();
+ }
+ } else if (this.state === STATE_WILL_CLOSE) {
+ if (clientX >= this.xAwayFromSidebar) {
+ this.close();
+ } else if (clientX < this.xSidebarEdge) {
+ this.open();
+ }
+ }
+ },
+ onDocumentLeave() {
+ if (this.state === STATE_OPEN) {
+ this.willClose();
+ } else if (this.state === STATE_WILL_OPEN) {
+ this.close();
+ }
+ },
+ willClose() {
+ if (this.changeState(STATE_WILL_CLOSE)) {
+ this.closeTimer = setTimeout(this.close, SUPER_SIDEBAR_PEEK_CLOSE_DELAY);
+ }
+ },
+ willOpen() {
+ if (this.changeState(STATE_WILL_OPEN)) {
+ this.openTimer = setTimeout(this.open, SUPER_SIDEBAR_PEEK_OPEN_DELAY);
+ }
+ },
+ open() {
+ if (this.changeState(STATE_OPEN)) {
+ this.clearTimers();
+ }
+ },
+ close() {
+ if (this.changeState(STATE_CLOSED)) {
+ this.clearTimers();
+ }
+ },
+ clearTimers() {
+ clearTimeout(this.closeTimer);
+ clearTimeout(this.openTimer);
+ },
+ /**
+ * Switches to the new state, and emits a change event.
+ *
+ * If the given state is the current state, do nothing.
+ *
+ * @param {string} state The state to transition to.
+ * @returns {boolean} True if the state changed, false otherwise.
+ */
+ changeState(state) {
+ if (this.state === state) return false;
+
+ this.state = state;
+ this.$emit('change', state);
+ return true;
+ },
+ },
+ render() {
+ return null;
+ },
+};
+</script>
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..6b1efc4217c 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -1,20 +1,35 @@
<script>
-import { GlCollapse } from '@gitlab/ui';
-import { context } from '../mock_data';
+import { GlButton } from '@gitlab/ui';
+import { Mousetrap } from '~/lib/mousetrap';
+import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings';
+import { __ } from '~/locale';
+import { sidebarState } from '../constants';
+import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import UserBar from './user_bar.vue';
-import ContextSwitcherToggle from './context_switcher_toggle.vue';
+import SidebarPortalTarget from './sidebar_portal_target.vue';
import ContextSwitcher from './context_switcher.vue';
import HelpCenter from './help_center.vue';
+import SidebarMenu from './sidebar_menu.vue';
+import SidebarPeekBehavior, { STATE_CLOSED, STATE_WILL_OPEN } from './sidebar_peek_behavior.vue';
export default {
- context,
components: {
- GlCollapse,
+ GlButton,
UserBar,
- ContextSwitcherToggle,
ContextSwitcher,
HelpCenter,
+ SidebarMenu,
+ SidebarPeekBehavior,
+ SidebarPortalTarget,
+ TrialStatusWidget: () =>
+ import('ee_component/contextual_sidebar/components/trial_status_widget.vue'),
+ TrialStatusPopover: () =>
+ import('ee_component/contextual_sidebar/components/trial_status_popover.vue'),
},
+ i18n: {
+ skipToMainContent: __('Skip to main content'),
+ },
+ inject: ['showTrialStatusWidget'],
props: {
sidebarData: {
type: Object,
@@ -23,29 +38,132 @@ export default {
},
data() {
return {
- contextSwitcherOpened: false,
+ sidebarState,
+ showPeekHint: false,
};
},
+ computed: {
+ menuItems() {
+ return this.sidebarData.current_menu_items || [];
+ },
+ peekClasses() {
+ return {
+ 'super-sidebar-peek-hint': this.showPeekHint,
+ 'super-sidebar-peek': this.sidebarState.isPeek,
+ };
+ },
+ },
+ watch: {
+ 'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) {
+ if (newIsCollapsed) {
+ this.$refs['context-switcher'].close();
+ }
+ },
+ },
+ mounted() {
+ Mousetrap.bind(keysFor(TOGGLE_SUPER_SIDEBAR), this.toggleSidebar);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(keysFor(TOGGLE_SUPER_SIDEBAR));
+ },
+ methods: {
+ toggleSidebar() {
+ toggleSuperSidebarCollapsed(!isCollapsed(), true);
+ },
+ collapseSidebar() {
+ toggleSuperSidebarCollapsed(true, false);
+ },
+ onPeekChange(state) {
+ if (state === STATE_CLOSED) {
+ this.sidebarState.isPeek = false;
+ this.sidebarState.isCollapsed = true;
+ this.showPeekHint = false;
+ } else if (state === STATE_WILL_OPEN) {
+ this.sidebarState.isPeek = false;
+ this.sidebarState.isCollapsed = true;
+ this.showPeekHint = true;
+ } else {
+ this.sidebarState.isPeek = true;
+ this.sidebarState.isCollapsed = false;
+ this.showPeekHint = false;
+ }
+ },
+ onContextSwitcherToggled(open) {
+ this.sidebarState.contextSwitcherOpen = open;
+ },
+ },
};
</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="super-sidebar-overlay" @click="collapseSidebar"></div>
+ <gl-button
+ class="super-sidebar-skip-to gl-sr-only-focusable gl-fixed gl-left-0 gl-m-3"
+ href="#content-body"
+ variant="confirm"
+ >
+ {{ $options.i18n.skipToMainContent }}
+ </gl-button>
+ <aside
+ id="super-sidebar"
+ class="super-sidebar"
+ :class="peekClasses"
+ data-testid="super-sidebar"
+ data-qa-selector="navbar"
+ :inert="sidebarState.isCollapsed"
+ >
+ <user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" />
+ <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2">
+ <trial-status-widget
+ class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! nav-item-link gl-py-3"
+ />
+ <trial-status-popover />
</div>
- <div class="gl-p-3">
- <help-center :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"
+ :class="{ 'gl-overflow-auto': !sidebarState.contextSwitcherOpen }"
+ data-testid="nav-container"
+ >
+ <context-switcher
+ ref="context-switcher"
+ :persistent-links="sidebarData.context_switcher_links"
+ :username="sidebarData.username"
+ :projects-path="sidebarData.projects_path"
+ :groups-path="sidebarData.groups_path"
+ :current-context="sidebarData.current_context"
+ :context-header="sidebarData.current_context_header"
+ @toggle="onContextSwitcherToggled"
+ />
+ <sidebar-menu
+ v-if="menuItems.length"
+ :items="menuItems"
+ :panel-type="sidebarData.panel_type"
+ :pinned-item-ids="sidebarData.pinned_items"
+ :update-pins-url="sidebarData.update_pins_url"
+ />
+ <sidebar-portal-target />
+ </div>
+ <div class="gl-p-3">
+ <help-center :sidebar-data="sidebarData" />
+ </div>
</div>
- </div>
- </aside>
+ </aside>
+ <a
+ v-for="shortcutLink in sidebarData.shortcut_links"
+ :key="shortcutLink.href"
+ :href="shortcutLink.href"
+ :class="shortcutLink.css_class"
+ class="gl-display-none"
+ >
+ {{ shortcutLink.title }}
+ </a>
+
+ <!--
+ Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid
+ setting up event listeners unnecessarily.
+ -->
+ <sidebar-peek-behavior v-if="sidebarState.isPeekable" @change="onPeekChange" />
+ </div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
new file mode 100644
index 00000000000..4fff5cf832e
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants';
+import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ tooltipContainer: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'right',
+ },
+ },
+ i18n: {
+ collapseSidebar: __('Hide sidebar'),
+ expandSidebar: __('Show sidebar'),
+ navigationSidebar: __('Navigation sidebar'),
+ },
+ data() {
+ return sidebarState;
+ },
+ computed: {
+ tooltipTitle() {
+ if (this.isPeek) return '';
+
+ return this.isCollapsed
+ ? this.$options.i18n.expandSidebar
+ : this.$options.i18n.collapseSidebar;
+ },
+ tooltip() {
+ return {
+ placement: this.tooltipPlacement,
+ container: this.tooltipContainer,
+ title: this.tooltipTitle,
+ };
+ },
+ ariaExpanded() {
+ return String(!this.isCollapsed);
+ },
+ },
+ methods: {
+ toggle() {
+ toggleSuperSidebarCollapsed(!this.isCollapsed, true);
+ this.focusOtherToggle();
+ },
+ focusOtherToggle() {
+ this.$nextTick(() => {
+ const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS;
+ const otherToggle = document.querySelector(`.${classSelector}`);
+ otherToggle?.focus();
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover="tooltip"
+ aria-controls="super-sidebar"
+ :aria-expanded="ariaExpanded"
+ :aria-label="$options.i18n.navigationSidebar"
+ icon="sidebar"
+ category="tertiary"
+ :disabled="isPeek"
+ @click="toggle"
+ />
+</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..768914584e8 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -1,90 +1,215 @@
<script>
-import { GlAvatar, GlDropdown, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
+import {
+ destroyUserCountsManager,
+ createUserCountsManager,
+ userCounts,
+} from '~/super_sidebar/user_counts_manager';
import logo from '../../../../views/shared/_logo.svg';
+import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants';
import CreateMenu from './create_menu.vue';
import Counter from './counter.vue';
import MergeRequestMenu from './merge_request_menu.vue';
+import UserMenu from './user_menu.vue';
+import SuperSidebarToggle from './super_sidebar_toggle.vue';
+import { SEARCH_MODAL_ID } from './global_search/constants';
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,
+ JS_TOGGLE_COLLAPSE_CLASS,
+ SEARCH_MODAL_ID,
components: {
- GlAvatar,
- GlDropdown,
- GlIcon,
- CreateMenu,
- NewNavToggle,
Counter,
+ CreateMenu,
+ GlBadge,
+ GlButton,
MergeRequestMenu,
+ UserMenu,
+ SearchModal: () =>
+ import(
+ /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue'
+ ),
+ SuperSidebarToggle,
},
i18n: {
createNew: __('Create new...'),
+ homepage: __('Homepage'),
issues: __('Issues'),
mergeRequests: __('Merge requests'),
+ search: __('Search'),
+ searchKbdHelp: sprintf(
+ s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'),
+ { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
+ false,
+ ),
todoList: __('To-Do list'),
+ stopImpersonating: __('Stop impersonating'),
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
SafeHtml,
},
- inject: ['rootPath', 'toggleNewNavEndpoint'],
+ inject: ['rootPath', 'isImpersonating'],
props: {
+ hasCollapseButton: {
+ default: true,
+ type: Boolean,
+ required: false,
+ },
sidebarData: {
type: Object,
required: true,
},
},
+ data() {
+ return {
+ mrMenuShown: false,
+ searchTooltip: this.$options.i18n.searchKbdHelp,
+ userCounts,
+ };
+ },
+ computed: {
+ mergeRequestTotalCount() {
+ return userCounts.assigned_merge_requests + userCounts.review_requested_merge_requests;
+ },
+ },
+ created() {
+ Object.assign(userCounts, this.sidebarData.user_counts);
+ createUserCountsManager();
+ },
+ mounted() {
+ document.addEventListener('todo:toggle', this.updateTodos);
+ },
+ beforeDestroy() {
+ document.removeEventListener('todo:toggle', this.updateTodos);
+ destroyUserCountsManager();
+ },
+ methods: {
+ updateTodos(e) {
+ userCounts.todos = e.detail.count || 0;
+ },
+ hideSearchTooltip() {
+ this.searchTooltip = '';
+ },
+ showSearchTooltip() {
+ this.searchTooltip = this.$options.i18n.searchKbdHelp;
+ },
+ },
};
</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">
+ <a
+ v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage"
+ class="tanuki-logo-container"
+ :href="rootPath"
+ :title="$options.i18n.homepage"
+ data-track-action="click_link"
+ data-track-label="gitlab_logo_link"
+ data-track-property="nav_core_menu"
+ >
+ <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"
+ class="gl-ml-2"
+ >
+ {{ $options.NEXT_LABEL }}
+ </gl-badge>
+ <div class="gl-flex-grow-1"></div>
+ <super-sidebar-toggle
+ v-if="hasCollapseButton"
+ :class="$options.JS_TOGGLE_COLLAPSE_CLASS"
+ tooltip-placement="bottom"
+ tooltip-container="super-sidebar"
+ data-testid="super-sidebar-collapse-button"
+ />
<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
+ id="super-sidebar-search"
+ v-gl-tooltip.bottom.hover.html="searchTooltip"
+ v-gl-modal="$options.SEARCH_MODAL_ID"
+ data-testid="super-sidebar-search-button"
+ data-qa-selector="global_search_button"
+ icon="search"
+ :aria-label="$options.i18n.search"
+ category="tertiary"
+ />
+ <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" />
+
+ <user-menu :data="sidebarData" />
+
+ <gl-button
+ v-if="isImpersonating"
+ v-gl-tooltip
+ :href="sidebarData.stop_impersonation_path"
+ :title="$options.i18n.stopImpersonating"
+ :aria-label="$options.i18n.stopImpersonating"
+ icon="incognito"
+ variant="confirm"
+ category="tertiary"
+ data-method="delete"
+ data-testid="stop-impersonation-btn"
+ />
</div>
<div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues"
- class="gl-flex-basis-third"
+ class="gl-flex-basis-third dashboard-shortcuts-issues"
icon="issues"
- :count="sidebarData.assigned_open_issues_count"
+ :count="userCounts.assigned_issues"
:href="sidebarData.issues_dashboard_path"
:label="$options.i18n.issues"
+ data-track-action="click_link"
+ data-track-label="issues_link"
+ data-track-property="nav_core_menu"
/>
<merge-request-menu
class="gl-flex-basis-third gl-display-block!"
:items="sidebarData.merge_request_menu"
+ @shown="mrMenuShown = true"
+ @hidden="mrMenuShown = false"
>
<counter
- v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests"
+ v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests"
class="gl-w-full"
- tabindex="-1"
icon="merge-request-open"
- :count="sidebarData.total_merge_requests_count"
+ :count="mergeRequestTotalCount"
:label="$options.i18n.mergeRequests"
+ data-track-action="click_dropdown"
+ data-track-label="merge_requests_menu"
+ data-track-property="nav_core_menu"
/>
</merge-request-menu>
<counter
v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList"
- class="gl-flex-basis-third"
+ class="gl-flex-basis-third shortcuts-todos js-todos-count"
icon="todo-done"
- :count="sidebarData.todos_pending_count"
- href="/dashboard/todos"
+ :count="userCounts.todos"
+ :href="sidebarData.todos_dashboard_path"
:label="$options.i18n.todoList"
+ data-qa-selector="todos_shortcut_button"
+ data-track-action="click_link"
+ data-track-label="todos_link"
+ data-track-property="nav_core_menu"
/>
</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..cd5a83c86cc
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -0,0 +1,340 @@
+<script>
+import {
+ GlAvatar,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlButton,
+} 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 { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants';
+import UserNameGroup from './user_name_group.vue';
+
+// Left offset required for the dropdown to be aligned with the super sidebar
+const DROPDOWN_X_OFFSET = -211;
+
+export default {
+ feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005',
+ i18n: {
+ newNavigation: {
+ 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,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlButton,
+ 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,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'start_trial',
+ },
+ };
+ },
+ showTrialItem() {
+ return this.data.trial?.has_start_trial;
+ },
+ editProfileItem() {
+ return {
+ text: this.$options.i18n.editProfile,
+ href: this.data.settings.profile_path,
+ extraAttrs: {
+ 'data-qa-selector': 'edit_profile_link',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_edit_profile',
+ },
+ };
+ },
+ preferencesItem() {
+ return {
+ text: this.$options.i18n.preferences,
+ href: this.data.settings.profile_preferences_path,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_preferences',
+ },
+ };
+ },
+ 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',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'buy_pipeline_minutes',
+ },
+ };
+ },
+ gitlabNextItem() {
+ return {
+ text: this.$options.i18n.gitlabNext,
+ href: this.data.canary_toggle_com_url,
+ extraAttrs: {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'switch_to_canary',
+ },
+ };
+ },
+ feedbackItem() {
+ return {
+ text: this.$options.i18n.provideFeedback,
+ href: this.$options.feedbackUrl,
+ extraAttrs: {
+ target: '_blank',
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'provide_nav_feedback',
+ },
+ };
+ },
+ 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',
+ };
+
+ const { busy, customized } = this.data.status;
+
+ if (!busy && !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.initBuyCIMinsCallout();
+ },
+ closeDropdown() {
+ this.$refs.userDropdown.close();
+ },
+ initBuyCIMinsCallout() {
+ if (this.showNotificationDot) {
+ PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el);
+ }
+ },
+ /* We're not sure this event is tracked by anyone
+ whether it stays will depend on the outcome of this discussion:
+ https://gitlab.com/gitlab-org/gitlab/-/issues/402713#note_1343072135 */
+ trackBuyCIMins() {
+ if (this.addBuyPipelineMinutesMenuItem) {
+ const {
+ 'track-action': trackAction,
+ 'track-label': label,
+ 'track-property': property,
+ } = this.data.pipeline_minutes.tracking_attrs;
+ this.track(trackAction, { label, property });
+ }
+ },
+ trackSignOut() {
+ this.track(USER_MENU_TRACKING_DEFAULTS['data-track-action'], {
+ label: 'user_sign_out',
+ property: USER_MENU_TRACKING_DEFAULTS['data-track-property'],
+ });
+ },
+ },
+ popperOptions: {
+ modifiers: [
+ {
+ name: 'offset',
+ options: {
+ offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET],
+ },
+ },
+ ],
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-disclosure-dropdown
+ ref="userDropdown"
+ :popper-options="$options.popperOptions"
+ data-testid="user-dropdown"
+ data-qa-selector="user_menu"
+ @shown="onShow"
+ >
+ <template #toggle>
+ <gl-button category="tertiary" 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>
+ </gl-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"
+ @action="closeDropdown"
+ />
+
+ <gl-disclosure-dropdown-item
+ v-if="showTrialItem"
+ :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"
+ @action="trackBuyCIMins"
+ >
+ <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>
+ </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"
+ @action="trackSignOut"
+ />
+ </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..dfaaaccf4a4
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue
@@ -0,0 +1,90 @@
+<script>
+import {
+ GlBadge,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ GlTooltip,
+} from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { s__ } from '~/locale';
+import { USER_MENU_TRACKING_DEFAULTS } from '../constants';
+
+export default {
+ i18n: {
+ user: {
+ busy: s__('UserProfile|Busy'),
+ },
+ },
+ components: {
+ GlBadge,
+ 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;
+
+ item.extraAttrs = {
+ ...USER_MENU_TRACKING_DEFAULTS,
+ 'data-track-label': 'user_profile',
+ 'data-qa-selector': 'user_profile_link',
+ };
+ }
+
+ 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>
+ <gl-badge v-if="user.status.busy" size="sm" variant="warning">
+ {{ $options.i18n.user.busy }}
+ </gl-badge>
+ </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..00ceaebe2cc
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -0,0 +1,54 @@
+// 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 JS_TOGGLE_COLLAPSE_CLASS = 'js-super-sidebar-toggle-collapse';
+export const JS_TOGGLE_EXPAND_CLASS = 'js-super-sidebar-toggle-expand';
+
+export const portalState = Vue.observable({
+ ready: false,
+});
+
+export const sidebarState = Vue.observable({
+ contextSwitcherOpen: false,
+ isCollapsed: false,
+ isPeek: false,
+ isPeekable: false,
+});
+
+export const helpCenterState = Vue.observable({
+ showTanukiBotChatDrawer: false,
+});
+
+export const MAX_FREQUENT_PROJECTS_COUNT = 5;
+export const MAX_FREQUENT_GROUPS_COUNT = 3;
+
+export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200;
+export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500;
+
+export const TRACKING_UNKNOWN_ID = 'item_without_id';
+export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown';
+export const CLICK_MENU_ITEM_ACTION = 'click_menu_item';
+export const CLICK_PINNED_MENU_ITEM_ACTION = 'click_pinned_menu_item';
+
+export const PANELS_WITH_PINS = ['group', 'project'];
+
+export const USER_MENU_TRACKING_DEFAULTS = {
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+};
+
+export const HELP_MENU_TRACKING_DEFAULTS = {
+ 'data-track-property': 'nav_help_menu',
+ 'data-track-action': 'click_link',
+};
+
+export const SIDEBAR_PINS_EXPANDED_COOKIE = 'sidebar_pinned_section_expanded';
+export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10;
+
+export const DROPDOWN_Y_OFFSET = 4;
+
+export const NAV_ITEM_LINK_ACTIVE_CLASS = 'gl-bg-t-gray-a-08';
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
deleted file mode 100644
index 0d1ac006df7..00000000000
--- a/app/assets/javascripts/super_sidebar/mock_data.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { s__ } from '~/locale';
-
-export const context = {
- title: 'Typeahead.js',
- link: '/',
- avatar: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png?width=32',
-};
-
-export const contextSwitcherItems = {
- yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' },
- recentProjects: [
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Orange',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64',
- },
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Lemon',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64',
- },
- {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- title: 'Coconut',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64',
- },
- ],
- recentGroups: [
- {
- title: 'Developer Evangelism at GitLab',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64',
- },
- {
- title: 'security-products',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64',
- },
- {
- title: 'Tanuki-Workshops',
- subtitle: 'tropical-tree',
- link: '/tropical-tree',
- avatar:
- 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64',
- },
- ],
-};
diff --git a/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js
new file mode 100644
index 00000000000..6581d521107
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js
@@ -0,0 +1,43 @@
+import { detectOverflow } from '@popperjs/core';
+
+/**
+ * These modifiers were copied from the community modifier popper-max-size-modifier
+ * https://www.npmjs.com/package/popper-max-size-modifier.
+ * We are considering upgrading Popper.js to Floating UI, at which point the behavior this
+ * introduces will be available out of the box.
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2213
+ */
+
+export const maxSize = {
+ name: 'maxSize',
+ enabled: true,
+ phase: 'main',
+ requiresIfExists: ['offset', 'preventOverflow', 'flip'],
+ fn({ state, name }) {
+ const overflow = detectOverflow(state);
+ const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 };
+ const { width, height } = state.rects.popper;
+ const [basePlacement] = state.placement.split('-');
+
+ const widthProp = basePlacement === 'left' ? 'left' : 'right';
+ const heightProp = basePlacement === 'top' ? 'top' : 'bottom';
+
+ state.modifiersData[name] = {
+ width: width - overflow[widthProp] - x,
+ height: height - overflow[heightProp] - y,
+ };
+ },
+};
+
+export const applyMaxSize = {
+ name: 'applyMaxSize',
+ enabled: true,
+ phase: 'write',
+ requires: ['maxSize'],
+ fn({ state }) {
+ // The `maxSize` modifier provides this data
+ const { width, height } = state.modifiersData.maxSize;
+ state.elements.popper.style.maxWidth = `${width}px`;
+ state.elements.popper.style.maxHeight = `${height}px`;
+ },
+};
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
index b9c7073df8c..63424277ffc 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js
@@ -1,26 +1,131 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
+import { initStatusTriggers } from '../header';
+import { JS_TOGGLE_EXPAND_CLASS } from './constants';
+import createStore from './components/global_search/store';
+import {
+ bindSuperSidebarCollapsedEvents,
+ initSuperSidebarCollapsedState,
+} from './super_sidebar_collapsed_state_manager';
import SuperSidebar from './components/super_sidebar.vue';
+import SuperSidebarToggle from './components/super_sidebar_toggle.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+const getTrialStatusWidgetData = (sidebarData) => {
+ if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) {
+ const {
+ containerId,
+ trialDaysUsed,
+ trialDuration,
+ navIconImagePath,
+ percentageComplete,
+ planName,
+ plansHref,
+ } = convertObjectPropsToCamelCase(sidebarData.trial_status_widget_data_attrs);
+
+ const {
+ daysRemaining,
+ targetId,
+ trialEndDate,
+ namespaceId,
+ userName,
+ firstName,
+ lastName,
+ companyName,
+ glmContent,
+ } = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs);
+
+ return {
+ showTrialStatusWidget: true,
+ containerId,
+ trialDaysUsed: Number(trialDaysUsed),
+ trialDuration: Number(trialDuration),
+ navIconImagePath,
+ percentageComplete: Number(percentageComplete),
+ planName,
+ plansHref,
+ daysRemaining,
+ targetId,
+ trialEndDate: new Date(trialEndDate),
+ user: { namespaceId, userName, firstName, lastName, companyName, glmContent },
+ };
+ }
+ return { showTrialStatusWidget: false };
+};
export const initSuperSidebar = () => {
const el = document.querySelector('.js-super-sidebar');
if (!el) return false;
- const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset;
+ const { rootPath, sidebar, toggleNewNavEndpoint, forceDesktopExpandedSidebar } = el.dataset;
+
+ bindSuperSidebarCollapsedEvents(forceDesktopExpandedSidebar);
+ initSuperSidebarCollapsedState(parseBoolean(forceDesktopExpandedSidebar));
+
+ const sidebarData = JSON.parse(sidebar);
+ const searchData = convertObjectPropsToCamelCase(sidebarData.search);
+
+ const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData;
+ const isImpersonating = parseBoolean(sidebarData.is_impersonating);
return new Vue({
el,
name: 'SuperSidebarRoot',
+ apolloProvider,
provide: {
rootPath,
toggleNewNavEndpoint,
+ isImpersonating,
+ ...getTrialStatusWidgetData(sidebarData),
},
+ store: createStore({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+ search: '',
+ }),
render(h) {
return h(SuperSidebar, {
props: {
- sidebarData: JSON.parse(sidebar),
+ sidebarData,
},
});
},
});
};
+
+/**
+ * Guard against multiple instantiations, since the js-* class is persisted
+ * in the Vue component.
+ */
+let toggleInstantiated = false;
+
+export const initSuperSidebarToggle = () => {
+ const el = document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`);
+
+ if (!el || toggleInstantiated) return false;
+
+ toggleInstantiated = true;
+
+ return new Vue({
+ el,
+ name: 'SuperSidebarToggleRoot',
+ render(h) {
+ // Copy classes from HAML-defined button to ensure same positioning,
+ // including JS_TOGGLE_EXPAND_CLASS.
+ return h(SuperSidebarToggle, { class: el.className });
+ },
+ });
+};
+
+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..1a359533435
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -0,0 +1,61 @@
+import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
+import { debounce } from 'lodash';
+import { setCookie, getCookie } from '~/lib/utils/common_utils';
+import { sidebarState } from './constants';
+
+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 SIDEBAR_TRANSITION_DURATION = 200;
+
+export const findPage = () => document.querySelector('.page-with-super-sidebar');
+export const findSidebar = () => document.querySelector('.super-sidebar');
+
+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) => {
+ findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed);
+
+ sidebarState.isPeek = false;
+ sidebarState.isPeekable = Boolean(gon.features?.superSidebarPeek) && collapsed;
+ sidebarState.isCollapsed = collapsed;
+
+ if (saveCookie && isDesktopBreakpoint()) {
+ setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, {
+ expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION,
+ });
+ }
+};
+
+export const initSuperSidebarCollapsedState = (forceDesktopExpandedSidebar = false) => {
+ let collapsed = true;
+ if (isDesktopBreakpoint()) {
+ collapsed = forceDesktopExpandedSidebar ? false : getCollapsedCookie();
+ }
+ toggleSuperSidebarCollapsed(collapsed, false);
+};
+
+export const bindSuperSidebarCollapsedEvents = (forceDesktopExpandedSidebar = false) => {
+ let previousWindowWidth = window.innerWidth;
+
+ const callback = debounce(() => {
+ const newWindowWidth = window.innerWidth;
+ const widthChanged = previousWindowWidth !== newWindowWidth;
+
+ if (widthChanged) {
+ initSuperSidebarCollapsedState(forceDesktopExpandedSidebar);
+ }
+ previousWindowWidth = newWindowWidth;
+ }, 100);
+
+ window.addEventListener('resize', callback);
+
+ return () => window.removeEventListener('resize', callback);
+};
diff --git a/app/assets/javascripts/super_sidebar/user_counts_manager.js b/app/assets/javascripts/super_sidebar/user_counts_manager.js
new file mode 100644
index 00000000000..40c9fc43252
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/user_counts_manager.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import { getUserCounts } from '~/api/user_api';
+
+export const userCounts = Vue.observable({
+ last_update: 0,
+ // The following fields are part of
+ // https://docs.gitlab.com/ee/api/users.html#user-counts
+ todos: 0,
+ assigned_issues: 0,
+ assigned_merge_requests: 0,
+ review_requested_merge_requests: 0,
+});
+
+function updateCounts(payload = {}) {
+ if ((payload.last_update ?? 0) < userCounts.last_update) {
+ return;
+ }
+ for (const key in userCounts) {
+ if (Number.isInteger(payload[key])) {
+ userCounts[key] = payload[key];
+ }
+ }
+}
+
+let broadcastChannel = null;
+
+function broadcastUserCounts(data) {
+ broadcastChannel?.postMessage(data);
+}
+
+async function retrieveUserCountsFromApi() {
+ try {
+ const lastUpdate = Date.now();
+ const { data } = await getUserCounts();
+ const payload = { ...data, last_update: lastUpdate };
+ updateCounts(payload);
+ broadcastUserCounts(userCounts);
+ } catch (e) {
+ // eslint-disable-next-line no-console, @gitlab/require-i18n-strings
+ console.error('Error retrieving user counts', e);
+ }
+}
+
+export function destroyUserCountsManager() {
+ document.removeEventListener('userCounts:fetch', retrieveUserCountsFromApi);
+ broadcastChannel?.close();
+ broadcastChannel = null;
+}
+
+/**
+ * The createUserCountsManager does three things:
+ * 1. Set the initial state of userCounts
+ * 2. Create a broadcast channel to communicate user count updates across tabs
+ * 3. Add event listeners for other parts in the app which:
+ * - Update todos
+ * - Trigger a refetch of all counts
+ */
+export function createUserCountsManager() {
+ destroyUserCountsManager();
+ document.addEventListener('userCounts:fetch', retrieveUserCountsFromApi);
+
+ if (window.BroadcastChannel && gon?.current_user_id) {
+ broadcastChannel = new BroadcastChannel(`user_counts_${gon?.current_user_id}`);
+ broadcastChannel.onmessage = (ev) => {
+ updateCounts(ev.data);
+ };
+ broadcastUserCounts(userCounts);
+ }
+}
diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js
new file mode 100644
index 00000000000..3b17a35c5bc
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/utils.js
@@ -0,0 +1,87 @@
+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) => {
+ if (!Array.isArray(items)) return [];
+
+ 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,
+ }));
+
+export const ariaCurrent = (isActive) => (isActive ? 'page' : null);
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index cb2bf24abc7..d79252f6bb7 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -1,5 +1,3 @@
-/* eslint-disable consistent-return */
-
// Syntax Highlighter
//
// Applies a syntax highlighting color scheme CSS class to any element with the
@@ -14,7 +12,12 @@ export default function syntaxHighlight($els = null) {
if (!$els || $els.length === 0) return;
const els = $els.get ? $els.get() : $els;
+ // eslint-disable-next-line consistent-return
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/tags/init_new_tag_ref_selector.js b/app/assets/javascripts/tags/init_new_tag_ref_selector.js
index 11c7516f16c..7667df00516 100644
--- a/app/assets/javascripts/tags/init_new_tag_ref_selector.js
+++ b/app/assets/javascripts/tags/init_new_tag_ref_selector.js
@@ -14,6 +14,7 @@ export default function initNewTagRefSelector() {
props: {
value: defaultBranchName,
name: hiddenInputName,
+ queryParams: { sort: 'updated_desc' },
projectId,
},
});
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/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue
index 6dae55bac50..551b5498571 100644
--- a/app/assets/javascripts/terraform/components/empty_state.vue
+++ b/app/assets/javascripts/terraform/components/empty_state.vue
@@ -1,18 +1,27 @@
<script>
-import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
+import InitCommandModal from './init_command_modal.vue';
export default {
+ COMMAND_MODAL_ID: 'init-command-modal',
i18n: {
title: s__("Terraform|Your project doesn't have any Terraform state files"),
- description: s__('Terraform|How to use GitLab-managed Terraform state?'),
+ buttonDoc: s__('Terraform|Explore documentation'),
+ buttonCopy: s__('Terraform|Copy Terraform init command'),
},
docsUrl: helpPagePath('user/infrastructure/iac/terraform_state'),
components: {
GlEmptyState,
- GlLink,
+ GlButton,
+ InitCommandModal,
},
+
+ directives: {
+ GlModalDirective,
+ },
+
props: {
image: {
type: String,
@@ -24,8 +33,18 @@ export default {
<template>
<gl-empty-state :svg-path="image" :title="$options.i18n.title">
- <template #description>
- <gl-link :href="$options.docsUrl">{{ $options.i18n.description }}</gl-link>
+ <template #actions>
+ <gl-button variant="confirm" :href="$options.docsUrl">
+ {{ $options.i18n.buttonDoc }}</gl-button
+ >
+ <gl-button
+ v-gl-modal-directive="$options.COMMAND_MODAL_ID"
+ data-testid="terraform-state-copy-init-command"
+ icon="copy-to-clipboard"
+ >{{ $options.i18n.buttonCopy }}</gl-button
+ >
+
+ <init-command-modal :modal-id="$options.COMMAND_MODAL_ID" />
</template>
</gl-empty-state>
</template>
diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue
index 0d8a883972f..a63c2025e8b 100644
--- a/app/assets/javascripts/terraform/components/init_command_modal.vue
+++ b/app/assets/javascripts/terraform/components/init_command_modal.vue
@@ -26,20 +26,23 @@ export default {
},
stateName: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
computed: {
closeModalProps() {
return {
text: this.$options.i18n.closeText,
- attributes: [],
+ attributes: {},
};
},
},
methods: {
getModalInfoCopyStr() {
- const stateNameEncoded = encodeURIComponent(this.stateName);
+ const stateNameEncoded = this.stateName
+ ? encodeURIComponent(this.stateName)
+ : '<YOUR-STATE-NAME>';
return `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN>
terraform init \\
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/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue
index f098b447d10..af8cc732753 100644
--- a/app/assets/javascripts/terraform/components/terraform_list.vue
+++ b/app/assets/javascripts/terraform/components/terraform_list.vue
@@ -97,7 +97,7 @@ export default {
<gl-tab>
<template #title>
<p class="gl-m-0">
- {{ s__('Terraform|States') }}
+ {{ s__('Terraform|Terraform states') }}
<gl-badge v-if="statesCount">{{ statesCount }}</gl-badge>
</p>
</template>
diff --git a/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
new file mode 100644
index 00000000000..3ba0ab29530
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/queries/get_timelogs.query.graphql
@@ -0,0 +1,69 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query timeTrackingReport(
+ $startDate: Time
+ $endDate: Time
+ $projectId: ProjectID
+ $groupId: GroupID
+ $username: String
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+) {
+ timelogs(
+ startDate: $startDate
+ endDate: $endDate
+ projectId: $projectId
+ groupId: $groupId
+ username: $username
+ first: $first
+ last: $last
+ after: $after
+ before: $before
+ sort: SPENT_AT_DESC
+ ) {
+ count
+ totalSpentTime
+ nodes {
+ id
+ project {
+ id
+ webUrl
+ fullPath
+ nameWithNamespace
+ }
+ timeSpent
+ user {
+ id
+ name
+ username
+ avatarUrl
+ webPath
+ }
+ spentAt
+ note {
+ id
+ body
+ }
+ summary
+ issue {
+ id
+ title
+ webUrl
+ state
+ reference
+ }
+ mergeRequest {
+ id
+ title
+ webUrl
+ state
+ reference
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+}
diff --git a/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
new file mode 100644
index 00000000000..950d2f2f05d
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelog_source_cell.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { issuableStatusText } from '~/issues/constants';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ timelog: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ subject() {
+ const { issue, mergeRequest } = this.timelog;
+ return issue || mergeRequest;
+ },
+ issuableStatus() {
+ return issuableStatusText[this.subject.state];
+ },
+ issuableFullReference() {
+ return this.timelog.project.fullPath + this.subject.reference;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-gap-2 gl-text-left!">
+ <gl-link
+ :href="subject.webUrl"
+ class="gl-text-gray-900 gl-hover-text-gray-900 gl-font-weight-bold"
+ data-testid="title-container"
+ >
+ {{ subject.title }}
+ </gl-link>
+ <span>
+ <gl-link
+ :href="subject.webUrl"
+ class="gl-text-gray-900 gl-hover-text-gray-900"
+ data-testid="reference-container"
+ >
+ {{ issuableFullReference }}
+ </gl-link>
+ • <span data-testid="state-container">{{ issuableStatus }}</span>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
new file mode 100644
index 00000000000..2069e4a6722
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue
@@ -0,0 +1,229 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlDatepicker,
+} from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { formatTimeSpent } from '~/lib/utils/datetime_utility';
+import { s__ } from '~/locale';
+import getTimelogsQuery from './queries/get_timelogs.query.graphql';
+import TimelogsTable from './timelogs_table.vue';
+
+const ENTRIES_PER_PAGE = 20;
+
+// Define initial dates to current date and time
+const INITIAL_TO_DATE = new Date();
+const INITIAL_FROM_DATE = new Date();
+
+// Set the initial 'from' date to 30 days before the current date
+INITIAL_FROM_DATE.setDate(INITIAL_TO_DATE.getDate() - 30);
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlDatepicker,
+ TimelogsTable,
+ },
+ props: {
+ limitToHours: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ projectId: null,
+ groupId: null,
+ username: null,
+ timeSpentFrom: INITIAL_FROM_DATE,
+ timeSpentTo: INITIAL_TO_DATE,
+ cursor: {
+ first: ENTRIES_PER_PAGE,
+ after: null,
+ last: null,
+ before: null,
+ },
+ queryVariables: {
+ startDate: INITIAL_FROM_DATE,
+ endDate: INITIAL_TO_DATE,
+ projectId: null,
+ groupId: null,
+ username: null,
+ },
+ pageInfo: {},
+ report: [],
+ totalSpentTime: 0,
+ };
+ },
+ apollo: {
+ report: {
+ query: getTimelogsQuery,
+ variables() {
+ return {
+ ...this.queryVariables,
+ ...this.cursor,
+ };
+ },
+ update({ timelogs: { nodes = [], pageInfo = {}, totalSpentTime = 0 } = {} }) {
+ this.pageInfo = pageInfo;
+ this.totalSpentTime = totalSpentTime;
+ return nodes;
+ },
+ error(error) {
+ createAlert({ message: s__('TimeTrackingReport|Something went wrong. Please try again.') });
+ Sentry.captureException(error);
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.report.loading;
+ },
+ showPagination() {
+ return this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage;
+ },
+ formattedTotalSpentTime() {
+ return formatTimeSpent(this.totalSpentTime, this.limitToHours);
+ },
+ },
+ methods: {
+ nullIfBlank(value) {
+ return value === '' ? null : value;
+ },
+ runReport() {
+ this.cursor = {
+ first: ENTRIES_PER_PAGE,
+ after: null,
+ last: null,
+ before: null,
+ };
+
+ this.queryVariables = {
+ startDate: this.nullIfBlank(this.timeSpentFrom),
+ endDate: this.nullIfBlank(this.timeSpentTo),
+ projectId: this.nullIfBlank(this.projectId),
+ groupId: this.nullIfBlank(this.groupId),
+ username: this.nullIfBlank(this.username),
+ };
+ },
+ nextPage(item) {
+ this.cursor = {
+ first: ENTRIES_PER_PAGE,
+ after: item,
+ last: null,
+ before: null,
+ };
+ },
+ prevPage(item) {
+ this.cursor = {
+ first: null,
+ after: null,
+ last: ENTRIES_PER_PAGE,
+ before: item,
+ };
+ },
+ clearTimeSpentFromDate() {
+ this.timeSpentFrom = null;
+ },
+ clearTimeSpentToDate() {
+ this.timeSpentTo = null;
+ },
+ },
+ i18n: {
+ username: s__('TimeTrackingReport|Username'),
+ from: s__('TimeTrackingReport|From'),
+ to: s__('TimeTrackingReport|To'),
+ runReport: s__('TimeTrackingReport|Run report'),
+ totalTimeSpentText: s__('TimeTrackingReport|Total time spent: '),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column gl-gap-5 gl-mt-5">
+ <form
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3"
+ @submit.prevent="runReport"
+ >
+ <gl-form-group
+ :label="$options.i18n.username"
+ label-for="timelog-form-username"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-form-input
+ id="timelog-form-username"
+ v-model="username"
+ data-testid="form-username"
+ class="gl-w-full"
+ />
+ </gl-form-group>
+ <gl-form-group
+ key="time-spent-from"
+ :label="$options.i18n.from"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-datepicker
+ v-model="timeSpentFrom"
+ :target="null"
+ show-clear-button
+ autocomplete="off"
+ data-testid="form-from-date"
+ class="gl-max-w-full!"
+ @clear="clearTimeSpentFromDate"
+ />
+ </gl-form-group>
+ <gl-form-group
+ key="time-spent-to"
+ :label="$options.i18n.to"
+ class="gl-mb-0 gl-md-form-input-md gl-w-full"
+ >
+ <gl-datepicker
+ v-model="timeSpentTo"
+ :target="null"
+ show-clear-button
+ autocomplete="off"
+ data-testid="form-to-date"
+ class="gl-max-w-full!"
+ @clear="clearTimeSpentToDate"
+ />
+ </gl-form-group>
+ <gl-button
+ class="gl-align-self-end gl-w-full gl-md-w-auto"
+ variant="confirm"
+ @click="runReport"
+ >{{ $options.i18n.runReport }}</gl-button
+ >
+ </form>
+ <div
+ v-if="!isLoading"
+ data-testid="table-container"
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <div v-if="report.length" class="gl-display-flex gl-gap-2 gl-border-t gl-py-4">
+ <span class="gl-font-weight-bold">{{ $options.i18n.totalTimeSpentText }}</span>
+ <span data-testid="total-time-spent-container">{{ formattedTotalSpentTime }}</span>
+ </div>
+
+ <timelogs-table :limit-to-hours="limitToHours" :entries="report" />
+
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pageInfo"
+ class="gl-mt-3 gl-align-self-center"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </div>
+ <gl-loading-icon v-else size="lg" class="gl-mt-5" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/time_tracking/components/timelogs_table.vue b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
new file mode 100644
index 00000000000..b2efb44f56f
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/components/timelogs_table.vue
@@ -0,0 +1,105 @@
+<script>
+import { GlTable } from '@gitlab/ui';
+import { formatDate, formatTimeSpent } from '~/lib/utils/datetime_utility';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { s__ } from '~/locale';
+import TimelogSourceCell from './timelog_source_cell.vue';
+
+const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
+
+export default {
+ components: {
+ GlTable,
+ UserAvatarLink,
+ TimelogSourceCell,
+ },
+ props: {
+ entries: {
+ type: Array,
+ required: true,
+ },
+ limitToHours: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ fields: [
+ {
+ key: 'spentAt',
+ label: s__('TimeTrackingReport|Spent at'),
+ tdClass: 'gl-md-w-30',
+ },
+ {
+ key: 'source',
+ label: s__('TimeTrackingReport|Source'),
+ },
+ {
+ key: 'user',
+ label: s__('TimeTrackingReport|User'),
+ tdClass: 'gl-md-w-20',
+ },
+ {
+ key: 'timeSpent',
+ label: s__('TimeTrackingReport|Time spent'),
+ tdClass: 'gl-md-w-15',
+ },
+ {
+ key: 'summary',
+ label: s__('TimeTrackingReport|Summary'),
+ },
+ ],
+ };
+ },
+ methods: {
+ formatDate(date) {
+ return formatDate(date, TIME_DATE_FORMAT);
+ },
+ formatTimeSpent(seconds) {
+ return formatTimeSpent(seconds, this.limitToHours);
+ },
+ extractTimelogSummary(timelog) {
+ const { note, summary } = timelog;
+ return note?.body || summary;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="entries" :fields="fields" stacked="md" show-empty>
+ <template #cell(spentAt)="{ item: { spentAt } }">
+ <div data-testid="date-container" class="gl-text-left!">{{ formatDate(spentAt) }}</div>
+ </template>
+
+ <template #cell(source)="{ item }">
+ <timelog-source-cell :timelog="item" />
+ </template>
+
+ <template #cell(user)="{ item: { user } }">
+ <user-avatar-link
+ class="gl-display-flex gl-text-gray-900 gl-hover-text-gray-900"
+ :link-href="user.webPath"
+ :img-src="user.avatarUrl"
+ :img-size="16"
+ :img-alt="user.name"
+ :tooltip-text="user.name"
+ :username="user.name"
+ />
+ </template>
+
+ <template #cell(timeSpent)="{ item: { timeSpent } }">
+ <div data-testid="time-spent-container" class="gl-text-left!">
+ {{ formatTimeSpent(timeSpent) }}
+ </div>
+ </template>
+
+ <template #cell(summary)="{ item }">
+ <div data-testid="summary-container" class="gl-text-left!">
+ {{ extractTimelogSummary(item) }}
+ </div>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/time_tracking/index.js b/app/assets/javascripts/time_tracking/index.js
new file mode 100644
index 00000000000..9cff01799d9
--- /dev/null
+++ b/app/assets/javascripts/time_tracking/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import TimelogsApp from './components/timelogs_app.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.getElementById('js-timelogs-app');
+ if (!el) {
+ return false;
+ }
+
+ const { limitToHours } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(TimelogsApp, {
+ props: {
+ limitToHours: parseBoolean(limitToHours),
+ },
+ });
+ },
+ });
+};
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..eb1222d5130 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';
@@ -124,7 +124,7 @@ export default {
try {
const {
data: {
- ciCdSettingsUpdate: { errors },
+ projectCiCdSettingsUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: inboundUpdateCIJobTokenScopeMutation,
diff --git a/app/assets/javascripts/token_access/components/opt_in_jwt.vue b/app/assets/javascripts/token_access/components/opt_in_jwt.vue
deleted file mode 100644
index c774f37b1e4..00000000000
--- a/app/assets/javascripts/token_access/components/opt_in_jwt.vue
+++ /dev/null
@@ -1,125 +0,0 @@
-<script>
-import { GlLink, GlLoadingIcon, GlSprintf, GlToggle } from '@gitlab/ui';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { createAlert } from '~/flash';
-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';
-import { LIMIT_JWT_ACCESS_SNIPPET, OPT_IN_JWT_HELP_LINK } from '../constants';
-
-export default {
- i18n: {
- labelText: s__('CICD|Limit JSON Web Token (JWT) access'),
- helpText: s__(
- `CICD|The JWT must be manually declared in each job that needs it. When disabled, the token is always available in all jobs in the pipeline. %{linkStart}Learn more.%{linkEnd}`,
- ),
- expandedText: s__(
- 'CICD|Use the %{codeStart}secrets%{codeEnd} keyword to configure a job with a JWT.',
- ),
- copyToClipboard: __('Copy to clipboard'),
- fetchError: s__('CICD|There was a problem fetching the token access settings.'),
- updateError: s__('CICD|An error occurred while update the setting. Please try again.'),
- },
- components: {
- CodeInstruction,
- GlLink,
- GlLoadingIcon,
- GlSprintf,
- GlToggle,
- },
- inject: ['fullPath'],
- apollo: {
- optInJwt: {
- query: getOptInJwtSettingQuery,
- variables() {
- return {
- fullPath: this.fullPath,
- };
- },
- update({
- project: {
- ciCdSettings: { optInJwt },
- },
- }) {
- return optInJwt;
- },
- error() {
- createAlert({ message: this.$options.i18n.fetchError });
- },
- },
- },
- data() {
- return {
- optInJwt: null,
- };
- },
- computed: {
- isLoading() {
- return this.$apollo.queries.optInJwt.loading;
- },
- },
- methods: {
- async updateOptInJwt() {
- try {
- const {
- data: {
- ciCdSettingsUpdate: { errors },
- },
- } = await this.$apollo.mutate({
- mutation: updateOptInJwtMutation,
- variables: {
- input: {
- fullPath: this.fullPath,
- optInJwt: this.optInJwt,
- },
- },
- });
-
- if (errors.length) {
- throw new Error(errors[0]);
- }
- } catch (error) {
- createAlert({ message: this.$options.i18n.updateError });
- }
- },
- },
- OPT_IN_JWT_HELP_LINK,
- LIMIT_JWT_ACCESS_SNIPPET,
-};
-</script>
-<template>
- <div>
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" />
- <template v-else>
- <gl-toggle
- v-model="optInJwt"
- class="gl-mt-5"
- :label="$options.i18n.labelText"
- @change="updateOptInJwt"
- >
- <template #help>
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="$options.OPT_IN_JWT_HELP_LINK" class="inline-link" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </template>
- </gl-toggle>
- <div v-if="optInJwt" class="gl-mt-5" data-testid="opt-in-jwt-expanded-section">
- <gl-sprintf :message="$options.i18n.expandedText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- <code-instruction
- class="gl-mt-3"
- :instruction="$options.LIMIT_JWT_ACCESS_SNIPPET"
- :copy-text="$options.i18n.copyToClipboard"
- multiline
- />
- </div>
- </template>
- </div>
-</template>
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..9c9b0d37b68 100644
--- a/app/assets/javascripts/token_access/components/outbound_token_access.vue
+++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue
@@ -9,9 +9,10 @@ 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql';
@@ -19,6 +20,8 @@ import getCIJobTokenScopeQuery from '../graphql/queries/get_ci_job_token_scope.q
import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects_with_ci_job_token_scope.query.graphql';
import TokenProjectsTable from './token_projects_table.vue';
+// Note: This component will be removed in 17.0, as the outbound access token is getting deprecated
+// Some warnings are behind the `frozen_outbound_job_token_scopes` feature flag
export default {
i18n: {
toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
@@ -34,7 +37,14 @@ export default {
addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'),
projectsFetchError: __('There was a problem fetching the projects'),
scopeFetchError: __('There was a problem fetching the job token scope value'),
+ outboundTokenAlertDeprecationMessage: s__(
+ `CICD|The %{boldStart}Limit CI_JOB_TOKEN%{boldEnd} scope is deprecated and will be removed the 17.0 milestone. Configure the %{boldStart}CI_JOB_TOKEN%{boldEnd} allowlist instead. %{linkStart}How do I do this?%{linkEnd}`,
+ ),
+ disableToggleWarning: s__('CICD|Disabling this feature is a permanent change.'),
},
+ deprecationDocumentationLink: helpPagePath('ci/jobs/ci_job_token', {
+ anchor: 'limit-your-projects-job-token-access',
+ }),
fields: [
{
key: 'project',
@@ -67,6 +77,7 @@ export default {
GlToggle,
TokenProjectsTable,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
fullPath: {
default: '',
@@ -116,13 +127,22 @@ export default {
ciJobTokenHelpPage() {
return helpPagePath('ci/jobs/ci_job_token#limit-your-projects-job-token-access');
},
+ disableOutboundToken() {
+ return (
+ this.glFeatures?.frozenOutboundJobTokenScopes &&
+ !this.glFeatures?.frozenOutboundJobTokenScopesOverride
+ );
+ },
+ disableTokenToggle() {
+ return !this.jobTokenScopeEnabled && this.disableOutboundToken;
+ },
},
methods: {
async updateCIJobTokenScope() {
try {
const {
data: {
- ciCdSettingsUpdate: { errors },
+ projectCiCdSettingsUpdate: { errors },
},
} = await this.$apollo.mutate({
mutation: updateCIJobTokenScopeMutation,
@@ -205,9 +225,33 @@ export default {
<div>
<gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" />
<template v-else>
+ <gl-alert
+ v-if="disableOutboundToken"
+ class="gl-mb-3"
+ variant="warning"
+ :dismissible="false"
+ :show-icon="false"
+ data-testid="deprecation-alert"
+ >
+ <gl-sprintf :message="$options.i18n.outboundTokenAlertDeprecationMessage">
+ <template #bold="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.deprecationDocumentationLink"
+ class="inline-link"
+ target="_blank"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<gl-toggle
v-model="jobTokenScopeEnabled"
:label="$options.i18n.toggleLabelTitle"
+ :disabled="disableTokenToggle"
@change="updateCIJobTokenScope"
>
<template #help>
@@ -216,6 +260,7 @@ export default {
<gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">
{{ content }}
</gl-link>
+ <strong v-if="disableOutboundToken">{{ $options.i18n.disableToggleWarning }} </strong>
</template>
</gl-sprintf>
</template>
@@ -229,7 +274,9 @@ export default {
<template #default>
<gl-form-input
v-model="targetProjectPath"
+ :disabled="disableOutboundToken"
:placeholder="$options.i18n.addProjectPlaceholder"
+ data-testid="project-path-input"
/>
</template>
<template #footer>
@@ -240,7 +287,7 @@ export default {
</template>
</gl-card>
<gl-alert
- v-if="!jobTokenScopeEnabled"
+ v-if="!jobTokenScopeEnabled && !disableOutboundToken"
class="gl-mb-3"
variant="warning"
:dismissible="false"
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..167eebc8d9b 100644
--- a/app/assets/javascripts/token_access/components/token_access_app.vue
+++ b/app/assets/javascripts/token_access/components/token_access_app.vue
@@ -1,27 +1,18 @@
<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';
export default {
+ name: 'TokenAccessApp',
components: {
OutboundTokenAccess,
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>
</template>
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/token_access/constants.js b/app/assets/javascripts/token_access/constants.js
deleted file mode 100644
index fb2128462f0..00000000000
--- a/app/assets/javascripts/token_access/constants.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import { helpPagePath } from '~/helpers/help_page_helper';
-
-export const LIMIT_JWT_ACCESS_SNIPPET = `job_name:
- id_tokens:
- ID_TOKEN_1: # or any other name
- aud: "..." # sub-keyword to configure the token's audience
- secrets:
- TEST_SECRET:
- vault: db/prod
-`;
-
-export const OPT_IN_JWT_HELP_LINK = helpPagePath('ci/secrets/id_token_authentication', {
- anchor: 'automatic-id-token-authentication-with-hashicorp-vault',
-});
diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
index aac9feab237..6c85839df07 100644
--- a/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
+++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
@@ -1,5 +1,5 @@
-mutation inboundUpdateCIJobTokenScope($input: CiCdSettingsUpdateInput!) {
- ciCdSettingsUpdate(input: $input) {
+mutation inboundUpdateCIJobTokenScope($input: ProjectCiCdSettingsUpdateInput!) {
+ projectCiCdSettingsUpdate(input: $input) {
ciCdSettings {
inboundJobTokenScopeEnabled
}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
index d99f2e3597d..30a6bb6106a 100644
--- a/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
+++ b/app/assets/javascripts/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql
@@ -1,5 +1,5 @@
-mutation updateCIJobTokenScope($input: CiCdSettingsUpdateInput!) {
- ciCdSettingsUpdate(input: $input) {
+mutation updateCIJobTokenScope($input: ProjectCiCdSettingsUpdateInput!) {
+ projectCiCdSettingsUpdate(input: $input) {
ciCdSettings {
jobTokenScopeEnabled
}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql
deleted file mode 100644
index c12b5646423..00000000000
--- a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-mutation updateOptInJwt($input: CiCdSettingsUpdateInput!) {
- ciCdSettingsUpdate(input: $input) {
- ciCdSettings {
- optInJwt
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql
deleted file mode 100644
index a1a216b7dc3..00000000000
--- a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-query getOptInJwtSetting($fullPath: ID!) {
- project(fullPath: $fullPath) {
- id
- ciCdSettings {
- optInJwt
- }
- }
-}
diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js
index 0253abe393e..9258d5eba45 100644
--- a/app/assets/javascripts/token_access/index.js
+++ b/app/assets/javascripts/token_access/index.js
@@ -20,6 +20,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
return new Vue({
el: containerEl,
+ name: 'TokenAccessAppsRoot',
apolloProvider,
provide: {
fullPath,
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 2593fbe6ed1..968e866eedd 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -5,14 +5,12 @@ export const DEFAULT_SNOWPLOW_OPTIONS = {
hostname: window.location.hostname,
cookieDomain: window.location.hostname,
appId: '',
- userFingerprint: false,
respectDoNotTrack: true,
- forceSecureTracker: true,
eventMethod: 'post',
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
- pageUnloadTimer: 10,
+ plugins: window.snowplowPlugins || [],
formTrackingConfig: {
forms: { allow: [] },
fields: { allow: [] },
diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
index 5daeaf1d85b..89d90cf89be 100644
--- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js
+++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js
@@ -26,7 +26,14 @@ export function dispatchSnowplowEvent(
}
try {
- window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
+ window.snowplow('trackStructEvent', {
+ category,
+ action,
+ label,
+ property,
+ value,
+ context: contexts,
+ });
return true;
} catch (error) {
Sentry.captureException(error);
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index d60eb37a9a2..472ce3c5bbf 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -7,7 +7,7 @@ export { Tracking as default };
/**
* Tracker initialization as defined in:
- * https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v2/tracker-setup/initializing-a-tracker-2/.
+ * https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/javascript-tracker/javascript-tracker-v3/tracker-setup/initialization-options/.
* It also dispatches any event emitted before its execution.
*
* @returns {undefined}
@@ -42,13 +42,19 @@ export function initDefaultTrackers() {
// must be before initializing the trackers
Tracking.setAnonymousUrls();
- window.snowplow('enableActivityTracking', 30, 30);
+ window.snowplow('enableActivityTracking', {
+ minimumVisitLength: 30,
+ heartbeatDelay: 30,
+ });
// must be after enableActivityTracking
const standardContext = getStandardContext();
const experimentContexts = getAllExperimentContexts();
// To not expose personal identifying information, the page title is hardcoded as `GitLab`
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/345243
- window.snowplow('trackPageView', 'GitLab', [standardContext, ...experimentContexts]);
+ window.snowplow('trackPageView', {
+ title: 'GitLab',
+ context: [standardContext, ...experimentContexts],
+ });
window.snowplow('setDocumentTitle', 'GitLab');
if (window.snowplowOptions.formTracking) {
diff --git a/app/assets/javascripts/tracking/tracker.js b/app/assets/javascripts/tracking/tracker.js
index 85f4979e752..b69b1714952 100644
--- a/app/assets/javascripts/tracking/tracker.js
+++ b/app/assets/javascripts/tracking/tracker.js
@@ -207,14 +207,18 @@ export const Tracker = {
const mappedConfig = {};
if (config.forms) {
- mappedConfig.forms = renameKey(config.forms, 'allow', 'whitelist');
+ mappedConfig.forms = renameKey(config.forms, 'allow', 'allowlist');
}
if (config.fields) {
- mappedConfig.fields = renameKey(config.fields, 'allow', 'whitelist');
+ mappedConfig.fields = renameKey(config.fields, 'allow', 'allowlist');
}
- const enabler = () => window.snowplow('enableFormTracking', mappedConfig, userProvidedContexts);
+ const enabler = () =>
+ window.snowplow('enableFormTracking', {
+ options: mappedConfig,
+ context: userProvidedContexts,
+ });
if (document.readyState === 'complete') {
enabler();
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
index 2b97886e650..beff3b4c0c3 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue
@@ -9,9 +9,6 @@ import {
PROJECT_TABLE_LABEL_USAGE,
containerRegistryId,
containerRegistryPopoverId,
- uploadsId,
- uploadsPopoverId,
- uploadsPopoverContent,
} from '../constants';
import { descendingStorageUsageSort } from '../utils';
import StorageTypeIcon from './storage_type_icon.vue';
@@ -40,10 +37,6 @@ export default {
popoverId: containerRegistryPopoverId,
popoverContent: this.containerRegistryPopoverContent,
},
- [uploadsId]: {
- popoverId: uploadsPopoverId,
- popoverContent: this.$options.i18n.uploadsPopoverContent,
- },
};
return this.storageTypes
@@ -77,9 +70,6 @@ export default {
thClass: thWidthPercent(10),
},
],
- i18n: {
- uploadsPopoverContent,
- },
};
</script>
<template>
@@ -100,7 +90,7 @@ export default {
:aria-label="helpLinkAriaLabel(item.storageType.name)"
:data-testid="`${item.storageType.id}-help-link`"
>
- <gl-icon name="question" :size="12" />
+ <gl-icon name="question-o" :size="12" />
</gl-link>
</p>
<p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
index bc7cd42df1e..5142c2c0915 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue
@@ -16,7 +16,6 @@ export default {
const storageTypeIconMap = {
lfsObjectsSize: 'doc-image',
snippetsSize: 'snippet',
- uploadsSize: 'upload',
repositorySize: 'infrastructure-registry',
packagesSize: 'package',
};
diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
index 7e001685060..e9683924ff8 100644
--- a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
+++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue
@@ -1,17 +1,10 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECT_STORAGE_TYPES } from '../constants';
import { descendingStorageUsageSort } from '../utils';
export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
mixins: [glFeatureFlagMixin()],
props: {
rootStorageStatistics: {
@@ -35,9 +28,7 @@ export default {
storageSize,
wikiSize,
snippetsSize,
- uploadsSize,
} = this.rootStorageStatistics;
- const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
if (storageSize === 0) {
return null;
@@ -70,9 +61,15 @@ export default {
},
{
id: 'buildArtifactsSize',
- style: this.usageStyle(this.barRatio(artifactsSize)),
- class: 'gl-bg-data-viz-green-600',
- size: artifactsSize,
+ style: this.usageStyle(this.barRatio(buildArtifactsSize)),
+ class: 'gl-bg-data-viz-green-500',
+ size: buildArtifactsSize,
+ },
+ {
+ id: 'pipelineArtifactsSize',
+ style: this.usageStyle(this.barRatio(pipelineArtifactsSize)),
+ class: 'gl-bg-data-viz-green-800',
+ size: pipelineArtifactsSize,
},
{
id: 'wikiSize',
@@ -86,12 +83,6 @@ export default {
class: 'gl-bg-data-viz-orange-800',
size: snippetsSize,
},
- {
- id: 'uploadsSize',
- style: this.usageStyle(this.barRatio(uploadsSize)),
- class: 'gl-bg-data-viz-aqua-700',
- size: uploadsSize,
- },
]
.filter((data) => data.size !== 0)
.sort(descendingStorageUsageSort('size'))
@@ -99,11 +90,10 @@ export default {
const storageTypeExtraData = PROJECT_STORAGE_TYPES.find(
(type) => storageType.id === type.id,
);
- const { name, tooltip } = storageTypeExtraData || {};
+ const name = storageTypeExtraData?.name;
return {
name,
- tooltip,
...storageType,
};
});
@@ -155,15 +145,6 @@ export default {
<span class="gl-text-gray-500 gl-font-sm">
{{ formatSize(storageType.size) }}
</span>
- <span
- v-if="storageType.tooltip"
- v-gl-tooltip
- :title="storageType.tooltip"
- :aria-label="storageType.tooltip"
- class="gl-ml-2"
- >
- <gl-icon name="question" :size="12" />
- </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js
index fab18cefc60..8e3eaff4496 100644
--- a/app/assets/javascripts/usage_quotas/storage/constants.js
+++ b/app/assets/javascripts/usage_quotas/storage/constants.js
@@ -8,7 +8,7 @@ export const LEARN_MORE_LABEL = __('Learn more.');
export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown');
export const TOTAL_USAGE_SUBTITLE = s__(
- 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.',
+ 'UsageQuota|Includes artifacts, repositories, wiki, and other items.',
);
export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.');
export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
@@ -20,58 +20,51 @@ export const projectContainerRegistryPopoverContent = s__(
export const containerRegistryId = 'containerRegistrySize';
export const containerRegistryPopoverId = 'container-registry-popover';
-export const uploadsId = 'uploadsSize';
-export const uploadsPopoverId = 'uploads-popover';
-export const uploadsPopoverContent = s__(
- 'NamespaceStorage|Uploads are not counted in namespace storage quotas.',
-);
-export const PROJECT_TABLE_LABEL_PROJECT = __('Project');
export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type');
export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage');
export const PROJECT_STORAGE_TYPES = [
{
id: 'containerRegistrySize',
- name: s__('UsageQuota|Container Registry'),
+ name: __('Container Registry'),
description: s__(
'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.',
),
},
{
id: 'buildArtifactsSize',
- name: s__('UsageQuota|Artifacts'),
- description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
- tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
+ name: __('Job artifacts'),
+ description: s__('UsageQuota|Job artifacts created by CI/CD.'),
+ },
+ {
+ id: 'pipelineArtifactsSize',
+ name: __('Pipeline artifacts'),
+ description: s__('UsageQuota|Pipeline artifacts created by CI/CD.'),
},
{
id: 'lfsObjectsSize',
- name: s__('UsageQuota|LFS storage'),
+ name: __('LFS'),
description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
},
{
id: 'packagesSize',
- name: s__('UsageQuota|Packages'),
+ name: __('Packages'),
description: s__('UsageQuota|Code packages and container images.'),
},
{
id: 'repositorySize',
- name: s__('UsageQuota|Repository'),
+ name: __('Repository'),
description: s__('UsageQuota|Git repository.'),
},
{
id: 'snippetsSize',
- name: s__('UsageQuota|Snippets'),
+ name: __('Snippets'),
description: s__('UsageQuota|Shared bits of code and text.'),
},
{
- id: 'uploadsSize',
- name: s__('UsageQuota|Uploads'),
- description: s__('UsageQuota|File attachments and smaller design graphics.'),
- },
- {
id: 'wikiSize',
- name: s__('UsageQuota|Wiki'),
+ name: __('Wiki'),
description: s__('UsageQuota|Wiki content.'),
},
];
@@ -87,6 +80,9 @@ export const projectHelpPaths = {
buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', {
anchor: 'when-job-artifacts-are-deleted',
}),
+ pipelineArtifacts: helpPagePath('/ci/pipelines/pipeline_artifacts', {
+ anchor: 'when-pipeline-artifacts-are-deleted',
+ }),
packages: helpPagePath('user/packages/package_registry/index.md', {
anchor: 'reduce-storage-usage',
}),
diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
index 6637e5e0865..d254f576219 100644
--- a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
+++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql
@@ -10,7 +10,6 @@ query getProjectStorageStatistics($fullPath: ID!) {
repositorySize
snippetsSize
storageSize
- uploadsSize
wikiSize
}
}
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/user_lists/store/edit/actions.js b/app/assets/javascripts/user_lists/store/edit/actions.js
index 6db2e65cf04..6f5d483a4c7 100644
--- a/app/assets/javascripts/user_lists/store/edit/actions.js
+++ b/app/assets/javascripts/user_lists/store/edit/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { getErrorMessages } from '../utils';
import * as types from './mutation_types';
@@ -17,6 +17,6 @@ export const updateUserList = ({ commit, state }, userList) => {
iid: userList.iid,
name: userList.name,
})
- .then(({ data }) => redirectTo(data.path))
+ .then(({ data }) => redirectTo(data.path)) // eslint-disable-line import/no-deprecated
.catch((response) => commit(types.RECEIVE_USER_LIST_ERROR, getErrorMessages(response)));
};
diff --git a/app/assets/javascripts/user_lists/store/new/actions.js b/app/assets/javascripts/user_lists/store/new/actions.js
index 478fca40142..030f1f59212 100644
--- a/app/assets/javascripts/user_lists/store/new/actions.js
+++ b/app/assets/javascripts/user_lists/store/new/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { getErrorMessages } from '../utils';
import * as types from './mutation_types';
@@ -10,6 +10,6 @@ export const createUserList = ({ commit, state }, userList) => {
...state.userList,
...userList,
})
- .then(({ data }) => redirectTo(data.path))
+ .then(({ data }) => redirectTo(data.path)) // eslint-disable-line import/no-deprecated
.catch((response) => commit(types.RECEIVE_CREATE_USER_LIST_ERROR, getErrorMessages(response)));
};
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 1af47b020f7..66e54b59187 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';
@@ -466,6 +467,8 @@ function UsersSelect(currentUser, els, options = {}) {
// display:block overrides the hide-collapse rule
$value.css('display', '');
}
+
+ $('.dropdown-input-field', $block).val('');
},
multiSelect: $dropdown.hasClass('js-multiselect'),
inputMeta: $dropdown.data('inputMeta'),
@@ -647,7 +650,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 +687,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}`;
@@ -693,17 +696,18 @@ UsersSelect.prototype.renderRow = function (
: '';
const dataUserSuggested = user.suggested ? `data-user-suggested=${user.suggested}` : '';
- const name =
+ const busyBadge =
user?.availability && isUserBusy(user.availability)
- ? sprintf(__('%{name} (Busy)'), { name: user.name })
- : user.name;
+ ? `<span class="badge badge-warning badge-pill gl-badge sm">${__('Busy')}</span>`
+ : '';
return `
<li data-user-id=${user.id} ${dataUserSuggested}>
<a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
<strong class="dropdown-menu-user-full-name gl-font-weight-bold">
- ${escape(name)}
+ ${escape(user.name)}
+ ${busyBadge}
</strong>
${
username
@@ -725,7 +729,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/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
index 77736fb6ef5..e30982985b3 100644
--- a/app/assets/javascripts/visibility_level/constants.js
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private';
export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal';
export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public';
@@ -18,3 +20,33 @@ export const VISIBILITY_LEVELS_INTEGER_TO_STRING = {
[VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING,
[VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING,
};
+
+export const GROUP_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The group and any public projects can be viewed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ 'Private - The group and its projects can only be viewed by members.',
+ ),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The project can be accessed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The project can be accessed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
+ ),
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
+};
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..952ff9b18e9 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
@@ -1,10 +1,21 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlPopover,
+ GlSprintf,
+ GlLink,
+ GlDropdown,
+ GlDropdownItem,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
export default {
components: {
GlButton,
+ GlPopover,
+ GlSprintf,
+ GlLink,
GlDropdown,
GlDropdownItem,
},
@@ -82,30 +93,46 @@ export default {
<template>
<div class="gl-display-flex gl-align-items-flex-start">
<template v-if="hasOneOption">
- <gl-button
- v-for="(btn, index) in tertiaryButtons"
- :id="btn.id"
- :key="index"
- v-gl-tooltip.hover
- :title="setTooltip(btn)"
- :href="btn.href"
- :target="btn.target"
- :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
- :data-clipboard-text="btn.dataClipboardText"
- :data-qa-selector="actionButtonQaSelector(btn)"
- :data-method="btn.dataMethod"
- :icon="btn.icon"
- :data-testid="btn.testId || 'extension-actions-button'"
- :variant="btn.variant || 'confirm'"
- :loading="btn.loading"
- :disabled="btn.loading"
- category="tertiary"
- size="small"
- class="gl-md-display-block gl-float-left"
- @click="onClickAction(btn)"
- >
- {{ btn.text }}
- </gl-button>
+ <span v-for="(btn, index) in tertiaryButtons" :key="index">
+ <gl-button
+ :id="btn.id"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
+ :href="btn.href"
+ :target="btn.target"
+ :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
+ :data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="actionButtonQaSelector(btn)"
+ :data-method="btn.dataMethod"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
+ category="tertiary"
+ size="small"
+ class="gl-md-display-block gl-float-left"
+ @click="onClickAction(btn)"
+ >
+ {{ btn.text }}
+ </gl-button>
+ <gl-popover v-if="btn.popoverTarget" :target="btn.popoverTarget">
+ <template #title> {{ btn.popoverTitle }} </template>
+
+ <span v-if="btn.popoverLink">
+ <gl-sprintf :message="btn.popoverText">
+ <template #link="{ content }">
+ <gl-link class="gl-font-sm" :href="btn.popoverLink" target="_blank">
+ {{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-else>
+ {{ btn.popoverText }}
+ </span>
+ </gl-popover>
+ </span>
</template>
<template v-if="hasMultipleOptions">
<gl-dropdown
@@ -134,30 +161,46 @@ export default {
{{ btn.text }}
</gl-dropdown-item>
</gl-dropdown>
- <gl-button
- v-for="(btn, index) in tertiaryButtons"
- :id="btn.id"
- :key="index"
- v-gl-tooltip.hover
- :title="setTooltip(btn)"
- :href="btn.href"
- :target="btn.target"
- :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
- :data-clipboard-text="btn.dataClipboardText"
- :data-qa-selector="actionButtonQaSelector(btn)"
- :data-method="btn.dataMethod"
- :icon="btn.icon"
- :data-testid="btn.testId || 'extension-actions-button'"
- :variant="btn.variant || 'confirm'"
- :loading="btn.loading"
- :disabled="btn.loading"
- category="tertiary"
- size="small"
- class="gl-display-none gl-md-display-block gl-float-left"
- @click="onClickAction(btn)"
- >
- {{ btn.text }}
- </gl-button>
+ <span v-for="(btn, index) in tertiaryButtons" :key="index">
+ <gl-button
+ :id="btn.id"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
+ :href="btn.href"
+ :target="btn.target"
+ :class="[{ 'gl-mr-1': index !== tertiaryButtons.length - 1 }, btn.class]"
+ :data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="actionButtonQaSelector(btn)"
+ :data-method="btn.dataMethod"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
+ category="tertiary"
+ size="small"
+ class="gl-display-none gl-md-display-block gl-float-left"
+ @click="onClickAction(btn)"
+ >
+ {{ btn.text }}
+ </gl-button>
+ <gl-popover v-if="btn.popoverTarget" :target="btn.popoverTarget">
+ <template #title> {{ btn.popoverTitle }} </template>
+
+ <span v-if="btn.popoverLink">
+ <gl-sprintf :message="btn.popoverText">
+ <template #link="{ content }">
+ <gl-link class="gl-font-sm" :href="btn.popoverLink" target="_blank">
+ {{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ <span v-else>
+ {{ btn.popoverText }}
+ </span>
+ </gl-popover>
+ </span>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
index f377a185879..5090081d281 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -1,6 +1,7 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { n__, s__, sprintf } from '~/locale';
@@ -49,7 +50,7 @@ export default {
},
computed: {
isMerged() {
- return this.state === 'merged';
+ return this.state === STATUS_MERGED;
},
targetBranchEscaped() {
return escape(this.targetBranch);
@@ -67,7 +68,7 @@ export default {
);
},
message() {
- if (this.state === 'closed') {
+ if (this.state === STATUS_CLOSED) {
return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.');
} else if (this.isMerged) {
return s__(
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..25cf5335fb5 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,32 +1,34 @@
<script>
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
-import { createAlert } from '~/flash';
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { STATUS_MERGED } from '~/issues/constants';
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 { s__, __, sprintf } 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';
-import MrWidgetIcon from '../mr_widget_icon.vue';
+import StateContainer from '../state_container.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',
components: {
- MrWidgetContainer,
- MrWidgetIcon,
ApprovalsSummary,
ApprovalsSummaryOptional,
+ StateContainer,
GlButton,
GlSprintf,
- GlLink,
},
mixins: [approvalsMixin, glFeatureFlagsMixin()],
+ provide: {
+ expandDetailsTooltip: __('Expand eligible approvers'),
+ collapseDetailsTooltip: __('Collapse eligible approvers'),
+ },
props: {
mr: {
type: Object,
@@ -56,13 +58,16 @@ export default {
required: false,
default: false,
},
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
- fetchingApprovals: true,
hasApprovalAuthError: false,
isApproving: false,
- updatedCount: 0,
};
},
computed: {
@@ -70,7 +75,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,32 +83,40 @@ export default {
hasAction() {
return Boolean(this.action);
},
- approvals() {
- return this.mr.approvals || {};
- },
invalidRules() {
- return this.approvals.invalid_approvers_rules || [];
+ return this.approvals.approvalState?.rules?.filter((rule) => rule.invalid) || [];
+ },
+ invalidApprovedRules() {
+ return this.invalidRules.filter((rule) => rule.allowMergeWhenInvalid);
+ },
+ invalidFailedRules() {
+ return this.invalidRules.filter((rule) => !rule.allowMergeWhenInvalid);
},
hasInvalidRules() {
- return this.approvals.merge_request_approvers_available && this.invalidRules.length;
+ return this.mr.mergeRequestApproversAvailable && this.invalidRules.length;
},
- invalidRulesText() {
- return humanizeInvalidApproversRules(this.invalidRules);
+ hasInvalidApprovedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidApprovedRules.length;
+ },
+ hasInvalidFailedRules() {
+ return this.mr.mergeRequestApproversAvailable && this.invalidFailedRules.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;
},
showUnapprove() {
- return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
+ return this.userHasApproved && !this.userCanApprove && this.mr.state !== STATUS_MERGED;
},
approvalText() {
return this.isApproved && this.approvedBy.length > 0
@@ -129,24 +142,29 @@ export default {
return null;
},
- pluralizedRuleText() {
- return this.invalidRules.length > 1
+ pluralizedApprovedRuleText() {
+ return this.invalidApprovedRules.length > 1
? this.$options.i18n.invalidRulesPlural
: this.$options.i18n.invalidRuleSingular;
},
- },
- created() {
- this.refreshApprovals()
- .then(() => {
- this.fetchingApprovals = false;
- })
- .catch(() =>
- this.alerts.push(
- createAlert({
- message: FETCH_ERROR,
- }),
- ),
- );
+ pluralizedFailedRuleText() {
+ return this.invalidFailedRules.length > 1
+ ? this.$options.i18n.invalidFailedRulesPlural
+ : this.$options.i18n.invalidFailedRuleSingular;
+ },
+ pluralizedRuleText() {
+ return [
+ this.hasInvalidFailedRules
+ ? sprintf(this.pluralizedFailedRuleText, { rules: this.invalidFailedRules.length })
+ : null,
+ this.hasInvalidApprovedRules
+ ? sprintf(this.pluralizedApprovedRuleText, { rules: this.invalidApprovedRules.length })
+ : null,
+ ]
+ .filter((text) => Boolean(text))
+ .join(', ')
+ .concat('.');
+ },
},
methods: {
approve() {
@@ -196,16 +214,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(() => {
@@ -216,21 +232,30 @@ export default {
FETCH_LOADING,
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}',
+ invalidRuleSingular: s__('mrWidget|%{rules} invalid rule has been approved automatically'),
+ invalidRulesPlural: s__('mrWidget|%{rules} invalid rules have been approved automatically'),
+ invalidFailedRuleSingular: s__(
+ "mrWidget|%{dangerStart}%{rules} rule can't be approved%{dangerEnd}",
),
- invalidRulesPlural: s__(
- 'mrWidget|Approval rules %{rules} are invalid. GitLab has approved these rules automatically to unblock the merge request. %{link}',
+ invalidFailedRulesPlural: s__(
+ "mrWidget|%{dangerStart}%{rules} rules can't be approved%{dangerEnd}",
),
learnMore: __('Learn more.'),
},
};
</script>
<template>
- <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 class="js-mr-approvals mr-section-container mr-widget-workflow">
+ <state-container
+ :is-loading="$apollo.queries.approvals.loading"
+ :mr="mr"
+ status="approval"
+ is-collapsible
+ collapse-on-desktop
+ :collapsed="collapsed"
+ @toggle="() => $emit('toggle')"
+ >
+ <template v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</template>
<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">
@@ -239,7 +264,7 @@ export default {
:variant="action.variant"
:category="action.category"
:loading="isApproving"
- class="gl-mr-5"
+ class="gl-mr-3"
data-qa-selector="approve_button"
@click="action.action"
>
@@ -252,21 +277,15 @@ export default {
/>
<approvals-summary
v-else
- :project-path="mr.targetProjectFullPath"
- :iid="`${mr.iid}`"
- :updated-count="updatedCount"
+ :approval-state="approvals"
+ :disable-committers-approval="disableCommittersApproval"
: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 #danger="{ content }">
+ <span class="gl-font-weight-bold text-danger">{{ content }}</span>
</template>
</gl-sprintf>
</div>
@@ -277,9 +296,7 @@ export default {
:has-approval-auth-error="hasApprovalAuthError"
></slot>
</template>
- </div>
- <template #footer>
- <slot name="footer"></slot>
- </template>
- </mr-widget-container>
+ </state-container>
+ <slot name="footer"></slot>
+ </div>
</template>
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..367395f4446 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,5 @@
<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
+import { GlLink, GlPopover } from '@gitlab/ui';
import { toNounSeriesText } from '~/lib/utils/grammar';
import { n__, sprintf } from '~/locale';
import {
@@ -10,40 +10,24 @@ 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,
+ GlLink,
+ GlPopover,
UserAvatarList,
},
props: {
- projectPath: {
- type: String,
- required: true,
+ multipleApprovalRulesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- iid: {
- type: String,
+ approvalState: {
+ type: Object,
required: true,
},
- updatedCount: {
- type: Number,
- required: false,
- default: 0,
- },
- multipleApprovalRulesAvailable: {
+ disableCommittersApproval: {
type: Boolean,
required: false,
default: false,
@@ -51,7 +35,7 @@ export default {
},
data() {
return {
- approvalState: {},
+ isUserAvatarListExpanded: false,
};
},
computed: {
@@ -130,13 +114,23 @@ export default {
(approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId,
);
},
+ currentUserHasCommitted() {
+ if (!this.currentUserId) return false;
+
+ return this.approvalState.committers?.nodes?.some(
+ (user) => getIdFromGraphQLId(user.id) === this.currentUserId,
+ );
+ },
currentUserId() {
return gon.current_user_id;
},
},
- watch: {
- updatedCount() {
- this.$apollo.queries.approvalState.refetch();
+ methods: {
+ onUserAvatarListExpanded() {
+ this.isUserAvatarListExpanded = true;
+ },
+ onUserAvatarListCollapsed() {
+ this.isUserAvatarListExpanded = false;
},
},
};
@@ -144,27 +138,26 @@ export default {
<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"
+ :class="{ 'gl-pt-1': isUserAvatarListExpanded }"
+ :img-size="24"
+ :items="approvers"
+ @expanded="onUserAvatarListExpanded"
+ @collapsed="onUserAvatarListCollapsed"
+ />
+ </template>
+ <template v-if="disableCommittersApproval && currentUserHasCommitted">
+ <gl-link id="cant-approve-popover" data-testid="commit-cant-approve" class="gl-cursor-help">{{
+ __("Why can't I approve?")
+ }}</gl-link>
+ <gl-popover target="cant-approve-popover">
+ {{ __("You can't approve because you added one or more commits to this merge request.") }}
+ </gl-popover>
</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/approvals/queries/approvals.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql
new file mode 100644
index 00000000000..d5092d9ae1a
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
+subscription mergeRequestApprovalStateUpdated($issuableId: IssuableID!) {
+ mergeRequestApprovalStateUpdated(issuableId: $issuableId) {
+ ... on MergeRequest {
+ id
+ approvedBy {
+ nodes {
+ ...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/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
index 39a6086e0d5..f4029c39225 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -7,10 +7,7 @@ import {
GlLink,
GlSearchBoxByType,
} from '@gitlab/ui';
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { s__, __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import ReviewAppLink from '../review_app_link.vue';
export default {
@@ -22,7 +19,6 @@ export default {
GlIcon,
GlLink,
GlSearchBoxByType,
- ModalCopyButton,
ReviewAppLink,
},
directives: {
@@ -54,13 +50,6 @@ export default {
filteredChanges() {
return this.deployment?.changes?.filter((change) => change.path.includes(this.searchTerm));
},
- isSafeUrl() {
- return isSafeURL(this.deploymentExternalUrl);
- },
- },
- i18n: {
- copy: __('Copy URL'),
- copyTitle: s__('Environments|Copy live environment URL'),
},
};
</script>
@@ -68,20 +57,11 @@ export default {
<span class="gl-display-inline-flex">
<gl-button-group v-if="shouldRenderDropdown" size="small">
<review-app-link
- v-if="isSafeUrl"
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
- css-class="deploy-link js-deploy-url inline"
+ css-class="deploy-link js-deploy-url gl-display-inline"
/>
- <modal-copy-button
- v-else
- :title="$options.i18n.copyTitle"
- :text="deploymentExternalUrl"
- size="small"
- >
- {{ $options.i18n.copy }}
- </modal-copy-button>
<gl-dropdown toggle-class="gl-px-2!" size="small" class="js-mr-wigdet-deployment-dropdown">
<template #button-content>
<gl-icon
@@ -110,22 +90,12 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
- <template v-else>
- <review-app-link
- v-if="isSafeUrl"
- :display="appButtonText"
- :link="deploymentExternalUrl"
- size="small"
- css-class="deploy-link js-deploy-url inline"
- />
- <modal-copy-button
- v-else
- :title="$options.i18n.copyTitle"
- :text="deploymentExternalUrl"
- size="small"
- >
- {{ $options.i18n.copy }}
- </modal-copy-button>
- </template>
+ <review-app-link
+ v-else
+ :display="appButtonText"
+ :link="deploymentExternalUrl"
+ size="small"
+ css-class="deploy-link js-deploy-url gl-display-inline"
+ />
</span>
</template>
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/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index f9d0986d60d..1e5f91e12cf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -60,7 +60,7 @@ export default {
>
<div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto">
<div class="gl-display-flex gl-m-auto gl-translate-y-n50">
- <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
index e3f87c08ad4..4f8f8d6cb58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -6,86 +6,6 @@ import {
TELEMETRY_WIDGET_FULL_REPORT_CLICKED,
} from '../../constants';
-/*
- * Additional events to send beyond the defaults for certain widget extensions
- */
-const nonStandardEvents = {
- codeQuality: {
- uniqueUser: {
- expand: ['i_testing_code_quality_widget_total'],
- },
- counter: {},
- },
- terraform: {
- uniqueUser: {
- expand: ['i_testing_terraform_widget_total'],
- },
- counter: {},
- },
- issues: {
- uniqueUser: {
- expand: ['i_testing_issues_widget_total'],
- },
- counter: {},
- },
- testSummary: {
- uniqueUser: {
- expand: ['i_testing_summary_widget_total'],
- },
- counter: {},
- },
- metrics: {
- uniqueUser: {
- expand: ['i_testing_metrics_report_widget_total'],
- },
- counter: {},
- },
- browserPerformance: {
- uniqueUser: {
- expand: ['i_testing_web_performance_widget_total'],
- },
- counter: {},
- },
- licenseCompliance: {
- uniqueUser: {
- expand: ['i_testing_license_compliance_widget_total'],
- },
- counter: {},
- },
- loadPerformance: {
- uniqueUser: {
- expand: ['i_testing_load_performance_widget_total'],
- },
- counter: {},
- },
- statusChecks: {
- uniqueUser: {
- expand: ['i_testing_status_checks_widget'],
- },
- counter: {},
- },
-};
-
-function combineDeepArray(path, ...objects) {
- const parts = path.split('.');
- const allEntries = objects.reduce((entries, currentObject) => {
- let expandedEntries = entries;
- let traversed = currentObject;
-
- parts.forEach((part) => {
- traversed = traversed?.[part];
- });
-
- if (traversed) {
- expandedEntries = [...entries, ...traversed];
- }
-
- return expandedEntries;
- }, []);
-
- return Array.from(new Set(allEntries));
-}
-
function simplifyWidgetName(componentName) {
const noWidget = componentName.replace(/^Widget/, '');
@@ -166,7 +86,6 @@ function defaultBehaviorEvents({ bus, config }) {
function baseTelemetry(componentName) {
const simpleExtensionName = simplifyWidgetName(componentName);
- const additionalNonStandard = nonStandardEvents[simpleExtensionName] || {};
/*
* Telemetry config format is:
* {
@@ -179,7 +98,7 @@ function baseTelemetry(componentName) {
* - uniqueUser is sent to RedisHLL
* - counter is sent to a regular Redis counter
*/
- const defaultTelemetry = {
+ return {
uniqueUser: {
view: [`${baseRedisEventName(simpleExtensionName)}_view`],
expand: [`${baseRedisEventName(simpleExtensionName)}_expand`],
@@ -191,27 +110,6 @@ function baseTelemetry(componentName) {
clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_count_click_full_report`],
},
};
-
- return {
- uniqueUser: {
- view: combineDeepArray('uniqueUser.view', defaultTelemetry, additionalNonStandard),
- expand: combineDeepArray('uniqueUser.expand', defaultTelemetry, additionalNonStandard),
- clickFullReport: combineDeepArray(
- 'uniqueUser.clickFullReport',
- defaultTelemetry,
- additionalNonStandard,
- ),
- },
- counter: {
- view: combineDeepArray('counter.view', defaultTelemetry, additionalNonStandard),
- expand: combineDeepArray('counter.expand', defaultTelemetry, additionalNonStandard),
- clickFullReport: combineDeepArray(
- 'counter.clickFullReport',
- defaultTelemetry,
- additionalNonStandard,
- ),
- },
- };
}
export function createTelemetryHub(componentName) {
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/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 20284c4a3d8..26527361b2e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/require-i18n-strings */
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { escapeShellString } from '~/lib/utils/text_utility';
@@ -87,11 +86,11 @@ export default {
const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`);
return this.isFork
- ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD`
- : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`;
+ ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` // eslint-disable-line @gitlab/require-i18n-strings
+ : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
mergeInfo2() {
- return `git push origin ${this.escapedSourceBranch}`;
+ return `git push origin ${this.escapedSourceBranch}`; // eslint-disable-line @gitlab/require-i18n-strings
},
escapedForkBranch() {
return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 2dec95c3fda..4e16b92fc05 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -173,7 +173,7 @@ export default {
</p>
</template>
<template v-else-if="!hasPipeline">
- <gl-loading-icon size="md" />
+ <gl-loading-icon size="sm" />
<p
class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0"
data-testid="monitoring-pipeline-message"
@@ -187,7 +187,7 @@ export default {
class="gl-display-flex gl-align-items-center gl-ml-2"
>
<gl-icon
- name="question"
+ name="question-o"
:aria-label="__('Link to go to GitLab pipeline documentation')"
/>
</gl-link>
@@ -251,7 +251,7 @@ export default {
</span>
{{ pipelineCoverageJobNumberText }}
<span ref="pipelineCoverageQuestion">
- <gl-icon name="question" :size="12" />
+ <gl-icon name="question-o" :size="12" />
</span>
<gl-tooltip
:target="() => $refs.pipelineCoverageQuestion"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index 1fd1e264c25..5d75f1d27b1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__, n__ } from '~/locale';
@@ -30,10 +31,10 @@ export default {
},
computed: {
closesText() {
- if (this.state === 'merged') {
+ if (this.state === STATUS_MERGED) {
return s__('mrWidget|Closed');
}
- if (this.state === 'closed') {
+ if (this.state === STATUS_CLOSED) {
return s__('mrWidget|Did not close');
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 3239285e53e..ea3f324b8f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './extensions/status_icon.vue';
export default {
@@ -14,22 +15,24 @@ export default {
},
},
computed: {
+ isClosed() {
+ return this.status === STATUS_CLOSED;
+ },
isLoading() {
return this.status === 'loading';
},
+ isMerged() {
+ return this.status === STATUS_MERGED;
+ },
},
};
</script>
<template>
- <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3">
+ <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-center gl-mr-3">
<div class="gl-display-flex gl-m-auto">
- <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" />
- <gl-icon
- v-else-if="status === 'closed'"
- name="merge-request-close"
- :size="16"
- class="gl-text-red-500"
- />
+ <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" />
+ <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" />
+ <gl-icon v-else-if="status === 'approval'" name="approval" :size="16" />
<status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" />
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index e31e69d0f3a..116bb251831 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -38,6 +38,7 @@ export default {
:size="size"
target="_blank"
rel="noopener noreferrer nofollow"
+ is-unsafe-link
:class="cssClass"
data-track-action="open_review_app"
data-track-label="review_app"
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..dd899701de0 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
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './mr_widget_status_icon.vue';
import Actions from './action_buttons.vue';
@@ -13,7 +13,30 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: {
+ expandDetailsTooltip: {
+ default: '',
+ },
+ collapseDetailsTooltip: {
+ default: '',
+ },
+ },
props: {
+ isCollapsible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapseOnDesktop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ collapsed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
mr: {
type: Object,
required: false,
@@ -35,14 +58,10 @@ export default {
default: () => [],
},
},
- i18n: {
- expandDetailsTooltip: __('Expand merge details'),
- collapseDetailsTooltip: __('Collapse merge details'),
- },
computed: {
wrapperClasses() {
- if (this.status === 'merged') return 'gl-bg-blue-50';
- if (this.status === 'closed') return 'gl-bg-red-50';
+ if (this.status === STATUS_MERGED) return 'gl-bg-blue-50';
+ if (this.status === STATUS_CLOSED) return 'gl-bg-red-50';
return null;
},
hasActionsSlot() {
@@ -54,15 +73,15 @@ export default {
<template>
<div
- class="mr-widget-body media gl-display-flex gl-align-items-center"
+ class="mr-widget-body media gl-display-flex gl-align-items-center gl-pl-5 gl-pr-4 gl-py-4"
:class="wrapperClasses"
v-on="$listeners"
>
- <div v-if="isLoading" class="gl-w-full mr-conflict-loader">
+ <div v-if="isLoading" class="gl-w-full mr-state-loader">
<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 +97,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
@@ -94,21 +113,19 @@ export default {
</div>
</div>
<div
- v-if="mr"
- class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1"
+ v-if="isCollapsible"
+ :class="{ 'gl-md-display-none': !collapseOnDesktop }"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
>
<gl-button
v-gl-tooltip
- :title="
- mr.mergeDetailsCollapsed
- ? $options.i18n.expandDetailsTooltip
- : $options.i18n.collapseDetailsTooltip
- "
- :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip"
+ :icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
size="small"
class="gl-vertical-align-top"
- @click="() => mr.toggleMergeDetails()"
+ data-testid="widget-toggle"
+ @click="() => $emit('toggle')"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index 6d7ec607557..61eec503951 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -43,7 +43,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
<bold-text :message="failedText" />
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 837f8b32637..722efe2e6d2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -24,7 +24,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<bold-text :message="$options.message" />
</state-container>
</template>
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..6299f0fcbb8 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';
@@ -131,16 +131,23 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions">
+ <state-container
+ status="scheduled"
+ :is-loading="loading"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="7" width="150" height="16" rx="4" />
- <rect x="190" y="7" width="144" height="16" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="150" height="20" rx="4" />
+ <rect x="190" y="2" width="144" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!loading">
- <h4 class="gl-mr-3" data-testid="statusText">
+ <h4 class="gl-mr-3 gl-flex-grow-1" data-testid="statusText">
<gl-sprintf :message="statusText" data-testid="statusText">
<template #merge_author>
<mr-widget-author v-if="state.mergeUser" :author="state.mergeUser" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index 448805cf8b9..db5ef6c1a0e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -54,7 +54,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="failed" :actions="actions">
+ <state-container
+ status="failed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-font-weight-bold">
<template v-if="mergeError">{{ mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index 670bd36d61e..d4b7d60568b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -15,7 +15,12 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="loading">
+ <state-container
+ status="loading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
{{ s__('mrWidget|Checking if merge request can be merged…') }}
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index 6bcf88713a5..aebba67b39a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -79,7 +79,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="closed" :actions="actions">
+ <state-container
+ status="closed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<mr-widget-author-time
:action-text="s__('mrWidget|Closed by')"
:author="mr.metrics.closedBy"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 83d718f5a54..55ae390216d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -72,12 +72,18 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" status="failed" :is-loading="isLoading">
+ <state-container
+ status="failed"
+ :is-loading="isLoading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<template #loading>
- <gl-skeleton-loader :width="334" :height="30">
- <rect x="0" y="7" width="150" height="16" rx="4" />
- <rect x="158" y="7" width="84" height="16" rx="4" />
- <rect x="250" y="7" width="84" height="16" rx="4" />
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" width="150" height="20" rx="4" />
+ <rect x="190" y="2" width="144" height="20" rx="4" />
</gl-skeleton-loader>
</template>
<template v-if="!isLoading">
@@ -106,7 +112,7 @@ export default {
v-if="userPermissions.canMerge"
size="small"
variant="confirm"
- category="secondary"
+ category="tertiary"
data-testid="merge-locally-button"
class="js-check-out-modal-trigger gl-align-self-start"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index bfc2c282f4c..742f5d4de14 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -95,12 +95,25 @@ export default {
};
</script>
<template>
- <state-container v-if="isRefreshing" :mr="mr" status="loading">
+ <state-container
+ v-if="isRefreshing"
+ status="loading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-font-weight-bold">
{{ s__('mrWidget|Refreshing now') }}
</span>
</state-container>
- <state-container v-else :mr="mr" status="failed" :actions="actions">
+ <state-container
+ v-else
+ status="failed"
+ :actions="actions"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span
v-if="mr.mergeError"
class="has-error-message gl-font-weight-bold"
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..4d906f29cb0 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';
@@ -150,7 +150,13 @@ export default {
};
</script>
<template>
- <state-container :mr="mr" :actions="actions" status="merged">
+ <state-container
+ :actions="actions"
+ status="merged"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<mr-widget-author-time
:action-text="s__('mrWidget|Merged by')"
:author="mr.metrics.mergedBy"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index c94718ca756..17c51bc4e6e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,5 +1,6 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import { STATUS_MERGED } from '~/issues/constants';
import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
@@ -50,7 +51,7 @@ export default {
.poll()
.then((res) => res.data)
.then((data) => {
- if (data.state === 'merged') {
+ if (data.state === STATUS_MERGED) {
// If state is merged we should update the widget and stop the polling
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('FetchActionsContent');
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..415f58ea8e6 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,109 @@ 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
+ :status="status"
+ :is-loading="isLoading"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
+ <template #loading>
+ <gl-skeleton-loader :width="334" :height="24">
+ <rect x="0" y="0" width="24" height="24" rx="4" />
+ <rect x="32" y="2" 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..30cd9fa752f 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/empty-state/empty-merge-requests-md.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-3 col-12 text-center d-flex justify-content-center align-items-center svg-content svg-150 pb-0 pt-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">
+ <div class="text col-md-9 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..f120680b440 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,11 +11,12 @@ 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 { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
import { __, s__, n__ } from '~/locale';
@@ -23,6 +24,7 @@ import SmartInterval from '~/smart_interval';
import { helpPagePath } from '~/helpers/help_page_helper';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import {
AUTO_MERGE_STRATEGIES,
WARNING,
@@ -86,7 +88,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();
}
},
@@ -143,6 +145,7 @@ export default {
),
AddedCommitMessage,
RelatedLinks,
+ HelpPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -261,7 +264,10 @@ export default {
if (this.isMergingImmediately) {
return __('Merge in progress');
}
- if (this.isAutoMergeAvailable) {
+ if (this.isAutoMergeAvailable && !this.autoMergeLabelsEnabled) {
+ return this.autoMergeTextLegacy;
+ }
+ if (this.isAutoMergeAvailable && this.autoMergeLabelsEnabled) {
return this.autoMergeText;
}
@@ -271,9 +277,24 @@ export default {
return __('Merge');
},
+ autoMergeLabelsEnabled() {
+ return window.gon?.features?.autoMergeLabelsMrWidget;
+ },
+ showAutoMergeHelperText() {
+ return (
+ !(this.status === PIPELINE_FAILED_STATE || this.isPipelineFailed) &&
+ this.isAutoMergeAvailable
+ );
+ },
hasPipelineMustSucceedConflict() {
return !this.hasCI && this.stateData.onlyAllowMergeIfPipelineSucceeds;
},
+ isNotClosed() {
+ return this.mr.state !== STATUS_CLOSED;
+ },
+ isNeitherClosedNorMerged() {
+ return this.mr.state !== STATUS_CLOSED && this.mr.state !== STATUS_MERGED;
+ },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -307,7 +328,7 @@ export default {
);
},
sourceBranchDeletedText() {
- const isPreMerge = this.mr.state !== 'merged';
+ const isPreMerge = this.mr.state !== STATUS_MERGED;
if (isPreMerge) {
return this.mr.shouldRemoveSourceBranch
@@ -325,6 +346,11 @@ export default {
showMergeDetailsHeader() {
return !['readyToMerge'].includes(this.mr.state);
},
+ autoMergeHelpPopoverOptions() {
+ return {
+ title: this.autoMergePopoverSettings.title,
+ };
+ },
},
mounted() {
eventHub.$on('ApprovalUpdated', this.updateGraphqlState);
@@ -495,17 +521,19 @@ export default {
<template>
<div
- :class="{ 'gl-bg-gray-10': mr.state !== 'closed' && mr.state !== 'merged' }"
+ :class="{ 'gl-bg-gray-10': isNeitherClosedNorMerged }"
data-testid="ready_to_merge_state"
class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-pl-7"
>
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">
- <gl-skeleton-loader :width="418" :height="30">
- <rect x="0" y="3" width="24" height="24" rx="4" />
- <rect x="32" y="0" width="70" height="30" rx="4" />
- <rect x="110" y="7" width="150" height="16" rx="4" />
- <rect x="268" y="7" width="150" height="16" rx="4" />
+ <gl-skeleton-loader :width="418" :height="86">
+ <rect x="0" y="0" width="144" height="20" rx="4" />
+ <rect x="0" y="26" width="100" height="16" rx="4" />
+ <rect x="108" y="26" width="100" height="16" rx="4" />
+ <rect x="0" y="48" width="130" height="16" rx="4" />
+ <rect x="0" y="70" width="80" height="16" rx="4" />
+ <rect x="88" y="70" width="90" height="16" rx="4" />
</gl-skeleton-loader>
</div>
</div>
@@ -517,14 +545,14 @@ export default {
<div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap">
<template v-if="shouldShowMergeControls">
<div
- class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full gl-md-pb-5"
+ class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full"
>
<gl-form-checkbox
v-if="canRemoveSourceBranch"
id="remove-source-branch-input"
v-model="removeSourceBranch"
:disabled="isRemoveSourceBranchButtonDisabled"
- class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5 gl-mb-3 gl-md-mb-0"
+ class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5"
data-testid="delete-source-branch-checkbox"
>
{{ __('Delete source branch') }}
@@ -536,14 +564,13 @@ export default {
v-model="squashBeforeMerge"
:help-path="mr.squashBeforeMergeHelpPath"
:is-disabled="isSquashReadOnly"
- class="gl-mr-5 gl-mb-3 gl-md-mb-0"
+ class="gl-mr-5"
/>
<gl-form-checkbox
v-if="shouldShowSquashEdit || shouldShowMergeEdit"
v-model="editCommitMessage"
data-testid="widget_edit_commit_message"
- class="gl-display-flex gl-align-items-center gl-mb-3 gl-md-mb-0"
>
{{ __('Edit commit message') }}
</gl-form-checkbox>
@@ -587,9 +614,7 @@ export default {
</li>
</ul>
</div>
- <div
- class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details"
- >
+ <div class="gl-w-full gl-text-gray-500 gl-mb-3 mr-widget-merge-details">
<template v-if="sourceHasDivergedFromTarget">
<gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText">
<template #link>
@@ -670,7 +695,31 @@ export default {
@cancel="isPipelineFailedModalVisibleNormalMerge = false"
/>
</gl-button-group>
- <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
+ <merge-train-helper-icon
+ v-if="shouldRenderMergeTrainHelperIcon && !autoMergeLabelsEnabled"
+ class="gl-mx-3"
+ />
+ <template v-if="showAutoMergeHelperText && autoMergeLabelsEnabled">
+ <div
+ class="gl-ml-4 gl-text-gray-500 gl-font-sm"
+ data-qa-selector="auto_merge_helper_text"
+ >
+ {{ autoMergeHelperText }}
+ </div>
+ <help-popover class="gl-ml-2" :options="autoMergeHelpPopoverOptions">
+ <gl-sprintf :message="autoMergePopoverSettings.bodyText">
+ <template #link="{ content }">
+ <gl-link
+ :href="autoMergePopoverSettings.helpLink"
+ target="_blank"
+ class="gl-font-sm"
+ >
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </help-popover>
+ </template>
</template>
<div
v-else
@@ -702,7 +751,7 @@ export default {
/>
</li>
<li
- v-if="mr.state !== 'closed'"
+ v-if="isNotClosed"
class="gl-line-height-normal"
data-testid="source-branch-deleted-text"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index 2aa345b420e..9da754d01fc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -24,7 +24,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span
class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
data-qa-selector="head_mismatch_content"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 1413a46b4b9..97ef96fe382 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -39,13 +39,13 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex">
<gl-form-checkbox
v-gl-tooltip
:checked="value"
:disabled="isDisabled"
name="squash"
- class="js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
+ class="js-squash-checkbox gl-mr-2"
data-qa-selector="squash_checkbox"
:title="tooltipTitle"
@change="(checked) => $emit('input', checked)"
@@ -57,7 +57,7 @@ export default {
v-gl-tooltip
:href="helpPath"
:title="$options.i18n.helpLabel"
- class="gl-text-blue-600"
+ class="gl-text-blue-600 gl-line-height-1"
target="_blank"
>
<gl-icon name="question-o" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 0fd5551979d..af036c01032 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import notesEventHub from '~/notes/event_hub';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import StateContainer from '../state_container.vue';
const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.');
@@ -15,6 +16,7 @@ export default {
GlButton,
StateContainer,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
mr: {
type: Object,
@@ -30,7 +32,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
<bold-text :message="$options.message" />
</span>
@@ -43,17 +50,17 @@ export default {
category="primary"
@click="jumpToFirstUnresolvedDiscussion"
>
- {{ s__('mrWidget|Jump to first unresolved thread') }}
+ {{ s__('mrWidget|Go to first unresolved thread') }}
</gl-button>
<gl-button
- v-if="mr.createIssueToResolveDiscussionsPath"
+ v-if="mr.createIssueToResolveDiscussionsPath && !glFeatures.hideCreateIssueResolveAll"
:href="mr.createIssueToResolveDiscussionsPath"
class="js-create-issue gl-align-self-start gl-vertical-align-top"
size="small"
variant="confirm"
category="secondary"
>
- {{ s__('mrWidget|Create issue to resolve all threads') }}
+ {{ s__('mrWidget|Resolve all with new issue') }}
</gl-button>
</template>
</state-container>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 02d4f2499fe..7fc4a06cbae 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';
@@ -137,7 +137,12 @@ export default {
</script>
<template>
- <state-container :mr="mr" status="failed">
+ <state-container
+ status="failed"
+ is-collapsible
+ :collapsed="mr.mergeDetailsCollapsed"
+ @toggle="() => mr.toggleMergeDetails()"
+ >
<span class="gl-ml-0! gl-text-body! gl-flex-grow-1">
<bold-text :message="$options.i18n.removeDraftStatus" />
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
index 6d17ac98d7f..4e8098677cc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue
@@ -52,7 +52,7 @@ export default {
}"
class="gl-relative gl-rounded-full gl-mr-3"
>
- <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
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..54eb15c8ac8 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
@@ -219,6 +219,8 @@ export default {
this.fetchExpandedContent();
}
}
+
+ this.$emit('toggle', { expanded: !this.isCollapsed });
},
async fetchExpandedContent() {
this.isLoadingExpandedContent = true;
@@ -287,7 +289,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-pr-4 gl-py-4 gl-align-items-center gl-display-flex">
<status-icon
:level="1"
:name="widgetName"
@@ -346,6 +348,7 @@ export default {
category="tertiary"
data-testid="toggle-button"
size="small"
+ data-qa-selector="expand_report_button"
@click="toggleCollapsed"
/>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
index b64f9c148d1..e67924d28ab 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue
@@ -79,7 +79,7 @@ export default {
</script>
<template>
<div
- class="gl-w-full gl-display-flex"
+ class="gl-display-flex"
:class="{
'gl-border-t gl-py-3 gl-pl-7 gl-align-items-baseline': level === 2,
'gl-align-items-center': level === 3,
@@ -91,7 +91,7 @@ export default {
:name="widgetName"
:icon-name="statusIconName"
/>
- <div class="gl-w-full">
+ <div class="gl-w-full gl-min-w-0">
<div class="gl-display-flex">
<slot name="header">
<div v-if="header" class="gl-mb-2">
@@ -135,7 +135,7 @@ export default {
/>
</div>
</div>
- <div class="gl-display-flex gl-align-items-baseline gl-w-full">
+ <div class="gl-display-flex gl-align-items-baseline">
<status-icon
v-if="statusIconName && header"
:level="2"
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/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
index 4f9bba1e0cb..713c9e610b3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
@@ -63,7 +63,9 @@ export default {
this.collapsedData.newErrors.map((e) => {
return fullData.push({
- text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ text: e.check_name
+ ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}`
+ : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
subtext: {
prependText: i18n.prependText,
text: `${e.file_path}:${e.line}`,
@@ -77,7 +79,9 @@ export default {
this.collapsedData.resolvedErrors.map((e) => {
return fullData.push({
- text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
+ text: e.check_name
+ ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}`
+ : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
subtext: {
prependText: i18n.prependText,
text: `${e.file_path}:${e.line}`,
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index ff225afbc7b..d2f2d394a1f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import { STATUS_CLOSED } from '~/issues/constants';
import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
@@ -82,7 +83,7 @@ export default {
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
- name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
+ name: issue.state === STATUS_CLOSED ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
},
// Badges get rendered next to the text on each row
// badge: issue.state === 'closed' && {
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 8d596465970..a2f088a7a58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -1,7 +1,3 @@
-// This is a false violation of @gitlab/no-runtime-template-compiler, since it
-// creates a new Vue instance by spreading a _valid_ Vue component definition
-// into the Vue constructor.
-/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
@@ -13,7 +9,18 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ typePolicies: {
+ MergeRequestApprovalState: {
+ merge: true,
+ },
+ },
+ },
+ },
+ ),
});
export default () => {
@@ -22,6 +29,10 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
+ // This is a false violation of @gitlab/no-runtime-template-compiler, since it
+ // creates a new Vue instance by spreading a _valid_ Vue component definition
+ // into the Vue constructor.
+ // eslint-disable-next-line @gitlab/no-runtime-template-compiler
const vm = new Vue({
el: '#js-vue-mr-widget',
provide: {
@@ -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..3228c09c9b6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -1,9 +1,76 @@
+import mergeRequestApprovalStateUpdated from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql';
+import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql';
+
+import { createAlert } from '~/alert';
+
+import { convertToGraphQLId } from '../../graphql_shared/utils';
+import { TYPENAME_MERGE_REQUEST } from '../../graphql_shared/constants';
+
+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.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval;
+
+ this.mr.setApprovals(mergeRequest);
+ },
+ error() {
+ createAlert({
+ message: FETCH_ERROR,
+ });
+ },
+ subscribeToMore: {
+ document: mergeRequestApprovalStateUpdated,
+ variables() {
+ return {
+ issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr.id),
+ };
+ },
+ skip() {
+ return !this.mr?.id || !this.isRealtimeEnabled;
+ },
+ updateQuery(
+ _,
+ {
+ subscriptionData: {
+ data: { mergeRequestApprovalStateUpdated: queryResult },
+ },
+ },
+ ) {
+ if (queryResult) {
+ this.mr.setApprovals(queryResult);
+ }
+ },
+ },
+ },
+ },
data() {
return {
alerts: [],
+ approvals: {},
+ disableCommittersApproval: false,
};
},
+ computed: {
+ isRealtimeEnabled() {
+ // This mixin needs glFeatureFlagsMixin, but fatals if it's included here.
+ // Parents that include this mixin (approvals) should also include the
+ // glFeatureFlagsMixin mixin, or this will always be false.
+ return Boolean(this.glFeatures?.realtimeApprovals);
+ },
+ },
methods: {
clearError() {
this.$emit('clearError');
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index d964b4bacac..10a54c73273 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,3 +1,4 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
@@ -30,10 +31,25 @@ export default {
pipelineMustSucceedConflictText() {
return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
},
- autoMergeText() {
+ autoMergeTextLegacy() {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
},
+ autoMergeText() {
+ return __('Set to auto-merge');
+ },
+ autoMergeHelperText() {
+ return __('Merge when pipeline succeeds');
+ },
+ autoMergePopoverSettings() {
+ return {
+ helpLink: helpPagePath('/user/project/merge_requests/merge_when_pipeline_succeeds.html'),
+ bodyText: __(
+ 'When the pipeline for this merge request succeeds, it will %{linkStart}automatically merge%{linkEnd}.',
+ ),
+ title: __('Merge when pipeline succeeds'),
+ };
+ },
shouldShowMergeImmediatelyDropdown() {
return this.isPipelineActive && !this.stateData.onlyAllowMergeIfPipelineSucceeds;
},
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..6e0ee1cb912 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,11 +9,10 @@ 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 { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import notify from '~/lib/utils/notify';
import { sprintf, s__, __ } from '~/locale';
-import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -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: {
@@ -140,6 +155,10 @@ export default {
},
},
mixins: [mergeRequestQueryVariablesMixin],
+ provide: {
+ expandDetailsTooltip: __('Expand merge details'),
+ collapseDetailsTooltip: __('Collapse merge details'),
+ },
props: {
mrData: {
type: Object,
@@ -158,9 +177,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';
},
@@ -193,7 +230,7 @@ export default {
return this.mr.allowCollaboration && this.mr.isOpen;
},
shouldRenderMergedPipeline() {
- return this.mr.state === 'merged' && !isEmpty(this.mr.mergePipeline);
+ return this.mr.state === STATUS_MERGED && !isEmpty(this.mr.mergePipeline);
},
showMergePipelineForkWarning() {
return Boolean(
@@ -231,7 +268,7 @@ export default {
return (this.mr.humanAccess || '').toLowerCase();
},
hasMergeError() {
- return this.mr.mergeError && this.state !== 'closed';
+ return this.mr.mergeError && this.state !== STATUS_CLOSED;
},
hasAlerts() {
return this.hasMergeError || this.showMergePipelineForkWarning;
@@ -284,7 +321,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 +333,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 +367,6 @@ export default {
this.initPostMergeDeploymentsPolling();
}
- this.initPolling();
this.bindEventHubListeners();
eventHub.$on('mr.discussion.updated', this.checkStatus);
@@ -363,8 +397,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()
@@ -384,22 +420,11 @@ export default {
);
},
setFaviconHelper() {
- if (this.mr.ciStatusFaviconPath) {
- return setFaviconOverlay(this.mr.ciStatusFaviconPath);
+ if (this.mr.faviconOverlayPath) {
+ return setFaviconOverlay(this.mr.faviconOverlayPath);
}
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);
},
@@ -453,7 +478,6 @@ export default {
el.innerHTML = res.data;
document.body.appendChild(el);
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
- Project.initRefSwitcher();
}
})
.catch(() =>
@@ -476,10 +500,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..39ae1fda9f4 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
@@ -1,5 +1,6 @@
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { badgeState } from '~/issuable/components/status_box.vue';
+import { STATUS_CLOSED, STATUS_MERGED, STATUS_OPEN } from '~/issues/constants';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
import { machine } from '~/lib/utils/finite_state_machine';
import {
@@ -121,7 +122,7 @@ export default class MergeRequestStore {
this.ffOnlyEnabled = data.ff_only_enabled;
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
this.mergeRequestState = data.state;
- this.isOpen = this.mergeRequestState === 'opened';
+ this.isOpen = this.mergeRequestState === STATUS_OPEN;
this.latestSHA = data.diff_head_sha;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
@@ -139,7 +140,7 @@ export default class MergeRequestStore {
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
this.isPipelineBlocked =
data.only_allow_merge_if_pipeline_succeeds && pipelineStatus?.group === 'manual';
- this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
+ this.faviconOverlayPath = data.favicon_overlay_path;
this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path;
this.accessibilityReportPath = data.accessibility_report_path;
@@ -236,11 +237,11 @@ export default class MergeRequestStore {
this.state = getStateKey.call(this);
} else {
switch (this.mergeRequestState) {
- case 'merged':
- this.state = 'merged';
+ case STATUS_MERGED:
+ this.state = STATUS_MERGED;
break;
- case 'closed':
- this.state = 'closed';
+ case STATUS_CLOSED:
+ this.state = STATUS_CLOSED;
break;
default:
this.state = null;
@@ -269,7 +270,7 @@ export default class MergeRequestStore {
this.conflictsDocsPath = data.conflicts_docs_path;
this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
- this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
+ this.codeCoverageCheckHelpPagePath = data.code_coverage_check_help_page_path;
this.licenseComplianceDocsPath = data.license_compliance_docs_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
@@ -294,6 +295,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 +360,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/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
deleted file mode 100644
index 9d5006564ef..00000000000
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import * as Sentry from '@sentry/browser';
-import Vue from 'vue';
-import Vuex from 'vuex';
-
-Vue.use(Vuex);
-
-export default {
- props: {
- dashboardUrl: {
- type: String,
- required: false,
- default: '',
- },
- },
- data() {
- return {
- metricEmbedComponent: null,
- namespace: 'alertMetrics',
- };
- },
- mounted() {
- if (this.dashboardUrl) {
- Promise.all([
- import('~/monitoring/components/embeds/metric_embed.vue'),
- import('~/monitoring/stores'),
- ])
- .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => {
- this.$store = new Vuex.Store({
- modules: {
- [this.namespace]: monitoringDashboard,
- },
- });
- this.metricEmbedComponent = MetricEmbed;
- })
- .catch((e) => Sentry.captureException(e));
- }
- },
-};
-</script>
-
-<template>
- <div class="gl-py-3">
- <div v-if="dashboardUrl" ref="metricsChart">
- <component
- :is="metricEmbedComponent"
- v-if="metricEmbedComponent"
- :dashboard-url="dashboardUrl"
- :namespace="namespace"
- />
- </div>
- <div v-else ref="emptyState">
- {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }}
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index f2c27cf611e..0577279cdd0 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -53,11 +53,11 @@ export default {
},
methods: {
updateToDoCount(add) {
- const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10);
+ const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10) || 0;
const count = add ? oldCount + 1 : oldCount - 1;
const headerTodoEvent = new CustomEvent('todo:toggle', {
detail: {
- count,
+ count: Math.max(count, 0),
},
});
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/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js
index d106f545c61..4ee8d19770d 100644
--- a/app/assets/javascripts/vue_shared/alert_details/constants.js
+++ b/app/assets/javascripts/vue_shared/alert_details/constants.js
@@ -9,7 +9,8 @@ export const SEVERITY_LEVELS = {
UNKNOWN: s__('severity|Unknown'),
};
-/* eslint-disable @gitlab/require-i18n-strings */
+const category = 'Alert Management'; // eslint-disable-line @gitlab/require-i18n-strings
+
export const PAGE_CONFIG = {
OPERATIONS: {
TITLE: 'OPERATIONS',
@@ -20,14 +21,14 @@ export const PAGE_CONFIG = {
},
// Tracks snowplow event when user views alert details
TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: {
- category: 'Alert Management',
+ category,
action: 'view_alert_details',
},
// Tracks snowplow event when alert status is updated
TRACK_ALERT_STATUS_UPDATE_OPTIONS: {
- category: 'Alert Management',
+ category,
action: 'update_alert_status',
- label: 'Status',
+ label: 'Status', // eslint-disable-line @gitlab/require-i18n-strings
},
},
};
diff --git a/app/assets/javascripts/vue_shared/alert_details/router.js b/app/assets/javascripts/vue_shared/alert_details/router.js
index 26477a3a66a..616d5c259b9 100644
--- a/app/assets/javascripts/vue_shared/alert_details/router.js
+++ b/app/assets/javascripts/vue_shared/alert_details/router.js
@@ -5,26 +5,9 @@ import { joinPaths } from '~/lib/utils/url_utility';
Vue.use(VueRouter);
export default function createRouter(base) {
- const router = new VueRouter({
+ return new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
routes: [{ path: '/:tabId', name: 'tab' }],
});
-
- /*
- Backward-compatible behavior. Redirects hash mode URLs to history mode ones.
- Ex: from #/overview to /overview
- from #/metrics to /metrics
- from #/activity to /activity
- */
- router.beforeEach((to, _, next) => {
- if (to.hash.startsWith('#/')) {
- const path = to.fullPath.substring(2);
- next(path);
- } else {
- next();
- }
- });
-
- return router;
}
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index cb38b3e13bb..8f1f7ba0ad8 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -35,11 +35,6 @@ export default {
required: false,
default: NO_USER_ID,
},
- addButtonClass: {
- type: String,
- required: false,
- default: '',
- },
defaultAwards: {
type: Array,
required: false,
@@ -50,6 +45,11 @@ export default {
required: false,
default: 'selected',
},
+ boundary: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -201,6 +201,8 @@ export default {
v-gl-tooltip.viewport
:title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
+ :right="false"
+ :boundary="boundary"
data-testid="emoji-picker"
@click="handleAward"
@shown="setIsMenuOpen(true)"
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..b4751d51fcb 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,12 @@
<script>
+import { v4 as uuidv4 } from 'uuid';
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: {
@@ -19,6 +18,15 @@ export default {
required: true,
},
},
+ data: () => ({
+ chartKey: uuidv4(),
+ }),
+ watch: {
+ chartData() {
+ // Re-render area chart when the data changes
+ this.chartKey = uuidv4();
+ },
+ },
chartContainerHeight: CHART_CONTAINER_HEIGHT,
};
</script>
@@ -27,24 +35,22 @@ 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"
+ :key="chartKey"
+ responsive
+ width="auto"
+ :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>
- </resizable-chart-container>
+ </gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index 47b96934420..a30b18348ec 100644
--- a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -39,11 +39,10 @@ export default {
</script>
<template>
<div>
- <segmented-control-button-group
- v-model="selectedChart"
- :options="chartRanges"
- class="gl-mb-4"
- />
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <segmented-control-button-group v-model="selectedChart" :options="chartRanges" />
+ <slot name="extend-button-group"></slot>
+ </div>
<ci-cd-analytics-area-chart
v-if="chart"
v-bind="$attrs"
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/diff_stats_dropdown.vue b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
index 56e6399a1b7..f62bfb551df 100644
--- a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
@@ -147,13 +147,5 @@ export default {
</template>
</gl-sprintf>
</span>
-
- <div
- class="diff-stats-additions-deletions-collapsed gl-float-right gl-display-none"
- data-testid="diff-stats-additions-deletions-collapsed"
- >
- <span class="gl-text-green-600 gl-font-weight-bold">+{{ added }}</span>
- <span class="gl-text-red-500 gl-font-weight-bold">-{{ deleted }}</span>
- </div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js
new file mode 100644
index 00000000000..97143d90c3f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/utils.js
@@ -0,0 +1,11 @@
+import { RENAMED_DIFF_TRANSITIONS } from '~/diffs/constants';
+
+export const transition = (currentState, transitionEvent) => {
+ const key = `${currentState}:${transitionEvent}`;
+
+ if (RENAMED_DIFF_TRANSITIONS[key]) {
+ return RENAMED_DIFF_TRANSITIONS[key];
+ }
+
+ return currentState;
+};
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index b786f7752df..f7b817423de 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -10,15 +10,14 @@ import {
STATE_IDLING,
STATE_LOADING,
STATE_ERRORED,
- RENAMED_DIFF_TRANSITIONS,
} from '~/diffs/constants';
import { truncateSha } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
+import { transition } from '../utils';
export default {
STATE_LOADING,
STATE_ERRORED,
- TRANSITIONS: RENAMED_DIFF_TRANSITIONS,
uiText: {
showLink: __('Show file contents'),
commitLink: __('View file @ %{commitSha}'),
@@ -52,27 +51,23 @@ export default {
},
methods: {
...mapActions('diffs', ['switchToFullDiffFromRenamedFile']),
- transition(transitionEvent) {
- const key = `${this.state}:${transitionEvent}`;
-
- if (this.$options.TRANSITIONS[key]) {
- this.state = this.$options.TRANSITIONS[key];
- }
- },
is(state) {
return this.state === state;
},
switchToFull() {
- this.transition(TRANSITION_LOAD_START);
+ this.transitionState(TRANSITION_LOAD_START);
this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile })
.then(() => {
- this.transition(TRANSITION_LOAD_SUCCEED);
+ this.transitionState(TRANSITION_LOAD_SUCCEED);
})
.catch(() => {
- this.transition(TRANSITION_LOAD_ERROR);
+ this.transitionState(TRANSITION_LOAD_ERROR);
});
},
+ transitionState(transitionEvent) {
+ this.state = transition(this.state, transitionEvent);
+ },
clickLink(event) {
if (this.canLoadFullDiff) {
event.preventDefault();
@@ -81,7 +76,7 @@ export default {
}
},
dismissError() {
- this.transition(TRANSITION_ACKNOWLEDGE_ERROR);
+ this.transitionState(TRANSITION_ACKNOWLEDGE_ERROR);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index d14d8c9b92e..1510d2f357a 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -38,7 +38,7 @@ export default {
<template>
<div v-show="showAlert">
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" />
- <gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
+ <gl-alert v-if="showAlert" v-bind="$attrs" @dismiss="dismissFeedbackAlert">
<slot></slot>
</gl-alert>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
index 1da84df022f..b920af593df 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
@@ -27,6 +27,12 @@ export default {
type: Number,
required: true,
},
+ /* enable possibility to cycle around */
+ enableCycle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
watch: {
max() {
@@ -64,15 +70,34 @@ export default {
return;
}
- const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
+ let nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
- // Return if the index didn't change
if (nextIndex === this.index) {
- return;
+ // Return if the index didn't change and cycle is not enabled
+ if (!this.enableCycle) {
+ return;
+ }
+ // Update nextIndex if the cycle is enabled
+ nextIndex = this.cycle(nextIndex, val);
}
this.$emit('change', nextIndex);
},
+ cycle(nextIndex, val) {
+ if (val === 1 && nextIndex === this.max) {
+ // if we are moving down +1 and we reached bottom (max)
+ // return top most index (min)
+ return this.min;
+ }
+
+ if (val === -1 && nextIndex === this.min) {
+ // if we are moving up -1 and we reached top (min)
+ // return bottom most index (max)
+ return this.max;
+ }
+
+ return nextIndex;
+ },
},
render() {
return this.$scopedSlots.default?.();
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
index 45c50dce8ce..1a3220d8db9 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
@@ -13,6 +13,11 @@ export default {
GlCollapsibleListbox,
},
props: {
+ block: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
label: {
type: String,
required: true,
@@ -70,6 +75,7 @@ export default {
computed: {
selected: {
set(value) {
+ this.$emit('input', value);
this.selectedValue = value;
this.selectedText =
value === null ? null : this.items.find((item) => item.value === value).text;
@@ -155,6 +161,7 @@ export default {
},
onReset() {
this.selected = null;
+ this.$emit('input', null);
},
onBottomReached() {
this.fetchEntities(this.page + 1);
@@ -176,6 +183,7 @@ export default {
<gl-collapsible-listbox
ref="listbox"
v-model="selected"
+ :block="block"
:header-text="headerText"
:reset-button-label="resetButtonLabel"
:toggle-text="toggleText"
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
index 1afbeda74c4..12db70d8e9c 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
+++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
@@ -20,6 +20,8 @@ export const initProjectSelects = () => {
orderBy,
selected: initialSelection,
} = el.dataset;
+ const block = parseBoolean(el.dataset.block);
+ const withShared = parseBoolean(el.dataset.withShared);
const includeSubgroups = parseBoolean(el.dataset.includeSubgroups);
const membership = parseBoolean(el.dataset.membership);
const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel);
@@ -37,6 +39,8 @@ export const initProjectSelects = () => {
groupId,
userId,
orderBy,
+ block,
+ withShared,
includeSubgroups,
membership,
initialSelection,
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
index 393991d746e..7af3819f2a5 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
@@ -20,6 +20,11 @@ export default {
SafeHtml,
},
props: {
+ block: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
label: {
type: String,
required: true,
@@ -47,6 +52,11 @@ export default {
required: false,
default: null,
},
+ withShared: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
includeSubgroups: {
type: Boolean,
required: false,
@@ -86,7 +96,7 @@ export default {
if (this.groupId) {
return Api.groupProjects(this.groupId, searchString, {
...commonParams,
- with_shared: true,
+ with_shared: this.withShared,
include_subgroups: this.includeSubgroups,
simple: true,
});
@@ -99,7 +109,7 @@ export default {
this.userId,
searchString,
{
- with_shared: true,
+ with_shared: this.withShared,
include_subgroups: this.includeSubgroups,
},
(res) => ({ data: res }),
@@ -154,6 +164,7 @@ export default {
:default-toggle-text="$options.i18n.searchForProject"
:fetch-items="fetchProjects"
:fetch-initial-selection-text="fetchProjectName"
+ :block="block"
clearable
>
<template v-if="hasHtmlLabel" #label>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 680f229d5e8..18f9d26a13d 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -1,8 +1,8 @@
<script>
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
+import { Mousetrap, addStopCallback } from '~/lib/mousetrap';
import { keysFor, MR_GO_TO_FILE } from '~/behaviors/shortcuts/keybindings';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import Item from './item.vue';
@@ -10,8 +10,6 @@ import Item from './item.vue';
export const MAX_FILE_FINDER_RESULTS = 40;
export const FILE_FINDER_ROW_HEIGHT = 55;
-const originalStopCallback = Mousetrap.prototype.stopCallback;
-
export default {
components: {
GlIcon,
@@ -140,7 +138,7 @@ export default {
this.toggle(!this.visible);
});
- Mousetrap.prototype.stopCallback = function customStopCallback(e, el, combo) {
+ addStopCallback(function fileFinderStopCallback(e, el, combo) {
if (
(combo === 't' && el.classList.contains('dropdown-input-field')) ||
el.classList.contains('inputarea')
@@ -150,8 +148,8 @@ export default {
return false;
}
- return originalStopCallback.call(this, e, el, combo);
- };
+ return undefined;
+ });
},
methods: {
toggle(visible) {
@@ -221,7 +219,12 @@ export default {
</script>
<template>
- <div v-if="visible" class="file-finder-overlay" @mousedown.self="toggle(false)">
+ <div
+ v-if="visible"
+ data-testid="overlay"
+ class="file-finder-overlay"
+ @mousedown.self="toggle(false)"
+ >
<div class="dropdown-menu diff-file-changes file-finder show">
<div :class="{ 'has-value': showClearInputButton }" class="dropdown-input">
<input
@@ -231,6 +234,7 @@ export default {
type="search"
class="dropdown-input-field"
autocomplete="off"
+ data-testid="search-input"
@keydown="onKeydown($event)"
@keyup="onKeyup($event)"
/>
@@ -241,6 +245,7 @@ export default {
/>
<gl-icon
name="close"
+ data-testid="clear-search-input"
class="dropdown-input-clear"
role="button"
:aria-label="__('Clear search input')"
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..88062bf245f 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';
@@ -99,6 +99,11 @@ export default {
required: false,
default: false,
},
+ termsAsTokens: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -356,7 +361,9 @@ export default {
:close-button-title="__('Close')"
:clear-recent-searches-text="__('Clear recent searches')"
:no-recent-searches-text="__(`You don't have any recent searches`)"
+ :search-text-option-label="__('Search for this text')"
:show-friendly-text="showFriendlyText"
+ :terms-as-tokens="termsAsTokens"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear="onClear"
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/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index b0fa3e4c27e..b5783265ffa 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT } from '../constants';
+import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants';
import {
getRecentlyUsedSuggestions,
setTokenValueToRecentlyUsed,
@@ -100,7 +100,7 @@ export default {
return this.getActiveTokenValue(this.suggestions, this.value.data);
},
availableDefaultSuggestions() {
- if (this.value.operator === OPERATOR_NOT) {
+ if ([OPERATOR_NOT, OPERATOR_OR].includes(this.value.operator)) {
return this.defaultSuggestions.filter(
(suggestion) => !FILTERS_NONE_ANY.includes(suggestion.value),
);
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..f22a17b4603 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,8 +1,7 @@
-<!-- eslint-disable-next-line vue/no-deprecated-functional-template -->
-<template functional>
- <footer class="form-actions d-flex justify-content-between">
- <div><slot name="prepend"></slot></div>
- <div><slot></slot></div>
- <div><slot name="append"></slot></div>
+<template>
+ <footer class="gl-mt-5 footer-block">
+ <slot name="prepend"></slot>
+ <slot></slot>
+ <slot name="append"></slot>
</footer>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 2f10e068542..dea279890b1 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -137,6 +137,7 @@ export default {
v-if="showCopyButton"
:text="value"
:title="copyButtonTitle"
+ data-qa-selector="clipboard_button"
@click="handleCopyButtonClick"
/>
</template>
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/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
new file mode 100644
index 00000000000..897ca2f84d2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlCollapsibleListbox, GlTooltip, GlButton } from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { updateText } from '~/lib/utils/text_markdown';
+import savedRepliesQuery from './saved_replies.query.graphql';
+
+export default {
+ apollo: {
+ savedReplies: {
+ query: savedRepliesQuery,
+ update: (r) => r.currentUser?.savedReplies?.nodes,
+ skip() {
+ return !this.shouldFetchCommentTemplates;
+ },
+ },
+ },
+ components: {
+ GlCollapsibleListbox,
+ GlButton,
+ GlTooltip,
+ },
+ props: {
+ newCommentTemplatePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ shouldFetchCommentTemplates: false,
+ savedReplies: [],
+ commentTemplateSearch: '',
+ loadingSavedReplies: false,
+ };
+ },
+ computed: {
+ filteredSavedReplies() {
+ const savedReplies = this.commentTemplateSearch
+ ? fuzzaldrinPlus.filter(this.savedReplies, this.commentTemplateSearch, { key: ['name'] })
+ : this.savedReplies;
+
+ return savedReplies.map((r) => ({ value: r.id, text: r.name, content: r.content }));
+ },
+ },
+ mounted() {
+ this.tooltipTarget = this.$el.querySelector('.js-comment-template-toggle');
+ },
+ methods: {
+ fetchCommentTemplates() {
+ this.shouldFetchCommentTemplates = true;
+ },
+ setCommentTemplateSearch(search) {
+ this.commentTemplateSearch = search;
+ },
+ onSelect(id) {
+ const savedReply = this.savedReplies.find((r) => r.id === id);
+ const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
+
+ if (savedReply && textArea) {
+ updateText({
+ textArea,
+ tag: savedReply.content,
+ cursorOffset: 0,
+ wrap: false,
+ });
+
+ // Wait for text to be added into textarea
+ requestAnimationFrame(() => {
+ textArea.focus();
+ });
+ }
+ },
+ },
+ popperOptions: { strategy: 'fixed' },
+};
+</script>
+
+<template>
+ <span>
+ <gl-collapsible-listbox
+ :header-text="__('Insert comment template')"
+ :items="filteredSavedReplies"
+ :toggle-text="__('Insert comment template')"
+ text-sr-only
+ toggle-class="js-comment-template-toggle"
+ icon="comment-lines"
+ category="tertiary"
+ placement="right"
+ searchable
+ size="small"
+ class="comment-template-dropdown"
+ :popper-options="$options.popperOptions"
+ :searching="$apollo.queries.savedReplies.loading"
+ @shown="fetchCommentTemplates"
+ @search="setCommentTemplateSearch"
+ @select="onSelect"
+ >
+ <template #list-item="{ item }">
+ <div class="gl-display-flex js-comment-template-content">
+ <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="newCommentTemplatePath"
+ category="tertiary"
+ block
+ class="gl-justify-content-start! gl-mt-0! gl-mb-0! gl-px-3!"
+ >{{ __('Add a new comment template') }}</gl-button
+ >
+ </div>
+ </template>
+ </gl-collapsible-listbox>
+ <gl-tooltip :target="() => tooltipTarget">
+ {{ __('Insert comment template') }}
+ </gl-tooltip>
+ </span>
+</template>
+
+<style>
+.comment-template-dropdown .gl-new-dropdown-panel {
+ width: 350px;
+}
+
+.comment-template-dropdown .gl-new-dropdown-item-check-icon {
+ display: none;
+}
+</style>
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..e88c7f75745
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue
@@ -0,0 +1,49 @@
+<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"
+ size="small"
+ @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
deleted file mode 100644
index 6702a81e747..00000000000
--- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownItem,
- },
- props: {
- size: {
- type: String,
- required: false,
- default: 'medium',
- },
- value: {
- type: String,
- required: true,
- },
- },
- computed: {
- markdownEditorSelected() {
- return this.value === 'markdown';
- },
- text() {
- return this.markdownEditorSelected ? __('View rich text') : __('View markdown');
- },
- },
-};
-</script>
-<template>
- <gl-dropdown
- category="tertiary"
- data-qa-selector="editing_mode_switcher"
- :size="size"
- :text="text"
- right
- >
- <gl-dropdown-item
- is-check-item
- :is-checked="!markdownEditorSelected"
- @click="$emit('input', 'richText')"
- ><div class="gl-font-weight-bold">{{ __('Rich text') }}</div>
- <div class="gl-text-secondary">
- {{ __('View the formatted output in real-time as you edit.') }}
- </div>
- </gl-dropdown-item>
- <gl-dropdown-item
- is-check-item
- :is-checked="markdownEditorSelected"
- @click="$emit('input', 'markdown')"
- ><div class="gl-font-weight-bold">{{ __('Markdown') }}</div>
- <div class="gl-text-secondary">
- {{ __('View and edit markdown, with the option to preview the formatted output.') }}
- </div></gl-dropdown-item
- >
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue
new file mode 100644
index 00000000000..645975ca565
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_switcher.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ markdownEditorSelected() {
+ return this.value === 'markdown';
+ },
+ text() {
+ return this.markdownEditorSelected ? __('Switch to rich text') : __('Switch to Markdown');
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ class="btn btn-default btn-sm gl-button btn-default-tertiary"
+ data-qa-selector="editing_mode_switcher"
+ @click="$emit('input')"
+ >{{ text }}</gl-button
+ >
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/eventhub.js b/app/assets/javascripts/vue_shared/components/markdown/eventhub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/eventhub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 6f4cddbdfa2..602a83132e4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,8 +1,8 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } 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';
@@ -27,6 +27,7 @@ export default {
},
directives: {
SafeHtml,
+ GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -62,6 +63,11 @@ export default {
required: false,
default: true,
},
+ removeBorder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
quickActionsDocsPath: {
type: String,
required: false,
@@ -132,6 +138,11 @@ export default {
required: false,
default: false,
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -240,7 +251,7 @@ export default {
immediate: true,
handler(newVal) {
if (!newVal) {
- this.showWriteTab();
+ this.hidePreview();
}
},
},
@@ -272,7 +283,7 @@ export default {
}
},
methods: {
- showPreviewTab() {
+ showPreview() {
if (this.previewMarkdown) return;
this.previewMarkdown = true;
@@ -292,7 +303,7 @@ export default {
this.renderMarkdown();
}
},
- showWriteTab() {
+ hidePreview() {
this.markdownPreview = '';
this.previewMarkdown = false;
},
@@ -344,7 +355,9 @@ export default {
<template>
<div
ref="gl-form"
- :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
+ :class="{
+ 'gl-border-none! gl-shadow-none!': removeBorder,
+ }"
class="js-vue-markdown-field md-area position-relative gfm-form"
:data-uploads-path="uploadsPath"
>
@@ -355,10 +368,16 @@ 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"
+ :show-content-editor-switcher="showContentEditorSwitcher"
+ @showPreview="showPreview"
+ @hidePreview="hidePreview"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
+ @enableContentEditor="$emit('enableContentEditor')"
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
@@ -375,8 +394,6 @@ export default {
:quick-actions-docs-path="quickActionsDocsPath"
:can-attach-file="canAttachFile"
:show-comment-tool-bar="showCommentToolBar"
- :show-content-editor-switcher="showContentEditorSwitcher"
- @enableContentEditor="$emit('enableContentEditor')"
/>
</div>
</div>
@@ -384,7 +401,7 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
- class="js-vue-md-preview md-preview-holder"
+ class="js-vue-md-preview md-preview-holder gl-px-5"
>
<suggestions
v-if="hasSuggestion"
@@ -401,13 +418,13 @@ export default {
v-show="previewMarkdown"
ref="markdown-preview"
v-safe-html:[$options.safeHtmlConfig]="markdownPreview"
- class="js-vue-md-preview md md-preview-holder"
+ class="js-vue-md-preview md md-preview-holder gl-px-5"
></div>
</template>
<div
v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading"
v-safe-html:[$options.safeHtmlConfig]="referencedCommands"
- class="referenced-commands"
+ class="referenced-commands gl-mx-2 gl-mb-2 gl-px-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
data-testid="referenced-commands"
></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index e83441e59a2..8802f364665 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,5 +1,5 @@
<script>
-import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui';
+import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui';
import $ from 'jquery';
import {
keysFor,
@@ -10,23 +10,37 @@ 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 { updateText } from '~/lib/utils/text_markdown';
import ToolbarButton from './toolbar_button.vue';
+import DrawioToolbarButton from './drawio_toolbar_button.vue';
+import CommentTemplatesDropdown from './comment_templates_dropdown.vue';
+import EditorModeSwitcher from './editor_mode_switcher.vue';
export default {
components: {
ToolbarButton,
GlPopover,
GlButton,
- GlTabs,
- GlTab,
+ DrawioToolbarButton,
+ CommentTemplatesDropdown,
+ AiActionsDropdown: () => import('ee_component/ai/components/ai_actions_dropdown.vue'),
+ EditorModeSwitcher,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ newCommentTemplatePath: {
+ default: null,
+ },
+ editorAiActions: { default: () => [] },
+ },
props: {
previewMarkdown: {
type: Boolean,
@@ -62,6 +76,26 @@ export default {
required: false,
default: () => [],
},
+ uploadsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showContentEditorSwitcher: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -92,6 +126,9 @@ export default {
const expandText = s__('MarkdownEditor|Click to expand');
return [`<details><summary>${expandText}</summary>`, `{text}`, '</details>'].join('\n');
},
+ showEditorModeSwitcher() {
+ return this.showContentEditorSwitcher && !this.previewMarkdown;
+ },
},
watch: {
showSuggestPopover() {
@@ -99,14 +136,14 @@ export default {
},
},
mounted() {
- $(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
- $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab);
+ $(document).on('markdown-preview:show.vue', this.showMarkdownPreview);
+ $(document).on('markdown-preview:hide.vue', this.hideMarkdownPreview);
this.updateSuggestPopoverVisibility();
},
beforeDestroy() {
- $(document).off('markdown-preview:show.vue', this.previewMarkdownTab);
- $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab);
+ $(document).off('markdown-preview:show.vue', this.showMarkdownPreview);
+ $(document).off('markdown-preview:hide.vue', this.hideMarkdownPreview);
},
methods: {
async updateSuggestPopoverVisibility() {
@@ -120,19 +157,15 @@ export default {
(form.find('.js-vue-markdown-field').length && $(this.$el).closest('form')[0] === form[0])
);
},
-
- previewMarkdownTab(event, form) {
- if (event.target.blur) event.target.blur();
+ showMarkdownPreview(_, form) {
if (!this.isValid(form)) return;
- this.$emit('preview-markdown');
+ this.$emit('showPreview');
},
-
- writeMarkdownTab(event, form) {
- if (event.target.blur) event.target.blur();
+ hideMarkdownPreview(_, form) {
if (!this.isValid(form)) return;
- this.$emit('write-markdown');
+ this.$emit('hidePreview');
},
handleSuggestDismissed() {
this.$emit('handleSuggestDismissed');
@@ -163,6 +196,28 @@ export default {
$gfmForm.find('.div-dropzone').click();
$gfmTextarea.focus();
},
+ insertIntoTextarea(text) {
+ const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
+ if (textArea) {
+ const generatedByText = `${text}\n\n---\n\n_${__('This comment was generated using AI')}_`;
+ updateText({
+ textArea,
+ tag: generatedByText,
+ cursorOffset: 0,
+ wrap: false,
+ });
+ }
+ },
+ handleEditorModeChanged() {
+ this.$emit('enableContentEditor');
+ },
+ switchPreview() {
+ if (this.previewMarkdown) {
+ this.hideMarkdownPreview();
+ } else {
+ this.showMarkdownPreview();
+ }
+ },
},
shortcuts: {
bold: keysFor(BOLD_TEXT),
@@ -173,211 +228,241 @@ export default {
outdent: keysFor(OUTDENT_LINE),
},
i18n: {
- writeTabTitle: __('Write'),
- previewTabTitle: __('Preview'),
+ preview: __('Preview'),
+ hidePreview: __('Continue editing'),
},
};
</script>
<template>
- <div class="md-header">
- <gl-tabs content-class="gl-display-none">
- <gl-tab
- title-link-class="gl-py-4 gl-px-3 js-md-write-button"
- :title="$options.i18n.writeTabTitle"
- :active="!previewMarkdown"
- data-testid="write-tab"
- @click="writeMarkdownTab($event)"
- />
- <gl-tab
- v-if="enablePreview"
- title-link-class="gl-py-4 gl-px-3 js-md-preview-button"
- :title="$options.i18n.previewTabTitle"
- :active="previewMarkdown"
- data-testid="preview-tab"
- @click="previewMarkdownTab($event)"
- />
-
- <template #tabs-end>
- <div
- data-testid="md-header-toolbar"
- :class="{ 'gl-display-none!': previewMarkdown }"
- class="md-header-toolbar gl-ml-auto gl-py-2 gl-justify-content-center"
- >
- <template v-if="canSuggest">
- <toolbar-button
- ref="suggestButton"
- :tag="mdSuggestion"
- :prepend="true"
- :button-title="__('Insert suggestion')"
- :cursor-offset="4"
- :tag-content="lineContent"
- icon="doc-code"
- data-qa-selector="suggestion_button"
- class="js-suggestion-btn"
- @click="handleSuggestDismissed"
- />
- <gl-popover
- v-if="suggestPopoverVisible"
- :target="$refs.suggestButton.$el"
- :css-classes="['diff-suggest-popover']"
- placement="bottom"
- :show="suggestPopoverVisible"
- >
- <strong>{{ __('New! Suggest changes directly') }}</strong>
- <p class="mb-2">
- {{
- __(
- 'Suggest code changes which can be immediately applied in one click. Try it out!',
- )
- }}
- </p>
- <gl-button
- variant="confirm"
- category="primary"
- size="small"
- data-qa-selector="dismiss_suggestion_popover_button"
- @click="handleSuggestDismissed"
- >
- {{ __('Got it') }}
- </gl-button>
- </gl-popover>
- </template>
- <toolbar-button
- tag="**"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), {
- modifierKey,
- }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
- "
- :shortcuts="$options.shortcuts.bold"
- icon="bold"
- />
+ <div class="md-header gl-bg-gray-50 gl-px-2 gl-rounded-base gl-mx-2 gl-mt-2">
+ <div
+ class="gl-display-flex gl-align-items-center gl-flex-wrap"
+ :class="{
+ 'gl-justify-content-end': previewMarkdown,
+ 'gl-justify-content-space-between': !previewMarkdown,
+ }"
+ >
+ <div
+ data-testid="md-header-toolbar"
+ class="md-header-toolbar gl-display-flex gl-py-2 gl-flex-wrap"
+ :class="{ 'gl-display-none!': previewMarkdown }"
+ >
+ <template v-if="canSuggest">
<toolbar-button
- tag="_"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), {
- modifierKey,
- }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
- "
- :shortcuts="$options.shortcuts.italic"
- icon="italic"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('strikethrough')"
- tag="~~"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
- modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
- })
- "
- :shortcuts="$options.shortcuts.strikethrough"
- icon="strikethrough"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('quote')"
- :prepend="true"
- :tag="tag"
- :button-title="__('Insert a quote')"
- icon="quote"
- @click="handleQuote"
- />
- <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
- <toolbar-button
- tag="[{text}](url)"
- tag-select="url"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), {
- modifierKey,
- }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
- "
- :shortcuts="$options.shortcuts.link"
- icon="link"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('bullet-list')"
+ ref="suggestButton"
+ :tag="mdSuggestion"
:prepend="true"
- tag="- "
- :button-title="__('Add a bullet list')"
- icon="list-bulleted"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('numbered-list')"
- :prepend="true"
- tag="1. "
- :button-title="__('Add a numbered list')"
- icon="list-numbered"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('task-list')"
- :prepend="true"
- tag="- [ ] "
- :button-title="__('Add a checklist')"
- icon="list-task"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('indent')"
- class="gl-display-none"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), {
- modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
- })
- "
- :shortcuts="$options.shortcuts.indent"
- command="indentLines"
- icon="list-indent"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ data-qa-selector="suggestion_button"
+ class="js-suggestion-btn"
+ @click="handleSuggestDismissed"
/>
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('outdent')"
- class="gl-display-none"
- :button-title="
- /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
- sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), {
- modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
- })
- "
- :shortcuts="$options.shortcuts.outdent"
- command="outdentLines"
- icon="list-outdent"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('collapsible-section')"
- :tag="mdCollapsibleSection"
- :prepend="true"
- tag-select="Click to expand"
- :button-title="__('Add a collapsible section')"
- icon="details-block"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('table')"
- :tag="mdTable"
- :prepend="true"
- :button-title="__('Add a table')"
- icon="table"
- />
- <gl-button
- v-if="!restrictedToolBarItems.includes('attach-file')"
- v-gl-tooltip
- :title="__('Attach a file or image')"
- data-testid="button-attach-file"
- category="tertiary"
- icon="paperclip"
- @click="handleAttachFile"
- />
- <toolbar-button
- v-if="!restrictedToolBarItems.includes('full-screen')"
- class="js-zen-enter"
- :prepend="true"
- :button-title="__('Go full screen')"
- icon="maximize"
- />
- </div>
- </template>
- </gl-tabs>
+ <gl-popover
+ v-if="suggestPopoverVisible"
+ :target="$refs.suggestButton.$el"
+ :css-classes="['diff-suggest-popover']"
+ placement="bottom"
+ :show="suggestPopoverVisible"
+ >
+ <strong>{{ __('New! Suggest changes directly') }}</strong>
+ <p class="mb-2">
+ {{
+ __(
+ 'Suggest code changes which can be immediately applied in one click. Try it out!',
+ )
+ }}
+ </p>
+ <gl-button
+ variant="confirm"
+ category="primary"
+ size="small"
+ data-qa-selector="dismiss_suggestion_popover_button"
+ @click="handleSuggestDismissed"
+ >
+ {{ __('Got it') }}
+ </gl-button>
+ </gl-popover>
+ </template>
+ <ai-actions-dropdown
+ v-if="editorAiActions.length"
+ :actions="editorAiActions"
+ @input="insertIntoTextarea"
+ />
+ <toolbar-button
+ tag="**"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), {
+ modifierKey,
+ }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
+ "
+ :shortcuts="$options.shortcuts.bold"
+ icon="bold"
+ />
+ <toolbar-button
+ tag="_"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), {
+ modifierKey,
+ }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
+ "
+ :shortcuts="$options.shortcuts.italic"
+ icon="italic"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('strikethrough')"
+ tag="~~"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
+ modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+ })
+ "
+ :shortcuts="$options.shortcuts.strikethrough"
+ icon="strikethrough"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('quote')"
+ :prepend="true"
+ :tag="tag"
+ :button-title="__('Insert a quote')"
+ icon="quote"
+ @click="handleQuote"
+ />
+ <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
+ <toolbar-button
+ tag="[{text}](url)"
+ tag-select="url"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), {
+ modifierKey,
+ }) /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */
+ "
+ :shortcuts="$options.shortcuts.link"
+ icon="link"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('bullet-list')"
+ :prepend="true"
+ tag="- "
+ :button-title="__('Add a bullet list')"
+ icon="list-bulleted"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('numbered-list')"
+ :prepend="true"
+ tag="1. "
+ :button-title="__('Add a numbered list')"
+ icon="list-numbered"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('task-list')"
+ :prepend="true"
+ tag="- [ ] "
+ :button-title="__('Add a checklist')"
+ icon="list-task"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('indent')"
+ class="gl-display-none"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Indent line (%{modifierKey}])'), {
+ modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+ })
+ "
+ :shortcuts="$options.shortcuts.indent"
+ command="indentLines"
+ icon="list-indent"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('outdent')"
+ class="gl-display-none"
+ :button-title="
+ /* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */
+ sprintf(s__('MarkdownEditor|Outdent line (%{modifierKey}[)'), {
+ modifierKey /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */,
+ })
+ "
+ :shortcuts="$options.shortcuts.outdent"
+ command="outdentLines"
+ icon="list-outdent"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('collapsible-section')"
+ :tag="mdCollapsibleSection"
+ :prepend="true"
+ tag-select="Click to expand"
+ :button-title="__('Add a collapsible section')"
+ icon="details-block"
+ />
+ <toolbar-button
+ v-if="!restrictedToolBarItems.includes('table')"
+ :tag="mdTable"
+ :prepend="true"
+ :button-title="__('Add a table')"
+ icon="table"
+ />
+ <gl-button
+ v-if="!restrictedToolBarItems.includes('attach-file')"
+ v-gl-tooltip
+ :aria-label="__('Attach a file or image')"
+ :title="__('Attach a file or image')"
+ class="gl-mr-2"
+ data-testid="button-attach-file"
+ category="tertiary"
+ icon="paperclip"
+ size="small"
+ @click="handleAttachFile"
+ />
+ <drawio-toolbar-button
+ v-if="drawioEnabled"
+ :uploads-path="uploadsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ />
+ <comment-templates-dropdown
+ v-if="newCommentTemplatePath && glFeatures.savedReplies"
+ :new-comment-template-path="newCommentTemplatePath"
+ />
+ </div>
+ <div class="switch-preview gl-py-2 gl-display-flex gl-align-items-center gl-ml-auto">
+ <editor-mode-switcher
+ v-if="showEditorModeSwitcher"
+ size="small"
+ class="gl-mr-2"
+ value="markdown"
+ @input="handleEditorModeChanged"
+ />
+ <gl-button
+ v-if="enablePreview"
+ data-testid="preview-toggle"
+ value="preview"
+ :label="$options.i18n.previewTabTitle"
+ class="js-md-preview-button gl-flex-direction-row-reverse gl-align-items-center gl-font-weight-normal!"
+ size="small"
+ category="tertiary"
+ @click="switchPreview"
+ >{{ previewMarkdown ? $options.i18n.hidePreview : $options.i18n.preview }}</gl-button
+ >
+ <gl-button
+ v-if="!restrictedToolBarItems.includes('full-screen')"
+ v-gl-tooltip
+ :class="{ 'gl-display-none!': previewMarkdown }"
+ class="js-zen-enter gl-ml-2"
+ category="tertiary"
+ icon="maximize"
+ size="small"
+ :title="__('Go full screen')"
+ :prepend="true"
+ :aria-label="__('Go full screen')"
+ />
+ </div>
+ </div>
</div>
</template>
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..d9d4056e997 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -1,8 +1,16 @@
<script>
+import Autosize from 'autosize';
import axios from '~/lib/utils/axios_utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants';
+import { updateDraft, clearDraft, getDraft } from '~/lib/utils/autosave';
+import { setUrlParams, joinPaths } from '~/lib/utils/url_utility';
+import {
+ EDITING_MODE_MARKDOWN_FIELD,
+ EDITING_MODE_CONTENT_EDITOR,
+ CLEAR_AUTOSAVE_ENTRY_EVENT,
+} from '../../constants';
import MarkdownField from './field.vue';
+import eventHub from './eventhub';
export default {
components: {
@@ -18,19 +26,15 @@ export default {
type: String,
required: true,
},
- renderMarkdownPath: {
- type: String,
- required: true,
+ setFacade: {
+ type: Function,
+ required: false,
+ default: null,
},
- markdownDocsPath: {
+ renderMarkdownPath: {
type: String,
required: true,
},
- quickActionsDocsPath: {
- type: String,
- required: false,
- default: '',
- },
uploadsPath: {
type: String,
required: false,
@@ -41,7 +45,17 @@ export default {
required: false,
default: true,
},
- enablePreview: {
+ formFieldProps: {
+ type: Object,
+ required: true,
+ validator: (prop) => prop.id && prop.name,
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ enableAutocomplete: {
type: Boolean,
required: false,
default: true,
@@ -51,27 +65,32 @@ export default {
required: false,
default: () => ({}),
},
- enableAutocomplete: {
+ supportsQuickActions: {
type: Boolean,
required: false,
- default: true,
+ default: false,
},
- formFieldProps: {
- type: Object,
- required: true,
- validator: (prop) => prop.id && prop.name,
+ autosaveKey: {
+ type: String,
+ required: false,
+ default: null,
},
- autofocus: {
- type: Boolean,
+ markdownDocsPath: {
+ type: String,
required: false,
- default: false,
+ default: '',
},
- supportsQuickActions: {
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
type: Boolean,
required: false,
default: false,
},
- useBottomToolbar: {
+ disabled: {
type: Boolean,
required: false,
default: false,
@@ -79,6 +98,7 @@ export default {
},
data() {
return {
+ markdown: this.value || (this.autosaveKey ? getDraft(this.autosaveKey) : '') || '',
editingMode: EDITING_MODE_MARKDOWN_FIELD,
autofocused: false,
};
@@ -92,24 +112,71 @@ export default {
return this.autofocus && !this.autofocused ? 'end' : false;
},
},
+ watch: {
+ value(val) {
+ this.markdown = val;
+
+ this.saveDraft();
+ this.autosizeTextarea();
+ },
+ },
mounted() {
this.autofocusTextarea();
+
+ this.$emit('input', this.markdown);
+ this.saveDraft();
+
+ this.setFacade?.({
+ getValue: () => this.getValue(),
+ setValue: (val) => this.setValue(val),
+ });
+
+ eventHub.$on(CLEAR_AUTOSAVE_ENTRY_EVENT, this.clearDraft);
+ },
+ beforeDestroy() {
+ eventHub.$off(CLEAR_AUTOSAVE_ENTRY_EVENT, this.clearDraft);
},
methods: {
+ getValue() {
+ return this.markdown;
+ },
+ setValue(value) {
+ this.markdown = value;
+ this.$emit('input', value);
+
+ this.saveDraft();
+ this.autosizeTextarea();
+ },
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);
+ const url = setUrlParams(
+ { render_quick_actions: this.supportsQuickActions },
+ joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath),
+ );
+ return axios.post(url, { text: markdown }).then(({ data }) => data.body);
},
onEditingModeChange(editingMode) {
this.editingMode = editingMode;
this.notifyEditingModeChange(editingMode);
},
onEditingModeRestored(editingMode) {
+ if (editingMode === EDITING_MODE_CONTENT_EDITOR && !this.enableContentEditor) {
+ this.editingMode = EDITING_MODE_MARKDOWN_FIELD;
+ return;
+ }
+
this.editingMode = editingMode;
this.$emit(editingMode);
this.notifyEditingModeChange(editingMode);
@@ -126,40 +193,67 @@ export default {
setEditorAsAutofocused() {
this.autofocused = true;
},
+ saveDraft() {
+ if (!this.autosaveKey) return;
+ if (this.markdown) updateDraft(this.autosaveKey, this.markdown);
+ else clearDraft(this.autosaveKey);
+ },
+ clearDraft(key) {
+ if (!this.autosaveKey || key !== this.autosaveKey) return;
+ 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>
<template>
- <div>
+ <div class="md-area gl-px-0! gl-overflow-hidden">
<local-storage-sync
- v-model="editingMode"
- storage-key="gl-wiki-content-editor-enabled"
+ :value="editingMode"
+ as-string
+ storage-key="gl-markdown-editor-mode"
@input="onEditingModeRestored"
/>
<markdown-field
v-if="!isContentEditorActive"
+ ref="markdownField"
+ v-bind="$attrs"
+ data-testid="markdown-field"
:markdown-preview-path="renderMarkdownPath"
can-attach-file
+ :textarea-value="markdown"
+ :uploads-path="uploadsPath"
:enable-autocomplete="enableAutocomplete"
- :textarea-value="value"
+ :autocomplete-data-sources="autocompleteDataSources"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
- :autocomplete-data-sources="autocompleteDataSources"
- :uploads-path="uploadsPath"
- :enable-preview="enablePreview"
- show-content-editor-switcher
- class="bordered-box"
+ :show-content-editor-switcher="enableContentEditor"
+ :drawio-enabled="drawioEnabled"
+ :remove-border="true"
@enableContentEditor="onEditingModeChange('contentEditor')"
+ @handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
<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 +262,17 @@ 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"
+ :enable-autocomplete="enableAutocomplete"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :editable="!disabled"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@keydown="$emit('keydown', $event)"
@@ -180,7 +280,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/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
new file mode 100644
index 00000000000..ac4f06a665d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
@@ -0,0 +1,116 @@
+import Vue from 'vue';
+import { queryToObject, objectToQuery } from '~/lib/utils/url_utility';
+import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '../../constants';
+import MarkdownEditor from './markdown_editor.vue';
+import eventHub from './eventhub';
+
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
+function organizeQuery(obj, isFallbackKey = false) {
+ if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
+ return obj;
+ }
+
+ if (isFallbackKey) {
+ return {
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
+ };
+ }
+
+ return {
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
+ [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH],
+ };
+}
+
+function format(searchTerm, isFallbackKey = false) {
+ const queryObject = queryToObject(searchTerm, { legacySpacesDecode: true });
+ const organizeQueryObject = organizeQuery(queryObject, isFallbackKey);
+ const formattedQuery = objectToQuery(organizeQueryObject);
+
+ return formattedQuery;
+}
+
+function getSearchTerm(newIssuePath) {
+ const { search, pathname } = document.location;
+ return newIssuePath === pathname ? '' : format(search);
+}
+
+function mountAutosaveClearOnSubmit(autosaveKey) {
+ const resetAutosaveButtons = document.querySelectorAll('.js-reset-autosave');
+ if (resetAutosaveButtons.length === 0) {
+ return;
+ }
+
+ for (const resetAutosaveButton of resetAutosaveButtons) {
+ resetAutosaveButton.addEventListener('click', () => {
+ eventHub.$emit(CLEAR_AUTOSAVE_ENTRY_EVENT, autosaveKey);
+ });
+ }
+}
+
+export function mountMarkdownEditor() {
+ const el = document.querySelector('.js-markdown-editor');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ renderMarkdownPath,
+ markdownDocsPath,
+ quickActionsDocsPath,
+ formFieldPlaceholder,
+ formFieldClasses,
+ qaSelector,
+ newIssuePath,
+ } = el.dataset;
+
+ const hiddenInput = el.querySelector('input[type="hidden"]');
+ const formFieldName = hiddenInput.getAttribute('name');
+ const formFieldId = hiddenInput.getAttribute('id');
+ const formFieldValue = hiddenInput.value;
+
+ const searchTerm = getSearchTerm(newIssuePath);
+ const facade = {
+ setValue() {},
+ getValue() {},
+ focus() {},
+ };
+
+ const setFacade = (props) => Object.assign(facade, props);
+ const autosaveKey = `autosave/${document.location.pathname}/${searchTerm}/description`;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(h) {
+ return h(MarkdownEditor, {
+ props: {
+ setFacade,
+ enableContentEditor: Boolean(gon.features?.contentEditorOnIssues),
+ value: formFieldValue,
+ renderMarkdownPath,
+ markdownDocsPath,
+ quickActionsDocsPath,
+ formFieldProps: {
+ placeholder: formFieldPlaceholder,
+ id: formFieldId,
+ name: formFieldName,
+ class: formFieldClasses,
+ 'data-qa-selector': qaSelector,
+ },
+ autosaveKey,
+ enableAutocomplete: true,
+ autocompleteDataSources: gl.GfmAutoComplete?.dataSources,
+ supportsQuickActions: true,
+ },
+ });
+ },
+ });
+
+ mountAutosaveClearOnSubmit(autosaveKey);
+
+ return facade;
+}
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/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index c307601e670..6d1cadf15be 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';
@@ -176,6 +176,12 @@ export default {
<template>
<div>
<div class="flash-container js-suggestions-flash"></div>
- <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div>
+ <div
+ v-show="isRendered"
+ ref="container"
+ v-safe-html="noteHtml"
+ data-testid="suggestions-container"
+ class="md suggestions"
+ ></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index e8be242f660..4733afb7504 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
-import EditorModeDropdown from './editor_mode_dropdown.vue';
export default {
components: {
@@ -9,7 +8,6 @@ export default {
GlLoadingIcon,
GlSprintf,
GlIcon,
- EditorModeDropdown,
},
props: {
markdownDocsPath: {
@@ -31,30 +29,21 @@ export default {
required: false,
default: true,
},
- showContentEditorSwitcher: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
},
- methods: {
- handleEditorModeChanged(mode) {
- if (mode === 'richText') {
- this.$emit('enableContentEditor');
- }
- },
- },
};
</script>
<template>
- <div v-if="showCommentToolBar" class="comment-toolbar clearfix">
- <div class="toolbar-text">
+ <div
+ v-if="showCommentToolBar"
+ class="comment-toolbar gl-mx-2 gl-mb-2 gl-px-4 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base clearfix"
+ >
+ <div class="toolbar-text gl-font-sm">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
<gl-sprintf
:message="
@@ -62,7 +51,9 @@ export default {
"
>
<template #markdownDocsLink="{ content }">
- <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
</template>
</gl-sprintf>
</template>
@@ -75,18 +66,22 @@ export default {
"
>
<template #markdownDocsLink="{ content }">
- <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
</template>
<template #keyboard="{ content }">
<kbd>{{ content }}</kbd>
</template>
<template #quickActionsDocsLink="{ content }">
- <gl-link :href="quickActionsDocsPath" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
</template>
</gl-sprintf>
</template>
</div>
- <span v-if="canAttachFile" class="uploading-container gl-line-height-32">
+ <span v-if="canAttachFile" class="uploading-container gl-font-sm gl-line-height-32">
<span class="uploading-progress-container hide">
<gl-icon name="paperclip" />
<span class="attaching-file-message"></span>
@@ -111,7 +106,7 @@ export default {
<gl-button
variant="link"
category="primary"
- class="retry-uploading-link gl-vertical-align-baseline"
+ class="retry-uploading-link gl-vertical-align-baseline gl-font-sm!"
>
{{ content }}
</gl-button>
@@ -120,7 +115,7 @@ export default {
<gl-button
variant="link"
category="primary"
- class="markdown-selector attach-new-file gl-vertical-align-baseline"
+ class="markdown-selector attach-new-file gl-vertical-align-baseline gl-font-sm!"
>
{{ content }}
</gl-button>
@@ -130,17 +125,10 @@ export default {
<gl-button
variant="link"
category="primary"
- class="button-cancel-uploading-files gl-vertical-align-baseline hide"
+ class="button-cancel-uploading-files gl-vertical-align-baseline hide gl-font-sm!"
>
{{ __('Cancel') }}
</gl-button>
</span>
- <editor-mode-dropdown
- v-if="showContentEditorSwitcher"
- size="small"
- class="gl-float-right gl-line-height-28 gl-display-block"
- value="markdown"
- @input="handleEditorModeChanged"
- />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 5ca21522d33..636c89c99d4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -92,7 +92,8 @@ export default {
:icon="icon"
type="button"
category="tertiary"
- class="js-md"
+ size="small"
+ class="js-md gl-mr-3"
data-container="body"
@click="$emit('click', $event)"
/>
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/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index 41c92fdba4f..d4f50e347cb 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -61,11 +61,6 @@ export default {
required: false,
default: 'primary',
},
- size: {
- type: String,
- required: false,
- default: 'medium',
- },
},
computed: {
modalDomId() {
@@ -108,9 +103,6 @@ export default {
:title="title"
:aria-label="title"
:category="category"
- :size="size"
icon="copy-to-clipboard"
- >
- <slot></slot>
- </gl-button>
+ />
</template>
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/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index 78a7fed6293..9ea04553536 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -2,7 +2,7 @@
import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-const NoteableTypeText = {
+const noteableTypeText = {
Issue: __('issue'),
Epic: __('epic'),
MergeRequest: __('merge request'),
@@ -53,7 +53,7 @@ export default {
return this.isConfidential && this.isLocked;
},
noteableTypeText() {
- return NoteableTypeText[this.noteableType];
+ return noteableTypeText[this.noteableType];
},
confidentialContextText() {
return sprintf(__('This is a confidential %{noteableTypeText}.'), {
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index e091fe74717..fac32bfdb24 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -13,7 +13,9 @@ export default {
<template>
<timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note_placeholder">
- <div class="timeline-icon"></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ ></div>
<div class="timeline-content">
<div class="note-header"></div>
<div class="note-body gl-mt-4"><gl-skeleton-loader /></div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 1cbbdf0deb0..06ca90fa8c6 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -25,11 +25,18 @@ import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import NoteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { spriteIcon } from '~/lib/utils/common_utils';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
+const MR_ICON_COLORS = {
+ check: 'gl-bg-green-100 gl-text-green-700',
+ 'merge-request-close': 'gl-bg-red-100 gl-text-red-700',
+ merge: 'gl-bg-blue-100 gl-text-blue-700',
+};
+const ICON_COLORS = {
+ 'issue-close': 'gl-bg-blue-100 gl-text-blue-700',
+};
export default {
i18n: {
@@ -63,7 +70,7 @@ export default {
};
},
computed: {
- ...mapGetters(['targetNoteHash', 'descriptionVersions']),
+ ...mapGetters(['targetNoteHash', 'descriptionVersions', 'getNoteableData']),
...mapState(['isLoadingDescriptionVersion']),
noteAnchorId() {
return `note_${this.note.id}`;
@@ -71,9 +78,6 @@ export default {
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
},
- iconHtml() {
- return spriteIcon(this.note.system_note_icon_name);
- },
toggleIcon() {
return this.expanded ? 'chevron-up' : 'chevron-down';
},
@@ -87,6 +91,19 @@ export default {
descriptionVersion() {
return this.descriptionVersions[this.note.description_version_id];
},
+ isMergeRequest() {
+ return this.getNoteableData.noteableType === 'MergeRequest';
+ },
+ hasIconColors() {
+ if (!this.isMergeRequest) return true;
+
+ return this.isMergeRequest && MR_ICON_COLORS[this.note.system_note_icon_name];
+ },
+ iconBgClass() {
+ const colors = this.isMergeRequest ? MR_ICON_COLORS : ICON_COLORS;
+
+ return colors[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600';
+ },
},
mounted() {
renderGFM(this.$refs['gfm-content']);
@@ -108,9 +125,6 @@ export default {
}
},
},
- safeHtmlConfig: {
- ADD_TAGS: ['use'], // to support icon SVGs
- },
userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
@@ -121,7 +135,24 @@ export default {
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
- <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div>
+ <div
+ :class="[
+ iconBgClass,
+ {
+ 'mr-system-note-empty gl-bg-gray-900!': !hasIconColors,
+ 'gl-w-6 gl-h-6 gl-mt-n1 gl-ml-2': !isMergeRequest,
+ 'mr-system-note-icon': isMergeRequest,
+ },
+ ]"
+ class="gl-float-left gl--flex-center gl-rounded-full gl-relative timeline-icon"
+ >
+ <gl-icon
+ v-if="note.system_note_icon_name && hasIconColors"
+ :name="note.system_note_icon_name"
+ :size="isMergeRequest ? 12 : 16"
+ data-testid="timeline-icon"
+ />
+ </div>
<div class="timeline-content">
<div class="note-header">
<note-header
diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
index 35f9ac14681..9d5f494579b 100644
--- a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue
@@ -1,3 +1,7 @@
<template>
- <div class="timeline-icon"><slot></slot></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
+ <slot></slot>
+ </div>
</template>
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_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue
index f65cc8bf2f3..3a279b93774 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue
@@ -56,5 +56,6 @@ export default {
:src="projectAvatarUrl"
:alt="avatarAlt"
:size="size"
+ :fallback-on-error="true"
/>
</template>
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/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
new file mode 100644
index 00000000000..11aa7b91745
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -0,0 +1,41 @@
+<script>
+import ProjectsListItem from './projects_list_item.vue';
+
+export default {
+ components: { ProjectsListItem },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * topics: string[];
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * };
+ * descriptionHtml: string;
+ * updatedAt: string;
+ * }[]
+ */
+ projects: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <projects-list-item v-for="project in projects" :key="project.id" :project="project" />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
new file mode 100644
index 00000000000..266cce29e50
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -0,0 +1,257 @@
+<script>
+import {
+ GlAvatarLabeled,
+ GlIcon,
+ GlLink,
+ GlBadge,
+ GlTooltipDirective,
+ GlPopover,
+ GlSprintf,
+} from '@gitlab/ui';
+import uniqueId from 'lodash/uniqueId';
+
+import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { FEATURABLE_ENABLED } from '~/featurable/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { __ } from '~/locale';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
+import { truncate } from '~/lib/utils/text_utility';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+const MAX_TOPICS_TO_SHOW = 3;
+const MAX_TOPIC_TITLE_LENGTH = 15;
+
+export default {
+ i18n: {
+ stars: __('Stars'),
+ forks: __('Forks'),
+ issues: __('Issues'),
+ archived: __('Archived'),
+ topics: __('Topics'),
+ topicsPopoverTargetText: __('+ %{count} more'),
+ moreTopics: __('More topics'),
+ updated: __('Updated'),
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
+ components: {
+ GlAvatarLabeled,
+ GlIcon,
+ UserAccessRoleBadge,
+ GlLink,
+ GlBadge,
+ GlPopover,
+ GlSprintf,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml,
+ },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * topics: string[];
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * };
+ * descriptionHtml: string;
+ * updatedAt: string;
+ * }
+ */
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ topicsPopoverTarget: uniqueId('project-topics-popover-'),
+ };
+ },
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.project.visibility];
+ },
+ visibilityTooltip() {
+ return PROJECT_VISIBILITY_TYPE[this.project.visibility];
+ },
+ accessLevel() {
+ return this.project.permissions?.projectAccess?.accessLevel;
+ },
+ accessLevelLabel() {
+ return ACCESS_LEVEL_LABELS[this.accessLevel];
+ },
+ shouldShowAccessLevel() {
+ return this.accessLevel !== undefined;
+ },
+ starsHref() {
+ return `${this.project.webUrl}/-/starrers`;
+ },
+ forksHref() {
+ return `${this.project.webUrl}/-/forks`;
+ },
+ issuesHref() {
+ return `${this.project.webUrl}/-/issues`;
+ },
+ isForkingEnabled() {
+ return (
+ this.project.forkingAccessLevel === FEATURABLE_ENABLED &&
+ this.project.forksCount !== undefined
+ );
+ },
+ isIssuesEnabled() {
+ return this.project.issuesAccessLevel === FEATURABLE_ENABLED;
+ },
+ hasTopics() {
+ return this.project.topics.length;
+ },
+ visibleTopics() {
+ return this.project.topics.slice(0, MAX_TOPICS_TO_SHOW);
+ },
+ popoverTopics() {
+ return this.project.topics.slice(MAX_TOPICS_TO_SHOW);
+ },
+ },
+ methods: {
+ numberToMetricPrefix,
+ topicPath(topic) {
+ return `/explore/projects/topics/${encodeURIComponent(topic)}`;
+ },
+ topicTitle(topic) {
+ return truncate(topic, MAX_TOPIC_TITLE_LENGTH);
+ },
+ topicTooltipTitle(topic) {
+ // Matches conditional in app/assets/javascripts/lib/utils/text_utility.js#L88
+ if (topic.length - 1 > MAX_TOPIC_TITLE_LENGTH) {
+ return topic;
+ }
+
+ return null;
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="projects-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
+ <gl-avatar-labeled
+ class="gl-flex-grow-1"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :label="project.name"
+ :label-link="project.webUrl"
+ shape="rect"
+ :size="48"
+ >
+ <template #meta>
+ <gl-icon
+ v-gl-tooltip="visibilityTooltip"
+ :name="visibilityIcon"
+ class="gl-text-secondary gl-ml-3"
+ />
+ <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{
+ accessLevelLabel
+ }}</user-access-role-badge>
+ </template>
+ <div
+ v-if="project.descriptionHtml"
+ v-safe-html:[$options.safeHtmlConfig]="project.descriptionHtml"
+ class="gl-font-sm gl-overflow-hidden gl-line-height-20 description"
+ data-testid="project-description"
+ ></div>
+ <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
+ <div
+ class="gl-w-full gl-display-inline-flex gl-flex-wrap gl-font-base gl-font-weight-normal gl-align-items-center gl-mx-n2 gl-my-n2"
+ >
+ <span class="gl-p-2 gl-text-secondary">{{ $options.i18n.topics }}:</span>
+ <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
+ </div>
+ <template v-if="popoverTopics.length">
+ <div
+ :id="topicsPopoverTarget"
+ class="gl-p-2 gl-text-secondary"
+ role="button"
+ tabindex="0"
+ >
+ <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
+ <template #count>{{ popoverTopics.length }}</template>
+ </gl-sprintf>
+ </div>
+ <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
+ <div class="gl-font-base gl-font-weight-normal gl-mx-n2 gl-my-n2">
+ <div
+ v-for="topic in popoverTopics"
+ :key="topic"
+ class="gl-p-2 gl-display-inline-block"
+ >
+ <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+ {{ topicTitle(topic) }}
+ </gl-badge>
+ </div>
+ </div>
+ </gl-popover>
+ </template>
+ </div>
+ </div>
+ </gl-avatar-labeled>
+ <div
+ class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-pl-10 gl-md-pl-0 gl-md-mt-0"
+ >
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+ <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge>
+ <gl-link
+ v-gl-tooltip="$options.i18n.stars"
+ :href="starsHref"
+ :aria-label="$options.i18n.stars"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="star-o" />
+ <span>{{ numberToMetricPrefix(project.starCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isForkingEnabled"
+ v-gl-tooltip="$options.i18n.forks"
+ :href="forksHref"
+ :aria-label="$options.i18n.forks"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="fork" />
+ <span>{{ numberToMetricPrefix(project.forksCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isIssuesEnabled"
+ v-gl-tooltip="$options.i18n.issues"
+ :href="issuesHref"
+ :aria-label="$options.i18n.issues"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="issues" />
+ <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span>
+ </gl-link>
+ </div>
+ <div class="gl-font-sm gl-white-space-nowrap gl-text-secondary gl-mt-3">
+ <span>{{ $options.i18n.updated }}</span>
+ <time-ago-tooltip :time="project.updatedAt" />
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/history_item.vue b/app/assets/javascripts/vue_shared/components/registry/history_item.vue
index 384b084ce09..d7d62df78f5 100644
--- a/app/assets/javascripts/vue_shared/components/registry/history_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/history_item.vue
@@ -19,7 +19,9 @@ export default {
<template>
<timeline-entry-item class="system-note note-wrapper">
- <div class="timeline-icon">
+ <div
+ class="gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600 gl-float-left"
+ >
<gl-icon :name="icon" />
</div>
<div class="timeline-content">
diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index c990baaa2f3..730e9e1c6cc 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -110,10 +110,14 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100">
+ <div
+ class="gl-md-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100"
+ >
+ <!-- `gl-w-full gl-md-w-15` forces fixed width needed to prevent
+ filtered component to grow beyond available width -->
<gl-filtered-search
v-model="internalFilter"
- class="gl-mr-4 gl-flex-grow-1"
+ class="gl-w-full gl-md-w-15 gl-mr-4 gl-flex-grow-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
@submit="submitSearch"
@@ -121,6 +125,10 @@ export default {
/>
<gl-sorting
data-testid="registry-sort-dropdown"
+ class="gl-mt-3 gl-md-mt-0 gl-w-full gl-md-w-auto"
+ dropdown-class="gl-w-full"
+ dropdown-toggle-class="gl-inset-border-1-gray-400!"
+ sort-direction-toggle-class="gl-inset-border-1-gray-400!"
:text="sortText"
:is-ascending="isSortAscending"
:sort-direction-tool-tip="sortDirectionData.tooltip"
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/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
index b66c89d1372..23b5af7fe5f 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
@@ -1,4 +1,5 @@
import { s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN';
@@ -68,3 +69,10 @@ export const AWS_EASY_BUTTONS = [
),
},
];
+
+export const LEGACY_REGISTER_HELP_URL = helpPagePath(
+ 'architecture/blueprints/runner_tokens/index.md',
+ {
+ anchor: 'using-the-authentication-token-in-place-of-the-registration-token',
+ },
+);
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 22d9b88fa41..94aa7bd2f88 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -7,14 +7,22 @@ import {
GlDropdown,
GlDropdownItem,
GlIcon,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlSkeletonLoader,
GlResizeObserverDirective,
} from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { __, s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql';
-import { PLATFORM_DOCKER, PLATFORM_KUBERNETES, PLATFORM_AWS } from './constants';
+import {
+ PLATFORM_DOCKER,
+ PLATFORM_KUBERNETES,
+ PLATFORM_AWS,
+ LEGACY_REGISTER_HELP_URL,
+} from './constants';
import RunnerCliInstructions from './instructions/runner_cli_instructions.vue';
import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue';
@@ -30,13 +38,16 @@ export default {
GlDropdownItem,
GlModal,
GlIcon,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlSkeletonLoader,
RunnerDockerInstructions,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
modalId: {
type: String,
@@ -91,7 +102,7 @@ export default {
shown: false,
platforms: [],
selectedPlatform: null,
- showAlert: false,
+ showErrorAlert: false,
platformsButtonGroupVertical: false,
};
},
@@ -111,6 +122,14 @@ export default {
return null;
}
},
+ showDeprecationAlert() {
+ return (
+ // create_runner_workflow_for_admin
+ this.glFeatures.createRunnerWorkflowForAdmin ||
+ // create_runner_workflow_for_namespace
+ this.glFeatures.createRunnerWorkflowForNamespace
+ );
+ },
},
updated() {
// Refocus on dom changes, after loading data
@@ -136,7 +155,9 @@ export default {
// get focused when setting a `defaultPlatformName`.
// This method refocuses the expected button.
// See more about this auto-focus: https://bootstrap-vue.org/docs/components/modal#auto-focus-on-open
- this.$refs[this.selectedPlatform?.name]?.[0].$el.focus();
+ this.$nextTick(() => {
+ this.$refs[this.selectedPlatform?.name]?.[0]?.$el.focus();
+ });
},
selectPlatform(platform) {
this.selectedPlatform = platform;
@@ -145,7 +166,7 @@ export default {
return this.selectedPlatform.name === platform.name;
},
toggleAlert(state) {
- this.showAlert = state;
+ this.showErrorAlert = state;
},
onPlatformsButtonResize() {
if (bp.getBreakpointSize() === 'xs') {
@@ -161,7 +182,12 @@ export default {
downloadInstallBinary: s__('Runners|Download and install binary'),
downloadLatestBinary: s__('Runners|Download latest binary'),
fetchError: s__('Runners|An error has occurred fetching instructions'),
+ deprecationAlertTitle: s__('Runners|Support for registration tokens is deprecated'),
+ deprecationAlertContent: s__(
+ "Runners|In GitLab Runner 15.6, the use of registration tokens and runner parameters in the 'register' command was deprecated. They have been replaced by authentication tokens. %{linkStart}How does this impact my current registration workflow?%{linkEnd}",
+ ),
},
+ LEGACY_REGISTER_HELP_URL,
};
</script>
<template>
@@ -174,7 +200,22 @@ export default {
v-on="$listeners"
@shown="onShown"
>
- <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ <gl-alert
+ v-if="showDeprecationAlert"
+ :title="$options.i18n.deprecationAlertTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ <gl-sprintf :message="$options.i18n.deprecationAlertContent">
+ <template #link="{ content }">
+ <gl-link target="_blank" :href="$options.LEGACY_REGISTER_HELP_URL"
+ >{{ content }} <gl-icon name="external-link"
+ /></gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <gl-alert v-if="showErrorAlert" variant="danger" @dismiss="toggleAlert(false)">
{{ $options.i18n.fetchError }}
</gl-alert>
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/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
index 092e8ba6c15..d77061d4b31 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -1,6 +1,5 @@
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getPageParamValue, getPageSearchString } from '~/blob/utils';
@@ -21,7 +20,6 @@ export default {
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagMixin()],
props: {
isHighlighted: {
type: Boolean,
@@ -69,7 +67,6 @@ export default {
return this.content.split('\n');
},
pageSearchString() {
- if (!this.glFeatures.fileLineBlame) return '';
const page = getPageParamValue(this.number);
return getPageSearchString(this.blamePath, page);
},
@@ -106,7 +103,6 @@ export default {
class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
>
<a
- v-if="glFeatures.fileLineBlame"
class="gl-user-select-none gl-shadow-none! file-line-blame"
:href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`"
></a>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
index ce6741f33b1..f121e84e1de 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -1,13 +1,11 @@
<script>
import SafeHtml from '~/vue_shared/directives/safe_html';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getPageParamValue, getPageSearchString } from '~/blob/utils';
export default {
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagMixin()],
props: {
number: {
type: Number,
@@ -28,7 +26,6 @@ export default {
},
computed: {
pageSearchString() {
- if (!this.glFeatures.fileLineBlame) return '';
const page = getPageParamValue(this.number);
return getPageSearchString(this.blamePath, page);
},
@@ -41,8 +38,7 @@ export default {
class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
>
<a
- v-if="glFeatures.fileLineBlame"
- class="gl-user-select-none gl-shadow-none! file-line-blame"
+ class="gl-user-select-none gl-shadow-none! file-line-blame gl-mx-n2 gl-flex-grow-1"
:href="`${blamePath}${pageSearchString}#L${number}`"
></a>
<a
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..58db1ceda95 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,9 @@ 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'];
+/**
+ * We fallback to highlighting these languages with Rouge, see the following issues for more detail:
+ * Python: https://gitlab.com/gitlab-org/gitlab/-/issues/384375#note_1212752013
+ * HAML: https://github.com/highlightjs/highlight.js/issues/3783
+ * */
+export const LEGACY_FALLBACKS = ['python', 'haml'];
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 0d466df1b7f..7c9a1bcd8cc 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -1,8 +1,8 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
+import { DATE_TIME_FORMATS, DEFAULT_DATE_TIME_FORMAT } from '~/lib/utils/datetime_utility';
import timeagoMixin from '../mixins/timeago';
-import '~/lib/utils/datetime_utility';
/**
* Port of ruby helper time_ago_with_tooltip
@@ -15,7 +15,7 @@ export default {
mixins: [timeagoMixin],
props: {
time: {
- type: [String, Number],
+ type: [String, Number, Date],
required: true,
},
tooltipPlacement: {
@@ -28,10 +28,16 @@ export default {
required: false,
default: '',
},
+ dateTimeFormat: {
+ type: String,
+ required: false,
+ default: DEFAULT_DATE_TIME_FORMAT,
+ validator: (timeFormat) => DATE_TIME_FORMATS.includes(timeFormat),
+ },
},
computed: {
timeAgo() {
- return this.timeFormatted(this.time);
+ return this.timeFormatted(this.time, this.dateTimeFormat);
},
},
};
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/truncated_text/constants.js b/app/assets/javascripts/vue_shared/components/truncated_text/constants.js
new file mode 100644
index 00000000000..c3b43d40adf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/truncated_text/constants.js
@@ -0,0 +1,9 @@
+import { __ } from '~/locale';
+
+export const SHOW_MORE = __('Show more');
+export const SHOW_LESS = __('Show less');
+export const STATES = {
+ INITIAL: 'initial',
+ TRUNCATED: 'truncated',
+ EXTENDED: 'extended',
+};
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js
new file mode 100644
index 00000000000..6a7ac72c31e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.stories.js
@@ -0,0 +1,26 @@
+import { escape } from 'lodash';
+import TruncatedText from './truncated_text.vue';
+
+export default {
+ component: TruncatedText,
+ title: 'vue_shared/truncated_text',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { TruncatedText },
+ props: Object.keys(argTypes),
+ template: `
+ <truncated-text v-bind="$props">
+ <template v-if="${'default' in args}" v-slot>
+ <span style="white-space: pre-line;">${escape(args.default)}</span>
+ </template>
+ </truncated-text>
+ `,
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ lines: 3,
+ mobileLines: 10,
+ default: [...Array(15)].map((_, i) => `line ${i + 1}`).join('\n'),
+};
diff --git a/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
new file mode 100644
index 00000000000..96fc04ec825
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/truncated_text/truncated_text.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlResizeObserverDirective, GlButton } from '@gitlab/ui';
+import { STATES, SHOW_MORE, SHOW_LESS } from './constants';
+
+export default {
+ name: 'TruncatedText',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlResizeObserver: GlResizeObserverDirective,
+ },
+ props: {
+ lines: {
+ type: Number,
+ required: false,
+ default: 3,
+ },
+ mobileLines: {
+ type: Number,
+ required: false,
+ default: 10,
+ },
+ },
+ data() {
+ return {
+ state: STATES.INITIAL,
+ };
+ },
+ computed: {
+ showTruncationToggle() {
+ return this.state !== STATES.INITIAL;
+ },
+ truncationToggleText() {
+ if (this.state === STATES.TRUNCATED) {
+ return SHOW_MORE;
+ }
+ return SHOW_LESS;
+ },
+ styleObject() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return { '--lines': this.lines, '--mobile-lines': this.mobileLines };
+ },
+ isTruncated() {
+ return this.state === STATES.EXTENDED ? null : 'gl-truncate-text-by-line gl-overflow-hidden';
+ },
+ },
+ methods: {
+ onResize({ target }) {
+ if (target.scrollHeight > target.offsetHeight) {
+ this.state = STATES.TRUNCATED;
+ } else if (this.state === STATES.TRUNCATED) {
+ this.state = STATES.INITIAL;
+ }
+ },
+ toggleTruncation() {
+ if (this.state === STATES.TRUNCATED) {
+ this.state = STATES.EXTENDED;
+ } else if (this.state === STATES.EXTENDED) {
+ this.state = STATES.TRUNCATED;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <article
+ ref="content"
+ v-gl-resize-observer="onResize"
+ :class="isTruncated"
+ :style="styleObject"
+ >
+ <slot></slot>
+ </article>
+ <gl-button v-if="showTruncationToggle" variant="link" @click="toggleTruncation">{{
+ truncationToggleText
+ }}</gl-button>
+ </section>
+</template>
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..00720f27934 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
@@ -55,6 +55,11 @@ export default {
required: false,
default: '',
},
+ imgCssWrapperClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
imgSize: {
type: [Number, Object],
required: true,
@@ -89,6 +94,7 @@ export default {
<template>
<gl-avatar-link :href="linkHref" class="user-avatar-link">
<user-avatar-image
+ :class="imgCssWrapperClasses"
:img-src="imgSrc"
:img-alt="imgAlt"
:css-classes="imgCssClasses"
@@ -105,7 +111,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_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index 167db3ce1f2..335f9ab1df4 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -60,9 +60,11 @@ export default {
methods: {
expand() {
this.isExpanded = true;
+ this.$emit('expanded');
},
collapse() {
this.isExpanded = false;
+ this.$emit('collapsed');
},
},
};
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..e09f193310b 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
@@ -1,5 +1,6 @@
<script>
import {
+ GlBadge,
GlPopover,
GlLink,
GlSkeletonLoader,
@@ -10,7 +11,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';
@@ -35,6 +36,7 @@ export default {
I18N_USER_LEARN,
USER_POPOVER_DELAY,
components: {
+ GlBadge,
GlIcon,
GlLink,
GlPopover,
@@ -226,9 +228,9 @@ export default {
data-testid="user-popover-pronouns"
>({{ user.pronouns }})</span
>
- <span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
- >({{ $options.I18N_USER_BUSY }})</span
- >
+ <gl-badge v-if="isBusy" size="sm" variant="warning" class="gl-ml-1">
+ {{ $options.I18N_USER_BUSY }}
+ </gl-badge>
</template>
</gl-avatar-labeled>
</div>
@@ -269,7 +271,7 @@ export default {
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
- <gl-icon name="question" />
+ <gl-icon name="question-o" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
<gl-sprintf :message="$options.I18N_USER_LEARN">
<template #name>{{ user.name }}</template>
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/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
index 4ef9bc07b1c..9665e188469 100644
--- a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
+++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
@@ -5,7 +5,9 @@ export default {
// We can't use this.vuexModule due to bug in vue-apollo when
// provide is called in beforeCreate
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
- vuexModule: this.$options.propsData.vuexModule,
+
+ // @vue-compat does not care to normalize propsData fields
+ vuexModule: this.$options.propsData.vuexModule ?? this.$options.propsData['vuex-module'],
};
},
props: {
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 28bec63b244..3c08142e2b9 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -1,8 +1,7 @@
<script>
-import { GlModal, GlSprintf, GlLink, GlPopover } from '@gitlab/ui';
+import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
-import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
@@ -34,9 +33,7 @@ export default {
GlModal,
GlSprintf,
GlLink,
- GlPopover,
ConfirmForkModal,
- UserCalloutDismisser,
},
i18n,
mixins: [glFeatureFlagsMixin()],
@@ -312,9 +309,6 @@ export default {
},
};
},
- displayVscodeWebIdeCallout() {
- return this.glFeatures.vscodeWebIde && !this.showEditButton;
- },
},
mounted() {
this.resetPreferredEditor();
@@ -340,11 +334,6 @@ export default {
this.select(KEY_WEB_IDE);
},
- dismissCalloutOnActionClicked(dismiss) {
- if (this.displayVscodeWebIdeCallout) {
- dismiss();
- }
- },
},
webIdeButtonId: 'web-ide-link',
PREFERRED_EDITOR_KEY,
@@ -352,84 +341,38 @@ export default {
</script>
<template>
- <user-callout-dismisser
- :skip-query="!displayVscodeWebIdeCallout"
- feature-name="vscode_web_ide_callout"
- >
- <template #default="{ dismiss, shouldShowCallout }">
- <div class="gl-sm-ml-3">
- <actions-button
- :id="$options.webIdeButtonId"
- :actions="actions"
- :selected-key="selection"
- :variant="isBlob ? 'confirm' : 'default'"
- :category="isBlob ? 'primary' : 'secondary'"
- :show-action-tooltip="!displayVscodeWebIdeCallout || !shouldShowCallout"
- @select="select"
- @actionClicked="dismissCalloutOnActionClicked(dismiss)"
- />
- <local-storage-sync
- :storage-key="$options.PREFERRED_EDITOR_KEY"
- :value="selection"
- as-string
- @input="select"
- />
- <gl-modal
- v-if="computedShowGitpodButton && !gitpodEnabled"
- v-model="showEnableGitpodModal"
- v-bind="enableGitpodModalProps"
- >
- <gl-sprintf :message="$options.i18n.modal.content">
- <template #link="{ content }">
- <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-modal>
- <confirm-fork-modal
- v-if="showWebIdeButton || showEditButton"
- v-model="showForkModal"
- :modal-id="forkModalId"
- :fork-path="forkPath"
- />
- <gl-popover
- v-if="displayVscodeWebIdeCallout"
- :target="$options.webIdeButtonId"
- :show="shouldShowCallout"
- :css-classes="['web-ide-promo-popover']"
- :boundary-padding="80"
- show-close-button
- triggers="manual"
- @close-button-clicked="dismiss"
- >
- <img
- :src="webIdePromoPopoverImg"
- class="web-ide-promo-popover-illustration"
- width="280"
- height="140"
- />
- <div class="gl-mx-2">
- <h5 class="gl-mt-3 gl-mb-3">{{ __('The new Web IDE') }}</h5>
- <p>
- {{
- __(
- 'VS Code in your browser. View code and make changes from the same UI as in your local IDE.',
- )
- }}
- </p>
- <gl-link
- class="gl-button btn btn-confirm block gl-mb-4 gl-mt-5"
- variant="confirm"
- category="primary"
- target="_blank"
- :href="webIdeUrl"
- block
- @click="dismissCalloutOnActionClicked(dismiss)"
- >
- {{ __('Try it out now') }}
- </gl-link>
- </div>
- </gl-popover>
- </div>
- </template>
- </user-callout-dismisser>
+ <div class="gl-sm-ml-3">
+ <actions-button
+ :id="$options.webIdeButtonId"
+ :actions="actions"
+ :selected-key="selection"
+ :variant="isBlob ? 'confirm' : 'default'"
+ :category="isBlob ? 'primary' : 'secondary'"
+ show-action-tooltip
+ @select="select"
+ />
+ <local-storage-sync
+ :storage-key="$options.PREFERRED_EDITOR_KEY"
+ :value="selection"
+ as-string
+ @input="select"
+ />
+ <gl-modal
+ v-if="computedShowGitpodButton && !gitpodEnabled"
+ v-model="showEnableGitpodModal"
+ v-bind="enableGitpodModalProps"
+ >
+ <gl-sprintf :message="$options.i18n.modal.content">
+ <template #link="{ content }">
+ <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ <confirm-fork-modal
+ v-if="showWebIdeButton || showEditButton"
+ v-model="showForkModal"
+ :modal-id="forkModalId"
+ :fork-path="forkPath"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index fd151751372..3896e963a53 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
@@ -98,3 +96,4 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
export const EDITING_MODE_MARKDOWN_FIELD = 'markdownField';
export const EDITING_MODE_CONTENT_EDITOR = 'contentEditor';
+export const CLEAR_AUTOSAVE_ENTRY_EVENT = 'markdown_clear_autosave_entry';
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
index fc0ff78e7b4..b9a09c2521c 100644
--- a/app/assets/javascripts/vue_shared/directives/validation.js
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -1,4 +1,5 @@
import { __ } from '~/locale';
+import { getInstanceFromDirective } from '~/lib/utils/vue3compat/get_instance_from_directive';
/**
* Validation messages will take priority based on the property order.
@@ -45,8 +46,7 @@ const getInputElement = (el) => {
const isEveryFieldValid = (form) => Object.values(form.fields).every(({ state }) => state === true);
-const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
- const { form } = context;
+const createValidator = (feedbackMap) => ({ el, form, reportInvalidInput = false }) => {
const { name } = el;
if (!name) {
@@ -69,6 +69,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
formField.state = reportInvalidInput ? isValid : isValid || null;
formField.feedback = reportInvalidInput ? getFeedbackForElement(feedbackMap, el) : '';
+ // eslint-disable-next-line no-param-reassign
form.state = isEveryFieldValid(form);
};
@@ -102,12 +103,13 @@ export default function initValidation(customFeedbackMap = {}) {
const elDataMap = new WeakMap();
return {
- inserted(element, binding, { context }) {
+ inserted(element, binding, vnode) {
const { arg: showGlobalValidation } = binding;
const el = getInputElement(element);
const { form: formEl } = el;
+ const instance = getInstanceFromDirective({ binding, vnode });
- const validate = createValidator(context, feedbackMap);
+ const validate = createValidator(feedbackMap);
const elData = { validate, isTouched: false, isBlurred: false };
elDataMap.set(el, elData);
@@ -121,7 +123,7 @@ export default function initValidation(customFeedbackMap = {}) {
el.addEventListener('blur', function markAsBlurred({ target }) {
if (elData.isTouched) {
elData.isBlurred = true;
- validate({ el: target, reportInvalidInput: true });
+ validate({ el: target, form: instance.form, reportInvalidInput: true });
// this event handler can be removed, since the live-feedback in `update` takes over
el.removeEventListener('blur', markAsBlurred);
}
@@ -131,15 +133,16 @@ export default function initValidation(customFeedbackMap = {}) {
formEl.addEventListener('submit', focusFirstInvalidInput);
}
- validate({ el, reportInvalidInput: showGlobalValidation });
+ validate({ el, form: instance.form, reportInvalidInput: showGlobalValidation });
},
- update(element, binding) {
+ update(element, binding, vnode) {
const el = getInputElement(element);
const { arg: showGlobalValidation } = binding;
const { validate, isTouched, isBlurred } = elDataMap.get(el);
const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);
- validate({ el, reportInvalidInput: showValidationFeedback });
+ const instance = getInstanceFromDirective({ binding, vnode });
+ validate({ el, form: instance.form, reportInvalidInput: showValidationFeedback });
},
};
}
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..4211b9578a2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -0,0 +1,77 @@
+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 MIN_SEARCH_TERM = s__(
+ 'GlobalSearch|The search term must be at least 3 characters long.',
+);
+
+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/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
index 2644befc902..a19b568801d 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -1,11 +1,11 @@
<script>
import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants';
import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue';
+import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants';
export default {
- LabelSelectVariant: DropdownVariant,
+ VARIANT_EMBEDDED,
components: {
GlForm,
GlFormInput,
@@ -105,7 +105,7 @@ export default {
:labels-list-title="__('Select label')"
:footer-create-label-title="__('Create project label')"
:footer-manage-label-title="__('Manage project labels')"
- :variant="$options.LabelSelectVariant.Embedded"
+ :variant="$options.VARIANT_EMBEDDED"
@updateSelectedLabels="handleUpdateSelectedLabels"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
index b3f9c8d9fcd..efb6e626c07 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue
@@ -1,5 +1,5 @@
<script>
-import { GlFormGroup, GlIcon } from '@gitlab/ui';
+import { GlFormGroup } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import LabelsSelect from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
import { __ } from '~/locale';
@@ -7,7 +7,6 @@ import { __ } from '~/locale';
export default {
components: {
GlFormGroup,
- GlIcon,
LabelsSelect,
},
inject: [
@@ -50,10 +49,6 @@ export default {
<gl-form-group class="row" label-class="gl-display-none">
<label class="col-12 gl-display-flex gl-align-center gl-mb-1">
{{ $options.i18n.fieldLabel }}
- <div class="gl-ml-3">
- <gl-icon name="labels" />
- <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span>
- </div>
</label>
<div class="col-12">
<div class="issuable-form-select-holder">
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index 5b303b9a314..62a32d8942a 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -2,6 +2,7 @@
import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { STATUS_CLOSED } from '~/issues/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
@@ -93,20 +94,22 @@ export default {
return getTimeago().format(this.issuable.createdAt);
},
timestamp() {
- if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
return this.issuable.closedAt;
}
return this.issuable.updatedAt;
},
formattedTimestamp() {
- if (this.issuable.state === 'closed' && this.issuable.closedAt) {
+ if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) {
return sprintf(__('closed %{timeago}'), {
timeago: getTimeago().format(this.issuable.closedAt),
});
+ } else if (this.issuable.updatedAt !== this.issuable.createdAt) {
+ return sprintf(__('updated %{timeAgo}'), {
+ timeAgo: getTimeago().format(this.issuable.updatedAt),
+ });
}
- return sprintf(__('updated %{timeAgo}'), {
- timeAgo: getTimeago().format(this.issuable.updatedAt),
- });
+ return undefined;
},
issuableTitleProps() {
if (this.isIssuableUrlExternal) {
@@ -200,6 +203,11 @@ export default {
</gl-form-checkbox>
<div class="issuable-main-info">
<div data-testid="issuable-title" class="issue-title title">
+ <work-item-type-icon
+ v-if="showWorkItemTypeIcon"
+ :work-item-type="issuable.type"
+ show-tooltip-on-hover
+ />
<gl-icon
v-if="issuable.confidential"
v-gl-tooltip
@@ -226,18 +234,13 @@ export default {
</gl-link>
<span
v-if="taskStatus"
- class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm"
data-testid="task-status"
>
{{ taskStatus }}
</span>
</div>
<div class="issuable-info">
- <work-item-type-icon
- v-if="showWorkItemTypeIcon"
- :work-item-type="issuable.type"
- show-tooltip-on-hover
- />
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference">
{{ reference }}
@@ -265,7 +268,7 @@ export default {
:data-avatar-url="author.avatarUrl"
:href="author.webUrl"
data-testid="issuable-author"
- class="author-link js-user-link"
+ class="author-link js-user-link gl-font-sm gl-text-gray-500!"
>
<span class="author">{{ author.name }}</span>
</gl-link>
@@ -285,8 +288,7 @@ export default {
</span>
<slot name="timeframe"></slot>
</span>
- &nbsp;
- <span v-if="labels.length" role="group" :aria-label="__('Labels')">
+ <p v-if="labels.length" role="group" :aria-label="__('Labels')" class="gl-mt-1 gl-mb-0">
<gl-label
v-for="(label, index) in labels"
:key="index"
@@ -295,10 +297,10 @@ export default {
:description="label.description"
:scoped="scopedLabel(label)"
:target="labelTarget(label)"
- :class="{ 'gl-ml-2': index }"
+ class="gl-mr-2"
size="sm"
/>
- </span>
+ </p>
</div>
</div>
<div class="issuable-meta">
@@ -312,7 +314,7 @@ export default {
:icon-size="16"
:max-visible="4"
img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
+ class="gl-align-items-center gl-display-flex"
/>
</li>
<slot name="statistics"></slot>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 5b6c5bf6e03..95108933a0b 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
+import { GlAlert, GlBadge, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
@@ -24,6 +24,7 @@ export default {
},
components: {
GlAlert,
+ GlBadge,
GlKeysetPagination,
GlSkeletonLoader,
IssuableTabs,
@@ -316,6 +317,7 @@ export default {
:show-checkbox="showBulkEditSidebar"
:checkbox-checked="allIssuablesChecked"
:show-friendly-text="showFilteredSearchFriendlyText"
+ terms-as-tokens
class="gl-flex-grow-1 gl-border-t-none row-content-block"
data-qa-selector="issuable_search_container"
@checked-input="handleAllIssuablesCheckedInput"
@@ -371,7 +373,9 @@ export default {
<slot name="timeframe" :issuable="issuable"></slot>
</template>
<template #status>
- <slot name="status" :issuable="issuable"></slot>
+ <gl-badge size="sm" variant="info">
+ <slot name="status" :issuable="issuable"></slot>
+ </gl-badge>
</template>
<template #statistics>
<slot name="statistics" :issuable="issuable"></slot>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index f6b864dfde0..7ece3b60bd5 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -1,33 +1,28 @@
+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 = [
+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.'),
},
];
-export const AvailableSortOptions = [
+export const availableSortOptions = [
{
id: 1,
title: __('Created date'),
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..45fde45f516 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-4">
<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/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index 2a0256548a8..61e45fa5195 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -5,8 +5,8 @@ import { formatDate, getTimeago, timeagoLanguageCode } from '~/lib/utils/datetim
*/
export default {
methods: {
- timeFormatted(time) {
- const timeago = getTimeago();
+ timeFormatted(time, format) {
+ const timeago = getTimeago(format);
return timeago.format(time, timeagoLanguageCode);
},
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
index 26309a25f07..caa85d3eaaf 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -20,7 +20,7 @@ export default {
};
</script>
<template>
- <div class="container gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-flex-direction-column">
<h2 class="gl-my-7 gl-font-size-h1 gl-text-center">
{{ title }}
</h2>
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..c8c7deff882 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
@@ -3,34 +3,31 @@ import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue';
+import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue';
+import { sidebarState, JS_TOGGLE_EXPAND_CLASS } from '~/super_sidebar/constants';
import LegacyContainer from './components/legacy_container.vue';
import WelcomePage from './components/welcome.vue';
export default {
+ JS_TOGGLE_EXPAND_CLASS,
components: {
NewTopLevelGroupAlert,
GlBreadcrumb,
GlIcon,
WelcomePage,
LegacyContainer,
- CreditCardVerification: () =>
- import('ee_component/namespaces/verification/components/credit_card_verification.vue'),
+ SuperSidebarToggle,
},
directives: {
SafeHtml,
},
- inject: {
- verificationRequired: {
- default: false,
- },
- },
props: {
title: {
type: String,
required: true,
},
- initialBreadcrumb: {
- type: String,
+ initialBreadcrumbs: {
+ type: Array,
required: true,
},
panels: {
@@ -51,7 +48,6 @@ export default {
data() {
return {
activePanelName: null,
- verificationCompleted: false,
};
},
@@ -60,6 +56,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,18 +69,15 @@ export default {
},
breadcrumbs() {
- if (!this.activePanel) {
- return null;
- }
-
- return [
- { text: this.initialBreadcrumb, href: '#' },
- { text: this.activePanel.title, href: `#${this.activePanel.name}` },
- ];
- },
-
- shouldVerify() {
- return this.verificationRequired && !this.verificationCompleted;
+ return this.activePanel
+ ? [
+ ...this.initialBreadcrumbs,
+ {
+ text: this.activePanel.title,
+ href: `#${this.activePanel.name}`,
+ },
+ ]
+ : this.initialBreadcrumbs;
},
showNewTopLevelGroupAlert() {
@@ -90,6 +87,10 @@ export default {
return this.activePanel.detailProps.parentGroupName === '';
},
+
+ showSuperSidebarToggle() {
+ return gon.use_new_navigation && sidebarState.isCollapsed;
+ },
},
created() {
@@ -116,34 +117,45 @@ export default {
localStorage.setItem(this.persistenceKey, this.activePanelName);
}
},
- onVerified() {
- this.verificationCompleted = true;
- },
},
};
</script>
<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>
+ <div>
+ <div
+ class="top-bar-container gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
+ >
+ <super-sidebar-toggle
+ v-if="showSuperSidebarToggle"
+ class="gl-mr-2"
+ :class="$options.JS_TOGGLE_EXPAND_CLASS"
+ />
+ <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" />
+ </div>
- <p v-if="hasTextDetails">{{ details }}</p>
- <component :is="details" v-else v-bind="activePanel.detailProps || {}" />
+ <template v-if="activePanel">
+ <div class="gl-display-flex gl-align-items-center gl-py-5">
+ <div v-safe-html="activePanel.illustration" class="gl-text-white col-auto"></div>
+ <div class="col">
+ <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>
+ <new-top-level-group-alert v-if="showNewTopLevelGroupAlert" />
+ <legacy-container :key="activePanel.name" :selector="activePanel.selector" />
+ </div>
+ </template>
- <slot name="extra-description"></slot>
- </div>
- <div class="col-lg-9">
- <new-top-level-group-alert v-if="showNewTopLevelGroupAlert" />
- <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs" />
- <legacy-container :key="activePanel.name" :selector="activePanel.selector" />
- </div>
+ <welcome-page v-else :panels="panels" :title="title">
+ <template #footer>
+ <slot name="welcome-footer"></slot>
+ </template>
+ </welcome-page>
</div>
</template>
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_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index 3afd1f9410b..fe408354f66 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -1,7 +1,8 @@
<script>
import { GlButton } from '@gitlab/ui';
import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { parseErrorMessage } from '~/lib/utils/error_message';
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { sprintf, s__ } from '~/locale';
import apolloProvider from '../provider';
@@ -9,6 +10,16 @@ function mutationSettingsForFeatureType(type) {
return featureToMutationMap[type];
}
+export const i18n = {
+ buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
+ noSuccessPathError: s__(
+ 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
+ ),
+ genericErrorText: s__(
+ `SecurityConfiguration|Something went wrong. Please refresh the page, or try again later.`,
+ ),
+};
+
export default {
apolloProvider,
components: {
@@ -55,15 +66,20 @@ export default {
throw new Error(errors[0]);
}
+ // Sending window.gon.uf_error_prefix prefixed messages should happen only in
+ // the backend. Hence the code below is an anti-pattern.
+ // The issue to refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/397714
if (!successPath) {
throw new Error(
- sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
+ `${window.gon.uf_error_prefix} ${sprintf(this.$options.i18n.noSuccessPathError, {
+ featureName: this.feature.name,
+ })}`,
);
}
- redirectTo(successPath);
+ redirectTo(successPath); // eslint-disable-line import/no-deprecated
} catch (e) {
- this.$emit('error', e.message);
+ this.$emit('error', parseErrorMessage(e, this.$options.i18n.genericErrorText));
this.isLoading = false;
}
},
@@ -84,12 +100,7 @@ export default {
Boolean(mutationSettingsForFeatureType(type))
);
},
- i18n: {
- buttonLabel: s__('SecurityConfiguration|Configure with a merge request'),
- noSuccessPathError: s__(
- 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
- ),
- },
+ i18n,
};
</script>
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/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
index 6fe98764fcd..5e8199c1bcd 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
@@ -2,7 +2,7 @@ export const SEVERITY_CLASS_NAME_MAP = {
critical: 'gl-text-red-800',
high: 'gl-text-red-600',
medium: 'gl-text-orange-400',
- low: 'gl-text-orange-200',
+ low: 'gl-text-orange-300',
info: 'gl-text-blue-400',
unknown: 'gl-text-gray-400',
};
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index fafbd02634f..a1d75e08be9 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -20,14 +20,15 @@ export const REPORT_TYPE_SAST = 'sast';
export const REPORT_TYPE_SAST_IAC = 'sast_iac';
export const REPORT_TYPE_DAST = 'dast';
export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles';
+export const REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION = 'breach_and_attack_simulation';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning';
export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning';
export const REPORT_TYPE_CLUSTER_IMAGE_SCANNING = 'cluster_image_scanning';
export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management';
-export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
+export const REPORT_TYPE_MANUALLY_ADDED = 'generic';
/**
* SecurityReportTypeEnum values for use with GraphQL.
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
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/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue
index 78e5dff6f59..90a8a7aa3e6 100644
--- a/app/assets/javascripts/webhooks/components/test_dropdown.vue
+++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue
@@ -19,45 +19,16 @@ export default {
},
},
computed: {
- itemsWithAction() {
- return this.items.map((item) => ({
- text: item.text,
- action: () => this.testHook(item.href),
+ webhookTriggers() {
+ return this.items.map(({ text, href }) => ({
+ text,
+ href,
+ extraAttrs: {
+ 'data-method': 'post',
+ },
}));
},
},
- methods: {
- testHook(href) {
- // HACK: Trigger @rails/ujs's data-method handling.
- //
- // The more obvious approaches of (1) declaratively rendering the
- // links using GlDisclosureDropdown's list-item slot and (2) using
- // item.extraAttrs to set the data-method attributes on the links
- // do not work for reasons laid out in
- // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134.
- //
- // Sending the POST with axios also doesn't work, since the
- // endpoints return 302 redirects. Since axios uses XMLHTTPRequest,
- // it transparently follows redirects, meaning the Location header
- // of the first response cannot be inspected/acted upon by JS. We
- // could manually trigger a reload afterwards, but that would mean
- // a duplicate fetch of the current page: one by the XHR, and one
- // by the explicit reload. It would also mean losing the flash
- // alert set by the backend, making the feature useless for the
- // user.
- //
- // The ideal fix here would be to refactor the test endpoint to
- // return a JSON response, removing the need for a redirect/page
- // reload to show the result.
- const a = document.createElement('a');
- a.setAttribute('hidden', '');
- a.href = href;
- a.dataset.method = 'post';
- document.body.appendChild(a);
- a.click();
- a.remove();
- },
- },
i18n: {
test: __('Test'),
},
@@ -65,5 +36,5 @@ export default {
</script>
<template>
- <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" />
+ <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="webhookTriggers" :size="size" />
</template>
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..f8dfa1c7f01 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;
@@ -127,7 +127,11 @@ export default {
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
- <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div>
+ <div
+ class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
+ <gl-icon :name="note.systemNoteIconName" />
+ </div>
<div class="timeline-content">
<div class="note-header">
<note-header
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..e10a82b5197 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
@@ -1,13 +1,11 @@
<script>
-import { GlAvatar, GlButton } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-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 { __ } from '~/locale';
+import { clearDraft } from '~/lib/utils/autosave';
import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
+import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants';
import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
import WorkItemCommentLocked from './work_item_comment_locked.vue';
@@ -18,31 +16,21 @@ export default {
avatarUrl: window.gon.current_user_avatar_url,
},
components: {
- GlAvatar,
- GlButton,
WorkItemNoteSignedOut,
WorkItemCommentLocked,
WorkItemCommentForm,
},
- mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
discussionId: {
type: String,
required: false,
@@ -67,28 +55,43 @@ export default {
required: false,
default: ASC,
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isNewDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
workItem: {},
- isEditing: false,
+ isEditing: this.isNewDiscussion,
isSubmitting: false,
isSubmittingWithKeydown: false,
};
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return !this.workItemIid;
},
error() {
this.$emit('error', i18n.fetchError);
@@ -110,15 +113,20 @@ export default {
property: `type_${this.workItemType}`,
};
},
- markdownPreviewPath() {
- return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
- this.workItemType
- }`;
+ timelineEntryInnerClass() {
+ return {
+ 'timeline-entry-inner': this.isNewDiscussion,
+ };
},
- timelineEntryClass() {
+ timelineContentClass() {
+ return {
+ 'timeline-content': true,
+ 'gl-border-0! gl-pl-0!': !this.addPadding,
+ };
+ },
+ parentClass() {
return {
- 'timeline-entry gl-mb-3': true,
- 'gl-p-4': this.addPadding,
+ 'gl-relative gl-display-flex gl-align-items-flex-start gl-flex-nowrap': !this.isEditing,
};
},
isProjectArchived() {
@@ -127,6 +135,21 @@ export default {
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
+ workItemState() {
+ return this.workItem?.state;
+ },
+ commentButtonText() {
+ return this.isNewDiscussion ? __('Comment') : __('Reply');
+ },
+ timelineEntryClass() {
+ return {
+ 'timeline-entry note-form': this.isNewDiscussion,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix': !this
+ .isNewDiscussion,
+ 'gl-bg-white! gl-pt-0!': this.isEditing,
+ };
+ },
},
watch: {
autofocus: {
@@ -142,8 +165,6 @@ export default {
async updateWorkItem(commentText) {
this.isSubmitting = true;
this.$emit('replying', commentText);
- const { queryVariables, fetchByIid } = this;
-
try {
this.track('add_work_item_comment');
@@ -157,26 +178,56 @@ export default {
},
},
update(store, createNoteData) {
- if (createNoteData.data?.createNote?.errors?.length) {
+ const numErrors = createNoteData.data?.createNote?.errors?.length;
+
+ if (numErrors) {
+ const { errors } = createNoteData.data.createNote;
+
+ // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/346557
+ // When a note only contains quick actions,
+ // additional "helpful" messages are embedded in the errors field.
+ // For instance, a note solely composed of "/assign @foobar" would
+ // return a message "Commands only Assigned @root." as an error on creation
+ // even though the quick action successfully executed.
+ if (
+ numErrors === 2 &&
+ errors[0].includes('Commands only') &&
+ errors[1].includes('Command names')
+ ) {
+ return;
+ }
+
throw new Error(createNoteData.data?.createNote?.errors[0]);
}
- updateCommentState(store, createNoteData, fetchByIid, queryVariables);
},
});
- clearDraft(this.autosaveKey);
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * Once form is successfully submitted, emit replied event,
+ * mark isSubmitting to false and clear storage before hiding the form.
+ * This will restrict comment form to restore the value while textarea
+ * input triggered due to keyboard event meta+enter.
+ *
+ */
this.$emit('replied');
+ clearDraft(this.autosaveKey);
this.cancelEditing();
} catch (error) {
this.$emit('error', error.message);
Sentry.captureException(error);
+ } finally {
+ this.isSubmitting = false;
}
-
- this.isSubmitting = false;
},
cancelEditing() {
- this.isEditing = false;
+ this.isEditing = this.isNewDiscussion;
this.$emit('cancelEditing');
},
+ showReplyForm() {
+ this.isEditing = true;
+ this.$emit('startReplying');
+ },
},
};
</script>
@@ -189,23 +240,38 @@ 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="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"
+ :is-new-discussion="isNewDiscussion"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :work-item-state="workItemState"
+ :work-item-id="workItemId"
+ :autofocus="autofocus"
+ :comment-button-text="commentButtonText"
+ @submitForm="updateWorkItem"
+ @cancelEditing="cancelEditing"
+ />
+ <textarea
+ v-else
+ ref="textarea"
+ rows="1"
+ class="reply-placeholder-text-field gl-font-regular!"
+ data-testid="note-reply-textarea"
+ :placeholder="__('Reply')"
+ :aria-label="__('Reply to comment')"
+ @focus="showReplyForm"
+ @click="showReplyForm"
+ ></textarea>
+ </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..cea28b30d42 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
@@ -1,11 +1,22 @@
<script>
import { GlButton } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { s__, __ } from '~/locale';
-import { joinPaths } from '~/lib/utils/url_utility';
+import { s__, __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ STATE_OPEN,
+ STATE_EVENT_REOPEN,
+ STATE_EVENT_CLOSE,
+ TRACKING_CATEGORY_SHOW,
+ i18n,
+} from '~/work_items/constants';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item';
export default {
constantOptions: {
@@ -15,8 +26,13 @@ export default {
GlButton,
MarkdownEditor,
},
+ mixins: [Tracking.mixin()],
inject: ['fullPath'],
props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
workItemType: {
type: String,
required: true,
@@ -44,20 +60,44 @@ export default {
required: false,
default: __('Comment'),
},
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isNewDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemState: {
+ type: String,
+ required: false,
+ default: STATE_OPEN,
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
commentText: getDraft(this.autosaveKey) || this.initialValue || '',
+ updateInProgress: false,
};
},
computed: {
- markdownPreviewPath() {
- return joinPaths(
- '/',
- gon.relative_url_root || '',
- this.fullPath,
- `/preview_markdown?target_type=${this.workItemType}`,
- );
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_task_status',
+ property: `type_${this.workItemType}`,
+ };
},
formFieldProps() {
return {
@@ -67,11 +107,30 @@ export default {
name: 'work-item-add-or-edit-comment',
};
},
+ isWorkItemOpen() {
+ return this.workItemState === STATE_OPEN;
+ },
+ toggleWorkItemStateText() {
+ return this.isWorkItemOpen
+ ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() })
+ : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() });
+ },
+ cancelButtonText() {
+ return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel');
+ },
},
methods: {
setCommentText(newText) {
- this.commentText = newText;
- updateDraft(this.autosaveKey, this.commentText);
+ /**
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/388314
+ *
+ * While the form is saving using meta+enter,
+ * avoid updating the data which is cleared after form submission.
+ */
+ if (!this.isSubmitting) {
+ this.commentText = newText;
+ updateDraft(this.autosaveKey, this.commentText);
+ }
},
async cancelEditing() {
if (this.commentText && this.commentText !== this.initialValue) {
@@ -91,36 +150,91 @@ export default {
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
+ async toggleWorkItemState() {
+ const input = {
+ id: this.workItemId,
+ stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN,
+ };
+
+ this.updateInProgress = true;
+
+ try {
+ this.track('updated_state');
+
+ const { mutation, variables } = getUpdateWorkItemMutation({
+ workItemParentId: this.workItemParentId,
+ input,
+ });
+
+ const { data } = await this.$apollo.mutate({
+ mutation,
+ variables,
+ });
+
+ const errors = data.workItemUpdate?.errors;
+
+ if (errors?.length) {
+ this.$emit('error', i18n.updateError);
+ }
+ } catch (error) {
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+
+ this.$emit('error', msg);
+ Sentry.captureException(error);
+ }
+
+ this.updateInProgress = false;
+ },
+ cancelButtonAction() {
+ if (this.isNewDiscussion) {
+ this.toggleWorkItemState();
+ } else {
+ this.cancelEditing();
+ }
+ },
},
};
</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 gl-overflow-visible!">
+ <div class="note-body gl-p-0! gl-overflow-visible!">
+ <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"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :form-field-props="formFieldProps"
+ :add-spacing-classes="false"
+ data-testid="work-item-add-comment"
+ class="gl-mb-5"
+ use-bottom-toolbar
+ supports-quick-actions
+ :autofocus="autofocus"
+ @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"
+ :disabled="!commentText.length"
+ :loading="isSubmitting"
+ @click="$emit('submitForm', commentText)"
+ >{{ commentButtonText }}
+ </gl-button>
+ <gl-button
+ data-testid="cancel-button"
+ category="primary"
+ class="gl-ml-3"
+ :loading="updateInProgress"
+ @click="cancelButtonAction"
+ >{{ cancelButtonText }}
+ </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..e98e03f76fd 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,24 +12,19 @@ import WorkItemAddNote from './work_item_add_note.vue';
export default {
components: {
TimelineEntryItem,
- GlAvatarLink,
- GlAvatar,
WorkItemNote,
WorkItemAddNote,
ToggleRepliesWidget,
DiscussionNotesRepliesWrapper,
WorkItemNoteReplying,
},
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- queryVariables: {
- type: Object,
- required: true,
- },
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
@@ -36,11 +32,6 @@ export default {
type: String,
required: true,
},
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
discussion: {
type: Array,
required: true,
@@ -50,13 +41,38 @@ export default {
default: ASC,
required: false,
},
+ isModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
- isExpanded: false,
+ isExpanded: true,
autofocus: false,
isReplying: false,
replyingText: '',
+ showForm: false,
};
},
computed: {
@@ -66,11 +82,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,23 +106,29 @@ 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 */
- return `${note.id}-thread`;
+ return `${note.id}-thread`; // eslint-disable-line @gitlab/require-i18n-strings
},
onReplied() {
this.isExpanded = true;
@@ -113,76 +144,107 @@ 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"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :class="{ 'gl-mb-4': hasReplies }"
+ :assignees="assignees"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', note)"
+ @reportAbuse="$emit('reportAbuse', 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 }"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', note)"
+ @reportAbuse="$emit('reportAbuse', 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"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', reply)"
+ @reportAbuse="$emit('reportAbuse', 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"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
:discussion-id="discussionId"
- :note="reply"
:work-item-type="workItemType"
+ :sort-order="sortOrder"
+ :add-padding="true"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
@startReplying="showReplyForm"
- @deleteNote="$emit('deleteNote', reply)"
+ @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..e7a80bf39fb
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_history_only_filter_note.vue
@@ -0,0 +1,63 @@
+<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="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600"
+ >
+ <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..75b0970a89e 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,23 +1,26 @@
<script>
-import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
+import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
+import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
+import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_ASSIGNEES } from '~/work_items/constants';
+import Tracking from '~/tracking';
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';
import NoteHeader from '~/notes/components/note_header.vue';
import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
+import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
name: 'WorkItemNoteThread',
- i18n: {
- moreActionsText: __('More actions'),
- deleteNoteText: __('Delete comment'),
- },
components: {
TimelineEntryItem,
NoteBody,
@@ -25,15 +28,20 @@ export default {
NoteActions,
GlAvatar,
GlAvatarLink,
- GlDropdown,
- GlDropdownItem,
WorkItemCommentForm,
EditedAt,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemIid: {
+ type: String,
+ required: true,
+ },
note: {
type: Object,
required: true,
@@ -43,10 +51,39 @@ 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,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -54,18 +91,31 @@ export default {
};
},
computed: {
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_note_actions',
+ property: `type_${this.workItemType}`,
+ };
+ },
author() {
return this.note.author;
},
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 +126,50 @@ 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;
+ },
+ isAuthorAnAssignee() {
+ return Boolean(this.assignees.filter((assignee) => assignee.id === this.author.id).length);
+ },
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ canReportAbuse() {
+ return getIdFromGraphQLId(this.author.id) !== this.currentUserId;
+ },
+ },
+ apollo: {
+ workItem: {
+ query: workItemByIidQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItems?.nodes[0];
+ },
+ skip() {
+ return !this.workItemIid;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
},
methods: {
showReplyForm() {
@@ -86,8 +180,8 @@ export default {
updateDraft(this.autosaveKey, this.note.body);
},
async updateNote(newText) {
- this.isEditing = false;
try {
+ this.isEditing = false;
await this.$apollo.mutate({
mutation: updateWorkItemNoteMutation,
variables: {
@@ -114,13 +208,75 @@ export default {
Sentry.captureException(error);
}
},
+ getNewAssigneesAndWidget() {
+ let newAssignees = [];
+ if (this.isAuthorAnAssignee) {
+ newAssignees = this.assignees.filter(({ id }) => id !== this.author.id);
+ } else {
+ newAssignees = [...this.assignees, this.author];
+ }
+
+ const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES;
+
+ const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget);
+
+ const editedWorkItemWidgets = [...this.workItem.widgets];
+
+ editedWorkItemWidgets[assigneesWidgetIndex] = {
+ ...editedWorkItemWidgets[assigneesWidgetIndex],
+ assignees: {
+ nodes: newAssignees,
+ },
+ };
+
+ return {
+ newAssignees,
+ editedWorkItemWidgets,
+ };
+ },
+ notifyCopyDone() {
+ if (this.isModal) {
+ navigator.clipboard.writeText(this.noteUrl);
+ }
+ toast(__('Link copied to clipboard.'));
+ },
+ async assignUserAction() {
+ const { newAssignees, editedWorkItemWidgets } = this.getNewAssigneesAndWidget();
+
+ try {
+ await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ assigneesWidget: {
+ assigneeIds: newAssignees.map(({ id }) => id),
+ },
+ },
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ ...this.workItem,
+ widgets: editedWorkItemWidgets,
+ },
+ },
+ },
+ });
+ this.track(`${this.isAuthorAnAssignee ? 'unassigned_user' : 'assigned_user'}`);
+ } catch (error) {
+ this.$emit('error', i18n.updateError);
+ Sentry.captureException(error);
+ }
+ },
},
};
</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 +286,63 @@ 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')"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :work-item-id="workItemId"
+ :autofocus="isEditing"
+ class="gl-pl-3 gl-mt-3"
+ @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"
+ :is-author-an-assignee="isAuthorAnAssignee"
+ :show-assign-unassign="canSetWorkItemMetadata"
+ :can-report-abuse="canReportAbuse"
+ @startReplying="showReplyForm"
+ @startEditing="startEditing"
+ @error="($event) => $emit('error', $event)"
+ @notifyCopyDone="notifyCopyDone"
+ @deleteNote="$emit('deleteNote')"
+ @assignUser="assignUserAction"
+ @reportAbuse="$emit('reportAbuse')"
+ />
+ </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..93f21f4fad8 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,20 +1,34 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlButton, GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } 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',
i18n: {
editButtonText: __('Edit comment'),
+ moreActionsText: __('More actions'),
+ deleteNoteText: __('Delete comment'),
+ copyLinkText: __('Copy link'),
+ assignUserText: __('Assign to commenting user'),
+ unassignUserText: __('Unassign from commenting user'),
+ reportAbuseText: __('Report abuse to administrator'),
},
components: {
GlButton,
+ GlIcon,
ReplyButton,
+ GlDropdown,
+ GlDropdownItem,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
showReply: {
type: Boolean,
@@ -24,12 +38,90 @@ export default {
type: Boolean,
required: true,
},
+ noteId: {
+ type: String,
+ required: true,
+ },
+ showAwardEmoji: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ noteUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isAuthorAnAssignee: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showAssignUnassign: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ canReportAbuse: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ assignUserActionText() {
+ return this.isAuthorAnAssignee
+ ? this.$options.i18n.unassignUserText
+ : this.$options.i18n.assignUserText;
+ },
+ },
+ 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"
@@ -43,5 +135,46 @@ export default {
:aria-label="$options.i18n.editButtonText"
@click="$emit('startEditing')"
/>
+ <gl-dropdown
+ v-gl-tooltip
+ data-testid="work-item-note-actions"
+ icon="ellipsis_v"
+ text-sr-only
+ right
+ :text="$options.i18n.moreActionsText"
+ :title="$options.i18n.moreActionsText"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item
+ v-if="canReportAbuse"
+ data-testid="abuse-note-action"
+ @click="$emit('reportAbuse')"
+ >
+ {{ $options.i18n.reportAbuseText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ data-testid="copy-link-action"
+ :data-clipboard-text="noteUrl"
+ @click="$emit('notifyCopyDone')"
+ >
+ <span>{{ $options.i18n.copyLinkText }}</span>
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showAssignUnassign"
+ data-testid="assign-note-action"
+ @click="$emit('assignUser')"
+ >
+ {{ assignUserActionText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="showEdit"
+ variant="danger"
+ data-testid="delete-note-action"
+ @click="$emit('deleteNote')"
+ >
+ {{ $options.i18n.deleteNoteText }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
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..6ae30e9b084 100644
--- a/app/assets/javascripts/work_items/components/widget_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -1,11 +1,12 @@
<script>
-import { GlAlert, GlButton } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlAlert,
GlButton,
+ GlLink,
},
props: {
error: {
@@ -42,15 +43,26 @@ export default {
</script>
<template>
- <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4">
+ <div
+ id="tasks"
+ class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5"
+ >
<div
- class="gl-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">
+ <h3 class="card-title h5 gl-m-0 gl-relative gl-line-height-24">
+ <gl-link
+ id="user-content-tasks-links"
+ class="anchor position-absolute gl-text-decoration-none"
+ href="#tasks"
+ aria-hidden="true"
+ />
<slot name="header"></slot>
- </h5>
+ </h3>
<slot name="header-suffix"></slot>
</div>
<slot name="header-right"></slot>
@@ -71,7 +83,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..8ea5873f73a 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -2,33 +2,62 @@
import {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
GlModalDirective,
+ GlToggle,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import toast from '~/vue_shared/plugins/global_toast';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import {
sprintfWorkItem,
I18N_WORK_ITEM_DELETE,
I18N_WORK_ITEM_ARE_YOU_SURE_DELETE,
+ TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ TEST_ID_DELETE_ACTION,
+ TEST_ID_PROMOTE_ACTION,
+ WIDGET_TYPE_NOTIFICATIONS,
+ I18N_WORK_ITEM_ERROR_CONVERTING,
+ WORK_ITEM_TYPE_VALUE_KEY_RESULT,
+ WORK_ITEM_TYPE_VALUE_OBJECTIVE,
} from '../constants';
+import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
+import convertWorkItemMutation from '../graphql/work_item_convert.mutation.graphql';
+import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
export default {
i18n: {
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
+ notifications: s__('WorkItem|Notifications'),
+ notificationOn: s__('WorkItem|Notifications turned on.'),
+ notificationOff: s__('WorkItem|Notifications turned off.'),
},
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
+ GlToggle,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin({ label: 'actions_menu' })],
+ isLoggedIn: isLoggedIn(),
+ notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ deleteActionTestId: TEST_ID_DELETE_ACTION,
+ promoteActionTestId: TEST_ID_PROMOTE_ACTION,
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -40,6 +69,11 @@ export default {
required: false,
default: null,
},
+ workItemTypeId: {
+ type: String,
+ required: false,
+ default: null,
+ },
canUpdate: {
type: Boolean,
required: false,
@@ -60,15 +94,52 @@ export default {
required: false,
default: false,
},
+ subscribedToNotifications: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ apollo: {
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.workItemTypes?.nodes;
+ },
+ skip() {
+ return !this.canUpdate;
+ },
+ },
},
- emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
computed: {
i18n() {
return {
deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType),
areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType),
+ convertError: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CONVERTING, this.workItemType),
};
},
+ canPromoteToObjective() {
+ return this.canUpdate && this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT;
+ },
+ objectiveWorkItemTypeId() {
+ return this.workItemTypes.find((type) => type.name === WORK_ITEM_TYPE_VALUE_OBJECTIVE).id;
+ },
+ },
+ watch: {
+ subscribedToNotifications() {
+ /**
+ * To toggle the value if mutation fails, assign the
+ * subscribedToNotifications boolean value directly
+ * to data prop.
+ */
+ this.initialSubscribed = this.subscribedToNotifications;
+ },
},
methods: {
handleToggleWorkItemConfidentiality() {
@@ -84,6 +155,85 @@ export default {
this.track('cancel_delete_work_item');
}
},
+ toggleNotifications(subscribed) {
+ const inputVariables = {
+ id: this.workItemId,
+ notificationsWidget: {
+ subscribed,
+ },
+ };
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemNotificationsMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ id: this.workItemId,
+ widgets: [
+ {
+ type: WIDGET_TYPE_NOTIFICATIONS,
+ subscribed,
+ __typename: 'WorkItemWidgetNotifications',
+ },
+ ],
+ __typename: 'WorkItem',
+ },
+ __typename: 'WorkItemUpdatePayload',
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ toast(
+ subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff,
+ );
+ },
+ )
+ .catch((error) => {
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
+ });
+ },
+ throwConvertError() {
+ this.$emit('error', this.i18n.convertError);
+ },
+ async promoteToObjective() {
+ try {
+ const {
+ data: {
+ workItemConvert: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: convertWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ workItemTypeId: this.objectiveWorkItemTypeId,
+ },
+ },
+ });
+ if (errors.length > 0) {
+ this.throwConvertError();
+ return;
+ }
+ this.$toast.show(s__('WorkItem|Promoted to objective.'));
+ this.track('promote_kr_to_objective');
+ } catch (error) {
+ this.throwConvertError();
+ Sentry.captureException(error);
+ }
+ },
},
};
</script>
@@ -99,9 +249,34 @@ export default {
no-caret
right
>
+ <template v-if="$options.isLoggedIn">
+ <gl-dropdown-form
+ class="work-item-notifications-form"
+ :data-testid="$options.notificationsToggleFormTestId"
+ >
+ <div class="gl-px-5 gl-pb-2 gl-pt-1">
+ <gl-toggle
+ :value="subscribedToNotifications"
+ :label="$options.i18n.notifications"
+ :data-testid="$options.notificationsToggleTestId"
+ label-position="left"
+ label-id="notifications-toggle"
+ @change="toggleNotifications($event)"
+ />
+ </div>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ </template>
+ <gl-dropdown-item
+ v-if="canPromoteToObjective"
+ :data-testid="$options.promoteActionTestId"
+ @click="promoteToObjective"
+ >
+ {{ __('Promote to objective') }}
+ </gl-dropdown-item>
<template v-if="canUpdate && !isParentConfidential">
<gl-dropdown-item
- data-testid="confidentiality-toggle-action"
+ :data-testid="$options.confidentialityTestId"
@click="handleToggleWorkItemConfidentiality"
>{{
isConfidential
@@ -114,7 +289,8 @@ export default {
<gl-dropdown-item
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
- data-testid="delete-action"
+ :data-testid="$options.deleteActionTestId"
+ 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..4e6583b65f8 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -54,6 +54,7 @@ export default {
GlIntersectionObserver,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -81,10 +82,6 @@ export default {
required: false,
default: false,
},
- fullPath: {
- type: String,
- required: true,
- },
},
data() {
return {
@@ -298,7 +295,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_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
new file mode 100644
index 00000000000..91f87be1233
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue
@@ -0,0 +1,144 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import AwardsList from '~/vue_shared/components/awards_list.vue';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import {
+ EMOJI_ACTION_REMOVE,
+ EMOJI_ACTION_ADD,
+ WIDGET_TYPE_AWARD_EMOJI,
+ EMOJI_THUMBSDOWN,
+ EMOJI_THUMBSUP,
+} from '../constants';
+
+export default {
+ defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN],
+ isLoggedIn: isLoggedIn(),
+ components: {
+ AwardsList,
+ },
+ props: {
+ workItem: {
+ type: Object,
+ required: true,
+ },
+ awardEmoji: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ currentUserId() {
+ return window.gon.current_user_id;
+ },
+ /**
+ * Parse and convert award emoji list to a format that AwardsList can understand
+ */
+ awards() {
+ return this.awardEmoji.nodes.map((emoji, index) => ({
+ id: index + 1,
+ name: emoji.name,
+ user: {
+ id: getIdFromGraphQLId(emoji.user.id),
+ },
+ }));
+ },
+ },
+ methods: {
+ handleAward(name) {
+ // Decide action based on emoji is already present
+ const action =
+ this.awards.findIndex((emoji) => emoji.name === name) > -1
+ ? EMOJI_ACTION_REMOVE
+ : EMOJI_ACTION_ADD;
+ const inputVariables = {
+ id: this.workItem.id,
+ awardEmojiWidget: {
+ action,
+ name,
+ },
+ };
+
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: this.getOptimisticResponse({ name, action }),
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ },
+ )
+ .catch((error) => {
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
+ });
+ },
+ /**
+ * Prepare workItemUpdate for optimistic response
+ */
+ getOptimisticResponse({ name, action }) {
+ let awardEmojiNodes = [
+ ...this.awardEmoji.nodes,
+ {
+ name,
+ __typename: 'AwardEmoji',
+ user: {
+ id: convertToGraphQLId(TYPENAME_USER, this.currentUserId),
+ __typename: 'UserCore',
+ },
+ },
+ ];
+ // Exclude the award emoji node in case of remove action
+ if (action === EMOJI_ACTION_REMOVE) {
+ awardEmojiNodes = [...this.awardEmoji.nodes.filter((emoji) => emoji.name !== name)];
+ }
+ return {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ ...this.workItem,
+ widgets: [
+ {
+ type: WIDGET_TYPE_AWARD_EMOJI,
+ awardEmoji: {
+ nodes: awardEmojiNodes,
+ __typename: 'AwardEmojiConnection',
+ },
+ __typename: 'WorkItemWidgetAwardEmoji',
+ },
+ ],
+ __typename: 'WorkItem',
+ },
+ __typename: 'WorkItemUpdatePayload',
+ },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mt-3">
+ <awards-list
+ data-testid="work-item-award-list"
+ :awards="awards"
+ :can-award-emoji="$options.isLoggedIn"
+ :current-user-id="currentUserId"
+ :default-awards="$options.defaultAwards"
+ selected-class="selected"
+ @award="handleAward"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
index d1a707f2a8a..78a86aa49a4 100644
--- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue
+++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
@@ -2,7 +2,7 @@
import { GlAvatarLink, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { getWorkItemQuery } from '../utils';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
export default {
components: {
@@ -10,26 +10,13 @@ export default {
GlSprintf,
TimeAgoTooltip,
},
+ inject: ['fullPath'],
props: {
- fetchByIid: {
- type: Boolean,
- required: true,
- },
- workItemId: {
- type: String,
- required: false,
- default: null,
- },
workItemIid: {
type: String,
required: false,
default: null,
},
- fullPath: {
- type: String,
- required: false,
- default: null,
- },
},
computed: {
createdAt() {
@@ -44,31 +31,21 @@ export default {
authorId() {
return getIdFromGraphQLId(this.author.id);
},
- queryVariables() {
- return this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid: this.workItemIid,
- }
- : {
- id: this.workItemId,
- };
- },
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
skip() {
- return !this.workItemId && !this.workItemIid;
+ return !this.workItemIid;
},
update(data) {
- const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
- return workItem ?? {};
+ return data.workspace.workItems.nodes[0] ?? {};
},
},
},
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..a4cbc430b84 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -10,9 +10,10 @@ import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
-import { getWorkItemQuery, autocompleteDataSources, markdownPreviewPath } from '../utils';
+import { autocompleteDataSources, markdownPreviewPath } from '../utils';
import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
import WorkItemDescriptionRendered from './work_item_description_rendered.vue';
@@ -27,24 +28,16 @@ export default {
WorkItemDescriptionRendered,
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
},
markdownDocsPath: helpPagePath('user/project/quick_actions'),
quickActionsDocsPath: helpPagePath('user/project/quick_actions'),
@@ -55,7 +48,6 @@ export default {
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
- descriptionHtml: '',
conflictedDescription: '',
formFieldProps: {
'aria-label': __('Description'),
@@ -67,26 +59,19 @@ export default {
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
- },
- skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return data.workspace.workItems.nodes[0];
},
result() {
if (this.isEditing) {
- if (this.descriptionText !== this.workItemDescription?.description) {
- this.conflictedDescription = this.workItemDescription?.description;
- }
- } else {
- this.descriptionText = this.workItemDescription?.description;
- this.descriptionHtml = this.workItemDescription?.descriptionHtml;
+ this.checkForConflicts();
}
},
error() {
@@ -148,6 +133,11 @@ export default {
},
},
methods: {
+ checkForConflicts() {
+ if (this.descriptionText !== this.workItemDescription?.description) {
+ this.conflictedDescription = this.workItemDescription?.description;
+ }
+ },
async startEditing() {
this.isEditing = true;
@@ -254,8 +244,7 @@ export default {
:autocomplete-data-sources="autocompleteDataSources"
enable-autocomplete
supports-quick-actions
- init-on-autofocus
- use-bottom-toolbar
+ autofocus
@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..0f1af44e8a1 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -14,19 +14,23 @@ import {
import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg';
import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
-import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import {
sprintfWorkItem,
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
+ WIDGET_TYPE_NOTIFICATIONS,
+ WIDGET_TYPE_CURRENT_USER_TODOS,
WIDGET_TYPE_DESCRIPTION,
+ WIDGET_TYPE_AWARD_EMOJI,
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_PROGRESS,
@@ -39,20 +43,23 @@ import {
WIDGET_TYPE_NOTES,
} from '../constants';
-import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql';
+import workItemDatesSubscription from '../../graphql_shared/subscriptions/work_item_dates.subscription.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql';
import workItemMilestoneSubscription from '../graphql/work_item_milestone.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
-import { getWorkItemQuery } from '../utils';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
+import { findHierarchyWidgetChildren } from '../utils';
import WorkItemTree from './work_item_links/work_item_tree.vue';
import WorkItemActions from './work_item_actions.vue';
+import WorkItemTodos from './work_item_todos.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemCreatedUpdated from './work_item_created_updated.vue';
import WorkItemDescription from './work_item_description.vue';
+import WorkItemAwardEmoji from './work_item_award_emoji.vue';
import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
@@ -65,6 +72,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ isLoggedIn: isLoggedIn(),
components: {
GlAlert,
GlBadge,
@@ -75,8 +83,10 @@ export default {
GlEmptyState,
WorkItemAssignees,
WorkItemActions,
+ WorkItemTodos,
WorkItemCreatedUpdated,
WorkItemDescription,
+ WorkItemAwardEmoji,
WorkItemDueDate,
WorkItemLabels,
WorkItemTitle,
@@ -91,9 +101,10 @@ export default {
WorkItemTree,
WorkItemNotes,
WorkItemDetailModal,
+ AbuseCategorySelector,
},
mixins: [glFeatureFlagMixin()],
- inject: ['fullPath'],
+ inject: ['fullPath', 'reportAbusePath'],
props: {
isModal: {
type: Boolean,
@@ -128,27 +139,34 @@ export default {
? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
: null,
modalWorkItemIid: getParameterByName('work_item_iid'),
+ isReportDrawerOpen: false,
+ reportedUrl: '',
+ reportedUserId: 0,
};
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
skip() {
- return !this.workItemId && !this.workItemIid;
+ return !this.workItemIid;
},
update(data) {
- const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
- return workItem ?? {};
+ return data.workspace.workItems.nodes[0] ?? {};
},
error() {
this.setEmptyState();
},
- result() {
+ result(res) {
+ // need to handle this when the res is loading: true, netWorkStatus: 1, partial: true
+ if (!res.data) {
+ return;
+ }
if (isEmpty(this.workItem)) {
this.setEmptyState();
}
@@ -210,26 +228,35 @@ export default {
},
computed: {
workItemLoading() {
- return this.$apollo.queries.workItem.loading;
+ return isEmpty(this.workItem) && this.$apollo.queries.workItem.loading;
},
workItemType() {
return this.workItem.workItemType?.name;
},
+ workItemTypeId() {
+ return this.workItem.workItemType?.id;
+ },
+ workItemBreadcrumbReference() {
+ return this.workItemType ? `${this.workItemType} #${this.workItem.iid}` : '';
+ },
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ canSetWorkItemMetadata() {
+ return this.workItem?.userPermissions?.setWorkItemMetadata;
+ },
+ canAssignUnassignUser() {
+ return this.workItemAssignees && this.canSetWorkItemMetadata;
+ },
confidentialTooltip() {
return sprintfWorkItem(this.$options.i18n.confidentialTooltip, this.workItemType);
},
fullPath() {
return this.workItem?.project.fullPath;
},
- workItemsMvcEnabled() {
- return this.glFeatures.workItemsMvc;
- },
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
@@ -245,6 +272,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.
@@ -262,6 +292,18 @@ export default {
hasDescriptionWidget() {
return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION);
},
+ workItemNotificationsSubscribed() {
+ return Boolean(this.isWidgetPresent(WIDGET_TYPE_NOTIFICATIONS)?.subscribed);
+ },
+ workItemCurrentUserTodos() {
+ return this.isWidgetPresent(WIDGET_TYPE_CURRENT_USER_TODOS);
+ },
+ showWorkItemCurrentUserTodos() {
+ return this.$options.isLoggedIn && this.workItemCurrentUserTodos;
+ },
+ currentUserTodos() {
+ return this.workItemCurrentUserTodos?.currentUserTodos?.edges;
+ },
workItemAssignees() {
return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
@@ -277,6 +319,9 @@ export default {
workItemProgress() {
return this.isWidgetPresent(WIDGET_TYPE_PROGRESS);
},
+ workItemAwardEmoji() {
+ return this.isWidgetPresent(WIDGET_TYPE_AWARD_EMOJI);
+ },
workItemHierarchy() {
return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY);
},
@@ -292,32 +337,21 @@ export default {
workItemNotes() {
return this.isWidgetPresent(WIDGET_TYPE_NOTES);
},
- fetchByIid() {
- return (
- (this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'))) ||
- false
- );
- },
- queryVariables() {
- return this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid: this.workItemIid,
- }
- : {
- id: this.workItemId,
- };
- },
children() {
- const widgetHierarchy = this.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
- return widgetHierarchy.children.nodes;
+ return this.workItem ? findHierarchyWidgetChildren(this.workItem) : [];
+ },
+ workItemBodyClass() {
+ return {
+ 'gl-pt-5': !this.updateError && !this.isModal,
+ };
},
},
mounted() {
if (this.modalWorkItemId || this.modalWorkItemIid) {
- this.openInModal(undefined, { id: this.modalWorkItemId, iid: this.modalWorkItemIid });
+ this.openInModal({
+ event: undefined,
+ modalWorkItem: { id: this.modalWorkItemId, iid: this.modalWorkItemIid },
+ });
}
},
methods: {
@@ -381,15 +415,15 @@ export default {
this.toggleChildFromCache(child, child.id, client);
},
toggleChildFromCache(workItem, childId, store) {
- const sourceData = store.readQuery({
- query: getWorkItemQuery(this.fetchByIid),
- variables: this.queryVariables,
- });
+ const query = {
+ query: workItemByIidQuery,
+ variables: { fullPath: this.fullPath, iid: this.workItemIid },
+ };
+
+ const sourceData = store.readQuery(query);
const newData = produce(sourceData, (draftState) => {
- const widgets = this.fetchByIid
- ? draftState.workspace.workItems.nodes[0].widgets
- : draftState.workItem.widgets;
+ const { widgets } = draftState.workspace.workItems.nodes[0];
const widgetHierarchy = widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
@@ -397,15 +431,11 @@ export default {
if (index >= 0) {
widgetHierarchy.children.nodes.splice(index, 1);
} else {
- widgetHierarchy.children.nodes.unshift(workItem);
+ widgetHierarchy.children.nodes.push(workItem);
}
});
- store.writeQuery({
- query: getWorkItemQuery(this.fetchByIid),
- variables: this.queryVariables,
- data: newData,
- });
+ store.writeQuery({ ...query, data: newData });
},
async updateWorkItem(workItem, childId, parentId) {
return this.$apollo.mutate({
@@ -428,15 +458,15 @@ export default {
this.activeToast?.hide();
}
},
- async removeChild(childId) {
+ async removeChild({ id }) {
try {
- const { data } = await this.updateWorkItem(null, childId, null);
+ const { data } = await this.updateWorkItem(null, id, null);
if (data.workItemUpdate.errors.length === 0) {
this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
action: {
text: s__('WorkItem|Undo'),
- onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId),
+ onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, id),
},
});
}
@@ -445,17 +475,16 @@ export default {
Sentry.captureException(error);
}
},
+ updateHasNotes() {
+ this.$emit('has-notes');
+ },
updateUrl(modalWorkItem) {
- const params = this.fetchByIid
- ? { work_item_iid: modalWorkItem?.iid }
- : { work_item_id: getIdFromGraphQLId(modalWorkItem?.id) };
-
updateHistory({
- url: setUrlParams(params),
+ url: setUrlParams({ work_item_iid: modalWorkItem?.iid }),
replace: true,
});
},
- openInModal(event, modalWorkItem) {
+ openInModal({ event, modalWorkItem }) {
if (!this.workItemsMvc2Enabled) {
return;
}
@@ -474,252 +503,270 @@ export default {
this.modalWorkItemIid = modalWorkItem.iid;
this.$refs.modal.show();
},
+ openReportAbuseDrawer(reply) {
+ if (this.isModal) {
+ this.$emit('openReportAbuse', reply);
+ } else {
+ this.toggleReportAbuseDrawer(true, reply);
+ }
+ },
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url || {};
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
},
+
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
};
</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: $event, modalWorkItem: 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>
+ <div
+ v-else-if="!error && !workItemLoading"
+ class="gl-mr-auto"
+ data-testid="work-item-type"
>
<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"
+ {{ workItemBreadcrumbReference }}
+ </div>
+ <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-todos
+ v-if="showWorkItemCurrentUserTodos"
+ :work-item="workItem"
+ :current-user-todos="currentUserTodos"
+ @error="updateError = $event"
+ />
+ <work-item-actions
+ v-if="canUpdate || canDelete"
+ :work-item-id="workItem.id"
+ :subscribed-to-notifications="workItemNotificationsSubscribed"
+ :work-item-type="workItemType"
+ :work-item-type-id="workItemTypeId"
+ :can-delete="canDelete"
+ :can-update="canUpdate"
+ :is-confidential="workItem.confidential"
+ :is-parent-confidential="parentWorkItemConfidentiality"
+ @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"
/>
- <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-created-updated :work-item-iid="workItemIid" />
+ <work-item-state
+ :work-item="workItem"
+ :work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
+ @error="updateError = $event"
+ />
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
:work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
:work-item-type="workItemType"
- :can-delete="canDelete"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ @error="updateError = $event"
+ />
+ <work-item-labels
+ v-if="workItemLabels"
:can-update="canUpdate"
- :is-confidential="workItem.confidential"
- :is-parent-confidential="parentWorkItemConfidentiality"
- @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
- @toggleWorkItemConfidentiality="toggleConfidentiality"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
@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"
+ :can-update="canUpdate"
+ @error="updateError = $event"
+ />
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-progress
+ v-if="workItemProgress"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :progress="workItemProgress.progress"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-iteration
+ v-if="workItemIteration"
+ class="gl-mb-5"
+ :iteration="workItemIteration.iteration"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-health-status
+ v-if="workItemHealthStatus"
+ class="gl-mb-5"
+ :health-status="workItemHealthStatus.healthStatus"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ :work-item-type="workItemType"
+ @error="updateError = $event"
+ />
+ <work-item-description
+ v-if="hasDescriptionWidget"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItem.iid"
+ class="gl-pt-5"
+ @error="updateError = $event"
+ />
+ <work-item-award-emoji
+ v-if="workItemAwardEmoji"
+ :work-item="workItem"
+ :award-emoji="workItemAwardEmoji.awardEmoji"
+ @error="updateError = $event"
+ />
+ <work-item-tree
+ v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE"
+ :work-item-type="workItemType"
+ :parent-work-item-type="workItem.workItemType.name"
+ :work-item-id="workItem.id"
+ :work-item-iid="workItemIid"
+ :children="children"
+ :can-update="canUpdate"
+ :confidential="workItem.confidential"
+ @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"
- :query-variables="queryVariables"
- :full-path="fullPath"
- :fetch-by-iid="fetchByIid"
+ :work-item-iid="workItem.iid"
:work-item-type="workItemType"
+ :is-modal="isModal"
+ :assignees="workItemAssignees && workItemAssignees.assignees.nodes"
+ :can-set-work-item-metadata="canAssignUnassignUser"
+ :report-abuse-path="reportAbusePath"
class="gl-pt-5"
@error="updateError = $event"
+ @has-notes="updateHasNotes"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <gl-empty-state
+ v-if="error"
+ :title="$options.i18n.fetchErrorTitle"
+ :description="error"
+ :svg-path="noAccessSvgPath"
/>
</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"
+ @openReportAbuse="toggleReportAbuseDrawer(true, $event)"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="true"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
- </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..f8422dda211 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,12 +1,14 @@
<script>
import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
-import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
+import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
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.'),
+ modalTitle: s__('WorkItem|Work item'),
},
components: {
GlAlert,
@@ -24,26 +26,6 @@ export default {
required: false,
default: null,
},
- issueGid: {
- type: String,
- required: false,
- default: '',
- },
- lockVersion: {
- type: Number,
- required: false,
- default: null,
- },
- lineNumberStart: {
- type: String,
- required: false,
- default: null,
- },
- lineNumberEnd: {
- type: String,
- required: false,
- default: null,
- },
},
emits: ['workItemDeleted', 'close', 'update-modal'],
data() {
@@ -51,6 +33,8 @@ export default {
error: undefined,
updatedWorkItemId: null,
updatedWorkItemIid: null,
+ isModalShown: false,
+ hasNotes: false,
};
},
computed: {
@@ -61,52 +45,15 @@ export default {
return this.updatedWorkItemIid || this.workItemIid;
},
},
- methods: {
- deleteWorkItem() {
- if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
- this.deleteWorkItemWithTaskData();
- } else {
- this.deleteWorkItemWithoutTaskData();
+ watch: {
+ hasNotes(newVal) {
+ if (newVal && this.isModalShown) {
+ scrollToTargetOnResize({ containerId: this.$options.WORK_ITEM_DETAIL_MODAL_ID });
}
},
- deleteWorkItemWithTaskData() {
- this.$apollo
- .mutate({
- mutation: deleteWorkItemFromTaskMutation,
- variables: {
- input: {
- id: this.issueGid,
- lockVersion: this.lockVersion,
- taskData: {
- id: this.workItemId,
- lineNumberStart: Number(this.lineNumberStart),
- lineNumberEnd: Number(this.lineNumberEnd),
- },
- },
- },
- })
- .then(
- ({
- data: {
- workItemDeleteTask: {
- workItem: { descriptionHtml },
- errors,
- },
- },
- }) => {
- if (errors?.length) {
- throw new Error(errors[0]);
- }
-
- this.$emit('workItemDeleted', descriptionHtml);
- this.hide();
- },
- )
- .catch((error) => {
- this.setErrorMessage(error.message);
- });
- },
- deleteWorkItemWithoutTaskData() {
+ },
+ methods: {
+ deleteWorkItem() {
this.$apollo
.mutate({
mutation: deleteWorkItemMutation,
@@ -128,6 +75,7 @@ export default {
this.updatedWorkItemId = null;
this.updatedWorkItemIid = null;
this.error = '';
+ this.isModalShown = false;
this.$emit('close');
},
hide() {
@@ -144,6 +92,16 @@ export default {
this.updatedWorkItemIid = workItem.iid;
this.$emit('update-modal', $event, workItem);
},
+ onModalShow() {
+ this.isModalShown = true;
+ },
+ updateHasNotes() {
+ this.hasNotes = true;
+ },
+ openReportAbuseDrawer(reply) {
+ this.hide();
+ this.$emit('openReportAbuse', reply);
+ },
},
};
</script>
@@ -151,13 +109,16 @@ 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"
+ :title="$options.i18n.modalTitle"
+ :data-testid="$options.WORK_ITEM_DETAIL_MODAL_ID"
@hide="closeModal"
+ @shown="onModalShow"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
@@ -165,13 +126,14 @@ export default {
<work-item-detail
is-modal
- :work-item-parent-id="issueGid"
:work-item-id="displayedWorkItemId"
:work-item-iid="displayedWorkItemIid"
- class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate"
+ class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolation-isolate"
@close="hide"
@deleteWorkItem="deleteWorkItem"
@update-modal="updateModal"
+ @has-notes="updateHasNotes"
+ @openReportAbuse="openReportAbuseDrawer"
/>
</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_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index 8e9e1def0b9..015c86ba043 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -8,8 +8,8 @@ import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_it
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql';
-import { getWorkItemQuery } from '../utils';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import {
i18n,
@@ -43,26 +43,18 @@ export default {
LabelItem,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- canUpdate: {
- type: Boolean,
- required: true,
- },
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
- fetchByIid: {
+ canUpdate: {
type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
required: true,
},
},
@@ -79,17 +71,18 @@ export default {
},
apollo: {
workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
+ query: workItemByIidQuery,
variables() {
- return this.queryVariables;
+ return {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ };
},
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return !this.workItemIid;
},
error() {
this.$emit('error', i18n.fetchError);
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..636c9357170 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
@@ -9,21 +9,20 @@ export default function initWorkItemLinks() {
const workItemLinksRoot = document.querySelector('.js-work-item-links-root');
if (!workItemLinksRoot) {
- return;
+ return null;
}
const {
- projectPath,
+ fullPath,
wiHasIssueWeightsFeature,
- iid,
wiHasIterationsFeature,
wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
+ wiReportAbusePath,
} = workItemLinksRoot.dataset;
- // eslint-disable-next-line no-new
- new Vue({
+ return new Vue({
el: workItemLinksRoot,
name: 'WorkItemLinksRoot',
apolloProvider,
@@ -31,14 +30,13 @@ export default function initWorkItemLinks() {
WorkItemLinks,
},
provide: {
- projectPath,
- iid,
- fullPath: projectPath,
+ fullPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature,
registerPath,
signInPath,
+ reportAbusePath: wiReportAbusePath,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
new file mode 100644
index 00000000000..4b6f581d76d
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue
@@ -0,0 +1,242 @@
+<script>
+import produce from 'immer';
+import Draggable from 'vuedraggable';
+
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { defaultSortableOptions } from '~/sortable/constants';
+
+import { WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '../../constants';
+import { findHierarchyWidgets, getWorkItemQuery } from '../../utils';
+import workItemQuery from '../../graphql/work_item.query.graphql';
+import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import reorderWorkItem from '../../graphql/reorder_work_item.mutation.graphql';
+import WorkItemLinkChild from './work_item_link_child.vue';
+
+export default {
+ components: {
+ WorkItemLinkChild,
+ },
+ inject: ['fullPath'],
+ props: {
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ children: {
+ type: Array,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ prefetchedWorkItem: null,
+ };
+ },
+ computed: {
+ canReorder() {
+ return isLoggedIn() && this.canUpdate;
+ },
+ treeRootWrapper() {
+ return this.canReorder ? Draggable : 'div';
+ },
+ treeRootOptions() {
+ const options = {
+ ...defaultSortableOptions,
+ fallbackOnBody: false,
+ group: 'sortable-container',
+ tag: 'div',
+ 'ghost-class': 'tree-item-drag-active',
+ 'data-parent-id': this.workItemId,
+ value: this.children,
+ };
+
+ return this.canReorder ? options : {};
+ },
+ hasIndirectChildren() {
+ return this.children
+ .map((child) => findHierarchyWidgets(child.widgets) || {})
+ .some((hierarchy) => hierarchy.hasChildren);
+ },
+ queryVariables() {
+ return this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ }
+ : {
+ id: this.workItemId,
+ };
+ },
+ },
+ methods: {
+ addWorkItemQuery({ id, iid }) {
+ const variables = this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid,
+ }
+ : {
+ id,
+ };
+ this.$apollo.addSmartQuery('prefetchedWorkItem', {
+ query() {
+ return this.fetchByIid ? workItemByIidQuery : workItemQuery;
+ },
+ variables,
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ });
+ },
+ prefetchWorkItem({ id, iid }) {
+ if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) {
+ this.prefetch = setTimeout(
+ () => this.addWorkItemQuery({ id, iid }),
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
+ }
+ },
+ clearPrefetching() {
+ if (this.prefetch) {
+ clearTimeout(this.prefetch);
+ }
+ },
+ getReorderParams({ oldIndex, newIndex }) {
+ let relativePosition;
+
+ // adjacentWorkItemId is always the item that's at the position
+ // where target was moved.
+ const adjacentWorkItemId = this.children[newIndex].id;
+
+ if (newIndex === 0) {
+ // If newIndex is `0`, item was moved to the top.
+ // Adjacent reference will be the one which is currently at the top,
+ // and it's relative position with respect to target's new position is `BEFORE`.
+ relativePosition = 'BEFORE';
+ } else if (newIndex === this.children.length - 1) {
+ // If newIndex is last position in list, item was moved to the bottom.
+ // Adjacent reference will be the one which is currently at the bottom,
+ // and it's relative position with respect to target's new position is `AFTER`.
+ relativePosition = 'AFTER';
+ } else if (oldIndex < newIndex) {
+ // If newIndex is neither top nor bottom, it was moved somewhere in the middle.
+ // Adjacent reference will be the one which is currently at that position,
+
+ // when the item is moved down, the newIndex is after the adjacent reference.
+ relativePosition = 'AFTER';
+ } else {
+ // when the item is moved up, the newIndex is before the adjacent reference.
+ relativePosition = 'BEFORE';
+ }
+
+ return {
+ relativePosition,
+ adjacentWorkItemId,
+ };
+ },
+ handleDragOnEnd(params) {
+ const { oldIndex, newIndex } = params;
+
+ if (oldIndex === newIndex) return;
+
+ const targetItem = this.children[oldIndex];
+
+ const updatedChildren = this.children.slice();
+ updatedChildren.splice(oldIndex, 1);
+ updatedChildren.splice(newIndex, 0, targetItem);
+
+ this.$apollo
+ .mutate({
+ mutation: reorderWorkItem,
+ variables: {
+ input: {
+ id: targetItem.id,
+ hierarchyWidget: this.getReorderParams({ oldIndex, newIndex }),
+ },
+ },
+ update: (store) => {
+ store.updateQuery(
+ { query: getWorkItemQuery(this.fetchByIid), variables: this.queryVariables },
+ (sourceData) =>
+ produce(sourceData, (draftData) => {
+ const widgets = this.fetchByIid
+ ? draftData.workspace.workItems.nodes[0].widgets
+ : draftData.workItem.widgets;
+ const hierarchyWidget = findHierarchyWidgets(widgets);
+ hierarchyWidget.children.nodes = updatedChildren;
+ }),
+ );
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ __typename: 'WorkItemUpdatePayload',
+ errors: [],
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ },
+ )
+ .catch((error) => {
+ this.updateError = error.message;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <component
+ :is="treeRootWrapper"
+ v-bind="treeRootOptions"
+ :class="{ 'gl-cursor-grab sortable-container': canReorder }"
+ @end="handleDragOnEnd"
+ >
+ <work-item-link-child
+ v-for="child in children"
+ :key="child.id"
+ :can-update="canUpdate"
+ :issuable-gid="workItemId"
+ :child-item="child"
+ :confidential="child.confidential"
+ :work-item-type="workItemType"
+ :has-indirect-children="hasIndirectChildren"
+ @mouseover="prefetchWorkItem(child)"
+ @mouseout="clearPrefetching"
+ @removeChild="$emit('removeChild', $event)"
+ @click="$emit('show-modal', { event: $event, child: $event.childItem || child })"
+ />
+ </component>
+</template>
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..401c8a53eb0 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
@@ -1,12 +1,13 @@
<script>
-import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
+import { GlButton, GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import * as Sentry from '@sentry/browser';
import { __, s__ } from '~/locale';
-import { createAlert } from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isScopedLabel } from '~/lib/utils/common_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';
-
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import {
STATE_OPEN,
TASK_TYPE_NAME,
@@ -25,6 +26,7 @@ import WorkItemTreeChildren from './work_item_tree_children.vue';
export default {
components: {
+ GlLabel,
GlLink,
GlButton,
GlIcon,
@@ -36,11 +38,8 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['fullPath'],
props: {
- projectPath: {
- type: String,
- required: true,
- },
canUpdate: {
type: Boolean,
required: true,
@@ -69,9 +68,18 @@ export default {
isExpanded: false,
children: [],
isLoadingChildren: false,
+ activeToast: null,
+ childrenBeforeRemoval: [],
+ hasChildren: false,
};
},
computed: {
+ labels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
+ },
+ allowsScopedLabels() {
+ return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
+ },
canHaveChildren() {
return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE;
},
@@ -110,10 +118,7 @@ export default {
return this.isItemOpen ? __('Created') : __('Closed');
},
childPath() {
- return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`;
- },
- hasChildren() {
- return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren;
+ return `${gon?.relative_url_root || ''}/${this.fullPath}/-/work_items/${this.childItem.iid}`;
},
chevronType() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
@@ -134,6 +139,17 @@ export default {
return false;
},
},
+ watch: {
+ childItem: {
+ handler(val) {
+ this.hasChildren = this.getWidgetByType(val, WIDGET_TYPE_HIERARCHY)?.hasChildren;
+ },
+ immediate: true,
+ },
+ children(val) {
+ this.hasChildren = val?.length > 0;
+ },
+ },
methods: {
toggleItem() {
this.isExpanded = !this.isExpanded;
@@ -165,105 +181,178 @@ export default {
this.isLoadingChildren = false;
}
},
+ showScopedLabel(label) {
+ return isScopedLabel(label) && this.allowsScopedLabels;
+ },
+ async removeChild({ id }) {
+ this.cloneChildren();
+ this.isLoadingChildren = true;
+
+ try {
+ const { data } = await this.updateWorkItem(id, null);
+ if (!data?.workItemUpdate?.errors?.length) {
+ this.filterRemovedChild(id);
+
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.undoChildRemoval.bind(this, id),
+ },
+ });
+ }
+ } catch (error) {
+ this.showAlert(s__('WorkItem|Something went wrong while removing child.'), error);
+ Sentry.captureException(error);
+ this.restoreChildren();
+ } finally {
+ this.isLoadingChildren = false;
+ }
+ },
+ async undoChildRemoval(childId) {
+ this.isLoadingChildren = true;
+ try {
+ const { data } = await this.updateWorkItem(childId, this.childItem.id);
+ if (!data?.workItemUpdate?.errors?.length) {
+ this.activeToast?.hide();
+ this.restoreChildren();
+ }
+ } catch (error) {
+ this.showAlert(s__('WorkItem|Something went wrong while undoing child removal.'), error);
+ Sentry.captureException(error);
+ } finally {
+ this.activeToast?.hide();
+ this.childrenBeforeRemoval = [];
+ this.isLoadingChildren = false;
+ }
+ },
+ async updateWorkItem(childId, parentId) {
+ return this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: childId, hierarchyWidget: { parentId } } },
+ });
+ },
+ cloneChildren() {
+ this.childrenBeforeRemoval = cloneDeep(this.children);
+ },
+ filterRemovedChild(childId) {
+ this.children = this.children.filter(({ id }) => id !== childId);
+ },
+ restoreChildren() {
+ this.children = [...this.childrenBeforeRemoval];
+ },
+ showAlert(message, error) {
+ createAlert({
+ message,
+ captureError: true,
+ error,
+ });
+ },
},
};
</script>
<template>
- <div>
+ <div class="tree-item">
<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
v-if="hasChildren"
- v-gl-tooltip.viewport
+ v-gl-tooltip.hover
:title="chevronTooltip"
: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="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-rounded-base"
data-testid="links-child"
>
- <span
- :id="`stateIcon-${childItem.id}`"
- class="gl-mr-3"
- :class="{ 'gl-display-flex': hasMetadata }"
- data-testid="item-status-icon"
- >
- <gl-icon
- class="gl-text-secondary"
- :class="iconClass"
- :name="iconName"
- :aria-label="stateTimestampTypeText"
- />
- </span>
- <div
- class="gl-display-flex gl-flex-grow-1"
- :class="{
- 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata,
- 'gl-align-items-center': !hasMetadata,
- }"
- >
- <div class="gl-display-flex">
- <rich-timestamp-tooltip
- :target="`stateIcon-${childItem.id}`"
- :raw-timestamp="stateTimestamp"
- :timestamp-type-text="stateTimestampTypeText"
+ <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0">
+ <div
+ class="gl-display-flex gl-flex-grow-1 gl-flex-wrap flex-xl-nowrap gl-align-items-center gl-justify-content-space-between gl-gap-3 gl-min-w-0"
+ >
+ <div class="item-title gl-display-flex gl-gap-3 gl-min-w-0">
+ <span
+ :id="`stateIcon-${childItem.id}`"
+ class="gl-cursor-help"
+ data-testid="item-status-icon"
+ >
+ <gl-icon
+ class="gl-text-secondary"
+ :class="iconClass"
+ :name="iconName"
+ :aria-label="stateTimestampTypeText"
+ />
+ </span>
+ <rich-timestamp-tooltip
+ :target="`stateIcon-${childItem.id}`"
+ :raw-timestamp="stateTimestamp"
+ :timestamp-type-text="stateTimestampTypeText"
+ />
+ <span v-if="childItem.confidential">
+ <gl-icon
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-text-orange-500"
+ data-testid="confidential-icon"
+ :aria-label="__('Confidential')"
+ :title="__('Confidential')"
+ />
+ </span>
+ <gl-link
+ :href="childPath"
+ class="gl-text-truncate gl-text-black-normal! gl-font-weight-semibold"
+ data-testid="item-title"
+ @click="$emit('click', $event)"
+ @mouseover="$emit('mouseover')"
+ @mouseout="$emit('mouseout')"
+ >
+ {{ childItem.title }}
+ </gl-link>
+ </div>
+ <work-item-link-child-metadata
+ v-if="hasMetadata"
+ :metadata-widgets="metadataWidgets"
+ class="gl-ml-6 ml-xl-0"
/>
- <gl-icon
- v-if="childItem.confidential"
- v-gl-tooltip.top
- name="eye-slash"
- class="gl-mr-2 gl-text-orange-500"
- data-testid="confidential-icon"
- :aria-label="__('Confidential')"
- :title="__('Confidential')"
+ </div>
+ <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6">
+ <gl-label
+ v-for="label in labels"
+ :key="label.id"
+ :title="label.title"
+ :background-color="label.color"
+ :description="label.description"
+ :scoped="showScopedLabel(label)"
+ class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
+ tooltip-placement="top"
/>
- <gl-link
- :href="childPath"
- class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold"
- data-testid="item-title"
- @click="$emit('click', $event)"
- @mouseover="$emit('mouseover')"
- @mouseout="$emit('mouseout')"
- >
- {{ childItem.title }}
- </gl-link>
</div>
- <work-item-link-child-metadata
- v-if="hasMetadata"
- :metadata-widgets="metadataWidgets"
- class="gl-mt-3"
- />
</div>
- <div
- v-if="canUpdate"
- class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"
- >
+ <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex">
<work-item-links-menu
:work-item-id="childItem.id"
:parent-work-item-id="issuableGid"
data-testid="links-menu"
- @removeChild="$emit('removeChild', childItem.id)"
+ @removeChild="$emit('removeChild', childItem)"
/>
</div>
</div>
</div>
<work-item-tree-children
v-if="isExpanded"
- :project-path="projectPath"
:can-update="canUpdate"
:work-item-id="issuableGid"
:work-item-type="workItemType"
:children="children"
- @removeChild="fetchChildren"
+ @removeChild="removeChild"
@click="$emit('click', $event)"
/>
</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..ddeac2b92ae 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
@@ -1,16 +1,14 @@
<script>
-import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui';
+import { GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
-import { isScopedLabel } from '~/lib/utils/common_utils';
import ItemMilestone from '~/issuable/components/issue_milestone.vue';
-import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS } from '../../constants';
+import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ASSIGNEES } from '../../constants';
export default {
components: {
- GlLabel,
GlAvatar,
GlAvatarLink,
GlAvatarsInline,
@@ -33,12 +31,6 @@ export default {
assignees() {
return this.metadataWidgets[WIDGET_TYPE_ASSIGNEES]?.assignees?.nodes || [];
},
- labels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.labels?.nodes || [];
- },
- allowsScopedLabels() {
- return this.metadataWidgets[WIDGET_TYPE_LABELS]?.allowsScopedLabels;
- },
assigneesCollapsedTooltip() {
if (this.assignees.length > 2) {
return sprintf(s__('WorkItem|%{count} more assignees'), {
@@ -56,21 +48,16 @@ export default {
return '';
},
},
- methods: {
- showScopedLabel(label) {
- return isScopedLabel(label) && this.allowsScopedLabels;
- },
- },
};
</script>
<template>
- <div class="gl-display-flex gl-flex-wrap gl-align-items-center">
+ <div class="gl-display-flex gl-md-justify-content-end gl-gap-3">
<slot></slot>
<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-max-w-15 gl-font-sm gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
/>
<gl-avatars-inline
v-if="assignees.length"
@@ -81,7 +68,6 @@ export default {
badge-tooltip-prop="name"
:badge-sr-only-text="assigneesCollapsedTooltip"
:class="assigneesContainerClass"
- class="gl-mr-5"
>
<template #avatar="{ avatar }">
<gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name">
@@ -89,18 +75,6 @@ export default {
</gl-avatar-link>
</template>
</gl-avatars-inline>
- <div v-if="labels.length" class="gl-display-flex gl-flex-wrap">
- <gl-label
- v-for="label in labels"
- :key="label.id"
- :title="label.title"
- :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"
- tooltip-placement="top"
- />
- </div>
</div>
</template>
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..5728e33880e 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,28 +4,22 @@ 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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { TYPENAME_ISSUE, TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
-import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils';
+import { isMetaKey } from '~/lib/utils/common_utils';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
-import {
- FORM_TYPES,
- WIDGET_ICONS,
- WIDGET_TYPE_HIERARCHY,
- WORK_ITEM_STATUS_TEXT,
-} from '../../constants';
-import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants';
+import { findHierarchyWidgetChildren, getWorkItemQuery } from '../../utils';
import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql';
import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
-import workItemQuery from '../../graphql/work_item.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemDetailModal from '../work_item_detail_modal.vue';
-import WorkItemLinkChild from './work_item_link_child.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
+import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
export default {
components: {
@@ -34,21 +28,16 @@ export default {
GlIcon,
GlLoadingIcon,
WidgetWrapper,
- WorkItemLinkChild,
WorkItemLinksForm,
WorkItemDetailModal,
+ AbuseCategorySelector,
+ WorkItemChildrenWrapper,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
- inject: ['projectPath', 'iid'],
+ inject: ['fullPath', 'reportAbusePath'],
props: {
- workItemId: {
- type: String,
- required: false,
- default: null,
- },
issuableId: {
type: Number,
required: false,
@@ -57,12 +46,17 @@ export default {
},
apollo: {
workItem: {
- query: getWorkItemLinksQuery,
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
variables() {
return {
id: this.issuableGid,
};
},
+ context: {
+ isSingleRequest: true,
+ },
skip() {
return !this.issuableId;
},
@@ -70,10 +64,8 @@ export default {
this.error = e.message || this.$options.i18n.fetchError;
},
async result() {
- const { id, iid } = this.childUrlParams;
- this.activeChild = this.fetchByIid
- ? this.children.find((child) => child.iid === iid) ?? {}
- : this.children.find((child) => child.id === id) ?? {};
+ const iid = getParameterByName('work_item_iid');
+ this.activeChild = this.children.find((child) => child.iid === iid) ?? {};
await this.$nextTick();
if (!isEmpty(this.activeChild)) {
this.$refs.modal.show();
@@ -86,13 +78,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() {
@@ -105,9 +94,15 @@ export default {
parentIssue: null,
formType: null,
workItem: null,
+ isReportDrawerOpen: false,
+ reportedUserId: 0,
+ reportedUrl: '',
};
},
computed: {
+ fetchByIid() {
+ return false;
+ },
confidential() {
return this.parentIssue?.confidential || this.workItem?.confidential || false;
},
@@ -118,14 +113,14 @@ export default {
return this.parentIssue?.milestone;
},
children() {
- return (
- this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
- .nodes ?? []
- );
+ return this.workItem ? findHierarchyWidgetChildren(this.workItem) : [];
},
canUpdate() {
return this.workItem?.userPermissions.updateWorkItem || false;
},
+ canAddTask() {
+ return this.workItem?.userPermissions.adminParentLink || false;
+ },
// Only used for children for now but should be extended later to support parents and siblings
isChildrenEmpty() {
return this.children?.length === 0;
@@ -142,29 +137,9 @@ export default {
childrenCountLabel() {
return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
},
- fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
- },
- childUrlParams() {
- const params = {};
- if (this.fetchByIid) {
- const iid = getParameterByName('work_item_iid');
- if (iid) {
- params.iid = iid;
- }
- } else {
- const workItemId = getParameterByName('work_item_id');
- if (workItemId) {
- params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId);
- }
- }
- return params;
- },
},
mounted() {
- if (!isEmpty(this.childUrlParams)) {
- this.addWorkItemQuery(this.childUrlParams);
- }
+ this.addWorkItemQuery(getParameterByName('work_item_iid'));
},
methods: {
showAddForm(formType) {
@@ -178,11 +153,11 @@ export default {
hideAddForm() {
this.isShownAddForm = false;
},
- openChild(child, e) {
- if (isMetaKey(e)) {
+ openChild({ event, child }) {
+ if (isMetaKey(event)) {
return;
}
- e.preventDefault();
+ event.preventDefault();
this.activeChild = child;
this.$refs.modal.show();
this.updateWorkItemIdUrlQuery(child);
@@ -195,11 +170,8 @@ export default {
this.removeHierarchyChild(child);
this.activeToast = this.$toast.show(s__('WorkItem|Task deleted'));
},
- updateWorkItemIdUrlQuery({ id, iid } = {}) {
- const params = this.fetchByIid
- ? { work_item_iid: iid }
- : { work_item_id: getIdFromGraphQLId(id) };
- updateHistory({ url: setUrlParams(params), replace: true });
+ updateWorkItemIdUrlQuery({ iid } = {}) {
+ updateHistory({ url: setUrlParams({ work_item_iid: iid }), replace: true });
},
async addHierarchyChild(workItem) {
return this.$apollo.mutate({
@@ -213,29 +185,26 @@ export default {
variables: { id: this.issuableGid, workItem },
});
},
- async updateWorkItem(workItem, childId, parentId) {
- const response = await this.$apollo.mutate({
+ async undoChildRemoval(workItem, childId) {
+ const { data } = await this.$apollo.mutate({
mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId } } },
+ variables: { input: { id: childId, hierarchyWidget: { parentId: this.issuableGid } } },
});
- if (parentId === null) {
- await this.removeHierarchyChild(workItem);
- } else {
- await this.addHierarchyChild(workItem);
- }
-
- return response;
- },
- async undoChildRemoval(workItem, childId) {
- const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid);
+ await this.addHierarchyChild(workItem);
if (data.workItemUpdate.errors.length === 0) {
this.activeToast?.hide();
}
},
- async removeChild(childId) {
- const { data } = await this.updateWorkItem({ id: childId }, childId, null);
+ async removeChild(workItem) {
+ const childId = workItem.id;
+ const { data } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: childId, hierarchyWidget: { parentId: null } } },
+ });
+
+ await this.removeHierarchyChild(workItem);
if (data.workItemUpdate.errors.length === 0) {
this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
@@ -246,37 +215,42 @@ export default {
});
}
},
- addWorkItemQuery({ id, iid }) {
- const variables = this.fetchByIid
- ? {
- fullPath: this.projectPath,
- iid,
- }
- : {
- id,
- };
+ addWorkItemQuery(iid) {
+ if (!iid) {
+ return;
+ }
+
this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query() {
- return this.fetchByIid ? workItemByIidQuery : workItemQuery;
+ query: workItemByIidQuery,
+ variables: {
+ fullPath: this.fullPath,
+ iid,
},
- variables,
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
context: {
isSingleRequest: true,
},
});
},
- prefetchWorkItem({ id, iid }) {
+ prefetchWorkItem({ iid }) {
this.prefetch = setTimeout(
- () => this.addWorkItemQuery({ id, iid }),
+ () => this.addWorkItemQuery(iid),
DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
);
},
clearPrefetching() {
clearTimeout(this.prefetch);
},
+ toggleReportAbuseDrawer(isOpen, reply = {}) {
+ this.isReportDrawerOpen = isOpen;
+ this.reportedUrl = reply.url;
+ this.reportedUserId = reply.author ? getIdFromGraphQLId(reply.author.id) : 0;
+ },
+ openReportAbuseDrawer(reply) {
+ this.toggleReportAbuseDrawer(true, reply);
+ },
},
i18n: {
title: s__('WorkItem|Tasks'),
@@ -304,16 +278,16 @@ 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>
<template #header-right>
<gl-dropdown
- v-if="canUpdate"
+ v-if="canUpdate && canAddTask"
right
size="small"
:text="$options.i18n.addChildButtonLabel"
@@ -334,11 +308,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>
@@ -356,17 +330,12 @@ export default {
@cancel="hideAddForm"
@addWorkItemChild="addHierarchyChild"
/>
- <work-item-link-child
- v-for="child in children"
- :key="child.id"
- :project-path="projectPath"
+ <work-item-children-wrapper
+ :children="children"
:can-update="canUpdate"
- :issuable-gid="issuableGid"
- :child-item="child"
- @click="openChild(child, $event)"
- @mouseover="prefetchWorkItem(child)"
- @mouseout="clearPrefetching"
+ :work-item-id="issuableGid"
@removeChild="removeChild"
+ @show-modal="openChild"
/>
<work-item-detail-modal
ref="modal"
@@ -374,6 +343,14 @@ export default {
:work-item-iid="activeChild.iid"
@close="closeModal"
@workItemDeleted="handleWorkItemDeleted(activeChild)"
+ @openReportAbuse="openReportAbuseDrawer"
+ />
+ <abuse-category-selector
+ v-if="isReportDrawerOpen && reportAbusePath"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedUrl"
+ :show-drawer="isReportDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
/>
</template>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index 5169a77dd33..51c83784d06 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
@@ -41,7 +41,7 @@ export default {
GlFormCheckbox,
GlTooltip,
},
- inject: ['projectPath', 'hasIterationsFeature'],
+ inject: ['fullPath', 'hasIterationsFeature'],
props: {
issuableGid: {
type: String,
@@ -88,7 +88,7 @@ export default {
query: projectWorkItemTypesQuery,
variables() {
return {
- fullPath: this.projectPath,
+ fullPath: this.fullPath,
};
},
update(data) {
@@ -99,7 +99,7 @@ export default {
query: projectWorkItemsQuery,
variables() {
return {
- projectPath: this.projectPath,
+ fullPath: this.fullPath,
searchTerm: this.search?.title || this.search,
types: [this.childrenType],
in: this.search ? 'TITLE' : undefined,
@@ -131,7 +131,7 @@ export default {
workItemInput() {
let workItemInput = {
title: this.search?.title || this.search,
- projectPath: this.projectPath,
+ projectPath: this.fullPath,
workItemTypeId: this.childWorkItemType,
hierarchyWidget: {
parentId: this.issuableGid,
@@ -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..53e8eedf060 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,13 @@ export default {
</script>
<template>
- <span class="gl-ml-2">
- <gl-dropdown category="tertiary" toggle-class="btn-icon" :right="true">
+ <div class="gl-ml-5">
+ <gl-dropdown
+ category="tertiary"
+ toggle-class="btn-icon btn-sm"
+ :right="true"
+ data-testid="work_items_links_menu"
+ >
<template #button-content>
<gl-icon name="ellipsis_v" :size="14" />
</template>
@@ -20,5 +25,5 @@ export default {
{{ s__('WorkItem|Remove') }}
</gl-dropdown-item>
</gl-dropdown>
- </span>
+ </div>
</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..cbca78e4b14 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
@@ -1,26 +1,16 @@
<script>
-import { isEmpty } from 'lodash';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterByName } from '~/lib/utils/url_utility';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
import {
FORM_TYPES,
WIDGET_TYPE_HIERARCHY,
WORK_ITEMS_TREE_TEXT_MAP,
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
- WORK_ITEM_TYPE_VALUE_OBJECTIVE,
} from '../../constants';
-import workItemQuery from '../../graphql/work_item.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import WidgetWrapper from '../widget_wrapper.vue';
import OkrActionsSplitButton from './okr_actions_split_button.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
-import WorkItemLinkChild from './work_item_link_child.vue';
+import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
export default {
FORM_TYPES,
@@ -31,9 +21,9 @@ export default {
OkrActionsSplitButton,
WidgetWrapper,
WorkItemLinksForm,
- WorkItemLinkChild,
+ WorkItemChildrenWrapper,
},
- mixins: [glFeatureFlagMixin()],
+ inject: ['fullPath'],
props: {
workItemType: {
type: String,
@@ -48,6 +38,11 @@ export default {
type: String,
required: true,
},
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
confidential: {
type: Boolean,
required: false,
@@ -63,10 +58,6 @@ export default {
required: false,
default: false,
},
- projectPath: {
- type: String,
- required: true,
- },
},
data() {
return {
@@ -77,9 +68,6 @@ export default {
};
},
computed: {
- fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
- },
childrenIds() {
return this.children.map((c) => c.id);
},
@@ -90,26 +78,6 @@ export default {
)
.some((hierarchy) => hierarchy.hasChildren);
},
- childUrlParams() {
- const params = {};
- if (this.fetchByIid) {
- const iid = getParameterByName('work_item_iid');
- if (iid) {
- params.iid = iid;
- }
- } else {
- const workItemId = getParameterByName('work_item_id');
- if (workItemId) {
- params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId);
- }
- }
- return params;
- },
- },
- mounted() {
- if (!isEmpty(this.childUrlParams)) {
- this.addWorkItemQuery(this.childUrlParams);
- }
},
methods: {
showAddForm(formType, childType) {
@@ -124,41 +92,28 @@ export default {
hideAddForm() {
this.isShownAddForm = false;
},
- addWorkItemQuery({ id, iid }) {
- const variables = this.fetchByIid
- ? {
- fullPath: this.projectPath,
- iid,
- }
- : {
- id,
- };
+ showModal({ event, child }) {
+ this.$emit('show-modal', { event, modalWorkItem: child });
+ },
+ addWorkItemQuery(iid) {
+ if (!iid) {
+ return;
+ }
+
this.$apollo.addSmartQuery('prefetchedWorkItem', {
- query() {
- return this.fetchByIid ? workItemByIidQuery : workItemQuery;
+ query: workItemByIidQuery,
+ variables: {
+ fullPath: this.fullPath,
+ iid,
},
- variables,
update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return data.workspace.workItems.nodes[0];
},
context: {
isSingleRequest: true,
},
});
},
- prefetchWorkItem({ id, iid }) {
- if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) {
- this.prefetch = setTimeout(
- () => this.addWorkItemQuery({ id, iid }),
- DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
- );
- }
- },
- clearPrefetching() {
- if (this.prefetch) {
- clearTimeout(this.prefetch);
- }
- },
},
};
</script>
@@ -170,6 +125,7 @@ export default {
</template>
<template #header-right>
<okr-actions-split-button
+ v-if="canUpdate"
@showCreateObjectiveForm="
showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE)
"
@@ -186,7 +142,7 @@ export default {
</template>
<template #body>
<div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty">
- <p class="gl-mb-3">
+ <p class="gl-mb-0 gl-py-2 gl-ml-3 gl-text-gray-500">
{{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }}
</p>
</div>
@@ -203,20 +159,15 @@ export default {
@addWorkItemChild="$emit('addWorkItemChild', $event)"
@cancel="hideAddForm"
/>
- <work-item-link-child
- v-for="child in children"
- :key="child.id"
- :project-path="projectPath"
+ <work-item-children-wrapper
+ :children="children"
:can-update="canUpdate"
- :issuable-gid="workItemId"
- :child-item="child"
- :confidential="child.confidential"
+ :work-item-id="workItemId"
+ :work-item-iid="workItemIid"
:work-item-type="workItemType"
- :has-indirect-children="hasIndirectChildren"
- @mouseover="prefetchWorkItem(child)"
- @mouseout="clearPrefetching"
+ fetch-by-iid
@removeChild="$emit('removeChild', $event)"
- @click="$emit('show-modal', $event, $event.childItem || child)"
+ @show-modal="showModal"
/>
</template>
</widget-wrapper>
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..2cabf489bc6 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,13 +1,9 @@
<script>
-import { createAlert } from '~/flash';
-import { s__ } from '~/locale';
-
-import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
-
export default {
components: {
WorkItemLinkChild: () => import('./work_item_link_child.vue'),
},
+ inject: ['fullPath'],
props: {
workItemType: {
type: String,
@@ -27,42 +23,20 @@ export default {
required: false,
default: false,
},
- projectPath: {
- type: String,
- required: true,
- },
- },
- methods: {
- async updateWorkItem(childId) {
- try {
- await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: { input: { id: childId, hierarchyWidget: { parentId: null } } },
- });
- this.$emit('removeChild');
- } catch (error) {
- createAlert({
- message: s__('Hierarchy|Something went wrong while removing a child item.'),
- captureError: true,
- error,
- });
- }
- },
},
};
</script>
<template>
- <div class="gl-ml-6">
+ <div class="gl-ml-6" data-testid="tree-children">
<work-item-link-child
v-for="child in children"
:key="child.id"
- :project-path="projectPath"
:can-update="canUpdate"
:issuable-gid="workItemId"
:child-item="child"
:work-item-type="workItemType"
- @removeChild="updateWorkItem"
+ @removeChild="$emit('removeChild', $event)"
@click="$emit('click', Object.assign($event, { childItem: child }))"
/>
</div>
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..693397686d0 100644
--- a/app/assets/javascripts/work_items/components/work_item_milestone.vue
+++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue
@@ -46,6 +46,7 @@ export default {
GlDropdownText,
},
mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -66,19 +67,6 @@ export default {
required: false,
default: false,
},
- fullPath: {
- type: String,
- required: true,
- },
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
},
data() {
return {
@@ -234,6 +222,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..092b90a5731 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,36 @@
<script>
import { GlSkeletonLoader, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { s__, __ } from '~/locale';
+import { uniqueId } from 'lodash';
+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 { autocompleteDataSources, markdownPreviewPath } 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 workItemNotesByIidQuery from '../graphql/notes/work_item_notes_by_iid.query.graphql';
import WorkItemAddNote from './notes/work_item_add_note.vue';
export default {
- i18n: {
- ACTIVITY_LABEL: s__('WorkItem|Activity'),
- },
loader: {
repeat: 10,
width: 1000,
@@ -24,21 +39,19 @@ export default {
components: {
GlSkeletonLoader,
GlModal,
- ActivityFilter,
SystemNote,
WorkItemAddNote,
WorkItemDiscussion,
+ WorkItemNotesActivityHeader,
+ WorkItemHistoryOnlyFilterNote,
},
+ inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
- queryVariables: {
- type: Object,
- required: true,
- },
- fullPath: {
+ workItemIid: {
type: String,
required: true,
},
@@ -46,18 +59,33 @@ export default {
type: String,
required: true,
},
- fetchByIid: {
+ isModal: {
type: Boolean,
required: false,
default: false,
},
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ canSetWorkItemMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ reportAbusePath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
isLoadingMore: false,
- perPage: DEFAULT_PAGE_SIZE_NOTES,
sortOrder: ASC,
noteToDelete: null,
+ discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ addNoteKey: uniqueId(`work-item-add-note-${this.workItemId}`),
};
},
computed: {
@@ -73,70 +101,135 @@ export default {
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
- showLoadingMoreSkeleton() {
- return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading;
- },
- disableActivityFilter() {
+ disableActivityFilterSort() {
return this.initialLoading || this.isLoadingMore;
},
formAtTop() {
return this.sortOrder === DESC;
},
+ markdownPreviewPath() {
+ return markdownPreviewPath(this.fullPath, this.workItemIid);
+ },
+ autocompleteDataSources() {
+ return autocompleteDataSources(this.fullPath, this.workItemIid);
+ },
workItemCommentFormProps() {
return {
- queryVariables: this.queryVariables,
fullPath: this.fullPath,
workItemId: this.workItemId,
- fetchByIid: this.fetchByIid,
+ workItemIid: this.workItemIid,
workItemType: this.workItemType,
sortOrder: this.sortOrder,
+ isNewDiscussion: true,
+ markdownPreviewPath: this.markdownPreviewPath,
+ autocompleteDataSources: this.autocompleteDataSources,
};
},
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: {
workItemNotes: {
- query() {
- return getWorkItemNotesQuery(this.fetchByIid);
- },
+ query: workItemNotesByIidQuery,
context: {
isSingleRequest: true,
},
variables() {
return {
- ...this.queryVariables,
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
after: this.after,
pageSize: DEFAULT_PAGE_SIZE_NOTES,
};
},
update(data) {
- const workItemWidgets = this.fetchByIid
- ? data.workspace?.workItems?.nodes[0]?.widgets
- : data.workItem?.widgets;
- const discussionNodes =
- workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || [];
- return discussionNodes;
+ const widgets = data.workspace?.workItems?.nodes[0]?.widgets;
+ return widgets?.find((widget) => widget.type === 'NOTES')?.discussions || [];
},
skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
+ return !this.workItemIid;
},
error() {
this.$emit('error', i18n.fetchError);
},
result() {
- this.updateSortingOrderIfApplicable();
-
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);
+ },
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId || this.hasNextPage;
+ },
+ },
+ {
+ document: workItemNoteDeletedSubscription,
+ updateQuery(previousResult, { subscriptionData }) {
+ return updateCacheAfterDeletingNote(previousResult, subscriptionData);
+ },
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId || this.hasNextPage;
+ },
+ },
+ {
+ document: workItemNoteUpdatedSubscription,
+ variables() {
+ return {
+ noteableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ },
+ ],
},
},
methods: {
@@ -148,30 +241,25 @@ export default {
isSystemNote(note) {
return note.notes.nodes[0].system;
},
- updateSortingOrderIfApplicable() {
- // when the sort order is DESC in local storage and there is only a single page, call
- // changeSortOrder manually
- if (
- this.changeNotesSortOrderAfterLoading &&
- this.perPage === DEFAULT_PAGE_SIZE_NOTES &&
- !this.hasNextPage
- ) {
- this.changeNotesSortOrder(DESC);
- }
- },
changeNotesSortOrder(direction) {
this.sortOrder = direction;
},
+ filterDiscussions(filterValue) {
+ this.discussionFilter = filterValue;
+ },
+ updateKey() {
+ this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`);
+ },
+ reportAbuse(isOpen, reply = {}) {
+ this.$emit('openReportAbuse', reply);
+ },
async fetchMoreNotes() {
this.isLoadingMore = true;
- // copied from discussions batch logic - every fetchMore call has a higher
- // amount of page size than the previous one with the limit being 100
- this.perPage = Math.min(Math.round(this.perPage * 1.5), 100);
await this.$apollo.queries.workItemNotes
.fetchMore({
variables: {
- ...this.queryVariables,
- pageSize: this.perPage,
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
after: this.pageInfo?.endCursor,
},
})
@@ -223,17 +311,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"
@@ -248,13 +333,17 @@ export default {
</div>
<div v-else class="issuable-discussion gl-mb-5 gl-clearfix!">
<template v-if="!initialLoading">
- <ul class="notes main-notes-list timeline gl-clearfix!">
- <work-item-add-note
- v-if="formAtTop"
- v-bind="workItemCommentFormProps"
- @error="$emit('error', $event)"
- />
-
+ <div v-if="formAtTop && !commentsDisabled" class="js-comment-form">
+ <ul class="notes notes-form timeline">
+ <work-item-add-note
+ v-bind="workItemCommentFormProps"
+ :key="addNoteKey"
+ @cancelEditing="updateKey"
+ @error="$emit('error', $event)"
+ />
+ </ul>
+ </div>
+ <ul class="notes main-notes-list timeline">
<template v-for="discussion in notesArray">
<system-note
v-if="isSystemNote(discussion)"
@@ -265,26 +354,39 @@ export default {
<work-item-discussion
:key="getDiscussionKey(discussion)"
:discussion="discussion.notes.nodes"
- :query-variables="queryVariables"
- :full-path="fullPath"
:work-item-id="workItemId"
- :fetch-by-iid="fetchByIid"
+ :work-item-iid="workItemIid"
:work-item-type="workItemType"
+ :is-modal="isModal"
+ :autocomplete-data-sources="autocompleteDataSources"
+ :markdown-preview-path="markdownPreviewPath"
+ :assignees="assignees"
+ :can-set-work-item-metadata="canSetWorkItemMetadata"
@deleteNote="showDeleteNoteModal($event, discussion)"
+ @reportAbuse="reportAbuse(true, $event)"
@error="$emit('error', $event)"
/>
</template>
</template>
- <work-item-add-note
- v-if="!formAtTop"
- v-bind="workItemCommentFormProps"
- @error="$emit('error', $event)"
+ <work-item-history-only-filter-note
+ v-if="commentsDisabled"
+ @changeFilter="filterDiscussions"
/>
</ul>
+ <div v-if="!formAtTop && !commentsDisabled" class="js-comment-form">
+ <ul class="notes notes-form timeline">
+ <work-item-add-note
+ v-bind="workItemCommentFormProps"
+ :key="addNoteKey"
+ @cancelEditing="updateKey"
+ @error="$emit('error', $event)"
+ />
+ </ul>
+ </div>
</template>
- <template v-if="showLoadingMoreSkeleton">
+ <template v-if="isLoadingMore">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
diff --git a/app/assets/javascripts/work_items/components/work_item_todos.vue b/app/assets/javascripts/work_items/components/work_item_todos.vue
new file mode 100644
index 00000000000..4e787720a42
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_todos.vue
@@ -0,0 +1,116 @@
+<script>
+import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { updateGlobalTodoCount } from '~/sidebar/utils';
+import { getWorkItemTodoOptimisticResponse } from '../utils';
+import { ADD, MARK_AS_DONE, TODO_ADD_ICON, TODO_DONE_ICON } from '../constants';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+
+export default {
+ i18n: {
+ addATodo: s__('WorkItem|Add a to do'),
+ markAsDone: s__('WorkItem|Mark as done'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ GlButton,
+ },
+ props: {
+ workItem: {
+ type: Object,
+ required: true,
+ },
+ currentUserTodos: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ buttonLabel:
+ this.currentUserTodos.length > 0
+ ? this.$options.i18n.markAsDone
+ : this.$options.i18n.addATodo,
+ };
+ },
+ computed: {
+ pendingTodo() {
+ return this.currentUserTodos.length > 0;
+ },
+ buttonIcon() {
+ return this.pendingTodo ? TODO_DONE_ICON : TODO_ADD_ICON;
+ },
+ },
+ methods: {
+ onToggle() {
+ this.isLoading = true;
+ this.buttonLabel = '';
+ const action = this.pendingTodo ? MARK_AS_DONE : ADD;
+ const inputVariables = {
+ id: this.workItem.id,
+ currentUserTodosWidget: {
+ action,
+ },
+ };
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: getWorkItemTodoOptimisticResponse({
+ workItem: this.workItem,
+ pendingTodo: this.pendingTodo,
+ }),
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ if (this.pendingTodo) {
+ updateGlobalTodoCount(1);
+ this.buttonLabel = this.$options.i18n.markAsDone;
+ } else {
+ updateGlobalTodoCount(-1);
+ this.buttonLabel = this.$options.i18n.addATodo;
+ }
+ },
+ )
+ .catch((error) => {
+ this.$emit('error', error.message);
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-gl-tooltip.hover
+ data-testid="work-item-todos-action"
+ :loading="isLoading"
+ :title="buttonLabel"
+ category="tertiary"
+ :aria-label="buttonLabel"
+ @click="onToggle"
+ >
+ <gl-icon
+ data-testid="work-item-todos-icon"
+ :class="{ 'gl-fill-blue-500': pendingTodo }"
+ :name="buttonIcon"
+ />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 81f9bf04bc8..6710c762c2e 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';
@@ -13,6 +14,9 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_AWARD_EMOJI = 'AWARD_EMOJI';
+export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS';
+export const WIDGET_TYPE_CURRENT_USER_TODOS = 'CURRENT_USER_TODOS';
export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
@@ -59,6 +63,9 @@ export const I18N_WORK_ITEM_ERROR_CREATING = s__(
export const I18N_WORK_ITEM_ERROR_UPDATING = s__(
'WorkItem|Something went wrong while updating the %{workItemType}. Please try again.',
);
+export const I18N_WORK_ITEM_ERROR_CONVERTING = s__(
+ 'WorkItem|Something went wrong while promoting the %{workItemType}. Please try again.',
+);
export const I18N_WORK_ITEM_ERROR_DELETING = s__(
'WorkItem|Something went wrong when deleting the %{workItemType}. Please try again.',
);
@@ -176,3 +183,53 @@ 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') },
+];
+
+export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action';
+export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action';
+export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form';
+export const TEST_ID_DELETE_ACTION = 'delete-action';
+export const TEST_ID_PROMOTE_ACTION = 'promote-action';
+
+export const ADD = 'ADD';
+export const MARK_AS_DONE = 'MARK_AS_DONE';
+export const TODO_ADD_ICON = 'todo-add';
+export const TODO_DONE_ICON = 'todo-done';
+export const TODO_TYPENAME = 'Todo';
+export const TODO_EDGE_TYPENAME = 'TodoEdge';
+export const TODO_CONNECTION_TYPENAME = 'TodoConnection';
+export const CURRENT_USER_TODOS_TYPENAME = 'WorkItemWidgetCurrentUserTodos';
+export const WORK_ITEM_TYPENAME = 'WorkItem';
+export const WORK_ITEM_UPDATE_PAYLOAD_TYPENAME = 'WorkItemUpdatePayload';
+
+export const EMOJI_ACTION_ADD = 'ADD';
+export const EMOJI_ACTION_REMOVE = 'REMOVE';
+export const EMOJI_THUMBSUP = 'thumbsup';
+export const EMOJI_THUMBSDOWN = 'thumbsdown';
diff --git a/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql
new file mode 100644
index 00000000000..85b88990cd6
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/award_emoji.fragment.graphql
@@ -0,0 +1,6 @@
+fragment AwardEmojiFragment on AwardEmoji {
+ name
+ user {
+ id
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js
index 16b892b3476..455d8b8ae7b 100644
--- a/app/assets/javascripts/work_items/graphql/cache_utils.js
+++ b/app/assets/javascripts/work_items/graphql/cache_utils.js
@@ -1,62 +1,88 @@
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) =>
+ draftData?.workspace?.workItems?.nodes[0]?.widgets.find(isNotesWidget);
+
+const updateNotesWidgetDataInDraftData = (draftData, notesWidget) => {
+ const noteWidgetIndex = draftData.workspace.workItems.nodes[0].widgets.findIndex(isNotesWidget);
+ draftData.workspace.workItems.nodes[0].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 fetchByIid
- * @param queryVariables
- * @param sortOrder
+ * @param currentNotes
+ * @param subscriptionData
*/
-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) => {
+ if (!subscriptionData.data?.workItemNoteCreated) {
+ return currentNotes;
+ }
+ const newNote = subscriptionData.data.workItemNoteCreated;
+
+ return produce(currentNotes, (draftData) => {
+ const notesWidget = getNotesWidgetFromSourceData(draftData);
+
+ 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);
});
+};
+
+/**
+ * Work Item note delete subscription update query callback
+ *
+ * @param currentNotes
+ * @param subscriptionData
+ */
+
+export const updateCacheAfterDeletingNote = (currentNotes, subscriptionData) => {
+ if (!subscriptionData.data?.workItemNoteDeleted) {
+ return currentNotes;
+ }
+ const deletedNote = subscriptionData.data.workItemNoteDeleted;
+ const { id, discussionId, lastDiscussionNote } = deletedNote;
- 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',
- });
+ return produce(currentNotes, (draftData) => {
+ const notesWidget = getNotesWidgetFromSourceData(draftData);
+
+ if (!notesWidget.discussions) {
+ return;
}
- if (fetchByIid) {
- draftData.workspace.workItems.nodes[0].widgets[6] = notesWidget;
+ const discussionIndex = notesWidget.discussions.nodes.findIndex(
+ (discussion) => discussion.id === discussionId,
+ );
+
+ if (discussionIndex === -1) {
+ return;
+ }
+
+ 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);
});
};
diff --git a/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql
deleted file mode 100644
index 32c07ed48c7..00000000000
--- a/app/assets/javascripts/work_items/graphql/delete_task_from_work_item.mutation.graphql
+++ /dev/null
@@ -1,9 +0,0 @@
-mutation workItemDeleteTask($input: WorkItemDeleteTaskInput!) {
- workItemDeleteTask(input: $input) {
- workItem {
- id
- descriptionHtml
- }
- errors
- }
-}
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/notes/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql
deleted file mode 100644
index 56dc175109f..00000000000
--- a/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql
+++ /dev/null
@@ -1,27 +0,0 @@
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "./work_item_note.fragment.graphql"
-
-query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
- workItem(id: $id) {
- id
- iid
- widgets {
- ... on WorkItemWidgetNotes {
- type
- discussions(first: $pageSize, after: $after, filter: ALL_NOTES) {
- pageInfo {
- ...PageInfo
- }
- nodes {
- id
- notes {
- nodes {
- ...WorkItemNote
- }
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
index fce10f6f2a6..7d63af448d4 100644
--- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql
@@ -1,10 +1,10 @@
query projectWorkItems(
$searchTerm: String
- $projectPath: ID!
+ $fullPath: ID!
$types: [IssueType!]
$in: [IssuableSearchableField!]
) {
- workspace: project(fullPath: $projectPath) {
+ workspace: project(fullPath: $fullPath) {
id
workItems(search: $searchTerm, types: $types, in: $in) {
nodes {
diff --git a/app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql
new file mode 100644
index 00000000000..13d10d5eef7
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/reorder_work_item.mutation.graphql
@@ -0,0 +1,5 @@
+mutation workItemReorder($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
new file mode 100644
index 00000000000..f8952b62f28
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
@@ -0,0 +1,13 @@
+mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
+ workItem {
+ id
+ widgets {
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index ada9f737e6e..b045796579b 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -27,6 +27,8 @@ fragment WorkItem on WorkItem {
userPermissions {
deleteWorkItem
updateWorkItem
+ setWorkItemMetadata @client
+ adminParentLink
}
widgets {
...WorkItemWidgets
diff --git a/app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql b/app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql
new file mode 100644
index 00000000000..0e4b87bdd67
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_convert.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./work_item.fragment.graphql"
+
+mutation workItemConvert($input: WorkItemConvertInput!) {
+ workItemConvert(input: $input) {
+ workItem {
+ ...WorkItem
+ }
+ 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
deleted file mode 100644
index 7d7bb9c7fc5..00000000000
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ /dev/null
@@ -1,40 +0,0 @@
-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
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
index b5d27231bef..42c057fb8fe 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -1,6 +1,7 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
+#import "~/work_items/graphql/award_emoji.fragment.graphql"
fragment WorkItemMetadataWidgets on WorkItemWidget {
... on WorkItemWidgetDescription {
@@ -36,4 +37,28 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
}
}
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+
+ ... on WorkItemWidgetCurrentUserTodos {
+ type
+ currentUserTodos(state: pending) {
+ edges {
+ node {
+ id
+ state
+ }
+ }
+ }
+ }
+ ... on WorkItemWidgetAwardEmoji {
+ type
+ awardEmoji {
+ nodes {
+ ...AwardEmojiFragment
+ }
+ }
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
index bf8eafe3211..bf8dc9ce9b0 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -1,6 +1,7 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/work_items/graphql/milestone.fragment.graphql"
+#import "~/work_items/graphql/award_emoji.fragment.graphql"
#import "ee_else_ce/work_items/graphql/work_item_metadata_widgets.fragment.graphql"
fragment WorkItemWidgets on WorkItemWidget {
@@ -85,4 +86,27 @@ fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetNotes {
type
}
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+ ... on WorkItemWidgetCurrentUserTodos {
+ type
+ currentUserTodos(state: pending) {
+ edges {
+ node {
+ id
+ state
+ }
+ }
+ }
+ }
+ ... on WorkItemWidgetAwardEmoji {
+ type
+ awardEmoji {
+ nodes {
+ ...AwardEmojiFragment
+ }
+ }
+ }
}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 6aa63aae172..70bda7d3783 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -18,6 +18,8 @@ export const initWorkItemsRoot = () => {
hasIterationsFeature,
hasOkrsFeature,
hasIssuableHealthStatusFeature,
+ newCommentTemplatePath,
+ reportAbusePath,
} = el.dataset;
return new Vue({
@@ -27,7 +29,6 @@ export const initWorkItemsRoot = () => {
apolloProvider,
provide: {
fullPath,
- projectPath: fullPath,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasOkrsFeature: parseBoolean(hasOkrsFeature),
issuesListPath,
@@ -35,6 +36,8 @@ export const initWorkItemsRoot = () => {
signInPath,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ newCommentTemplatePath,
+ reportAbusePath,
},
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..49ec12db4e1 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,13 +1,12 @@
<script>
import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { getPreferredLocales, s__ } from '~/locale';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql';
-import { getWorkItemQuery } from '../utils';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
import ItemTitle from '../components/item_title.vue';
@@ -22,7 +21,6 @@ export default {
ItemTitle,
GlFormSelect,
},
- mixins: [glFeatureFlagMixin()],
inject: ['fullPath'],
props: {
initialTitle: {
@@ -73,9 +71,6 @@ export default {
return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType);
},
- fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath;
- },
},
methods: {
async createWorkItem() {
@@ -96,45 +91,31 @@ export default {
},
update: (store, { data: { workItemCreate } }) => {
const { workItem } = workItemCreate;
- const data = this.fetchByIid
- ? {
- workspace: {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'Project',
- id: workItem.project.id,
- workItems: {
- __typename: 'WorkItemConnection',
- nodes: [workItem],
- },
- },
- }
- : { workItem };
store.writeQuery({
- query: getWorkItemQuery(this.fetchByIid),
- variables: this.fetchByIid
- ? {
- fullPath: this.fullPath,
- iid: workItem.iid,
- }
- : {
- id: workItem.id,
+ query: workItemByIidQuery,
+ variables: {
+ fullPath: this.fullPath,
+ iid: workItem.iid,
+ },
+ data: {
+ workspace: {
+ __typename: TYPENAME_PROJECT,
+ id: workItem.project.id,
+ workItems: {
+ __typename: 'WorkItemConnection',
+ nodes: [workItem],
},
- data,
+ },
+ },
});
},
});
- const {
- data: {
- workItemCreate: {
- workItem: { id, iid },
- },
- },
- } = response;
- const routerParams = this.fetchByIid
- ? { name: 'workItem', params: { id: iid }, query: { iid_path: 'true' } }
- : { name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } };
- this.$router.push(routerParams);
+
+ this.$router.push({
+ name: 'workItem',
+ params: { id: response.data.workItemCreate.workItem.iid },
+ });
} catch {
this.error = this.createErrorText;
}
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/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index f2af87d476c..653819904af 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -1,19 +1,26 @@
-import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
+import { uniqueId } from 'lodash';
+import {
+ WIDGET_TYPE_HIERARCHY,
+ WIDGET_TYPE_CURRENT_USER_TODOS,
+ CURRENT_USER_TODOS_TYPENAME,
+ TODO_CONNECTION_TYPENAME,
+ TODO_EDGE_TYPENAME,
+ TODO_TYPENAME,
+ WORK_ITEM_TYPENAME,
+ WORK_ITEM_UPDATE_PAYLOAD_TYPENAME,
+} from '~/work_items/constants';
import workItemQuery from './graphql/work_item.query.graphql';
import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql';
-import workItemNotesIdQuery from './graphql/notes/work_item_notes.query.graphql';
-import workItemNotesByIidQuery from './graphql/notes/work_item_notes_by_iid.query.graphql';
export function getWorkItemQuery(isFetchedByIid) {
return isFetchedByIid ? workItemByIidQuery : workItemQuery;
}
-export function getWorkItemNotesQuery(isFetchedByIid) {
- return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery;
-}
+export const findHierarchyWidgets = (widgets) =>
+ widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
export const findHierarchyWidgetChildren = (workItem) =>
- workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY).children.nodes;
+ findHierarchyWidgets(workItem.widgets).children.nodes;
const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => {
return `${
@@ -31,3 +38,38 @@ export const markdownPreviewPath = (fullPath, iid) =>
`${
gon.relative_url_root || ''
}/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`;
+
+export const getWorkItemTodoOptimisticResponse = ({ workItem, pendingTodo }) => {
+ const todo = pendingTodo
+ ? [
+ {
+ node: {
+ id: -uniqueId(),
+ state: 'pending',
+ __typename: TODO_TYPENAME,
+ },
+ __typename: TODO_EDGE_TYPENAME,
+ },
+ ]
+ : [];
+ return {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ ...workItem,
+ widgets: [
+ {
+ type: WIDGET_TYPE_CURRENT_USER_TODOS,
+ currentUserTodos: {
+ edges: todo,
+ __typename: TODO_CONNECTION_TYPENAME,
+ },
+ __typename: CURRENT_USER_TODOS_TYPENAME,
+ },
+ ],
+ __typename: WORK_ITEM_TYPENAME,
+ },
+ __typename: WORK_ITEM_UPDATE_PAYLOAD_TYPENAME,
+ },
+ };
+};
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 134c2858849..6634d0cc617 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,13 +1,12 @@
-/* eslint-disable consistent-return */
-
// Zen Mode (full screen) textarea
//
/*= provides zen_mode:enter */
/*= provides zen_mode:leave */
+import autosize from 'autosize';
import Dropzone from 'dropzone';
import $ from 'jquery';
-import Mousetrap from 'mousetrap';
+import { Mousetrap } from '~/lib/mousetrap';
import 'mousetrap/plugins/pause/mousetrap-pause';
import { scrollToElement } from '~/lib/utils/common_utils';
@@ -39,6 +38,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');
@@ -53,6 +53,7 @@ export default class ZenMode {
$(document).on('zen_mode:leave', () => {
this.exit();
});
+ // eslint-disable-next-line consistent-return
$(document).on('keydown', (e) => {
// Esc
if (e.keyCode === 27) {
@@ -68,6 +69,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 +79,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;