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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/__helpers__/fixtures.js29
-rw-r--r--spec/frontend/__helpers__/mock_dom_observer.js4
-rw-r--r--spec/frontend/__helpers__/mock_window_location_helper.js5
-rw-r--r--spec/frontend/__helpers__/mocks/mr_notes/stores/index.js15
-rw-r--r--spec/frontend/__helpers__/test_constants.js2
-rw-r--r--spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js39
-rw-r--r--spec/frontend/admin/abuse_report/components/report_actions_spec.js194
-rw-r--r--spec/frontend/admin/abuse_report/components/report_header_spec.js49
-rw-r--r--spec/frontend/admin/abuse_report/components/reported_content_spec.js7
-rw-r--r--spec/frontend/admin/abuse_report/mock_data.js10
-rw-r--r--spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js202
-rw-r--r--spec/frontend/admin/broadcast_messages/components/message_form_spec.js22
-rw-r--r--spec/frontend/admin/users/components/user_actions_spec.js2
-rw-r--r--spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap3
-rw-r--r--spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js4
-rw-r--r--spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js222
-rw-r--r--spec/frontend/api/user_api_spec.js19
-rw-r--r--spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap59
-rw-r--r--spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js24
-rw-r--r--spec/frontend/batch_comments/components/diff_file_drafts_spec.js7
-rw-r--r--spec/frontend/batch_comments/components/preview_item_spec.js47
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js13
-rw-r--r--spec/frontend/behaviors/markdown/utils_spec.js18
-rw-r--r--spec/frontend/blame/streaming/index_spec.js9
-rw-r--r--spec/frontend/boards/board_list_helper.js2
-rw-r--r--spec/frontend/boards/boards_util_spec.js39
-rw-r--r--spec/frontend/boards/components/board_add_new_column_form_spec.js14
-rw-r--r--spec/frontend/boards/components/board_add_new_column_spec.js107
-rw-r--r--spec/frontend/boards/components/board_add_new_column_trigger_spec.js22
-rw-r--r--spec/frontend/boards/components/board_card_move_to_position_spec.js39
-rw-r--r--spec/frontend/boards/components/board_content_spec.js77
-rw-r--r--spec/frontend/boards/components/board_form_spec.js239
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js12
-rw-r--r--spec/frontend/boards/components/board_top_bar_spec.js1
-rw-r--r--spec/frontend/boards/mock_data.js44
-rw-r--r--spec/frontend/boards/project_select_spec.js57
-rw-r--r--spec/frontend/boards/stores/actions_spec.js12
-rw-r--r--spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap26
-rw-r--r--spec/frontend/branches/components/branch_more_actions_spec.js70
-rw-r--r--spec/frontend/branches/components/delete_branch_button_spec.js92
-rw-r--r--spec/frontend/branches/components/delete_merged_branches_spec.js2
-rw-r--r--spec/frontend/ci/artifacts/components/artifact_row_spec.js52
-rw-r--r--spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js466
-rw-r--r--spec/frontend/ci/artifacts/components/job_checkbox_spec.js6
-rw-r--r--spec/frontend/ci/artifacts/utils_spec.js16
-rw-r--r--spec/frontend/ci/ci_lint/components/ci_lint_spec.js12
-rw-r--r--spec/frontend/ci/ci_lint/mock_data.js23
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js29
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js1
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js102
-rw-r--r--spec/frontend/ci/ci_variable_list/mocks.js39
-rw-r--r--spec/frontend/ci/inherited_ci_variables/components/inherited_ci_variables_app_spec.js114
-rw-r--r--spec/frontend/ci/inherited_ci_variables/mocks.js44
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js5
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js20
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js31
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js17
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js16
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js12
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js16
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js13
-rw-r--r--spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js4
-rw-r--r--spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js171
-rw-r--r--spec/frontend/ci/pipeline_editor/index_spec.js27
-rw-r--r--spec/frontend/ci/pipeline_editor/mock_data.js44
-rw-r--r--spec/frontend/ci/pipeline_editor/options_spec.js27
-rw-r--r--spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js82
-rw-r--r--spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js8
-rw-r--r--spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js12
-rw-r--r--spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js13
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js4
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js14
-rw-r--r--spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js9
-rw-r--r--spec/frontend/ci/runner/components/runner_create_form_spec.js40
-rw-r--r--spec/frontend/ci/runner/components/runner_delete_button_spec.js2
-rw-r--r--spec/frontend/ci/runner/components/runner_delete_modal_spec.js51
-rw-r--r--spec/frontend/ci/runner/components/runner_details_spec.js25
-rw-r--r--spec/frontend/ci/runner/components/runner_details_tabs_spec.js12
-rw-r--r--spec/frontend/ci/runner/components/runner_form_fields_spec.js141
-rw-r--r--spec/frontend/ci/runner/components/runner_header_spec.js5
-rw-r--r--spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js2
-rw-r--r--spec/frontend/ci/runner/components/runner_list_empty_state_spec.js201
-rw-r--r--spec/frontend/ci/runner/components/runner_list_spec.js2
-rw-r--r--spec/frontend/ci/runner/components/runner_managers_badge_spec.js57
-rw-r--r--spec/frontend/ci/runner/components/runner_managers_detail_spec.js169
-rw-r--r--spec/frontend/ci/runner/components/runner_managers_table_spec.js144
-rw-r--r--spec/frontend/ci/runner/components/runner_pause_button_spec.js40
-rw-r--r--spec/frontend/ci/runner/components/runner_status_badge_spec.js20
-rw-r--r--spec/frontend/ci/runner/components/runner_update_form_spec.js189
-rw-r--r--spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js8
-rw-r--r--spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js12
-rw-r--r--spec/frontend/ci/runner/group_runners/group_runners_app_spec.js9
-rw-r--r--spec/frontend/ci/runner/mock_data.js2
-rw-r--r--spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js8
-rw-r--r--spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js3
-rw-r--r--spec/frontend/ci/runner/runner_update_form_utils_spec.js4
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js47
-rw-r--r--spec/frontend/clusters_list/components/agents_spec.js117
-rw-r--r--spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js1
-rw-r--r--spec/frontend/clusters_list/components/delete_agent_button_spec.js6
-rw-r--r--spec/frontend/clusters_list/components/mock_data.js10
-rw-r--r--spec/frontend/clusters_list/mocks/apollo.js14
-rw-r--r--spec/frontend/code_review/signals_spec.js15
-rw-r--r--spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap2
-rw-r--r--spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js44
-rw-r--r--spec/frontend/commit/components/commit_refs_spec.js97
-rw-r--r--spec/frontend/commit/components/refs_list_spec.js77
-rw-r--r--spec/frontend/commit/mock_data.js59
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js4
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js247
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js18
-rw-r--r--spec/frontend/content_editor/components/formatting_toolbar_spec.js11
-rw-r--r--spec/frontend/content_editor/components/toolbar_table_button_spec.js97
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js205
-rw-r--r--spec/frontend/content_editor/extensions/code_spec.js58
-rw-r--r--spec/frontend/content_editor/extensions/description_item_spec.js121
-rw-r--r--spec/frontend/content_editor/extensions/description_list_spec.js36
-rw-r--r--spec/frontend/content_editor/extensions/details_content_spec.js20
-rw-r--r--spec/frontend/content_editor/extensions/details_spec.js23
-rw-r--r--spec/frontend/content_editor/extensions/drawio_diagram_spec.js15
-rw-r--r--spec/frontend/content_editor/extensions/paste_markdown_spec.js168
-rw-r--r--spec/frontend/content_editor/extensions/reference_spec.js162
-rw-r--r--spec/frontend/content_editor/remark_markdown_processing_spec.js4
-rw-r--r--spec/frontend/content_editor/services/asset_resolver_spec.js68
-rw-r--r--spec/frontend/content_editor/services/create_content_editor_spec.js3
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js170
-rw-r--r--spec/frontend/content_editor/test_constants.js9
-rw-r--r--spec/frontend/content_editor/test_utils.js29
-rw-r--r--spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js47
-rw-r--r--spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js62
-rw-r--r--spec/frontend/contribution_events/components/contribution_events_spec.js31
-rw-r--r--spec/frontend/contribution_events/components/resource_parent_link_spec.js30
-rw-r--r--spec/frontend/contribution_events/components/target_link_spec.js33
-rw-r--r--spec/frontend/design_management/components/design_description/description_form_spec.js299
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap24
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js4
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js43
-rw-r--r--spec/frontend/design_management/components/design_sidebar_spec.js8
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap8
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js62
-rw-r--r--spec/frontend/design_management/mock_data/design.js2
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap60
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap12
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js6
-rw-r--r--spec/frontend/design_management/pages/index_spec.js51
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js2
-rw-r--r--spec/frontend/diffs/components/app_spec.js112
-rw-r--r--spec/frontend/diffs/components/compare_versions_spec.js51
-rw-r--r--spec/frontend/diffs/components/diff_content_spec.js43
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js27
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js65
-rw-r--r--spec/frontend/diffs/components/diff_line_note_form_spec.js119
-rw-r--r--spec/frontend/diffs/components/diff_view_spec.js25
-rw-r--r--spec/frontend/diffs/components/no_changes_spec.js57
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js93
-rw-r--r--spec/frontend/diffs/components/tree_list_spec.js2
-rw-r--r--spec/frontend/diffs/mock_data/diff_file.js1
-rw-r--r--spec/frontend/diffs/store/actions_spec.js136
-rw-r--r--spec/frontend/diffs/store/getters_spec.js27
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js68
-rw-r--r--spec/frontend/diffs/store/utils_spec.js10
-rw-r--r--spec/frontend/drawio/drawio_editor_spec.js12
-rw-r--r--spec/frontend/editor/components/source_editor_toolbar_button_spec.js11
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml2
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml7
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml28
-rw-r--r--spec/frontend/editor/source_editor_extension_base_spec.js10
-rw-r--r--spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js4
-rw-r--r--spec/frontend/environment.js6
-rw-r--r--spec/frontend/environments/edit_environment_spec.js232
-rw-r--r--spec/frontend/environments/environment_delete_spec.js16
-rw-r--r--spec/frontend/environments/environment_folder_spec.js2
-rw-r--r--spec/frontend/environments/environment_form_spec.js122
-rw-r--r--spec/frontend/environments/environment_item_spec.js22
-rw-r--r--spec/frontend/environments/environment_monitoring_spec.js26
-rw-r--r--spec/frontend/environments/environment_pin_spec.js14
-rw-r--r--spec/frontend/environments/environment_rollback_spec.js26
-rw-r--r--spec/frontend/environments/environment_terminal_button_spec.js2
-rw-r--r--spec/frontend/environments/environments_detail_header_spec.js47
-rw-r--r--spec/frontend/environments/graphql/mock_data.js6
-rw-r--r--spec/frontend/environments/kubernetes_agent_info_spec.js71
-rw-r--r--spec/frontend/environments/kubernetes_overview_spec.js64
-rw-r--r--spec/frontend/environments/kubernetes_pods_spec.js15
-rw-r--r--spec/frontend/environments/kubernetes_status_bar_spec.js42
-rw-r--r--spec/frontend/environments/kubernetes_summary_spec.js12
-rw-r--r--spec/frontend/environments/kubernetes_tabs_spec.js19
-rw-r--r--spec/frontend/environments/new_environment_item_spec.js155
-rw-r--r--spec/frontend/environments/new_environment_spec.js215
-rw-r--r--spec/frontend/error_tracking/components/error_details_info_spec.js80
-rw-r--r--spec/frontend/error_tracking/components/error_details_spec.js115
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js149
-rw-r--r--spec/frontend/error_tracking/components/list_mock.json38
-rw-r--r--spec/frontend/error_tracking/components/stacktrace_entry_spec.js17
-rw-r--r--spec/frontend/error_tracking/components/stacktrace_spec.js26
-rw-r--r--spec/frontend/error_tracking/components/timeline_chart_spec.js94
-rw-r--r--spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js26
-rw-r--r--spec/frontend/fixtures/merge_requests.rb4
-rw-r--r--spec/frontend/fixtures/pipeline_details.rb38
-rw-r--r--spec/frontend/fixtures/pipeline_header.rb118
-rw-r--r--spec/frontend/fixtures/project.rb51
-rw-r--r--spec/frontend/fixtures/runner.rb19
-rw-r--r--spec/frontend/fixtures/startup_css.rb3
-rw-r--r--spec/frontend/fixtures/static/whats_new_notification.html1
-rw-r--r--spec/frontend/fixtures/users.rb42
-rw-r--r--spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap110
-rw-r--r--spec/frontend/grafana_integration/components/grafana_integration_spec.js119
-rw-r--r--spec/frontend/grafana_integration/store/mutations_spec.js35
-rw-r--r--spec/frontend/groups/components/app_spec.js5
-rw-r--r--spec/frontend/groups/components/group_folder_spec.js2
-rw-r--r--spec/frontend/groups/components/group_item_spec.js2
-rw-r--r--spec/frontend/groups/components/groups_spec.js2
-rw-r--r--spec/frontend/groups/components/overview_tabs_spec.js3
-rw-r--r--spec/frontend/header_search/init_spec.js2
-rw-r--r--spec/frontend/ide/stores/modules/file_templates/getters_spec.js2
-rw-r--r--spec/frontend/import_entities/components/import_status_spec.js8
-rw-r--r--spec/frontend/integrations/edit/components/jira_auth_fields_spec.js142
-rw-r--r--spec/frontend/integrations/edit/components/override_dropdown_spec.js8
-rw-r--r--spec/frontend/integrations/edit/components/sections/connection_spec.js45
-rw-r--r--spec/frontend/integrations/edit/mock_data.js18
-rw-r--r--spec/frontend/integrations/gitlab_slack_application/components/gitlab_slack_application_spec.js105
-rw-r--r--spec/frontend/integrations/gitlab_slack_application/mock_data.js14
-rw-r--r--spec/frontend/invite_members/components/import_project_members_modal_spec.js50
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js18
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js12
-rw-r--r--spec/frontend/issuable/components/csv_import_export_buttons_spec.js8
-rw-r--r--spec/frontend/issuable/components/issuable_header_warnings_spec.js28
-rw-r--r--spec/frontend/issues/dashboard/mock_data.js4
-rw-r--r--spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js6
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js54
-rw-r--r--spec/frontend/issues/list/mock_data.js16
-rw-r--r--spec/frontend/issues/show/components/app_spec.js13
-rw-r--r--spec/frontend/issues/show/components/description_spec.js21
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js44
-rw-r--r--spec/frontend/issues/show/components/task_list_item_actions_spec.js5
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js4
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js4
-rw-r--r--spec/frontend/jobs/components/job/manual_variables_form_spec.js146
-rw-r--r--spec/frontend/jobs/components/job/stages_dropdown_spec.js13
-rw-r--r--spec/frontend/jobs/components/table/job_table_app_spec.js46
-rw-r--r--spec/frontend/layout_nav_spec.js39
-rw-r--r--spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js9
-rw-r--r--spec/frontend/lib/utils/dom_utils_spec.js6
-rw-r--r--spec/frontend/lib/utils/listbox_helpers_spec.js89
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js4
-rw-r--r--spec/frontend/lib/utils/secret_detection_spec.js19
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js17
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js59
-rw-r--r--spec/frontend/listbox/index_spec.js9
-rw-r--r--spec/frontend/listbox/redirect_behavior_spec.js9
-rw-r--r--spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js4
-rw-r--r--spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js6
-rw-r--r--spec/frontend/members/components/table/role_dropdown_spec.js96
-rw-r--r--spec/frontend/merge_request_spec.js17
-rw-r--r--spec/frontend/merge_requests/components/compare_dropdown_spec.js10
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js26
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js97
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js15
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js4
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js11
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js30
-rw-r--r--spec/frontend/notes/components/diff_with_note_spec.js42
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js5
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js115
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js66
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js6
-rw-r--r--spec/frontend/notes/mixins/discussion_navigation_spec.js106
-rw-r--r--spec/frontend/notes/stores/actions_spec.js45
-rw-r--r--spec/frontend/notes/stores/mutation_spec.js22
-rw-r--r--spec/frontend/notes/utils_spec.js46
-rw-r--r--spec/frontend/operation_settings/components/metrics_settings_spec.js214
-rw-r--r--spec/frontend/operation_settings/store/mutations_spec.js29
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js513
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js15
-rw-r--r--spec/frontend/packages_and_registries/package_registry/mock_data.js24
-rw-r--r--spec/frontend/packages_and_registries/package_registry/pages/details_spec.js263
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js6
-rw-r--r--spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js40
-rw-r--r--spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js2
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js147
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/mock_data.js7
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js98
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js20
-rw-r--r--spec/frontend/pipelines/__snapshots__/utils_spec.js.snap471
-rw-r--r--spec/frontend/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph_spec.js123
-rw-r--r--spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js150
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js45
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js144
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js58
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js140
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js8
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js23
-rw-r--r--spec/frontend/pipelines/graph/job_item_spec.js44
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_column_spec.js8
-rw-r--r--spec/frontend/pipelines/graph/mock_data.js704
-rw-r--r--spec/frontend/pipelines/graph_shared/links_layer_spec.js4
-rw-r--r--spec/frontend/pipelines/mock_data.js36
-rw-r--r--spec/frontend/pipelines/pipeline_details_header_spec.js440
-rw-r--r--spec/frontend/pipelines/pipeline_multi_actions_spec.js122
-rw-r--r--spec/frontend/pipelines/pipeline_url_spec.js14
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js69
-rw-r--r--spec/frontend/pipelines/pipelines_table_spec.js83
-rw-r--r--spec/frontend/pipelines/time_ago_spec.js76
-rw-r--r--spec/frontend/pipelines/utils_spec.js11
-rw-r--r--spec/frontend/profile/components/follow_spec.js99
-rw-r--r--spec/frontend/profile/components/followers_tab_spec.js119
-rw-r--r--spec/frontend/profile/components/following_tab_spec.js2
-rw-r--r--spec/frontend/profile/components/overview_tab_spec.js66
-rw-r--r--spec/frontend/profile/components/profile_tabs_spec.js2
-rw-r--r--spec/frontend/profile/components/snippets/snippet_row_spec.js146
-rw-r--r--spec/frontend/profile/components/snippets/snippets_tab_spec.js162
-rw-r--r--spec/frontend/profile/components/snippets_tab_spec.js19
-rw-r--r--spec/frontend/profile/components/user_achievements_spec.js9
-rw-r--r--spec/frontend/profile/mock_data.js76
-rw-r--r--spec/frontend/projects/commit/components/commit_options_dropdown_spec.js37
-rw-r--r--spec/frontend/projects/commit_box/info/load_branches_spec.js86
-rw-r--r--spec/frontend/projects/compare/components/repo_dropdown_spec.js27
-rw-r--r--spec/frontend/projects/project_new_spec.js33
-rw-r--r--spec/frontend/projects/settings/components/new_access_dropdown_spec.js34
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js2
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js30
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js1
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js23
-rw-r--r--spec/frontend/repository/components/blob_viewers/geo_json/geo_json_viewer_spec.js40
-rw-r--r--spec/frontend/repository/components/blob_viewers/geo_json/utils_spec.js68
-rw-r--r--spec/frontend/repository/components/fork_info_spec.js6
-rw-r--r--spec/frontend/repository/components/table/index_spec.js50
-rw-r--r--spec/frontend/repository/mock_data.js2
-rw-r--r--spec/frontend/search/mock_data.js345
-rw-r--r--spec/frontend/search/sidebar/components/app_spec.js40
-rw-r--r--spec/frontend/search/sidebar/components/checkbox_filter_spec.js2
-rw-r--r--spec/frontend/search/sidebar/components/filters_spec.js4
-rw-r--r--spec/frontend/search/sidebar/components/label_dropdown_items_spec.js57
-rw-r--r--spec/frontend/search/sidebar/components/label_filter_spec.js322
-rw-r--r--spec/frontend/search/sidebar/components/language_filter_spec.js39
-rw-r--r--spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js (renamed from spec/frontend/search/sidebar/components/scope_navigation_spec.js)6
-rw-r--r--spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js (renamed from spec/frontend/search/sidebar/components/scope_new_navigation_spec.js)8
-rw-r--r--spec/frontend/search/sort/components/app_spec.js23
-rw-r--r--spec/frontend/search/store/actions_spec.js58
-rw-r--r--spec/frontend/search/store/getters_spec.js92
-rw-r--r--spec/frontend/search/store/mutations_spec.js8
-rw-r--r--spec/frontend/sentry/index_spec.js4
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js30
-rw-r--r--spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js8
-rw-r--r--spec/frontend/sidebar/components/status/status_dropdown_spec.js61
-rw-r--r--spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js5
-rw-r--r--spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js62
-rw-r--r--spec/frontend/snippets/components/edit_spec.js2
-rw-r--r--spec/frontend/snippets/components/show_spec.js42
-rw-r--r--spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js14
-rw-r--r--spec/frontend/snippets/test_utils.js1
-rw-r--r--spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js94
-rw-r--r--spec/frontend/super_sidebar/components/brand_logo_spec.js42
-rw-r--r--spec/frontend/super_sidebar/components/context_switcher_spec.js6
-rw-r--r--spec/frontend/super_sidebar/components/create_menu_spec.js19
-rw-r--r--spec/frontend/super_sidebar/components/frequent_items_list_spec.js28
-rw-r--r--spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap122
-rw-r--r--spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js143
-rw-r--r--spec/frontend/super_sidebar/components/global_search/command_palette/fake_search_input_spec.js44
-rw-r--r--spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js133
-rw-r--r--spec/frontend/super_sidebar/components/global_search/command_palette/search_item_spec.js33
-rw-r--r--spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js18
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js62
-rw-r--r--spec/frontend/super_sidebar/components/help_center_spec.js49
-rw-r--r--spec/frontend/super_sidebar/components/items_list_spec.js44
-rw-r--r--spec/frontend/super_sidebar/components/sidebar_menu_spec.js204
-rw-r--r--spec/frontend/super_sidebar/components/user_bar_spec.js6
-rw-r--r--spec/frontend/super_sidebar/components/user_menu_spec.js33
-rw-r--r--spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js23
-rw-r--r--spec/frontend/tabs/index_spec.js11
-rw-r--r--spec/frontend/tags/components/sort_dropdown_spec.js10
-rw-r--r--spec/frontend/usage_quotas/components/sectioned_percentage_bar_spec.js101
-rw-r--r--spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js12
-rw-r--r--spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js10
-rw-r--r--spec/frontend/usage_quotas/storage/mock_data.js24
-rw-r--r--spec/frontend/usage_quotas/storage/utils_spec.js25
-rw-r--r--spec/frontend/user_popovers_spec.js2
-rw-r--r--spec/frontend/users_select/index_spec.js5
-rw-r--r--spec/frontend/users_select/test_helper.js10
-rw-r--r--spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js56
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js34
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js29
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js32
-rw-r--r--spec/frontend/vue_merge_request_widget/components/widget/app_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js17
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js151
-rw-r--r--spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js1
-rw-r--r--spec/frontend/vue_merge_request_widget/mock_data.js2
-rw-r--r--spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js205
-rw-r--r--spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap103
-rw-r--r--spec/frontend/vue_shared/components/actions_button_spec.js197
-rw-r--r--spec/frontend/vue_shared/components/chronic_duration_input_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/ci_badge_link_spec.js58
-rw-r--r--spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js52
-rw-r--r--spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_spec.js (renamed from spec/frontend/vue_shared/components/clone_dropdown_spec.js)33
-rw-r--r--spec/frontend/vue_shared/components/confirm_fork_modal_spec.js63
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js21
-rw-r--r--spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/mr_more_dropdown_spec.js137
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_new_spec.js.snap (renamed from spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap)0
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js121
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js84
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js93
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js178
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js45
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js173
-rw-r--r--spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js113
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js157
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js16
-rw-r--r--spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js37
-rw-r--r--spec/frontend/whats_new/components/app_spec.js194
-rw-r--r--spec/frontend/whats_new/utils/notification_spec.js15
-rw-r--r--spec/frontend/work_items/components/notes/system_note_spec.js96
-rw-r--r--spec/frontend/work_items/components/notes/work_item_add_note_spec.js249
-rw-r--r--spec/frontend/work_items/components/notes/work_item_comment_form_spec.js51
-rw-r--r--spec/frontend/work_items/components/notes/work_item_discussion_spec.js17
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_actions_spec.js84
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_replying_spec.js8
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_spec.js96
-rw-r--r--spec/frontend/work_items/components/work_item_actions_spec.js75
-rw-r--r--spec/frontend/work_items/components/work_item_assignees_spec.js11
-rw-r--r--spec/frontend/work_items/components/work_item_award_emoji_spec.js165
-rw-r--r--spec/frontend/work_items/components/work_item_description_spec.js294
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js41
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js12
-rw-r--r--spec/frontend/work_items/components/work_item_due_date_spec.js19
-rw-r--r--spec/frontend/work_items/components/work_item_labels_spec.js2
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js86
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_spec.js116
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js12
-rw-r--r--spec/frontend/work_items/graphql/cache_utils_spec.js153
-rw-r--r--spec/frontend/work_items/mock_data.js721
-rw-r--r--spec/frontend/work_items/notes/collapse_utils_spec.js29
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js3
444 files changed, 17158 insertions, 7777 deletions
diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js
index c66411979e9..5ae63bb1744 100644
--- a/spec/frontend/__helpers__/fixtures.js
+++ b/spec/frontend/__helpers__/fixtures.js
@@ -1,28 +1,3 @@
-import fs from 'fs';
-import path from 'path';
-
-import { ErrorWithStack } from 'jest-util';
-
-export function getFixture(relativePath) {
- const basePath = relativePath.startsWith('static/')
- ? global.staticFixturesBasePath
- : global.fixturesBasePath;
- const absolutePath = path.join(basePath, relativePath);
- if (!fs.existsSync(absolutePath)) {
- throw new ErrorWithStack(
- `Fixture file ${relativePath} does not exist.
-
-Did you run bin/rake frontend:fixtures? You can also download fixtures from the gitlab-org/gitlab package registry.
-
-See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#download-fixtures for more info.
-`,
- getFixture,
- );
- }
-
- return fs.readFileSync(absolutePath, 'utf8');
-}
-
export const resetHTMLFixture = () => {
document.head.innerHTML = '';
document.body.innerHTML = '';
@@ -31,7 +6,3 @@ export const resetHTMLFixture = () => {
export const setHTMLFixture = (htmlContent) => {
document.body.innerHTML = htmlContent;
};
-
-export const loadHTMLFixture = (relativePath) => {
- setHTMLFixture(getFixture(relativePath));
-};
diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js
index 8c9c435041e..fd3945adfd8 100644
--- a/spec/frontend/__helpers__/mock_dom_observer.js
+++ b/spec/frontend/__helpers__/mock_dom_observer.js
@@ -22,9 +22,9 @@ class MockObserver {
takeRecords() {}
- $_triggerObserve(node, { entry = {}, options = {} } = {}) {
+ $_triggerObserve(node, { entry = {}, observer = {}, options = {} } = {}) {
if (this.$_hasObserver(node, options)) {
- this.$_cb([{ target: node, ...entry }]);
+ this.$_cb([{ target: node, ...entry }], observer);
}
}
diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js
index de1e8c99b54..577d8226fad 100644
--- a/spec/frontend/__helpers__/mock_window_location_helper.js
+++ b/spec/frontend/__helpers__/mock_window_location_helper.js
@@ -1,3 +1,5 @@
+import { TEST_HOST } from 'helpers/test_constants';
+
/**
* Manage the instance of a custom `window.location`
*
@@ -12,6 +14,7 @@ const useMockLocation = (fn) => {
Object.defineProperty(window, 'location', {
get: () => currentWindowLocation,
+ assign: jest.fn(),
});
beforeEach(() => {
@@ -41,6 +44,8 @@ export const createWindowLocationSpy = () => {
replace: jest.fn(),
toString: jest.fn(),
origin,
+ protocol: 'http:',
+ host: TEST_HOST,
// TODO: Do we need to update `origin` if `href` is changed?
href,
};
diff --git a/spec/frontend/__helpers__/mocks/mr_notes/stores/index.js b/spec/frontend/__helpers__/mocks/mr_notes/stores/index.js
new file mode 100644
index 00000000000..a983edbbb72
--- /dev/null
+++ b/spec/frontend/__helpers__/mocks/mr_notes/stores/index.js
@@ -0,0 +1,15 @@
+import { Store } from 'vuex-mock-store';
+import createDiffState from 'ee_else_ce/diffs/store/modules/diff_state';
+import createNotesState from '~/notes/stores/state';
+
+const store = new Store({
+ state: {
+ diffs: createDiffState(),
+ notes: createNotesState(),
+ },
+ spy: {
+ create: (handler) => jest.fn(handler).mockImplementation(() => Promise.resolve()),
+ },
+});
+
+export default store;
diff --git a/spec/frontend/__helpers__/test_constants.js b/spec/frontend/__helpers__/test_constants.js
index 628b9b054d3..b5a585811d1 100644
--- a/spec/frontend/__helpers__/test_constants.js
+++ b/spec/frontend/__helpers__/test_constants.js
@@ -1,5 +1,6 @@
const FIXTURES_PATH = `/fixtures`;
const TEST_HOST = 'http://test.host';
+const DRAWIO_ORIGIN = 'https://embed.diagrams.net';
const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/static/images/one_white_pixel.png`;
@@ -15,6 +16,7 @@ const DUMMY_IMAGE_BLOB_PATH = 'SpongeBlob.png';
module.exports = {
FIXTURES_PATH,
TEST_HOST,
+ DRAWIO_ORIGIN,
DUMMY_IMAGE_URL,
GREEN_BOX_IMAGE_URL,
RED_BOX_IMAGE_URL,
diff --git a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js
index cabbb5e1591..e519684bbc5 100644
--- a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js
+++ b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js
@@ -1,14 +1,17 @@
import { shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
import AbuseReportApp from '~/admin/abuse_report/components/abuse_report_app.vue';
import ReportHeader from '~/admin/abuse_report/components/report_header.vue';
import UserDetails from '~/admin/abuse_report/components/user_details.vue';
import ReportedContent from '~/admin/abuse_report/components/reported_content.vue';
import HistoryItems from '~/admin/abuse_report/components/history_items.vue';
+import { SUCCESS_ALERT } from '~/admin/abuse_report/constants';
import { mockAbuseReport } from '../mock_data';
describe('AbuseReportApp', () => {
let wrapper;
+ const findAlert = () => wrapper.findComponent(GlAlert);
const findReportHeader = () => wrapper.findComponent(ReportHeader);
const findUserDetails = () => wrapper.findComponent(UserDetails);
const findReportedContent = () => wrapper.findComponent(ReportedContent);
@@ -27,10 +30,44 @@ describe('AbuseReportApp', () => {
createComponent();
});
+ it('does not show the alert by default', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ describe('when emitting the showAlert event from the report header', () => {
+ const message = 'alert message';
+
+ beforeEach(() => {
+ findReportHeader().vm.$emit('showAlert', SUCCESS_ALERT, message);
+ });
+
+ it('shows the alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('displays the message', () => {
+ expect(findAlert().text()).toBe(message);
+ });
+
+ it('sets the variant property', () => {
+ expect(findAlert().props('variant')).toBe(SUCCESS_ALERT);
+ });
+
+ describe('when dismissing the alert', () => {
+ beforeEach(() => {
+ findAlert().vm.$emit('dismiss');
+ });
+
+ it('hides the alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+ });
+
describe('ReportHeader', () => {
it('renders ReportHeader', () => {
expect(findReportHeader().props('user')).toBe(mockAbuseReport.user);
- expect(findReportHeader().props('actions')).toBe(mockAbuseReport.actions);
+ expect(findReportHeader().props('report')).toBe(mockAbuseReport.report);
});
describe('when no user is present', () => {
diff --git a/spec/frontend/admin/abuse_report/components/report_actions_spec.js b/spec/frontend/admin/abuse_report/components/report_actions_spec.js
new file mode 100644
index 00000000000..ec7dd31a046
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/report_actions_spec.js
@@ -0,0 +1,194 @@
+import MockAdapter from 'axios-mock-adapter';
+import { GlDrawer } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import axios from '~/lib/utils/axios_utils';
+import {
+ HTTP_STATUS_OK,
+ HTTP_STATUS_UNPROCESSABLE_ENTITY,
+ HTTP_STATUS_INTERNAL_SERVER_ERROR,
+} from '~/lib/utils/http_status';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ReportActions from '~/admin/abuse_report/components/report_actions.vue';
+import {
+ ACTIONS_I18N,
+ SUCCESS_ALERT,
+ FAILED_ALERT,
+ ERROR_MESSAGE,
+ NO_ACTION,
+ USER_ACTION_OPTIONS,
+} from '~/admin/abuse_report/constants';
+import { mockAbuseReport } from '../mock_data';
+
+describe('ReportActions', () => {
+ let wrapper;
+ let axiosMock;
+
+ const params = {
+ user_action: 'ban_user',
+ close: true,
+ comment: 'my comment',
+ reason: 'spam',
+ };
+
+ const { user, report } = mockAbuseReport;
+
+ const clickActionsButton = () => wrapper.findByTestId('actions-button').vm.$emit('click');
+ const isDrawerOpen = () => wrapper.findComponent(GlDrawer).props('open');
+ const findErrorFor = (id) => wrapper.findByTestId(id).find('.d-block.invalid-feedback');
+ const findUserActionOptions = () => wrapper.findByTestId('action-select');
+ const setCloseReport = (close) => wrapper.findByTestId('close').find('input').setChecked(close);
+ const setSelectOption = (id, value) =>
+ wrapper.findByTestId(`${id}-select`).find(`option[value=${value}]`).setSelected();
+ const selectAction = (action) => setSelectOption('action', action);
+ const selectReason = (reason) => setSelectOption('reason', reason);
+ const setComment = (comment) => wrapper.findByTestId('comment').find('input').setValue(comment);
+ const submitForm = () => wrapper.findByTestId('submit-button').vm.$emit('click');
+
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(ReportActions, {
+ propsData: {
+ user,
+ report,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ createComponent();
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
+ it('initially hides the drawer', () => {
+ expect(isDrawerOpen()).toBe(false);
+ });
+
+ describe('actions', () => {
+ describe('when logged in user is not the user being reported', () => {
+ beforeEach(() => {
+ clickActionsButton();
+ });
+
+ it('shows "No action", "Block user", "Ban user" and "Delete user" options', () => {
+ const options = findUserActionOptions().findAll('option');
+
+ expect(options).toHaveLength(USER_ACTION_OPTIONS.length);
+
+ USER_ACTION_OPTIONS.forEach((action, index) => {
+ expect(options.at(index).text()).toBe(action.text);
+ });
+ });
+ });
+
+ describe('when logged in user is the user being reported', () => {
+ beforeEach(() => {
+ gon.current_username = user.username;
+ clickActionsButton();
+ });
+
+ it('only shows "No action" option', () => {
+ const options = findUserActionOptions().findAll('option');
+
+ expect(options).toHaveLength(1);
+ expect(options.at(0).text()).toBe(NO_ACTION.text);
+ });
+ });
+ });
+
+ describe('when clicking the actions button', () => {
+ beforeEach(() => {
+ clickActionsButton();
+ });
+
+ it('shows the drawer', () => {
+ expect(isDrawerOpen()).toBe(true);
+ });
+
+ describe.each`
+ input | errorFor | messageShown
+ ${null} | ${'action'} | ${true}
+ ${null} | ${'reason'} | ${true}
+ ${'close'} | ${'action'} | ${false}
+ ${'action'} | ${'action'} | ${false}
+ ${'reason'} | ${'reason'} | ${false}
+ `('when submitting an invalid form', ({ input, errorFor, messageShown }) => {
+ describe(`when ${
+ input ? `providing a value for the ${input} field` : 'not providing any values'
+ }`, () => {
+ beforeEach(() => {
+ submitForm();
+
+ if (input === 'close') {
+ setCloseReport(params.close);
+ } else if (input === 'action') {
+ selectAction(params.user_action);
+ } else if (input === 'reason') {
+ selectReason(params.reason);
+ }
+ });
+
+ it(`${messageShown ? 'shows' : 'hides'} ${errorFor} error message`, () => {
+ if (messageShown) {
+ expect(findErrorFor(errorFor).text()).toBe(ACTIONS_I18N.requiredFieldFeedback);
+ } else {
+ expect(findErrorFor(errorFor).exists()).toBe(false);
+ }
+ });
+ });
+ });
+
+ describe('when submitting a valid form', () => {
+ describe.each`
+ response | success | responseStatus | responseData | alertType | alertMessage
+ ${'successful'} | ${true} | ${HTTP_STATUS_OK} | ${{ message: 'success!' }} | ${SUCCESS_ALERT} | ${'success!'}
+ ${'custom failure'} | ${false} | ${HTTP_STATUS_UNPROCESSABLE_ENTITY} | ${{ message: 'fail!' }} | ${FAILED_ALERT} | ${'fail!'}
+ ${'generic failure'} | ${false} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} | ${{}} | ${FAILED_ALERT} | ${ERROR_MESSAGE}
+ `(
+ 'when the server responds with a $response response',
+ ({ success, responseStatus, responseData, alertType, alertMessage }) => {
+ beforeEach(async () => {
+ jest.spyOn(axios, 'put');
+
+ axiosMock.onPut(report.updatePath).replyOnce(responseStatus, responseData);
+
+ selectAction(params.user_action);
+ setCloseReport(params.close);
+ selectReason(params.reason);
+ setComment(params.comment);
+
+ await nextTick();
+
+ submitForm();
+
+ await waitForPromises();
+ });
+
+ it('does a put call with the right data', () => {
+ expect(axios.put).toHaveBeenCalledWith(report.updatePath, params);
+ });
+
+ it('closes the drawer', () => {
+ expect(isDrawerOpen()).toBe(false);
+ });
+
+ it('emits the showAlert event', () => {
+ expect(wrapper.emitted('showAlert')).toStrictEqual([[alertType, alertMessage]]);
+ });
+
+ it(`${success ? 'does' : 'does not'} emit the closeReport event`, () => {
+ if (success) {
+ expect(wrapper.emitted('closeReport')).toBeDefined();
+ } else {
+ expect(wrapper.emitted('closeReport')).toBeUndefined();
+ }
+ });
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/report_header_spec.js b/spec/frontend/admin/abuse_report/components/report_header_spec.js
index d584cab05b3..f22f3af091f 100644
--- a/spec/frontend/admin/abuse_report/components/report_header_spec.js
+++ b/spec/frontend/admin/abuse_report/components/report_header_spec.js
@@ -1,25 +1,27 @@
-import { GlAvatar, GlLink, GlButton } from '@gitlab/ui';
+import { GlBadge, GlIcon, GlAvatar, GlLink, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ReportHeader from '~/admin/abuse_report/components/report_header.vue';
-import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue';
-import { REPORT_HEADER_I18N } from '~/admin/abuse_report/constants';
+import ReportActions from '~/admin/abuse_report/components/report_actions.vue';
+import { REPORT_HEADER_I18N, STATUS_OPEN, STATUS_CLOSED } from '~/admin/abuse_report/constants';
import { mockAbuseReport } from '../mock_data';
describe('ReportHeader', () => {
let wrapper;
- const { user, actions } = mockAbuseReport;
+ const { user, report } = mockAbuseReport;
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findIcon = () => wrapper.findComponent(GlIcon);
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findLink = () => wrapper.findComponent(GlLink);
const findButton = () => wrapper.findComponent(GlButton);
- const findActions = () => wrapper.findComponent(AbuseReportActions);
+ const findActions = () => wrapper.findComponent(ReportActions);
const createComponent = (props = {}) => {
wrapper = shallowMount(ReportHeader, {
propsData: {
user,
- actions,
+ report,
...props,
},
});
@@ -51,9 +53,42 @@ describe('ReportHeader', () => {
expect(button.text()).toBe(REPORT_HEADER_I18N.adminProfile);
});
+ describe.each`
+ status | text | variant | className | badgeIcon
+ ${STATUS_OPEN} | ${REPORT_HEADER_I18N[STATUS_OPEN]} | ${'success'} | ${'issuable-status-badge-open'} | ${'issues'}
+ ${STATUS_CLOSED} | ${REPORT_HEADER_I18N[STATUS_CLOSED]} | ${'info'} | ${'issuable-status-badge-closed'} | ${'issue-closed'}
+ `(
+ 'rendering the report $status status badge',
+ ({ status, text, variant, className, badgeIcon }) => {
+ beforeEach(() => {
+ createComponent({ report: { ...report, status } });
+ });
+
+ it(`indicates the ${status} status`, () => {
+ expect(findBadge().text()).toBe(text);
+ });
+
+ it(`with the ${variant} variant`, () => {
+ expect(findBadge().props('variant')).toBe(variant);
+ });
+
+ it(`with the text '${text}' as 'aria-label'`, () => {
+ expect(findBadge().attributes('aria-label')).toBe(text);
+ });
+
+ it(`contains the ${className} class`, () => {
+ expect(findBadge().element.classList).toContain(className);
+ });
+
+ it(`has an icon with the ${badgeIcon} name`, () => {
+ expect(findIcon().props('name')).toBe(badgeIcon);
+ });
+ },
+ );
+
it('renders the actions', () => {
const actionsComponent = findActions();
- expect(actionsComponent.props('report')).toMatchObject(actions);
+ expect(actionsComponent.props('report')).toMatchObject(report);
});
});
diff --git a/spec/frontend/admin/abuse_report/components/reported_content_spec.js b/spec/frontend/admin/abuse_report/components/reported_content_spec.js
index ecc5ad6ad47..9fc49f08f8c 100644
--- a/spec/frontend/admin/abuse_report/components/reported_content_spec.js
+++ b/spec/frontend/admin/abuse_report/components/reported_content_spec.js
@@ -1,9 +1,8 @@
-import { GlSprintf, GlButton, GlModal, GlCard, GlAvatar, GlLink } from '@gitlab/ui';
+import { GlSprintf, GlButton, GlModal, GlCard, GlAvatar, GlLink, GlTruncateText } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { sprintf } from '~/locale';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import ReportedContent from '~/admin/abuse_report/components/reported_content.vue';
-import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { REPORTED_CONTENT_I18N } from '~/admin/abuse_report/constants';
import { mockAbuseReport } from '../mock_data';
@@ -22,7 +21,7 @@ describe('ReportedContent', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findCard = () => wrapper.findComponent(GlCard);
const findCardHeader = () => findCard().find('.js-test-card-header');
- const findTruncatedText = () => findCardHeader().findComponent(TruncatedText);
+ const findTruncatedText = () => findCardHeader().findComponent(GlTruncateText);
const findCardBody = () => findCard().find('.js-test-card-body');
const findCardFooter = () => findCard().find('.js-test-card-footer');
const findAvatar = () => findCardFooter().findComponent(GlAvatar);
@@ -40,7 +39,7 @@ describe('ReportedContent', () => {
GlSprintf,
GlButton,
GlCard,
- TruncatedText,
+ GlTruncateText,
},
});
};
diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js
index ee0f0967735..8c0ae223c87 100644
--- a/spec/frontend/admin/abuse_report/mock_data.js
+++ b/spec/frontend/admin/abuse_report/mock_data.js
@@ -40,6 +40,7 @@ export const mockAbuseReport = {
path: '/reporter',
},
report: {
+ status: 'open',
message: 'This is obvious spam',
reportedAt: '2023-03-29T09:39:50.502Z',
category: 'spam',
@@ -49,13 +50,6 @@ export const mockAbuseReport = {
url: 'http://localhost:3000/spamuser417/project/-/merge_requests/1#note_1375',
screenshot:
'/uploads/-/system/abuse_report/screenshot/27/Screenshot_2023-03-30_at_16.56.37.png',
- },
- actions: {
- reportedUser: { name: 'Sp4m User', createdAt: '2023-03-29T09:30:23.885Z' },
- userBlocked: false,
- blockUserPath: '/admin/users/spamuser417/block',
- removeReportPath: '/admin/abuse_reports/27',
- removeUserAndReportPath: '/admin/abuse_reports/27?remove_user=true',
- redirectPath: '/admin/abuse_reports',
+ updatePath: '/admin/abuse_reports/27',
},
};
diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js
deleted file mode 100644
index 09b6b1edc44..00000000000
--- a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js
+++ /dev/null
@@ -1,202 +0,0 @@
-import { nextTick } from 'vue';
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlModal } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { sprintf } from '~/locale';
-import { ACTIONS_I18N } from '~/admin/abuse_reports/constants';
-import { mockAbuseReports } from '../mock_data';
-
-jest.mock('~/alert');
-jest.mock('~/lib/utils/url_utility');
-
-describe('AbuseReportActions', () => {
- let wrapper;
-
- const findRemoveUserAndReportButton = () => wrapper.findByText('Remove user & report');
- const findBlockUserButton = () => wrapper.findByTestId('block-user-button');
- const findRemoveReportButton = () => wrapper.findByText('Remove report');
- const findConfirmationModal = () => wrapper.findComponent(GlModal);
-
- const report = mockAbuseReports[0];
-
- const createComponent = (props = {}) => {
- wrapper = shallowMountExtended(AbuseReportActions, {
- propsData: {
- report,
- ...props,
- },
- stubs: {
- GlDisclosureDropdown,
- GlDisclosureDropdownItem,
- },
- });
- };
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('displays "Block user", "Remove user & report", and "Remove report" buttons', () => {
- expect(findRemoveUserAndReportButton().text()).toBe(ACTIONS_I18N.removeUserAndReport);
-
- const blockButton = findBlockUserButton();
- expect(blockButton.text()).toBe(ACTIONS_I18N.blockUser);
- expect(blockButton.attributes('disabled')).toBeUndefined();
-
- expect(findRemoveReportButton().text()).toBe(ACTIONS_I18N.removeReport);
- });
-
- it('does not show the confirmation modal initially', () => {
- expect(findConfirmationModal().props('visible')).toBe(false);
- });
- });
-
- describe('block button when user is already blocked', () => {
- it('is disabled and has the correct text', () => {
- createComponent({ report: { ...report, userBlocked: true } });
-
- const button = findBlockUserButton();
- expect(button.text()).toBe(ACTIONS_I18N.alreadyBlocked);
- expect(button.attributes('disabled')).toBeDefined();
- });
- });
-
- describe('actions', () => {
- let axiosMock;
-
- beforeEach(() => {
- axiosMock = new MockAdapter(axios);
-
- createComponent();
- });
-
- afterEach(() => {
- axiosMock.restore();
- createAlert.mockClear();
- });
-
- describe('on remove user and report', () => {
- it('shows confirmation modal and reloads the page on success', async () => {
- findRemoveUserAndReportButton().trigger('click');
- await nextTick();
-
- expect(findConfirmationModal().props()).toMatchObject({
- visible: true,
- title: sprintf(ACTIONS_I18N.removeUserAndReportConfirm, {
- user: report.reportedUser.name,
- }),
- });
-
- axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK);
-
- findConfirmationModal().vm.$emit('primary');
- await axios.waitForAll();
-
- expect(refreshCurrentPage).toHaveBeenCalled();
- });
-
- describe('when a redirect path is present', () => {
- beforeEach(() => {
- createComponent({ report: { ...report, redirectPath: '/redirect_path' } });
- });
-
- it('redirects to the given path', async () => {
- findRemoveUserAndReportButton().trigger('click');
- await nextTick();
-
- axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK);
-
- findConfirmationModal().vm.$emit('primary');
- await axios.waitForAll();
-
- expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated
- });
- });
- });
-
- describe('on block user', () => {
- beforeEach(async () => {
- findBlockUserButton().trigger('click');
- await nextTick();
- });
-
- it('shows confirmation modal', () => {
- expect(findConfirmationModal().props()).toMatchObject({
- visible: true,
- title: ACTIONS_I18N.blockUserConfirm,
- });
- });
-
- describe.each([
- {
- responseData: { notice: 'Notice' },
- createAlertArgs: { message: 'Notice', variant: VARIANT_SUCCESS },
- blockButtonText: ACTIONS_I18N.alreadyBlocked,
- blockButtonDisabled: 'disabled',
- },
- {
- responseData: { error: 'Error' },
- createAlertArgs: { message: 'Error' },
- blockButtonText: ACTIONS_I18N.blockUser,
- blockButtonDisabled: undefined,
- },
- ])(
- 'when response JSON is $responseData',
- ({ responseData, createAlertArgs, blockButtonText, blockButtonDisabled }) => {
- beforeEach(async () => {
- axiosMock.onPut(report.blockUserPath).reply(HTTP_STATUS_OK, responseData);
-
- findConfirmationModal().vm.$emit('primary');
- await axios.waitForAll();
- });
-
- it('updates the block button correctly', () => {
- const button = findBlockUserButton();
- expect(button.text()).toBe(blockButtonText);
- expect(button.attributes('disabled')).toBe(blockButtonDisabled);
- });
-
- it('displays the returned message', () => {
- expect(createAlert).toHaveBeenCalledWith(createAlertArgs);
- });
- },
- );
- });
-
- describe('on remove report', () => {
- it('reloads the page on success', async () => {
- axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK);
-
- findRemoveReportButton().trigger('click');
-
- expect(findConfirmationModal().props('visible')).toBe(false);
-
- await axios.waitForAll();
-
- expect(refreshCurrentPage).toHaveBeenCalled();
- });
-
- describe('when a redirect path is present', () => {
- beforeEach(() => {
- createComponent({ report: { ...report, redirectPath: '/redirect_path' } });
- });
-
- it('redirects to the given path', async () => {
- axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK);
-
- findRemoveReportButton().trigger('click');
-
- await axios.waitForAll();
-
- expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated
- });
- });
- });
- });
-});
diff --git a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js
index 212f26b8faf..dca77e67cac 100644
--- a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js
+++ b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js
@@ -34,7 +34,9 @@ describe('MessageForm', () => {
const findDismissable = () => wrapper.findComponent('[data-testid=dismissable-checkbox]');
const findTargetRoles = () => wrapper.findComponent('[data-testid=target-roles-checkboxes]');
const findSubmitButton = () => wrapper.findComponent('[data-testid=submit-button]');
+ const findCancelButton = () => wrapper.findComponent('[data-testid=cancel-button]');
const findForm = () => wrapper.findComponent(GlForm);
+ const findShowInCli = () => wrapper.findComponent('[data-testid=show-in-cli-checkbox]');
function createComponent({ broadcastMessage = {} } = {}) {
wrapper = mount(MessageForm, {
@@ -98,6 +100,18 @@ describe('MessageForm', () => {
});
});
+ describe('showInCli checkbox', () => {
+ it('renders for Banners', () => {
+ createComponent({ broadcastMessage: { broadcastType: TYPE_BANNER } });
+ expect(findShowInCli().exists()).toBe(true);
+ });
+
+ it('does not render for Notifications', () => {
+ createComponent({ broadcastMessage: { broadcastType: TYPE_NOTIFICATION } });
+ expect(findShowInCli().exists()).toBe(false);
+ });
+ });
+
describe('target roles checkboxes', () => {
it('renders target roles', () => {
createComponent();
@@ -127,6 +141,14 @@ describe('MessageForm', () => {
});
});
+ describe('form cancel button', () => {
+ it('renders when the editing a message and has href back to message index page', () => {
+ createComponent({ broadcastMessage: { id: 100 } });
+ expect(wrapper.text()).toContain('Cancel');
+ expect(findCancelButton().attributes('href')).toBe(wrapper.vm.messagesPath);
+ });
+ });
+
describe('form submission', () => {
const defaultPayload = {
message: defaultProps.message,
diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js
index 73d8c082bb9..69755c6142a 100644
--- a/spec/frontend/admin/users/components/user_actions_spec.js
+++ b/spec/frontend/admin/users/components/user_actions_spec.js
@@ -91,7 +91,7 @@ describe('AdminUserActions component', () => {
initComponent({ actions: [LDAP] });
});
- it('renders the LDAP dropdown item without a link', () => {
+ it('renders the LDAP dropdown footer without a link', () => {
const dropdownAction = wrapper.find(`[data-testid="${LDAP}"]`);
expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(undefined);
diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
index 202a0a04192..80d3676ffee 100644
--- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
+++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
@@ -61,10 +61,11 @@ exports[`Alert integration settings form default state should match the default
items="[object Object]"
noresultstext="No results found"
placement="left"
- popperoptions="[object Object]"
+ positioningstrategy="absolute"
resetbuttonlabel=""
searchplaceholder="Search"
selected="selecte_tmpl"
+ showselectallbuttonlabel=""
size="medium"
toggletext=""
variant="default"
diff --git a/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js b/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js
index f1b3af39199..f57d8559ddf 100644
--- a/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js
+++ b/spec/frontend/analytics/cycle_analytics/components/filter_bar_spec.js
@@ -119,6 +119,10 @@ describe('Filter bar', () => {
it('renders FilteredSearchBar component', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
+
+ it('passes the `terms-as-tokens` prop', () => {
+ expect(findFilteredSearch().props('termsAsTokens')).toBe(true);
+ });
});
describe('when the state has data', () => {
diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
index 33801fb8552..4e0b546b3d2 100644
--- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
+++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
@@ -1,7 +1,6 @@
-import { GlDropdown, GlDropdownItem, GlTruncate, GlSearchBoxByType } from '@gitlab/ui';
+import { GlButton, GlTruncate, GlCollapsibleListbox, GlListboxItem, GlAvatar } from '@gitlab/ui';
import { nextTick } from 'vue';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { stubComponent } from 'helpers/stub_component';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
@@ -28,18 +27,6 @@ const projects = [
},
];
-const MockGlDropdown = stubComponent(GlDropdown, {
- template: `
- <div>
- <slot name="header"></slot>
- <div data-testid="vsa-highlighted-items">
- <slot name="highlighted-items"></slot>
- </div>
- <div data-testid="vsa-default-items"><slot></slot></div>
- </div>
- `,
-});
-
const defaultMocks = {
$apollo: {
query: jest.fn().mockResolvedValue({
@@ -53,42 +40,36 @@ let spyQuery;
describe('ProjectsDropdownFilter component', () => {
let wrapper;
- const createComponent = (props = {}, stubs = {}) => {
+ const createComponent = ({ mountFn = shallowMountExtended, props = {}, stubs = {} } = {}) => {
spyQuery = defaultMocks.$apollo.query;
- wrapper = mountExtended(ProjectsDropdownFilter, {
+ wrapper = mountFn(ProjectsDropdownFilter, {
mocks: { ...defaultMocks },
propsData: {
groupId: 1,
groupNamespace: 'gitlab-org',
...props,
},
- stubs,
+ stubs: {
+ GlButton,
+ GlCollapsibleListbox,
+ ...stubs,
+ },
});
};
- const createWithMockDropdown = (props) => {
- createComponent(props, { GlDropdown: MockGlDropdown });
- return waitForPromises();
- };
-
- const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
- const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items');
- const findClearAllButton = () => wrapper.findByText('Clear all');
+ const findClearAllButton = () => wrapper.findByTestId('listbox-reset-button');
const findSelectedProjectsLabel = () => wrapper.findComponent(GlTruncate);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
- const findDropdownItems = () =>
- findDropdown()
- .findAllComponents(GlDropdownItem)
- .filter((w) => w.text() !== 'No matching results');
+ const findDropdownItems = () => findDropdown().findAllComponents(GlListboxItem);
const findDropdownAtIndex = (index) => findDropdownItems().at(index);
- const findDropdownButton = () => findDropdown().find('.dropdown-toggle');
+ const findDropdownButton = () => findDropdown().findComponent(GlButton);
const findDropdownButtonAvatar = () => findDropdown().find('.gl-avatar');
const findDropdownButtonAvatarAtIndex = (index) =>
- findDropdownAtIndex(index).find('img.gl-avatar');
+ findDropdownAtIndex(index).findComponent(GlAvatar);
const findDropdownButtonIdentIconAtIndex = (index) =>
findDropdownAtIndex(index).find('div.gl-avatar-identicon');
@@ -97,13 +78,15 @@ describe('ProjectsDropdownFilter component', () => {
const findDropdownFullPathAtIndex = (index) =>
findDropdownAtIndex(index).find('[data-testid="project-full-path"]');
- const selectDropdownItemAtIndex = async (index) => {
- findDropdownAtIndex(index).find('button').trigger('click');
+ const selectDropdownItemAtIndex = async (indexes, multi = true) => {
+ const payload = indexes.map((index) => projects[index]?.id).filter(Boolean);
+ findDropdown().vm.$emit('select', multi ? payload : payload[0]);
await nextTick();
};
// NOTE: Selected items are now visually separated from unselected items
- const findSelectedDropdownItems = () => findHighlightedItems().findAllComponents(GlDropdownItem);
+ const findSelectedDropdownItems = () =>
+ findDropdownItems().filter((component) => component.props('isSelected') === true);
const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index);
const findSelectedButtonIdentIconAtIndex = (index) =>
@@ -111,22 +94,20 @@ describe('ProjectsDropdownFilter component', () => {
const findSelectedButtonAvatarItemAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('img.gl-avatar');
- const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id);
-
- const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
-
describe('queryParams are applied when fetching data', () => {
beforeEach(() => {
createComponent({
- queryParams: {
- first: 50,
- includeSubgroups: true,
+ props: {
+ queryParams: {
+ first: 50,
+ includeSubgroups: true,
+ },
},
});
});
it('applies the correct queryParams when making an api call', async () => {
- findSearchBoxByType().vm.$emit('input', 'gitlab');
+ findDropdown().vm.$emit('search', 'gitlab');
expect(spyQuery).toHaveBeenCalledTimes(1);
@@ -147,17 +128,19 @@ describe('ProjectsDropdownFilter component', () => {
const blockDefaultProps = { multiSelect: true };
beforeEach(() => {
- createComponent(blockDefaultProps);
+ createComponent({
+ props: blockDefaultProps,
+ });
});
describe('with no project selected', () => {
- it('does not render the highlighted items', async () => {
- await createWithMockDropdown(blockDefaultProps);
-
- expect(findSelectedDropdownItems().length).toBe(0);
+ it('does not render the highlighted items', () => {
+ expect(findSelectedDropdownItems()).toHaveLength(0);
});
it('renders the default project label text', () => {
+ createComponent({ mountFn: mountExtended, props: blockDefaultProps });
+
expect(findSelectedProjectsLabel().text()).toBe('Select projects');
});
@@ -167,31 +150,43 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('with a selected project', () => {
- beforeEach(async () => {
- await selectDropdownItemAtIndex(0);
+ beforeEach(() => {
+ createComponent({
+ mountFn: mountExtended,
+ props: blockDefaultProps,
+ });
});
it('renders the highlighted items', async () => {
- await createWithMockDropdown(blockDefaultProps);
- await selectDropdownItemAtIndex(0);
+ await selectDropdownItemAtIndex([0], false);
- expect(findSelectedDropdownItems().length).toBe(1);
+ expect(findSelectedDropdownItems()).toHaveLength(1);
});
- it('renders the highlighted items title', () => {
+ it('renders the highlighted items title', async () => {
+ await selectDropdownItemAtIndex([0], false);
+
expect(findSelectedProjectsLabel().text()).toBe(projects[0].name);
});
- it('renders the clear all button', () => {
+ it('renders the clear all button', async () => {
+ await selectDropdownItemAtIndex([0], false);
+
expect(findClearAllButton().exists()).toBe(true);
});
it('clears all selected items when the clear all button is clicked', async () => {
- await selectDropdownItemAtIndex(1);
+ createComponent({
+ mountFn: mountExtended,
+ props: blockDefaultProps,
+ });
+ await waitForPromises();
+
+ await selectDropdownItemAtIndex([0, 1]);
expect(findSelectedProjectsLabel().text()).toBe('2 projects selected');
- await findClearAllButton().trigger('click');
+ await findClearAllButton().vm.$emit('click');
expect(findSelectedProjectsLabel().text()).toBe('Select projects');
});
@@ -200,27 +195,35 @@ describe('ProjectsDropdownFilter component', () => {
describe('with a selected project and search term', () => {
beforeEach(async () => {
- await createWithMockDropdown({ multiSelect: true });
+ createComponent({
+ props: { multiSelect: true },
+ });
+ await waitForPromises();
- selectDropdownItemAtIndex(0);
- findSearchBoxByType().vm.$emit('input', 'this is a very long search string');
+ await selectDropdownItemAtIndex([0]);
+
+ findDropdown().vm.$emit('search', 'this is a very long search string');
});
it('renders the highlighted items', () => {
- expect(findUnhighlightedItems().findAll('li').length).toBe(1);
+ expect(findSelectedDropdownItems()).toHaveLength(1);
});
it('hides the unhighlighted items that do not match the string', () => {
- expect(findUnhighlightedItems().findAll('li').length).toBe(1);
- expect(findUnhighlightedItems().text()).toContain('No matching results');
+ expect(wrapper.find(`[name="Selected"]`).findAllComponents(GlListboxItem).length).toBe(1);
+ expect(wrapper.find(`[name="Unselected"]`).findAllComponents(GlListboxItem).length).toBe(0);
});
});
describe('when passed an array of defaultProject as prop', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent({
- defaultProjects: [projects[0]],
+ mountFn: mountExtended,
+ props: {
+ defaultProjects: [projects[0]],
+ },
});
+ await waitForPromises();
});
it("displays the defaultProject's name", () => {
@@ -232,14 +235,18 @@ describe('ProjectsDropdownFilter component', () => {
});
it('marks the defaultProject as selected', () => {
- expect(findDropdownAtIndex(0).props('isChecked')).toBe(true);
+ expect(
+ wrapper.findAll('[role="group"]').at(0).findAllComponents(GlListboxItem).at(0).text(),
+ ).toContain(projects[0].name);
});
});
describe('when multiSelect is false', () => {
const blockDefaultProps = { multiSelect: false };
beforeEach(() => {
- createComponent(blockDefaultProps);
+ createComponent({
+ props: blockDefaultProps,
+ });
});
describe('displays the correct information', () => {
@@ -248,13 +255,12 @@ describe('ProjectsDropdownFilter component', () => {
});
it('renders an avatar when the project has an avatarUrl', () => {
- expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
+ expect(findDropdownButtonAvatarAtIndex(0).props('src')).toBe(projects[0].avatarUrl);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
- it("renders an identicon when the project doesn't have an avatarUrl", () => {
- expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
- expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
+ it("does not render an avatar when the project doesn't have an avatarUrl", () => {
+ expect(findDropdownButtonAvatarAtIndex(1).props('src')).toEqual(null);
});
it('renders the project name', () => {
@@ -271,37 +277,46 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('on project click', () => {
- it('should emit the "selected" event with the selected project', () => {
- selectDropdownItemAtIndex(0);
+ it('should emit the "selected" event with the selected project', async () => {
+ await selectDropdownItemAtIndex([0], false);
- expect(wrapper.emitted().selected).toEqual([[[projects[0]]]]);
+ expect(wrapper.emitted('selected')).toEqual([[[projects[0]]]]);
});
it('should change selection when new project is clicked', () => {
- selectDropdownItemAtIndex(1);
+ selectDropdownItemAtIndex([1], false);
- expect(wrapper.emitted().selected).toEqual([[[projects[1]]]]);
+ expect(wrapper.emitted('selected')).toEqual([[[projects[1]]]]);
});
- it('selection should be emptied when a project is deselected', () => {
- selectDropdownItemAtIndex(0); // Select the item
- selectDropdownItemAtIndex(0); // deselect it
+ it('selection should be emptied when a project is deselected', async () => {
+ await selectDropdownItemAtIndex([0], false); // Select the item
+ await selectDropdownItemAtIndex([0], false);
- expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]);
+ expect(wrapper.emitted('selected')).toEqual([[[projects[0]]], [[]]]);
});
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
- await createWithMockDropdown(blockDefaultProps);
- await selectDropdownItemAtIndex(0);
+ createComponent({
+ mountFn: mountExtended,
+ props: blockDefaultProps,
+ });
+ await waitForPromises();
+
+ await selectDropdownItemAtIndex([0], false);
expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true);
expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
- await createWithMockDropdown(blockDefaultProps);
- await selectDropdownItemAtIndex(1);
+ createComponent({
+ mountFn: mountExtended,
+ props: blockDefaultProps,
+ });
+ await waitForPromises();
+ await selectDropdownItemAtIndex([1], false);
expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false);
expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true);
});
@@ -310,7 +325,9 @@ describe('ProjectsDropdownFilter component', () => {
describe('when multiSelect is true', () => {
beforeEach(() => {
- createComponent({ multiSelect: true });
+ createComponent({
+ props: { multiSelect: true },
+ });
});
describe('displays the correct information', () => {
@@ -319,13 +336,12 @@ describe('ProjectsDropdownFilter component', () => {
});
it('renders an avatar when the project has an avatarUrl', () => {
- expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
+ expect(findDropdownButtonAvatarAtIndex(0).props('src')).toBe(projects[0].avatarUrl);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon when the project doesn't have an avatarUrl", () => {
- expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
- expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
+ expect(findDropdownButtonAvatarAtIndex(1).props('src')).toEqual(null);
});
it('renders the project name', () => {
@@ -342,27 +358,31 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('on project click', () => {
- it('should add to selection when new project is clicked', () => {
- selectDropdownItemAtIndex(0);
- selectDropdownItemAtIndex(1);
+ it('should add to selection when new project is clicked', async () => {
+ await selectDropdownItemAtIndex([0, 1]);
- expect(selectedIds()).toEqual([projects[0].id, projects[1].id]);
+ expect(findSelectedDropdownItems().at(0).text()).toContain(projects[1].name);
+ expect(findSelectedDropdownItems().at(1).text()).toContain(projects[0].name);
});
- it('should remove from selection when clicked again', () => {
- selectDropdownItemAtIndex(0);
+ it('should remove from selection when clicked again', async () => {
+ await selectDropdownItemAtIndex([0]);
- expect(selectedIds()).toEqual([projects[0].id]);
+ expect(findSelectedDropdownItems().at(0).text()).toContain(projects[0].name);
- selectDropdownItemAtIndex(0);
+ await selectDropdownItemAtIndex([]);
- expect(selectedIds()).toEqual([]);
+ expect(findSelectedDropdownItems()).toHaveLength(0);
});
it('renders the correct placeholder text when multiple projects are selected', async () => {
- selectDropdownItemAtIndex(0);
- selectDropdownItemAtIndex(1);
- await nextTick();
+ createComponent({
+ props: { multiSelect: true },
+ mountFn: mountExtended,
+ });
+ await waitForPromises();
+
+ await selectDropdownItemAtIndex([0, 1]);
expect(findDropdownButton().text()).toBe('2 projects selected');
});
diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js
index a879c229581..b2ecfeb8394 100644
--- a/spec/frontend/api/user_api_spec.js
+++ b/spec/frontend/api/user_api_spec.js
@@ -1,12 +1,14 @@
import MockAdapter from 'axios-mock-adapter';
import projects from 'test_fixtures/api/users/projects/get.json';
+import followers from 'test_fixtures/api/users/followers/get.json';
import {
followUser,
unfollowUser,
associationsCount,
updateUserStatus,
getUserProjects,
+ getUserFollowers,
} from '~/api/user_api';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
@@ -16,6 +18,7 @@ import {
} from 'jest/admin/users/mock_data';
import { AVAILABILITY_STATUS } from '~/set_status_modal/constants';
import { timeRanges } from '~/vue_shared/constants';
+import { DEFAULT_PER_PAGE } from '~/api';
describe('~/api/user_api', () => {
let axiosMock;
@@ -112,4 +115,20 @@ describe('~/api/user_api', () => {
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
});
});
+
+ describe('getUserFollowers', () => {
+ it('calls correct URL and returns expected response', async () => {
+ const expectedUrl = '/api/v4/users/1/followers';
+ const expectedResponse = { data: followers };
+ const params = { page: 2 };
+
+ axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse);
+
+ await expect(getUserFollowers(1, params)).resolves.toEqual(
+ expect.objectContaining({ data: expectedResponse }),
+ );
+ expect(axiosMock.history.get[0].url).toBe(expectedUrl);
+ expect(axiosMock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE });
+ });
+ });
});
diff --git a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
index ba8215f4e00..0bee37dbf15 100644
--- a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
+++ b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
@@ -1,32 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Keep latest artifact checkbox when application keep latest artifact setting is enabled sets correct setting value in checkbox with query result 1`] = `
+exports[`Keep latest artifact toggle when application keep latest artifact setting is enabled sets correct setting value in toggle with query result 1`] = `
<div>
<!---->
- <b-form-checkbox-stub
- checked="true"
- class="gl-form-checkbox"
- id="4"
- value="true"
+ <div
+ class="gl-toggle-wrapper gl-display-flex gl-mb-0 gl-flex-direction-column"
+ data-testid="toggle-wrapper"
>
- <strong
- class="gl-mr-3"
+ <span
+ class="gl-toggle-label gl-flex-shrink-0 gl-mb-3"
+ data-testid="toggle-label"
+ id="toggle-label-4"
>
Keep artifacts from most recent successful jobs
- </strong>
+ </span>
- <gl-link-stub
- href="/help/ci/pipelines/job_artifacts"
+ <!---->
+
+ <!---->
+
+ <button
+ aria-checked="true"
+ aria-describedby="toggle-help-2"
+ aria-labelledby="toggle-label-4"
+ class="gl-flex-shrink-0 gl-toggle is-checked"
+ role="switch"
+ type="button"
>
- More information
- </gl-link-stub>
+ <span
+ class="toggle-icon"
+ >
+ <gl-icon-stub
+ name="mobile-issue-close"
+ size="16"
+ />
+ </span>
+ </button>
- <p
- class="help-text"
+ <span
+ class="gl-help-label"
+ data-testid="toggle-help"
+ id="toggle-help-2"
>
+
The latest artifacts created by jobs in the most recent successful pipeline will be stored.
- </p>
- </b-form-checkbox-stub>
+
+ <gl-link-stub
+ href="/help/ci/pipelines/job_artifacts"
+ >
+ Learn more.
+ </gl-link-stub>
+ </span>
+ </div>
</div>
`;
diff --git a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
index 8dafff350f2..d0a7515432b 100644
--- a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
+++ b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
@@ -1,4 +1,4 @@
-import { GlFormCheckbox, GlLink } from '@gitlab/ui';
+import { GlToggle, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -7,7 +7,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import UpdateKeepLatestArtifactProjectSetting from '~/artifacts_settings/graphql/mutations/update_keep_latest_artifact_project_setting.mutation.graphql';
import GetKeepLatestArtifactApplicationSetting from '~/artifacts_settings/graphql/queries/get_keep_latest_artifact_application_setting.query.graphql';
import GetKeepLatestArtifactProjectSetting from '~/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql';
-import KeepLatestArtifactCheckbox from '~/artifacts_settings/keep_latest_artifact_checkbox.vue';
+import KeepLatestArtifactToggle from '~/artifacts_settings/keep_latest_artifact_toggle.vue';
Vue.use(VueApollo);
@@ -34,7 +34,7 @@ const keepLatestArtifactMockResponse = {
},
};
-describe('Keep latest artifact checkbox', () => {
+describe('Keep latest artifact toggle', () => {
let wrapper;
let apolloProvider;
let requestHandlers;
@@ -42,7 +42,7 @@ describe('Keep latest artifact checkbox', () => {
const fullPath = 'gitlab-org/gitlab';
const helpPagePath = '/help/ci/pipelines/job_artifacts';
- const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+ const findToggle = () => wrapper.findComponent(GlToggle);
const findHelpLink = () => wrapper.findComponent(GlLink);
const createComponent = (handlers) => {
@@ -68,13 +68,13 @@ describe('Keep latest artifact checkbox', () => {
[UpdateKeepLatestArtifactProjectSetting, requestHandlers.keepLatestArtifactMutationHandler],
]);
- wrapper = shallowMount(KeepLatestArtifactCheckbox, {
+ wrapper = shallowMount(KeepLatestArtifactToggle, {
provide: {
fullPath,
helpPagePath,
},
stubs: {
- GlFormCheckbox,
+ GlToggle,
},
apolloProvider,
});
@@ -89,13 +89,13 @@ describe('Keep latest artifact checkbox', () => {
createComponent();
});
- it('displays the checkbox and the help link', () => {
- expect(findCheckbox().exists()).toBe(true);
+ it('displays the toggle and the help link', () => {
+ expect(findToggle().exists()).toBe(true);
expect(findHelpLink().exists()).toBe(true);
});
it('calls mutation on artifact setting change with correct payload', () => {
- findCheckbox().vm.$emit('change', false);
+ findToggle().vm.$emit('change', false);
expect(requestHandlers.keepLatestArtifactMutationHandler).toHaveBeenCalledWith({
fullPath,
@@ -110,12 +110,12 @@ describe('Keep latest artifact checkbox', () => {
await waitForPromises();
});
- it('sets correct setting value in checkbox with query result', () => {
+ it('sets correct setting value in toggle with query result', () => {
expect(wrapper.element).toMatchSnapshot();
});
- it('checkbox is enabled when application setting is enabled', () => {
- expect(findCheckbox().attributes('disabled')).toBeUndefined();
+ it('toggle is enabled when application setting is enabled', () => {
+ expect(findToggle().attributes('disabled')).toBeUndefined();
});
});
});
diff --git a/spec/frontend/batch_comments/components/diff_file_drafts_spec.js b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js
index f667ebc0fcb..014e28b7509 100644
--- a/spec/frontend/batch_comments/components/diff_file_drafts_spec.js
+++ b/spec/frontend/batch_comments/components/diff_file_drafts_spec.js
@@ -16,7 +16,10 @@ describe('Batch comments diff file drafts component', () => {
batchComments: {
namespaced: true,
getters: {
- draftsForFile: () => () => [{ id: 1 }, { id: 2 }],
+ draftsForFile: () => () => [
+ { id: 1, position: { position_type: 'file' } },
+ { id: 2, position: { position_type: 'file' } },
+ ],
},
},
},
@@ -24,7 +27,7 @@ describe('Batch comments diff file drafts component', () => {
vm = shallowMount(DiffFileDrafts, {
store,
- propsData: { fileHash: 'filehash' },
+ propsData: { fileHash: 'filehash', positionType: 'file' },
});
}
diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js
index a19a72af813..191586e44cc 100644
--- a/spec/frontend/batch_comments/components/preview_item_spec.js
+++ b/spec/frontend/batch_comments/components/preview_item_spec.js
@@ -1,29 +1,33 @@
import { mount } from '@vue/test-utils';
import PreviewItem from '~/batch_comments/components/preview_item.vue';
-import { createStore } from '~/batch_comments/stores';
-import diffsModule from '~/diffs/store/modules';
-import notesModule from '~/notes/stores/modules';
+import store from '~/mr_notes/stores';
import { createDraft } from '../mock_data';
jest.mock('~/behaviors/markdown/render_gfm');
+jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
describe('Batch comments draft preview item component', () => {
let wrapper;
let draft;
- function createComponent(isLast = false, extra = {}, extendStore = () => {}) {
- const store = createStore();
- store.registerModule('diffs', diffsModule());
- store.registerModule('notes', notesModule());
+ beforeEach(() => {
+ store.reset();
- extendStore(store);
+ store.getters.getDiscussion = jest.fn(() => null);
+ });
+ function createComponent(isLast = false, extra = {}) {
draft = {
...createDraft(),
...extra,
};
- wrapper = mount(PreviewItem, { store, propsData: { draft, isLast } });
+ wrapper = mount(PreviewItem, {
+ mocks: {
+ $store: store,
+ },
+ propsData: { draft, isLast },
+ });
}
it('renders text content', () => {
@@ -87,18 +91,19 @@ describe('Batch comments draft preview item component', () => {
describe('for thread', () => {
beforeEach(() => {
- createComponent(false, { discussion_id: '1', resolve_discussion: true }, (store) => {
- store.state.notes.discussions.push({
- id: '1',
- notes: [
- {
- author: {
- name: "Author 'Nick' Name",
- },
+ store.getters.getDiscussion.mockReturnValue({
+ id: '1',
+ notes: [
+ {
+ author: {
+ name: "Author 'Nick' Name",
},
- ],
- });
+ },
+ ],
});
+ store.getters.isDiscussionResolved = jest.fn().mockReturnValue(false);
+
+ createComponent(false, { discussion_id: '1', resolve_discussion: true });
});
it('renders title', () => {
@@ -114,9 +119,7 @@ describe('Batch comments draft preview item component', () => {
describe('for new comment', () => {
it('renders title', () => {
- createComponent(false, {}, (store) => {
- store.state.notes.discussions.push({});
- });
+ createComponent();
expect(wrapper.find('.review-preview-item-header-text').text()).toContain('Your new comment');
});
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
index 57bafb51cd6..521bbf06b02 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
@@ -70,6 +70,19 @@ describe('Batch comments store actions', () => {
);
});
+ it('dispatchs addDraftToFile if draft is on file', () => {
+ res = { id: 1, position: { position_type: 'file' }, file_path: 'index.js' };
+ mock.onAny().reply(HTTP_STATUS_OK, res);
+
+ return testAction(
+ actions.createNewDraft,
+ { endpoint: TEST_HOST, data: 'test' },
+ null,
+ [{ type: 'ADD_NEW_DRAFT', payload: res }],
+ [{ type: 'diffs/addDraftToFile', payload: { draft: res, filePath: 'index.js' } }],
+ );
+ });
+
it('does not commit ADD_NEW_DRAFT if errors returned', () => {
mock.onAny().reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
diff --git a/spec/frontend/behaviors/markdown/utils_spec.js b/spec/frontend/behaviors/markdown/utils_spec.js
new file mode 100644
index 00000000000..f4e7ca621d9
--- /dev/null
+++ b/spec/frontend/behaviors/markdown/utils_spec.js
@@ -0,0 +1,18 @@
+import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils';
+
+describe('toggleMarkCheckboxes', () => {
+ const rawMarkdown = `- [x] todo 1\n- [ ] todo 2`;
+
+ it.each`
+ assertionName | sourcepos | checkboxChecked | expectedMarkdown
+ ${'marks'} | ${'2:1-2:12'} | ${true} | ${'- [x] todo 1\n- [x] todo 2'}
+ ${'unmarks'} | ${'1:1-1:12'} | ${false} | ${'- [ ] todo 1\n- [ ] todo 2'}
+ `(
+ '$assertionName the checkbox at correct position',
+ ({ sourcepos, checkboxChecked, expectedMarkdown }) => {
+ expect(toggleMarkCheckboxes({ rawMarkdown, sourcepos, checkboxChecked })).toEqual(
+ expectedMarkdown,
+ );
+ },
+ );
+});
diff --git a/spec/frontend/blame/streaming/index_spec.js b/spec/frontend/blame/streaming/index_spec.js
index e048ce3f70e..29beb6beffa 100644
--- a/spec/frontend/blame/streaming/index_spec.js
+++ b/spec/frontend/blame/streaming/index_spec.js
@@ -4,12 +4,14 @@ import { setHTMLFixture } from 'helpers/fixtures';
import { renderHtmlStreams } from '~/streaming/render_html_streams';
import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps';
import { toPolyfillReadable } from '~/streaming/polyfills';
import { createAlert } from '~/alert';
jest.mock('~/streaming/render_html_streams');
jest.mock('~/streaming/rate_limit_stream_requests');
jest.mock('~/streaming/handle_streamed_anchor_link');
+jest.mock('~/streaming/handle_streamed_relative_timestamps');
jest.mock('~/streaming/polyfills');
jest.mock('~/sentry');
jest.mock('~/alert');
@@ -18,6 +20,7 @@ global.fetch = jest.fn();
describe('renderBlamePageStreams', () => {
let stopAnchor;
+ let stopTimetamps;
const PAGES_URL = 'https://example.com/';
const findStreamContainer = () => document.querySelector('#blame-stream-container');
const findStreamLoadingIndicator = () => document.querySelector('#blame-stream-loading');
@@ -34,6 +37,7 @@ describe('renderBlamePageStreams', () => {
};
handleStreamedAnchorLink.mockImplementation(() => stopAnchor);
+ handleStreamedRelativeTimestamps.mockImplementation(() => Promise.resolve(stopTimetamps));
rateLimitStreamRequests.mockImplementation(({ factory, total }) => {
return Array.from({ length: total }, (_, i) => {
return Promise.resolve(factory(i));
@@ -43,6 +47,7 @@ describe('renderBlamePageStreams', () => {
beforeEach(() => {
stopAnchor = jest.fn();
+ stopTimetamps = jest.fn();
fetch.mockClear();
});
@@ -50,6 +55,7 @@ describe('renderBlamePageStreams', () => {
await renderBlamePageStreams();
expect(handleStreamedAnchorLink).not.toHaveBeenCalled();
+ expect(handleStreamedRelativeTimestamps).not.toHaveBeenCalled();
expect(renderHtmlStreams).not.toHaveBeenCalled();
});
@@ -64,7 +70,9 @@ describe('renderBlamePageStreams', () => {
renderBlamePageStreams(stream);
expect(handleStreamedAnchorLink).toHaveBeenCalledTimes(1);
+ expect(handleStreamedRelativeTimestamps).toHaveBeenCalledTimes(1);
expect(stopAnchor).toHaveBeenCalledTimes(0);
+ expect(stopTimetamps).toHaveBeenCalledTimes(0);
expect(renderHtmlStreams).toHaveBeenCalledWith([stream], findStreamContainer());
expect(findStreamLoadingIndicator()).not.toBe(null);
@@ -72,6 +80,7 @@ describe('renderBlamePageStreams', () => {
await waitForPromises();
expect(stopAnchor).toHaveBeenCalledTimes(1);
+ expect(stopTimetamps).toHaveBeenCalledTimes(1);
expect(findStreamLoadingIndicator()).toBe(null);
});
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index 43cf6ead1c1..e3cdec1ab6e 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -39,7 +39,7 @@ export default function createComponent({
Vue.use(Vuex);
const fakeApollo = createMockApollo([
- [listQuery, jest.fn().mockResolvedValue(boardListQueryResponse(issuesCount))],
+ [listQuery, jest.fn().mockResolvedValue(boardListQueryResponse({ issuesCount }))],
...apolloQueryHandlers,
]);
diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js
index ab3cf072357..3601bf14703 100644
--- a/spec/frontend/boards/boards_util_spec.js
+++ b/spec/frontend/boards/boards_util_spec.js
@@ -1,4 +1,5 @@
-import { formatIssueInput, filterVariables } from '~/boards/boards_util';
+import { formatIssueInput, filterVariables, FiltersInfo } from '~/boards/boards_util';
+import { FilterFields } from '~/boards/constants';
describe('formatIssueInput', () => {
const issueInput = {
@@ -149,4 +150,40 @@ describe('filterVariables', () => {
expect(result).toEqual(expected);
});
+
+ it.each([
+ [
+ 'converts milestone wild card',
+ {
+ filters: {
+ milestoneTitle: 'Started',
+ },
+ expected: {
+ milestoneWildcardId: 'STARTED',
+ not: {},
+ },
+ },
+ ],
+ [
+ 'converts assignee wild card',
+ {
+ filters: {
+ assigneeUsername: 'Any',
+ },
+ expected: {
+ assigneeWildcardId: 'ANY',
+ not: {},
+ },
+ },
+ ],
+ ])('%s', (_, { filters, issuableType = 'issue', expected }) => {
+ const result = filterVariables({
+ filters,
+ issuableType,
+ filterInfo: FiltersInfo,
+ filterFields: FilterFields,
+ });
+
+ expect(result).toEqual(expected);
+ });
});
diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js
index 4fc9a6859a6..35296f36b89 100644
--- a/spec/frontend/boards/components/board_add_new_column_form_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js
@@ -29,10 +29,7 @@ describe('BoardAddNewColumnForm', () => {
},
slots,
store: createStore({
- actions: {
- setAddColumnFormVisibility: jest.fn(),
- ...actions,
- },
+ actions,
}),
});
};
@@ -48,16 +45,11 @@ describe('BoardAddNewColumnForm', () => {
});
it('clicking cancel hides the form', () => {
- const setAddColumnFormVisibility = jest.fn();
- mountComponent({
- actions: {
- setAddColumnFormVisibility,
- },
- });
+ mountComponent();
cancelButton().vm.$emit('click');
- expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false);
+ expect(wrapper.emitted('setAddColumnFormVisibility')).toEqual([[false]]);
});
describe('Add list button', () => {
diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js
index a09c3aaa55e..8d6cc9373af 100644
--- a/spec/frontend/boards/components/board_add_new_column_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_spec.js
@@ -1,18 +1,36 @@
import { GlCollapsibleListbox } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import defaultState from '~/boards/stores/state';
-import { mockLabelList } from '../mock_data';
+import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
+import boardLabelsQuery from '~/boards/graphql/board_labels.query.graphql';
+import {
+ mockLabelList,
+ createBoardListResponse,
+ labelsQueryResponse,
+ boardListsQueryResponse,
+} from '../mock_data';
Vue.use(Vuex);
+Vue.use(VueApollo);
-describe('Board card layout', () => {
+describe('BoardAddNewColumn', () => {
let wrapper;
+ const createBoardListQueryHandler = jest.fn().mockResolvedValue(createBoardListResponse);
+ const labelsQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse);
+ const mockApollo = createMockApollo([
+ [boardLabelsQuery, labelsQueryHandler],
+ [createBoardListMutation, createBoardListQueryHandler],
+ ]);
+
const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findAddNewColumnForm = () => wrapper.findComponent(BoardAddNewColumnForm);
const selectLabel = (id) => {
findDropdown().vm.$emit('select', id);
};
@@ -33,8 +51,22 @@ describe('Board card layout', () => {
labels = [],
getListByLabelId = jest.fn(),
actions = {},
+ provide = {},
+ lists = {},
} = {}) => {
wrapper = shallowMountExtended(BoardAddNewColumn, {
+ apolloProvider: mockApollo,
+ propsData: {
+ listQueryVariables: {
+ isGroup: false,
+ isProject: true,
+ fullPath: 'gitlab-org/gitlab',
+ boardId: 'gid://gitlab/Board/1',
+ filters: {},
+ },
+ boardId: 'gid://gitlab/Board/1',
+ lists,
+ },
data() {
return {
selectedId,
@@ -43,7 +75,6 @@ describe('Board card layout', () => {
store: createStore({
actions: {
fetchLabels: jest.fn(),
- setAddColumnFormVisibility: jest.fn(),
...actions,
},
getters: {
@@ -57,6 +88,11 @@ describe('Board card layout', () => {
provide: {
scopedLabelsAvailable: true,
isEpicBoard: false,
+ issuableType: 'issue',
+ fullPath: 'gitlab-org/gitlab',
+ boardType: 'project',
+ isApolloBoard: false,
+ ...provide,
},
stubs: {
GlCollapsibleListbox,
@@ -67,6 +103,12 @@ describe('Board card layout', () => {
if (selectedId) {
selectLabel(selectedId);
}
+
+ // Necessary for cache update
+ mockApollo.clients.defaultClient.cache.readQuery = jest
+ .fn()
+ .mockReturnValue(boardListsQueryResponse.data);
+ mockApollo.clients.defaultClient.cache.writeQuery = jest.fn();
};
describe('Add list button', () => {
@@ -85,7 +127,7 @@ describe('Board card layout', () => {
},
});
- wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list');
+ findAddNewColumnForm().vm.$emit('add-list');
await nextTick();
@@ -110,7 +152,7 @@ describe('Board card layout', () => {
},
});
- wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list');
+ findAddNewColumnForm().vm.$emit('add-list');
await nextTick();
@@ -118,4 +160,59 @@ describe('Board card layout', () => {
expect(createList).not.toHaveBeenCalled();
});
});
+
+ describe('Apollo boards', () => {
+ describe('when list is new', () => {
+ beforeEach(() => {
+ mountComponent({ selectedId: mockLabelList.label.id, provide: { isApolloBoard: true } });
+ });
+
+ it('fetches labels and adds list', async () => {
+ findDropdown().vm.$emit('show');
+
+ await nextTick();
+ expect(labelsQueryHandler).toHaveBeenCalled();
+
+ selectLabel(mockLabelList.label.id);
+
+ findAddNewColumnForm().vm.$emit('add-list');
+
+ await nextTick();
+
+ expect(wrapper.emitted('highlight-list')).toBeUndefined();
+ expect(createBoardListQueryHandler).toHaveBeenCalledWith({
+ labelId: mockLabelList.label.id,
+ boardId: 'gid://gitlab/Board/1',
+ });
+ });
+ });
+
+ describe('when list already exists in board', () => {
+ beforeEach(() => {
+ mountComponent({
+ lists: {
+ [mockLabelList.id]: mockLabelList,
+ },
+ selectedId: mockLabelList.label.id,
+ provide: { isApolloBoard: true },
+ });
+ });
+
+ it('highlights existing list if trying to re-add', async () => {
+ findDropdown().vm.$emit('show');
+
+ await nextTick();
+ expect(labelsQueryHandler).toHaveBeenCalled();
+
+ selectLabel(mockLabelList.label.id);
+
+ findAddNewColumnForm().vm.$emit('add-list');
+
+ await nextTick();
+
+ expect(wrapper.emitted('highlight-list')).toEqual([[mockLabelList.id]]);
+ expect(createBoardListQueryHandler).not.toHaveBeenCalledWith();
+ });
+ });
+ });
});
diff --git a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
index d8b93e1f3b6..825cfc9453a 100644
--- a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
@@ -1,5 +1,5 @@
import { GlButton } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import Vuex from 'vuex';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
@@ -13,12 +13,16 @@ describe('BoardAddNewColumnTrigger', () => {
const findBoardsCreateList = () => wrapper.findByTestId('boards-create-list');
const findTooltipText = () => getBinding(findBoardsCreateList().element, 'gl-tooltip');
+ const findCreateButton = () => wrapper.findComponent(GlButton);
- const mountComponent = () => {
+ const mountComponent = ({ isNewListShowing = false } = {}) => {
wrapper = mountExtended(BoardAddNewColumnTrigger, {
directives: {
GlTooltip: createMockDirective('gl-tooltip'),
},
+ propsData: {
+ isNewListShowing,
+ },
store: createStore(),
});
};
@@ -35,17 +39,19 @@ describe('BoardAddNewColumnTrigger', () => {
});
it('renders an enabled button', () => {
- const button = wrapper.findComponent(GlButton);
+ expect(findCreateButton().props('disabled')).toBe(false);
+ });
- expect(button.props('disabled')).toBe(false);
+ it('shows form on click button', () => {
+ findCreateButton().vm.$emit('click');
+
+ expect(wrapper.emitted('setAddColumnFormVisibility')).toEqual([[true]]);
});
});
describe('when button is disabled', () => {
- it('shows the tooltip', async () => {
- wrapper.findComponent(GlButton).vm.$emit('click');
-
- await nextTick();
+ it('shows the tooltip', () => {
+ mountComponent({ isNewListShowing: true });
const tooltip = findTooltipText();
diff --git a/spec/frontend/boards/components/board_card_move_to_position_spec.js b/spec/frontend/boards/components/board_card_move_to_position_spec.js
index 8af772ba6d0..5f308be5580 100644
--- a/spec/frontend/boards/components/board_card_move_to_position_spec.js
+++ b/spec/frontend/boards/components/board_card_move_to_position_spec.js
@@ -51,9 +51,12 @@ describe('Board Card Move to position', () => {
};
};
- const createComponent = (propsData) => {
+ const createComponent = (propsData, isApolloBoard = false) => {
wrapper = shallowMount(BoardCardMoveToPosition, {
store,
+ provide: {
+ isApolloBoard,
+ },
propsData: {
item: mockIssue2,
list: mockList,
@@ -134,5 +137,39 @@ describe('Board Card Move to position', () => {
},
);
});
+
+ describe('Apollo boards', () => {
+ beforeEach(() => {
+ createComponent({ index: itemIndex }, true);
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it.each`
+ dropdownIndex | dropdownItem | trackLabel | positionInList
+ ${0} | ${dropdownOptions[0]} | ${'move_to_start'} | ${0}
+ ${1} | ${dropdownOptions[1]} | ${'move_to_end'} | ${-1}
+ `(
+ 'on click of dropdown index $dropdownIndex with label $dropdownLabel emits moveToPosition event with tracking label $trackLabel',
+ async ({ dropdownIndex, dropdownItem, trackLabel, positionInList }) => {
+ await findMoveToPositionDropdown().vm.$emit('shown');
+
+ expect(findDropdownItemAtIndex(dropdownIndex).text()).toBe(dropdownItem.text);
+
+ await findMoveToPositionDropdown().vm.$emit('action', dropdownItem);
+
+ expect(trackingSpy).toHaveBeenCalledWith('boards:list', 'click_toggle_button', {
+ category: 'boards:list',
+ label: trackLabel,
+ property: 'type_card',
+ });
+
+ expect(wrapper.emitted('moveToPosition')).toEqual([[positionInList]]);
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index e14f661a8bd..9260718a94b 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -1,9 +1,11 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
import Vue from 'vue';
import Draggable from 'vuedraggable';
import Vuex from 'vuex';
import eventHub from '~/boards/eventhub';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
@@ -11,8 +13,18 @@ import getters from 'ee_else_ce/boards/stores/getters';
import BoardColumn from '~/boards/components/board_column.vue';
import BoardContent from '~/boards/components/board_content.vue';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
-import { mockLists, mockListsById } from '../mock_data';
-
+import updateBoardListMutation from '~/boards/graphql/board_list_update.mutation.graphql';
+import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
+import { DraggableItemTypes } from 'ee_else_ce/boards/constants';
+import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
+import {
+ mockLists,
+ mockListsById,
+ updateBoardListResponse,
+ boardListsQueryResponse,
+} from '../mock_data';
+
+Vue.use(VueApollo);
Vue.use(Vuex);
const actions = {
@@ -21,10 +33,13 @@ const actions = {
describe('BoardContent', () => {
let wrapper;
+ let mockApollo;
+
+ const updateListHandler = jest.fn().mockResolvedValue(updateBoardListResponse);
const defaultState = {
isShowingEpicsSwimlanes: false,
- boardLists: mockLists,
+ boardLists: mockListsById,
error: undefined,
issuableType: 'issue',
};
@@ -46,19 +61,32 @@ describe('BoardContent', () => {
isIssueBoard = true,
isEpicBoard = false,
} = {}) => {
+ mockApollo = createMockApollo([[updateBoardListMutation, updateListHandler]]);
+ const listQueryVariables = { isProject: true };
+
+ mockApollo.clients.defaultClient.writeQuery({
+ query: boardListsQuery,
+ variables: listQueryVariables,
+ data: boardListsQueryResponse.data,
+ });
+
const store = createStore({
...defaultState,
...state,
});
wrapper = shallowMount(BoardContent, {
+ apolloProvider: mockApollo,
propsData: {
boardId: 'gid://gitlab/Board/1',
filterParams: {},
isSwimlanesOn: false,
boardListsApollo: mockListsById,
+ listQueryVariables,
+ addColumnFormVisible: false,
...props,
},
provide: {
+ boardType: 'project',
canAdminList,
issuableType,
isIssueBoard,
@@ -76,6 +104,10 @@ describe('BoardContent', () => {
});
};
+ const findBoardColumns = () => wrapper.findAllComponents(BoardColumn);
+ const findBoardAddNewColumn = () => wrapper.findComponent(BoardAddNewColumn);
+ const findDraggable = () => wrapper.findComponent(Draggable);
+
describe('default', () => {
beforeEach(() => {
createComponent();
@@ -100,6 +132,10 @@ describe('BoardContent', () => {
expect(listEl.attributes('delay')).toBe('100');
expect(listEl.attributes('delayontouchonly')).toBe('true');
});
+
+ it('does not show the "add column" form', () => {
+ expect(findBoardAddNewColumn().exists()).toBe(false);
+ });
});
describe('when issuableType is not issue', () => {
@@ -118,7 +154,7 @@ describe('BoardContent', () => {
});
it('renders draggable component', () => {
- expect(wrapper.findComponent(Draggable).exists()).toBe(true);
+ expect(findDraggable().exists()).toBe(true);
});
});
@@ -128,7 +164,7 @@ describe('BoardContent', () => {
});
it('does not render draggable component', () => {
- expect(wrapper.findComponent(Draggable).exists()).toBe(false);
+ expect(findDraggable().exists()).toBe(false);
});
});
@@ -154,5 +190,36 @@ describe('BoardContent', () => {
expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists);
});
+
+ it('reorders lists', async () => {
+ const movableListsOrder = [mockLists[0].id, mockLists[1].id];
+
+ findDraggable().vm.$emit('end', {
+ item: { dataset: { listId: mockLists[0].id, draggableItemType: DraggableItemTypes.list } },
+ newIndex: 1,
+ to: {
+ children: movableListsOrder.map((listId) => ({ dataset: { listId } })),
+ },
+ });
+ await waitForPromises();
+
+ expect(updateListHandler).toHaveBeenCalled();
+ });
+ });
+
+ describe('when "add column" form is visible', () => {
+ beforeEach(() => {
+ createComponent({ props: { addColumnFormVisible: true } });
+ });
+
+ it('shows the "add column" form', () => {
+ expect(findBoardAddNewColumn().exists()).toBe(true);
+ });
+
+ it('hides other columns on mobile viewports', () => {
+ findBoardColumns().wrappers.forEach((column) => {
+ expect(column.classes()).toEqual(['gl-display-none!', 'gl-sm-display-inline-block!']);
+ });
+ });
});
});
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index f340dfab359..5604c589e37 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -1,9 +1,11 @@
import { GlModal } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
+import VueApollo from 'vue-apollo';
import setWindowLocation from 'helpers/set_window_location_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import createApolloProvider from 'helpers/mock_apollo_helper';
import BoardForm from '~/boards/components/board_form.vue';
import { formType } from '~/boards/constants';
@@ -42,7 +44,7 @@ const defaultProps = {
describe('BoardForm', () => {
let wrapper;
- let mutate;
+ let requestHandlers;
const findModal = () => wrapper.findComponent(GlModal);
const findModalActionPrimary = () => findModal().props('actionPrimary');
@@ -61,8 +63,43 @@ describe('BoardForm', () => {
},
});
- const createComponent = (props, provide) => {
+ const defaultHandlers = {
+ createBoardMutationHandler: jest.fn().mockResolvedValue({
+ data: {
+ createBoard: {
+ board: { id: '1' },
+ errors: [],
+ },
+ },
+ }),
+ destroyBoardMutationHandler: jest.fn().mockResolvedValue({
+ data: {
+ destroyBoard: {
+ board: { id: '1' },
+ },
+ },
+ }),
+ updateBoardMutationHandler: jest.fn().mockResolvedValue({
+ data: {
+ updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' }, errors: [] },
+ },
+ }),
+ };
+
+ const createMockApolloProvider = (handlers = {}) => {
+ Vue.use(VueApollo);
+ requestHandlers = handlers;
+
+ return createApolloProvider([
+ [createBoardMutation, handlers.createBoardMutationHandler],
+ [destroyBoardMutation, handlers.destroyBoardMutationHandler],
+ [updateBoardMutation, handlers.updateBoardMutationHandler],
+ ]);
+ };
+
+ const createComponent = ({ props, provide, handlers = defaultHandlers } = {}) => {
wrapper = shallowMountExtended(BoardForm, {
+ apolloProvider: createMockApolloProvider(handlers),
propsData: { ...defaultProps, ...props },
provide: {
boardBaseUrl: 'root',
@@ -70,23 +107,16 @@ describe('BoardForm', () => {
isProjectBoard: false,
...provide,
},
- mocks: {
- $apollo: {
- mutate,
- },
- },
store,
attachTo: document.body,
});
};
- afterEach(() => {
- mutate = null;
- });
-
describe('when user can not admin the board', () => {
beforeEach(() => {
- createComponent({ currentPage: formType.new });
+ createComponent({
+ props: { currentPage: formType.new },
+ });
});
it('hides modal footer when user is not a board admin', () => {
@@ -104,7 +134,9 @@ describe('BoardForm', () => {
describe('when user can admin the board', () => {
beforeEach(() => {
- createComponent({ canAdminBoard: true, currentPage: formType.new });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.new },
+ });
});
it('shows modal footer when user is a board admin', () => {
@@ -123,7 +155,9 @@ describe('BoardForm', () => {
describe('when creating a new board', () => {
describe('on non-scoped-board', () => {
beforeEach(() => {
- createComponent({ canAdminBoard: true, currentPage: formType.new });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.new },
+ });
});
it('clears the form', () => {
@@ -155,36 +189,30 @@ describe('BoardForm', () => {
findInput().trigger('keyup.enter', { metaKey: true });
};
- beforeEach(() => {
- mutate = jest.fn().mockResolvedValue({
- data: {
- createBoard: { board: { id: 'gid://gitlab/Board/123', webPath: 'test-path' } },
- },
- });
- });
-
it('does not call API if board name is empty', async () => {
- createComponent({ canAdminBoard: true, currentPage: formType.new });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.new },
+ });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
- expect(mutate).not.toHaveBeenCalled();
+ expect(requestHandlers.createBoardMutationHandler).not.toHaveBeenCalled();
});
it('calls a correct GraphQL mutation and sets board in state', async () => {
- createComponent({ canAdminBoard: true, currentPage: formType.new });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.new },
+ });
+
fillForm();
await waitForPromises();
- expect(mutate).toHaveBeenCalledWith({
- mutation: createBoardMutation,
- variables: {
- input: expect.objectContaining({
- name: 'test',
- }),
- },
+ expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalledWith({
+ input: expect.objectContaining({
+ name: 'test',
+ }),
});
await waitForPromises();
@@ -192,14 +220,19 @@ describe('BoardForm', () => {
});
it('sets error in state if GraphQL mutation fails', async () => {
- mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
- createComponent({ canAdminBoard: true, currentPage: formType.new });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.new },
+ handlers: {
+ ...defaultHandlers,
+ createBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
+ },
+ });
fillForm();
await waitForPromises();
- expect(mutate).toHaveBeenCalled();
+ expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalled();
await waitForPromises();
expect(setBoardMock).not.toHaveBeenCalled();
@@ -208,21 +241,19 @@ describe('BoardForm', () => {
describe('when Apollo boards FF is on', () => {
it('calls a correct GraphQL mutation and emits addBoard event when creating a board', async () => {
- createComponent(
- { canAdminBoard: true, currentPage: formType.new },
- { isApolloBoard: true },
- );
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.new },
+ provide: { isApolloBoard: true },
+ });
+
fillForm();
await waitForPromises();
- expect(mutate).toHaveBeenCalledWith({
- mutation: createBoardMutation,
- variables: {
- input: expect.objectContaining({
- name: 'test',
- }),
- },
+ expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalledWith({
+ input: expect.objectContaining({
+ name: 'test',
+ }),
});
await waitForPromises();
@@ -235,7 +266,9 @@ describe('BoardForm', () => {
describe('when editing a board', () => {
describe('on non-scoped-board', () => {
beforeEach(() => {
- createComponent({ canAdminBoard: true, currentPage: formType.edit });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.edit },
+ });
});
it('clears the form', () => {
@@ -261,25 +294,19 @@ describe('BoardForm', () => {
});
it('calls GraphQL mutation with correct parameters when issues are not grouped', async () => {
- mutate = jest.fn().mockResolvedValue({
- data: {
- updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } },
- },
- });
setWindowLocation('https://test/boards/1');
- createComponent({ canAdminBoard: true, currentPage: formType.edit });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.edit },
+ });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
- expect(mutate).toHaveBeenCalledWith({
- mutation: updateBoardMutation,
- variables: {
- input: expect.objectContaining({
- id: currentBoard.id,
- }),
- },
+ expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({
+ input: expect.objectContaining({
+ id: currentBoard.id,
+ }),
});
await waitForPromises();
@@ -288,25 +315,19 @@ describe('BoardForm', () => {
});
it('calls GraphQL mutation with correct parameters when issues are grouped by epic', async () => {
- mutate = jest.fn().mockResolvedValue({
- data: {
- updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } },
- },
- });
setWindowLocation('https://test/boards/1?group_by=epic');
- createComponent({ canAdminBoard: true, currentPage: formType.edit });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.edit },
+ });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
- expect(mutate).toHaveBeenCalledWith({
- mutation: updateBoardMutation,
- variables: {
- input: expect.objectContaining({
- id: currentBoard.id,
- }),
- },
+ expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({
+ input: expect.objectContaining({
+ id: currentBoard.id,
+ }),
});
await waitForPromises();
@@ -315,14 +336,19 @@ describe('BoardForm', () => {
});
it('sets error in state if GraphQL mutation fails', async () => {
- mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
- createComponent({ canAdminBoard: true, currentPage: formType.edit });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.edit },
+ handlers: {
+ ...defaultHandlers,
+ updateBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
+ },
+ });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
- expect(mutate).toHaveBeenCalled();
+ expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalled();
await waitForPromises();
expect(setBoardMock).not.toHaveBeenCalled();
@@ -331,28 +357,20 @@ describe('BoardForm', () => {
describe('when Apollo boards FF is on', () => {
it('calls a correct GraphQL mutation and emits updateBoard event when updating a board', async () => {
- mutate = jest.fn().mockResolvedValue({
- data: {
- updateBoard: { board: { id: 'gid://gitlab/Board/321', webPath: 'test-path' } },
- },
- });
setWindowLocation('https://test/boards/1');
- createComponent(
- { canAdminBoard: true, currentPage: formType.edit },
- { isApolloBoard: true },
- );
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.edit },
+ provide: { isApolloBoard: true },
+ });
findInput().trigger('keyup.enter', { metaKey: true });
await waitForPromises();
- expect(mutate).toHaveBeenCalledWith({
- mutation: updateBoardMutation,
- variables: {
- input: expect.objectContaining({
- id: currentBoard.id,
- }),
- },
+ expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({
+ input: expect.objectContaining({
+ id: currentBoard.id,
+ }),
});
await waitForPromises();
@@ -367,28 +385,30 @@ describe('BoardForm', () => {
describe('when deleting a board', () => {
it('passes correct primary action text and variant', () => {
- createComponent({ canAdminBoard: true, currentPage: formType.delete });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.delete },
+ });
expect(findModalActionPrimary().text).toBe('Delete');
expect(findModalActionPrimary().attributes.variant).toBe('danger');
});
it('renders delete confirmation message', () => {
- createComponent({ canAdminBoard: true, currentPage: formType.delete });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.delete },
+ });
expect(findDeleteConfirmation().exists()).toBe(true);
});
it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => {
- mutate = jest.fn().mockResolvedValue({});
- createComponent({ canAdminBoard: true, currentPage: formType.delete });
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.delete },
+ });
findModal().vm.$emit('primary');
await waitForPromises();
- expect(mutate).toHaveBeenCalledWith({
- mutation: destroyBoardMutation,
- variables: {
- id: currentBoard.id,
- },
+ expect(requestHandlers.destroyBoardMutationHandler).toHaveBeenCalledWith({
+ id: currentBoard.id,
});
await waitForPromises();
@@ -396,19 +416,26 @@ describe('BoardForm', () => {
});
it('dispatches `setError` action when GraphQL mutation fails', async () => {
- mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
- createComponent({ canAdminBoard: true, currentPage: formType.delete });
- jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
+ createComponent({
+ props: { canAdminBoard: true, currentPage: formType.delete },
+ handlers: {
+ ...defaultHandlers,
+ destroyBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
+ },
+ });
+ jest.spyOn(store, 'dispatch').mockImplementation(() => {});
findModal().vm.$emit('primary');
await waitForPromises();
- expect(mutate).toHaveBeenCalled();
+ expect(requestHandlers.destroyBoardMutationHandler).toHaveBeenCalled();
await waitForPromises();
expect(visitUrl).not.toHaveBeenCalled();
- expect(wrapper.vm.setError).toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledWith('setError', {
+ message: 'Failed to delete board. Please try again.',
+ });
});
});
});
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index d4489b3c535..ad2674f9d3b 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -105,6 +105,18 @@ describe('Board List Header Component', () => {
const findCaret = () => wrapper.findByTestId('board-title-caret');
const findNewIssueButton = () => wrapper.findByTestId('newIssueBtn');
const findSettingsButton = () => wrapper.findByTestId('settingsBtn');
+ const findBoardListHeader = () => wrapper.findByTestId('board-list-header');
+
+ it('renders border when label color is present', () => {
+ createComponent({ listType: ListType.label });
+
+ expect(findBoardListHeader().classes()).toContain(
+ 'gl-border-t-solid',
+ 'gl-border-4',
+ 'gl-rounded-top-left-base',
+ 'gl-rounded-top-right-base',
+ );
+ });
describe('Add issue button', () => {
const hasNoAddButton = [ListType.closed];
diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js
index d97a1dbff47..afc7da97617 100644
--- a/spec/frontend/boards/components/board_top_bar_spec.js
+++ b/spec/frontend/boards/components/board_top_bar_spec.js
@@ -46,6 +46,7 @@ describe('BoardTopBar', () => {
propsData: {
boardId: 'gid://gitlab/Board/1',
isSwimlanesOn: false,
+ addColumnFormVisible: false,
},
provide: {
swimlanesFeatureAvailable: false,
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index ec3ae27b6a1..447aacd9cea 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -526,6 +526,27 @@ export const mockList = {
__typename: 'BoardList',
};
+export const labelsQueryResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/33',
+ labels: {
+ nodes: [
+ {
+ id: 'gid://gitlab/GroupLabel/121',
+ title: 'To Do',
+ color: '#F0AD4E',
+ textColor: '#FFFFFF',
+ description: null,
+ descriptionHtml: null,
+ },
+ ],
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
export const mockLabelList = {
id: 'gid://gitlab/List/2',
title: 'To Do',
@@ -913,8 +934,8 @@ export const mockGroupLabelsResponse = {
export const boardListsQueryResponse = {
data: {
- group: {
- id: 'gid://gitlab/Group/1',
+ project: {
+ id: 'gid://gitlab/Project/1',
board: {
id: 'gid://gitlab/Board/1',
hideBacklogList: false,
@@ -922,7 +943,7 @@ export const boardListsQueryResponse = {
nodes: mockLists,
},
},
- __typename: 'Group',
+ __typename: 'Project',
},
},
};
@@ -943,11 +964,14 @@ export const issueBoardListsQueryResponse = {
},
};
-export const boardListQueryResponse = (issuesCount = 20) => ({
+export const boardListQueryResponse = ({
+ listId = 'gid://gitlab/List/5',
+ issuesCount = 20,
+} = {}) => ({
data: {
boardList: {
__typename: 'BoardList',
- id: 'gid://gitlab/BoardList/5',
+ id: listId,
totalWeight: 5,
issuesCount,
},
@@ -989,10 +1013,20 @@ export const updateEpicTitleResponse = {
},
};
+export const createBoardListResponse = {
+ data: {
+ boardListCreate: {
+ list: mockLabelList,
+ errors: [],
+ },
+ },
+};
+
export const updateBoardListResponse = {
data: {
updateBoardList: {
list: mockList,
+ errors: [],
},
},
};
diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js
index 74ce4b6b786..b4308b38947 100644
--- a/spec/frontend/boards/project_select_spec.js
+++ b/spec/frontend/boards/project_select_spec.js
@@ -1,17 +1,11 @@
-import {
- GlDropdown,
- GlDropdownItem,
- GlFormInput,
- GlSearchBoxByType,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlCollapsibleListbox, GlListboxItem, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import ProjectSelect from '~/boards/components/project_select.vue';
import defaultState from '~/boards/stores/state';
-import { mockList, mockActiveGroupProjects } from './mock_data';
+import { mockActiveGroupProjects, mockList } from './mock_data';
const mockProjectsList1 = mockActiveGroupProjects.slice(0, 1);
@@ -20,14 +14,17 @@ describe('ProjectSelect component', () => {
let store;
const findLabel = () => wrapper.find("[data-testid='header-label']");
- const findGlDropdown = () => wrapper.findComponent(GlDropdown);
+ const findGlCollapsibleListBox = () => wrapper.findComponent(GlCollapsibleListbox);
const findGlDropdownLoadingIcon = () =>
- findGlDropdown().find('button:first-child').findComponent(GlLoadingIcon);
- const findGlSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
- const findGlDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findFirstGlDropdownItem = () => findGlDropdownItems().at(0);
- const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']");
- const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']");
+ findGlCollapsibleListBox()
+ .find("[data-testid='base-dropdown-toggle'")
+ .findComponent(GlLoadingIcon);
+ const findGlListboxSearchInput = () =>
+ wrapper.find("[data-testid='listbox-search-input'] > .gl-listbox-search-input");
+ const findGlListboxItem = () => wrapper.findAllComponents(GlListboxItem);
+ const findFirstGlDropdownItem = () => findGlListboxItem().at(0);
+ const findInMenuLoadingIcon = () => wrapper.find("[data-testid='listbox-search-loader']");
+ const findEmptySearchMessage = () => wrapper.find("[data-testid='listbox-no-results-text']");
const createStore = ({ state, activeGroupProjects }) => {
Vue.use(Vuex);
@@ -80,8 +77,8 @@ describe('ProjectSelect component', () => {
it('renders a default dropdown text', () => {
createWrapper();
- expect(findGlDropdown().exists()).toBe(true);
- expect(findGlDropdown().text()).toContain('Select a project');
+ expect(findGlCollapsibleListBox().exists()).toBe(true);
+ expect(findGlCollapsibleListBox().text()).toContain('Select a project');
});
describe('when mounted', () => {
@@ -102,12 +99,9 @@ describe('ProjectSelect component', () => {
createWrapper({ activeGroupProjects: mockActiveGroupProjects });
});
- it('shows GlSearchBoxByType with default attributes', () => {
- expect(findGlSearchBoxByType().exists()).toBe(true);
- expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({
- placeholder: 'Search projects',
- debounce: '250',
- });
+ it('shows GlListboxSearchInput with placeholder text', () => {
+ expect(findGlListboxSearchInput().exists()).toBe(true);
+ expect(findGlListboxSearchInput().attributes('placeholder')).toBe('Search projects');
});
it("displays the fetched project's name", () => {
@@ -116,23 +110,12 @@ describe('ProjectSelect component', () => {
});
it("doesn't render loading icon in the menu", () => {
- expect(findInMenuLoadingIcon().isVisible()).toBe(false);
+ expect(findInMenuLoadingIcon().exists()).toBe(false);
});
it('does not render empty search result message', () => {
expect(findEmptySearchMessage().exists()).toBe(false);
});
-
- it('focuses on the search input', async () => {
- const dropdownToggle = findGlDropdown().find('.dropdown-toggle');
-
- await dropdownToggle.trigger('click');
- jest.runOnlyPendingTimers();
- await nextTick();
-
- const searchInput = findGlDropdown().findComponent(GlFormInput).element;
- expect(document.activeElement).toBe(searchInput);
- });
});
describe('when no projects are being returned', () => {
@@ -147,11 +130,11 @@ describe('ProjectSelect component', () => {
beforeEach(() => {
createWrapper({ activeGroupProjects: mockProjectsList1 });
- findFirstGlDropdownItem().find('button').trigger('click');
+ findFirstGlDropdownItem().find('li').trigger('click');
});
it('renders the name of the selected project', () => {
- expect(findGlDropdown().find('.gl-dropdown-button-text').text()).toBe(
+ expect(findGlCollapsibleListBox().find('.gl-new-dropdown-button-text').text()).toBe(
mockProjectsList1[0].name,
);
});
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index b8d3be28ca6..f3800ce8324 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -1340,8 +1340,8 @@ describe('updateIssueOrder', () => {
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
- issueMoveList: {
- issue: rawIssue,
+ issuableMoveList: {
+ issuable: rawIssue,
errors: [],
},
},
@@ -1355,8 +1355,8 @@ describe('updateIssueOrder', () => {
it('should commit MUTATE_ISSUE_SUCCESS mutation when successful', () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
- issueMoveList: {
- issue: rawIssue,
+ issuableMoveList: {
+ issuable: rawIssue,
errors: [],
},
},
@@ -1387,8 +1387,8 @@ describe('updateIssueOrder', () => {
it('should commit SET_ERROR and dispatch undoMoveIssueCard', () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
- issueMoveList: {
- issue: {},
+ issuableMoveList: {
+ issuable: {},
errors: [{ foo: 'bar' }],
},
},
diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
index 300b6f4a39a..9db6a523dec 100644
--- a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
+++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
@@ -4,12 +4,13 @@ exports[`Delete merged branches component Delete merged branches confirmation mo
<div>
<gl-base-dropdown-stub
category="tertiary"
- class="gl-disclosure-dropdown"
+ class="gl-disclosure-dropdown gl-display-none gl-md-display-block!"
data-qa-selector="delete_merged_branches_dropdown_button"
icon="ellipsis_v"
nocaret="true"
+ offset="[object Object]"
placement="right"
- popperoptions="[object Object]"
+ positioningstrategy="absolute"
size="medium"
textsronly="true"
toggleid="dropdown-toggle-btn-25"
@@ -31,6 +32,27 @@ exports[`Delete merged branches component Delete merged branches confirmation mo
</gl-base-dropdown-stub>
+ <b-button-stub
+ class="gl-display-block gl-md-display-none! gl-button btn-danger-secondary"
+ data-qa-selector="delete_merged_branches_button"
+ size="md"
+ tag="button"
+ type="button"
+ variant="danger"
+ >
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Delete merged branches
+
+ </span>
+ </b-button-stub>
+
<div>
<form
action="/namespace/project/-/merged_branches"
diff --git a/spec/frontend/branches/components/branch_more_actions_spec.js b/spec/frontend/branches/components/branch_more_actions_spec.js
new file mode 100644
index 00000000000..32b850a62a0
--- /dev/null
+++ b/spec/frontend/branches/components/branch_more_actions_spec.js
@@ -0,0 +1,70 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import BranchMoreDropdown from '~/branches/components/branch_more_actions.vue';
+import eventHub from '~/branches/event_hub';
+
+describe('Delete branch button', () => {
+ let wrapper;
+ let eventHubSpy;
+
+ const findCompareButton = () => wrapper.findByTestId('compare-branch-button');
+ const findDeleteButton = () => wrapper.findByTestId('delete-branch-button');
+
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(BranchMoreDropdown, {
+ propsData: {
+ branchName: 'test',
+ defaultBranchName: 'main',
+ canDeleteBranch: true,
+ isProtectedBranch: false,
+ merged: false,
+ comparePath: '/path/to/branch',
+ deletePath: '/path/to/branch',
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ eventHubSpy = jest.spyOn(eventHub, '$emit');
+ });
+
+ it('renders the compare action', () => {
+ createComponent();
+
+ expect(findCompareButton().exists()).toBe(true);
+ expect(findCompareButton().text()).toBe('Compare');
+ });
+
+ it('renders the delete action', () => {
+ createComponent();
+
+ expect(findDeleteButton().exists()).toBe(true);
+ expect(findDeleteButton().text()).toBe('Delete branch');
+ });
+
+ it('renders a different text for a protected branch', () => {
+ createComponent({ isProtectedBranch: true });
+
+ expect(findDeleteButton().text()).toBe('Delete protected branch');
+ });
+
+ it('emits the data to eventHub when button is clicked', async () => {
+ createComponent({ merged: true });
+
+ await findDeleteButton().trigger('click');
+
+ expect(eventHubSpy).toHaveBeenCalledWith('openModal', {
+ branchName: 'test',
+ defaultBranchName: 'main',
+ deletePath: '/path/to/branch',
+ isProtectedBranch: false,
+ merged: true,
+ });
+ });
+
+ it('doesn`t render the delete action when user cannot delete branch', () => {
+ createComponent({ canDeleteBranch: false });
+
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/branches/components/delete_branch_button_spec.js b/spec/frontend/branches/components/delete_branch_button_spec.js
deleted file mode 100644
index 5b2ec443c59..00000000000
--- a/spec/frontend/branches/components/delete_branch_button_spec.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import DeleteBranchButton from '~/branches/components/delete_branch_button.vue';
-import eventHub from '~/branches/event_hub';
-
-let wrapper;
-let findDeleteButton;
-
-const createComponent = (props = {}) => {
- wrapper = shallowMount(DeleteBranchButton, {
- propsData: {
- branchName: 'test',
- deletePath: '/path/to/branch',
- defaultBranchName: 'main',
- ...props,
- },
- });
-};
-
-describe('Delete branch button', () => {
- let eventHubSpy;
-
- beforeEach(() => {
- findDeleteButton = () => wrapper.findComponent(GlButton);
- eventHubSpy = jest.spyOn(eventHub, '$emit');
- });
-
- it('renders the button with default tooltip, style, and icon', () => {
- createComponent();
-
- expect(findDeleteButton().attributes()).toMatchObject({
- title: 'Delete branch',
- variant: 'default',
- icon: 'remove',
- });
- });
-
- it('renders a different tooltip for a protected branch', () => {
- createComponent({ isProtectedBranch: true });
-
- expect(findDeleteButton().attributes()).toMatchObject({
- title: 'Delete protected branch',
- variant: 'default',
- icon: 'remove',
- });
- });
-
- it('renders a different protected tooltip when it is both protected and disabled', () => {
- createComponent({ isProtectedBranch: true, disabled: true });
-
- expect(findDeleteButton().attributes()).toMatchObject({
- title: 'Only a project maintainer or owner can delete a protected branch',
- variant: 'default',
- });
- });
-
- it('emits the data to eventHub when button is clicked', () => {
- createComponent({ merged: true });
-
- findDeleteButton().vm.$emit('click');
-
- expect(eventHubSpy).toHaveBeenCalledWith('openModal', {
- branchName: 'test',
- defaultBranchName: 'main',
- deletePath: '/path/to/branch',
- isProtectedBranch: false,
- merged: true,
- });
- });
-
- describe('#disabled', () => {
- it('does not disable the button by default when mounted', () => {
- createComponent();
-
- expect(findDeleteButton().attributes()).toMatchObject({
- title: 'Delete branch',
- variant: 'default',
- });
- });
-
- // Used for unallowed users and for the default branch.
- it('disables the button when mounted for a disabled modal', () => {
- createComponent({ disabled: true, tooltip: 'The default branch cannot be deleted' });
-
- expect(findDeleteButton().attributes()).toMatchObject({
- title: 'The default branch cannot be deleted',
- disabled: 'true',
- variant: 'default',
- });
- });
- });
-});
diff --git a/spec/frontend/branches/components/delete_merged_branches_spec.js b/spec/frontend/branches/components/delete_merged_branches_spec.js
index 4d8b887efd3..3e47e76622d 100644
--- a/spec/frontend/branches/components/delete_merged_branches_spec.js
+++ b/spec/frontend/branches/components/delete_merged_branches_spec.js
@@ -44,7 +44,7 @@ const findConfirmationButton = () =>
const findCancelButton = () => wrapper.findByTestId('delete-merged-branches-cancel-button');
const findFormInput = () => wrapper.findComponent(GlFormInput);
const findForm = () => wrapper.find('form');
-const submitFormSpy = () => jest.spyOn(wrapper.vm.$refs.form, 'submit');
+const submitFormSpy = () => jest.spyOn(findForm().element, 'submit');
describe('Delete merged branches component', () => {
beforeEach(() => {
diff --git a/spec/frontend/ci/artifacts/components/artifact_row_spec.js b/spec/frontend/ci/artifacts/components/artifact_row_spec.js
index 96ddedc3a9d..8bf1138bc85 100644
--- a/spec/frontend/ci/artifacts/components/artifact_row_spec.js
+++ b/spec/frontend/ci/artifacts/components/artifact_row_spec.js
@@ -4,7 +4,7 @@ import { numberToHumanSize } from '~/lib/utils/number_utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ArtifactRow from '~/ci/artifacts/components/artifact_row.vue';
-import { BULK_DELETE_FEATURE_FLAG, I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants';
+import { I18N_BULK_DELETE_MAX_SELECTED } from '~/ci/artifacts/constants';
describe('ArtifactRow component', () => {
let wrapper;
@@ -18,7 +18,7 @@ describe('ArtifactRow component', () => {
const findDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
- const createComponent = ({ canDestroyArtifacts = true, glFeatures = {}, props = {} } = {}) => {
+ const createComponent = ({ canDestroyArtifacts = true, props = {} } = {}) => {
wrapper = shallowMountExtended(ArtifactRow, {
propsData: {
artifact,
@@ -28,7 +28,7 @@ describe('ArtifactRow component', () => {
isSelectedArtifactsLimitReached: false,
...props,
},
- provide: { canDestroyArtifacts, glFeatures },
+ provide: { canDestroyArtifacts },
stubs: { GlBadge, GlFriendlyWrap },
});
};
@@ -80,35 +80,31 @@ describe('ArtifactRow component', () => {
});
describe('bulk delete checkbox', () => {
- describe('with permission and feature flag enabled', () => {
- it('emits selectArtifact when toggled', () => {
- createComponent({ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true } });
-
- findCheckbox().vm.$emit('input', true);
+ it('emits selectArtifact when toggled', () => {
+ createComponent();
- expect(wrapper.emitted('selectArtifact')).toStrictEqual([[artifact, true]]);
- });
+ findCheckbox().vm.$emit('input', true);
- describe('when the selected artifacts limit is reached', () => {
- it('remains enabled if the artifact was selected', () => {
- createComponent({
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
- props: { isSelected: true, isSelectedArtifactsLimitReached: true },
- });
+ expect(wrapper.emitted('selectArtifact')).toStrictEqual([[artifact, true]]);
+ });
- expect(findCheckbox().attributes('disabled')).toBeUndefined();
- expect(findCheckbox().attributes('title')).toBe('');
+ describe('when the selected artifacts limit is reached', () => {
+ it('remains enabled if the artifact was selected', () => {
+ createComponent({
+ props: { isSelected: true, isSelectedArtifactsLimitReached: true },
});
- it('is disabled if the artifact was not selected', () => {
- createComponent({
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
- props: { isSelected: false, isSelectedArtifactsLimitReached: true },
- });
+ expect(findCheckbox().attributes('disabled')).toBeUndefined();
+ expect(findCheckbox().attributes('title')).toBe('');
+ });
- expect(findCheckbox().attributes('disabled')).toBeDefined();
- expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED);
+ it('is disabled if the artifact was not selected', () => {
+ createComponent({
+ props: { isSelected: false, isSelectedArtifactsLimitReached: true },
});
+
+ expect(findCheckbox().attributes('disabled')).toBeDefined();
+ expect(findCheckbox().attributes('title')).toBe(I18N_BULK_DELETE_MAX_SELECTED);
});
});
@@ -117,11 +113,5 @@ describe('ArtifactRow component', () => {
expect(findCheckbox().exists()).toBe(false);
});
-
- it('is not shown with feature flag disabled', () => {
- createComponent();
-
- expect(findCheckbox().exists()).toBe(false);
- });
});
});
diff --git a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js
index 514644a92f2..e062140246b 100644
--- a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js
+++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js
@@ -30,7 +30,6 @@ import {
JOBS_PER_PAGE,
I18N_FETCH_ERROR,
INITIAL_CURRENT_PAGE,
- BULK_DELETE_FEATURE_FLAG,
I18N_BULK_DELETE_ERROR,
SELECTED_ARTIFACTS_MAX_COUNT,
} from '~/ci/artifacts/constants';
@@ -79,6 +78,16 @@ describe('JobArtifactsTable component', () => {
const findDeleteButton = () => wrapper.findByTestId('job-artifacts-delete-button');
const findArtifactDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
+ // first checkbox is the "select all" checkbox in the table header
+ const findSelectAllCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+ const findSelectAllCheckboxChecked = () => findSelectAllCheckbox().find('input').element.checked;
+ const findSelectAllCheckboxIndeterminate = () =>
+ findSelectAllCheckbox().find('input').element.indeterminate;
+ const findSelectAllCheckboxDisabled = () =>
+ findSelectAllCheckbox().find('input').element.disabled;
+ const toggleSelectAllCheckbox = () =>
+ findSelectAllCheckbox().vm.$emit('change', !findSelectAllCheckboxChecked());
+
// first checkbox is a "select all", this finder should get the first job checkbox
const findJobCheckbox = (i = 1) => wrapper.findAllComponents(GlFormCheckbox).at(i);
const findAnyCheckbox = () => wrapper.findComponent(GlFormCheckbox);
@@ -125,7 +134,15 @@ describe('JobArtifactsTable component', () => {
},
});
- const maxSelectedArtifacts = new Array(SELECTED_ARTIFACTS_MAX_COUNT).fill({});
+ const allArtifacts = getJobArtifactsResponse.data.project.jobs.nodes
+ .map((jobNode) => jobNode.artifacts.nodes.map((artifactNode) => artifactNode.id))
+ .reduce((artifacts, jobArtifacts) => artifacts.concat(jobArtifacts));
+
+ const maxSelectedArtifacts = new Array(SELECTED_ARTIFACTS_MAX_COUNT).fill('artifact-id');
+ const maxSelectedArtifactsIncludingCurrentPage = [
+ ...allArtifacts,
+ ...new Array(SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length).fill('artifact-id'),
+ ];
const createComponent = ({
handlers = {
@@ -134,7 +151,6 @@ describe('JobArtifactsTable component', () => {
},
data = {},
canDestroyArtifacts = true,
- glFeatures = {},
} = {}) => {
requestHandlers = handlers;
wrapper = mountExtended(JobArtifactsTable, {
@@ -147,7 +163,6 @@ describe('JobArtifactsTable component', () => {
projectId,
canDestroyArtifacts,
artifactsManagementFeedbackImagePath: 'banner/image/path',
- glFeatures,
},
mocks: {
$toast: {
@@ -314,6 +329,7 @@ describe('JobArtifactsTable component', () => {
it('is disabled when there is no download path', async () => {
const jobWithoutDownloadPath = {
...job,
+ hasArtifacts: true,
archive: { downloadPath: null },
};
@@ -340,6 +356,7 @@ describe('JobArtifactsTable component', () => {
it('is disabled when there is no browse path', async () => {
const jobWithoutBrowsePath = {
...job,
+ hasArtifacts: true,
browseArtifactsPath: null,
};
@@ -352,80 +369,108 @@ describe('JobArtifactsTable component', () => {
expect(findBrowseButton().attributes('disabled')).toBeDefined();
});
- });
- describe('delete button', () => {
- const artifactsFromJob = job.artifacts.nodes.map((node) => node.id);
+ it('is disabled when job has no metadata.gz', async () => {
+ const jobWithoutMetadata = {
+ ...job,
+ artifacts: { nodes: [archiveArtifact] },
+ };
- describe('with delete permission and bulk delete feature flag enabled', () => {
- beforeEach(async () => {
- createComponent({
- canDestroyArtifacts: true,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
- });
+ createComponent({
+ handlers: { getJobArtifactsQuery: jest.fn() },
+ data: { jobArtifacts: [jobWithoutMetadata] },
+ });
- await waitForPromises();
+ await waitForPromises();
+
+ expect(findBrowseButton().attributes('disabled')).toBe('disabled');
+ });
+
+ it('is disabled when job has no artifacts', async () => {
+ const jobWithoutArtifacts = {
+ ...job,
+ artifacts: { nodes: [] },
+ };
+
+ createComponent({
+ handlers: { getJobArtifactsQuery: jest.fn() },
+ data: { jobArtifacts: [jobWithoutArtifacts] },
});
- it('opens the confirmation modal with the artifacts from the job', async () => {
- await findDeleteButton().vm.$emit('click');
+ await waitForPromises();
- expect(findBulkDeleteModal().props()).toMatchObject({
- visible: true,
- artifactsToDelete: artifactsFromJob,
- });
+ expect(findBrowseButton().attributes('disabled')).toBe('disabled');
+ });
+ });
+
+ describe('delete button', () => {
+ const artifactsFromJob = job.artifacts.nodes.map((node) => node.id);
+
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
});
- it('on confirm, deletes the artifacts from the job and shows a toast', async () => {
- findDeleteButton().vm.$emit('click');
- findBulkDeleteModal().vm.$emit('primary');
+ await waitForPromises();
+ });
- expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
- projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
- ids: artifactsFromJob,
- });
+ it('opens the confirmation modal with the artifacts from the job', async () => {
+ await findDeleteButton().vm.$emit('click');
- await waitForPromises();
+ expect(findBulkDeleteModal().props()).toMatchObject({
+ visible: true,
+ artifactsToDelete: artifactsFromJob,
+ });
+ });
- expect(mockToastShow).toHaveBeenCalledWith(
- `${artifactsFromJob.length} selected artifacts deleted`,
- );
+ it('on confirm, deletes the artifacts from the job and shows a toast', async () => {
+ findDeleteButton().vm.$emit('click');
+ findBulkDeleteModal().vm.$emit('primary');
+
+ expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
+ ids: artifactsFromJob,
});
- it('does not clear selected artifacts on success', async () => {
- // select job 2 via checkbox
- findJobCheckbox(2).vm.$emit('input', true);
+ await waitForPromises();
- // click delete button job 1
- findDeleteButton().vm.$emit('click');
+ expect(mockToastShow).toHaveBeenCalledWith(
+ `${artifactsFromJob.length} selected artifacts deleted`,
+ );
+ });
- // job 2's artifacts should still be selected
- expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
- job2.artifacts.nodes.map((node) => node.id),
- );
+ it('does not clear selected artifacts on success', async () => {
+ // select job 2 via checkbox
+ findJobCheckbox(2).vm.$emit('change', true);
- // confirm delete
- findBulkDeleteModal().vm.$emit('primary');
+ // click delete button job 1
+ findDeleteButton().vm.$emit('click');
- // job 1's artifacts should be deleted
- expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
- projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
- ids: artifactsFromJob,
- });
+ // job 2's artifacts should still be selected
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
+ job2.artifacts.nodes.map((node) => node.id),
+ );
- await waitForPromises();
+ // confirm delete
+ findBulkDeleteModal().vm.$emit('primary');
- // job 2's artifacts should still be selected
- expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
- job2.artifacts.nodes.map((node) => node.id),
- );
+ // job 1's artifacts should be deleted
+ expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
+ ids: artifactsFromJob,
});
+
+ await waitForPromises();
+
+ // job 2's artifacts should still be selected
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
+ job2.artifacts.nodes.map((node) => node.id),
+ );
});
it('shows an alert and does not clear selected artifacts on error', async () => {
createComponent({
canDestroyArtifacts: true,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
handlers: {
getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse),
bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(),
@@ -434,7 +479,7 @@ describe('JobArtifactsTable component', () => {
await waitForPromises();
// select job 2 via checkbox
- findJobCheckbox(2).vm.$emit('input', true);
+ findJobCheckbox(2).vm.$emit('change', true);
// click delete button job 1
findDeleteButton().vm.$emit('click');
@@ -455,131 +500,290 @@ describe('JobArtifactsTable component', () => {
});
});
- it('is disabled when bulk delete feature flag is disabled', async () => {
+ it('is hidden when user does not have delete permission', async () => {
createComponent({
- canDestroyArtifacts: true,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false },
+ canDestroyArtifacts: false,
});
await waitForPromises();
- expect(findDeleteButton().attributes('disabled')).toBeDefined();
+ expect(findDeleteButton().exists()).toBe(false);
});
+ });
- it('is hidden when user does not have delete permission', async () => {
+ describe('bulk delete', () => {
+ const selectedArtifacts = job.artifacts.nodes.map((node) => node.id);
+
+ beforeEach(async () => {
createComponent({
- canDestroyArtifacts: false,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false },
+ canDestroyArtifacts: true,
});
await waitForPromises();
+ });
- expect(findDeleteButton().exists()).toBe(false);
+ it('shows selected artifacts when a job is checked', async () => {
+ expect(findBulkDeleteContainer().exists()).toBe(false);
+
+ await findJobCheckbox().vm.$emit('change', true);
+
+ expect(findBulkDeleteContainer().exists()).toBe(true);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts);
});
- });
- describe('bulk delete', () => {
- const selectedArtifacts = job.artifacts.nodes.map((node) => node.id);
+ it('disappears when selected artifacts are cleared', async () => {
+ await findJobCheckbox().vm.$emit('change', true);
- describe('with permission and feature flag enabled', () => {
- beforeEach(async () => {
- createComponent({
- canDestroyArtifacts: true,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
- });
+ expect(findBulkDeleteContainer().exists()).toBe(true);
- await waitForPromises();
- });
+ await findBulkDelete().vm.$emit('clearSelectedArtifacts');
+
+ expect(findBulkDeleteContainer().exists()).toBe(false);
+ });
- it('shows selected artifacts when a job is checked', async () => {
- expect(findBulkDeleteContainer().exists()).toBe(false);
+ it('shows a modal to confirm bulk delete', async () => {
+ findJobCheckbox().vm.$emit('change', true);
+ findBulkDelete().vm.$emit('showBulkDeleteModal');
- await findJobCheckbox().vm.$emit('input', true);
+ await nextTick();
- expect(findBulkDeleteContainer().exists()).toBe(true);
- expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(selectedArtifacts);
+ expect(findBulkDeleteModal().props('visible')).toBe(true);
+ });
+
+ it('deletes the selected artifacts and shows a toast', async () => {
+ findJobCheckbox().vm.$emit('change', true);
+ findBulkDelete().vm.$emit('showBulkDeleteModal');
+ findBulkDeleteModal().vm.$emit('primary');
+
+ expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
+ ids: selectedArtifacts,
});
- it('disappears when selected artifacts are cleared', async () => {
- await findJobCheckbox().vm.$emit('input', true);
+ await waitForPromises();
+
+ expect(mockToastShow).toHaveBeenCalledWith(
+ `${selectedArtifacts.length} selected artifacts deleted`,
+ );
+ });
+
+ it('clears selected artifacts on success', async () => {
+ findJobCheckbox().vm.$emit('change', true);
+ findBulkDelete().vm.$emit('showBulkDeleteModal');
+ findBulkDeleteModal().vm.$emit('primary');
+
+ await waitForPromises();
+
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]);
+ });
+
+ describe('select all checkbox', () => {
+ describe('when no artifacts are selected', () => {
+ it('is not checked', () => {
+ expect(findSelectAllCheckboxChecked()).toBe(false);
+ expect(findSelectAllCheckboxIndeterminate()).toBe(false);
+ });
- expect(findBulkDeleteContainer().exists()).toBe(true);
+ it('selects all artifacts when toggled', async () => {
+ toggleSelectAllCheckbox();
- await findBulkDelete().vm.$emit('clearSelectedArtifacts');
+ await nextTick();
- expect(findBulkDeleteContainer().exists()).toBe(false);
+ expect(findSelectAllCheckboxChecked()).toBe(true);
+ expect(findSelectAllCheckboxIndeterminate()).toBe(false);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(allArtifacts);
+ });
});
- it('shows a modal to confirm bulk delete', async () => {
- findJobCheckbox().vm.$emit('input', true);
- findBulkDelete().vm.$emit('showBulkDeleteModal');
+ describe('when some artifacts are selected', () => {
+ beforeEach(async () => {
+ findJobCheckbox().vm.$emit('change', true);
- await nextTick();
+ await nextTick();
+ });
- expect(findBulkDeleteModal().props('visible')).toBe(true);
+ it('is indeterminate', () => {
+ expect(findSelectAllCheckboxChecked()).toBe(true);
+ expect(findSelectAllCheckboxIndeterminate()).toBe(true);
+ });
+
+ it('deselects all artifacts when toggled', async () => {
+ toggleSelectAllCheckbox();
+
+ await nextTick();
+
+ expect(findSelectAllCheckboxChecked()).toBe(false);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]);
+ });
});
- it('deletes the selected artifacts and shows a toast', async () => {
- findJobCheckbox().vm.$emit('input', true);
- findBulkDelete().vm.$emit('showBulkDeleteModal');
- findBulkDeleteModal().vm.$emit('primary');
+ describe('when all artifacts are selected', () => {
+ beforeEach(async () => {
+ findJobCheckbox(1).vm.$emit('change', true);
+ findJobCheckbox(2).vm.$emit('change', true);
- expect(bulkDestroyMutationHandler).toHaveBeenCalledWith({
- projectId: convertToGraphQLId(TYPENAME_PROJECT, projectId),
- ids: selectedArtifacts,
+ await nextTick();
});
- await waitForPromises();
+ it('is checked', () => {
+ expect(findSelectAllCheckboxChecked()).toBe(true);
+ expect(findSelectAllCheckboxIndeterminate()).toBe(false);
+ });
+
+ it('deselects all artifacts when toggled', async () => {
+ toggleSelectAllCheckbox();
+
+ await nextTick();
- expect(mockToastShow).toHaveBeenCalledWith(
- `${selectedArtifacts.length} selected artifacts deleted`,
- );
+ expect(findSelectAllCheckboxChecked()).toBe(false);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]);
+ });
});
- it('clears selected artifacts on success', async () => {
- findJobCheckbox().vm.$emit('input', true);
- findBulkDelete().vm.$emit('showBulkDeleteModal');
- findBulkDeleteModal().vm.$emit('primary');
+ describe('when an artifact is selected on another page', () => {
+ const otherPageArtifact = { id: 'gid://gitlab/Ci::JobArtifact/some/other/id' };
- await waitForPromises();
+ beforeEach(async () => {
+ // expand the first job row to access the details component
+ findCount().trigger('click');
+
+ await nextTick();
+
+ // mock the selection of an artifact on another page by emitting a select event
+ findDetailsInRow(1).vm.$emit('selectArtifact', otherPageArtifact, true);
+ });
+
+ it('is not checked even though an artifact is selected', () => {
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([otherPageArtifact.id]);
+ expect(findSelectAllCheckboxChecked()).toBe(false);
+ expect(findSelectAllCheckboxIndeterminate()).toBe(false);
+ });
- expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([]);
+ it('only toggles selection of visible artifacts, leaving the other artifact selected', async () => {
+ toggleSelectAllCheckbox();
+
+ await nextTick();
+
+ expect(findSelectAllCheckboxChecked()).toBe(true);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([
+ otherPageArtifact.id,
+ ...allArtifacts,
+ ]);
+
+ toggleSelectAllCheckbox();
+
+ await nextTick();
+
+ expect(findSelectAllCheckboxChecked()).toBe(false);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual([otherPageArtifact.id]);
+ });
});
});
- describe('when the selected artifacts limit is reached', () => {
- beforeEach(async () => {
- createComponent({
- canDestroyArtifacts: true,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
- data: { selectedArtifacts: maxSelectedArtifacts },
+ describe('select all checkbox respects selected artifacts limit', () => {
+ describe('when selecting all visible artifacts would exceed the limit', () => {
+ const selectedArtifactsLength = SELECTED_ARTIFACTS_MAX_COUNT - 1;
+
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ data: {
+ selectedArtifacts: new Array(selectedArtifactsLength).fill('artifact-id'),
+ },
+ });
+
+ await nextTick();
});
- await nextTick();
- });
+ it('selects only up to the limit', async () => {
+ expect(findSelectAllCheckboxChecked()).toBe(false);
+ expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(selectedArtifactsLength);
+
+ toggleSelectAllCheckbox();
- it('passes isSelectedArtifactsLimitReached to bulk delete', () => {
- expect(findBulkDelete().props('isSelectedArtifactsLimitReached')).toBe(true);
+ await nextTick();
+
+ expect(findSelectAllCheckboxChecked()).toBe(true);
+ expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(
+ SELECTED_ARTIFACTS_MAX_COUNT,
+ );
+ expect(findBulkDelete().props('selectedArtifacts')).not.toContain(
+ allArtifacts[allArtifacts.length - 1],
+ );
+ });
});
- it('passes isSelectedArtifactsLimitReached to job checkbox', () => {
- expect(wrapper.findComponent(JobCheckbox).props('isSelectedArtifactsLimitReached')).toBe(
- true,
- );
+ describe('when limit has been reached without artifacts on the current page', () => {
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ data: { selectedArtifacts: maxSelectedArtifacts },
+ });
+
+ await nextTick();
+ });
+
+ it('passes isSelectedArtifactsLimitReached to bulk delete', () => {
+ expect(findBulkDelete().props('isSelectedArtifactsLimitReached')).toBe(true);
+ });
+
+ it('passes isSelectedArtifactsLimitReached to job checkbox', () => {
+ expect(wrapper.findComponent(JobCheckbox).props('isSelectedArtifactsLimitReached')).toBe(
+ true,
+ );
+ });
+
+ it('passes isSelectedArtifactsLimitReached to table row details', async () => {
+ findCount().trigger('click');
+ await nextTick();
+
+ expect(findDetailsInRow(1).props('isSelectedArtifactsLimitReached')).toBe(true);
+ });
+
+ it('disables the select all checkbox', () => {
+ expect(findSelectAllCheckboxDisabled()).toBe(true);
+ });
});
- it('passes isSelectedArtifactsLimitReached to table row details', async () => {
- findCount().trigger('click');
- await nextTick();
+ describe('when limit has been reached including artifacts on the current page', () => {
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ data: {
+ selectedArtifacts: maxSelectedArtifactsIncludingCurrentPage,
+ },
+ });
+
+ await nextTick();
+ });
+
+ describe('the select all checkbox', () => {
+ it('is checked', () => {
+ expect(findSelectAllCheckboxChecked()).toBe(true);
+ expect(findSelectAllCheckboxIndeterminate()).toBe(false);
+ });
+
+ it('deselects all artifacts when toggled', async () => {
+ expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(
+ SELECTED_ARTIFACTS_MAX_COUNT,
+ );
+
+ toggleSelectAllCheckbox();
+
+ await nextTick();
- expect(findDetailsInRow(1).props('isSelectedArtifactsLimitReached')).toBe(true);
+ expect(findSelectAllCheckboxChecked()).toBe(false);
+ expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(
+ SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length,
+ );
+ });
+ });
});
});
it('shows an alert and does not clear selected artifacts on error', async () => {
createComponent({
canDestroyArtifacts: true,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
handlers: {
getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse),
bulkDestroyArtifactsMutation: jest.fn().mockRejectedValue(),
@@ -588,7 +792,7 @@ describe('JobArtifactsTable component', () => {
await waitForPromises();
- findJobCheckbox().vm.$emit('input', true);
+ findJobCheckbox().vm.$emit('change', true);
findBulkDelete().vm.$emit('showBulkDeleteModal');
findBulkDeleteModal().vm.$emit('primary');
@@ -605,18 +809,6 @@ describe('JobArtifactsTable component', () => {
it('shows no checkboxes without permission', async () => {
createComponent({
canDestroyArtifacts: false,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
- });
-
- await waitForPromises();
-
- expect(findAnyCheckbox().exists()).toBe(false);
- });
-
- it('shows no checkboxes with feature flag disabled', async () => {
- createComponent({
- canDestroyArtifacts: true,
- glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false },
});
await waitForPromises();
diff --git a/spec/frontend/ci/artifacts/components/job_checkbox_spec.js b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js
index 8b47571239c..73a49506564 100644
--- a/spec/frontend/ci/artifacts/components/job_checkbox_spec.js
+++ b/spec/frontend/ci/artifacts/components/job_checkbox_spec.js
@@ -48,7 +48,7 @@ describe('JobCheckbox component', () => {
});
it('selects the unselected artifacts on click', () => {
- findCheckbox().vm.$emit('input', true);
+ findCheckbox().vm.$emit('change', true);
expect(wrapper.emitted('selectArtifact')).toMatchObject([
[mockUnselectedArtifacts[0], true],
@@ -83,7 +83,7 @@ describe('JobCheckbox component', () => {
});
it('deselects the selected artifacts on click', () => {
- findCheckbox().vm.$emit('input', false);
+ findCheckbox().vm.$emit('change', false);
expect(wrapper.emitted('selectArtifact')).toMatchObject([
[mockSelectedArtifacts[0], false],
@@ -105,7 +105,7 @@ describe('JobCheckbox component', () => {
});
it('selects the artifacts on click', () => {
- findCheckbox().vm.$emit('input', true);
+ findCheckbox().vm.$emit('change', true);
expect(wrapper.emitted('selectArtifact')).toMatchObject([
[mockUnselectedArtifacts[0], true],
diff --git a/spec/frontend/ci/artifacts/utils_spec.js b/spec/frontend/ci/artifacts/utils_spec.js
new file mode 100644
index 00000000000..17b4a9f162b
--- /dev/null
+++ b/spec/frontend/ci/artifacts/utils_spec.js
@@ -0,0 +1,16 @@
+import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { totalArtifactsSizeForJob } from '~/ci/artifacts/utils';
+
+const job = getJobArtifactsResponse.data.project.jobs.nodes[0];
+const artifacts = job.artifacts.nodes;
+
+describe('totalArtifactsSizeForJob', () => {
+ it('adds artifact sizes together', () => {
+ expect(totalArtifactsSizeForJob(job)).toBe(
+ numberToHumanSize(
+ Number(artifacts[0].size) + Number(artifacts[1].size) + Number(artifacts[2].size),
+ ),
+ );
+ });
+});
diff --git a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js
index 4b7ca36f331..7c8863adddd 100644
--- a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js
+++ b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js
@@ -41,6 +41,7 @@ describe('CI Lint', () => {
const findCiLintResults = () => wrapper.findComponent(CiLintResults);
const findValidateBtn = () => wrapper.find('[data-testid="ci-lint-validate"]');
const findClearBtn = () => wrapper.find('[data-testid="ci-lint-clear"]');
+ const findDryRunToggle = () => wrapper.find('[data-testid="ci-lint-dryrun"]');
beforeEach(() => {
createComponent();
@@ -63,18 +64,13 @@ describe('CI Lint', () => {
});
});
- it('validate action calls mutation with dry run', async () => {
- const dryRunEnabled = true;
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- await wrapper.setData({ dryRun: dryRunEnabled });
-
+ it('validate action calls mutation with dry run', () => {
+ findDryRunToggle().vm.$emit('input', true);
findValidateBtn().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: lintCIMutation,
- variables: { content, dry: dryRunEnabled, endpoint },
+ variables: { content, dry: true, endpoint },
});
});
diff --git a/spec/frontend/ci/ci_lint/mock_data.js b/spec/frontend/ci/ci_lint/mock_data.js
index 05582470dfa..1a9888817d0 100644
--- a/spec/frontend/ci/ci_lint/mock_data.js
+++ b/spec/frontend/ci/ci_lint/mock_data.js
@@ -1,4 +1,5 @@
import { mockJobs } from 'jest/ci/pipeline_editor/mock_data';
+import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
export const mockLintDataError = {
data: {
@@ -6,7 +7,11 @@ export const mockLintDataError = {
errors: ['Error message'],
warnings: ['Warning message'],
valid: false,
- jobs: mockJobs,
+ jobs: mockJobs.map((j) => {
+ const job = { ...j, tags: j.tagList };
+ delete job.tagList;
+ return job;
+ }),
},
},
};
@@ -17,7 +22,21 @@ export const mockLintDataValid = {
errors: [],
warnings: [],
valid: true,
- jobs: mockJobs,
+ jobs: mockJobs.map((j) => {
+ const job = { ...j, tags: j.tagList };
+ delete job.tagList;
+ return job;
+ }),
},
},
};
+
+export const mockLintDataErrorRest = {
+ ...mockLintDataError.data.lintCI,
+ jobs: mockJobs.map((j) => convertObjectPropsToSnakeCase(j)),
+};
+
+export const mockLintDataValidRest = {
+ ...mockLintDataValid.data.lintCI,
+ jobs: mockJobs.map((j) => convertObjectPropsToSnakeCase(j)),
+};
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
index b6ffde9b33f..e9484cfce57 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_modal_spec.js
@@ -458,7 +458,8 @@ describe('Ci variable modal', () => {
});
describe('Validations', () => {
- const maskError = 'This variable can not be masked.';
+ const maskError = 'This variable value does not meet the masking requirements.';
+ const helpText = 'Value must meet regular expression requirements to be masked.';
describe('when the variable is raw', () => {
const [variable] = mockVariables;
@@ -488,6 +489,25 @@ describe('Ci variable modal', () => {
expect(findModal().text()).toContain(maskError);
});
+
+ it('does not show the masked variable help text', () => {
+ expect(findModal().text()).not.toContain(helpText);
+ });
+ });
+
+ describe('when the value is empty', () => {
+ beforeEach(() => {
+ const [variable] = mockVariables;
+ const emptyValueVariable = { ...variable, value: '' };
+ createComponent({
+ mountFn: mountExtended,
+ props: { selectedVariable: emptyValueVariable },
+ });
+ });
+
+ it('allows user to submit', () => {
+ expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined();
+ });
});
describe('when the mask state is invalid', () => {
@@ -510,8 +530,9 @@ describe('Ci variable modal', () => {
expect(findAddorUpdateButton().attributes('disabled')).toBeDefined();
});
- it('shows the correct error text', () => {
+ it('shows the correct error text and help text', () => {
expect(findModal().text()).toContain(maskError);
+ expect(findModal().text()).toContain(helpText);
});
it('sends the correct tracking event', () => {
@@ -578,6 +599,10 @@ describe('Ci variable modal', () => {
});
});
+ it('shows the help text', () => {
+ expect(findModal().text()).toContain(helpText);
+ });
+
it('does not disable the submit button', () => {
expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined();
});
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
index a25d325f7a1..f7b90c3da30 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
@@ -46,6 +46,7 @@ Vue.use(VueApollo);
const mockProvide = {
endpoint: '/variables',
isGroup: false,
+ isInheritedGroupVars: false,
isProject: false,
};
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js
index 0b28cb06cec..f3f1c5bd2c5 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js
@@ -1,9 +1,9 @@
-import { GlAlert } from '@gitlab/ui';
+import { GlAlert, GlBadge, GlKeysetPagination } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue';
import { EXCEEDS_VARIABLE_LIMIT_TEXT, projectString } from '~/ci/ci_variable_list/constants';
-import { mockVariables } from '../mocks';
+import { mockInheritedVariables, mockVariables } from '../mocks';
describe('Ci variable table', () => {
let wrapper;
@@ -29,6 +29,7 @@ describe('Ci variable table', () => {
glFeatures: {
ciVariablesPages: false,
},
+ isInheritedGroupVars: false,
...provide,
},
});
@@ -41,8 +42,14 @@ describe('Ci variable table', () => {
const findHiddenValues = () => wrapper.findAllByTestId('hiddenValue');
const findLimitReachedAlerts = () => wrapper.findAllComponents(GlAlert);
const findRevealedValues = () => wrapper.findAllByTestId('revealedValue');
- const findOptionsValues = (rowIndex) =>
- wrapper.findAllByTestId('ci-variable-table-row-options').at(rowIndex).text();
+ const findAttributesRow = (rowIndex) =>
+ wrapper.findAllByTestId('ci-variable-table-row-attributes').at(rowIndex);
+ const findAttributeByIndex = (rowIndex, attributeIndex) =>
+ findAttributesRow(rowIndex).findAllComponents(GlBadge).at(attributeIndex).text();
+ const findTableColumnText = (index) => wrapper.findAll('th').at(index).text();
+ const findGroupCiCdSettingsLink = (rowIndex) =>
+ wrapper.findAllByTestId('ci-variable-table-row-cicd-path').at(rowIndex).attributes('href');
+ const findKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const generateExceedsVariableLimitText = (entity, currentVariableCount, maxVariableLimit) => {
return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { entity, currentVariableCount, maxVariableLimit });
@@ -69,26 +76,48 @@ describe('Ci variable table', () => {
});
});
- describe('When table has variables', () => {
+ describe('When table has CI variables', () => {
beforeEach(() => {
createComponent({ provide });
});
- it('does not display the empty message', () => {
- expect(findEmptyVariablesPlaceholder().exists()).toBe(false);
+ // last column is for the edit button, which has no text
+ it.each`
+ index | text
+ ${0} | ${'Key (Click to sort descending)'}
+ ${1} | ${'Value'}
+ ${2} | ${'Attributes'}
+ ${3} | ${'Environments'}
+ ${4} | ${''}
+ `('renders the $text column', ({ index, text }) => {
+ expect(findTableColumnText(index)).toEqual(text);
});
- it('displays the reveal button', () => {
- expect(findRevealButton().exists()).toBe(true);
+ it('does not display the empty message', () => {
+ expect(findEmptyVariablesPlaceholder().exists()).toBe(false);
});
it('displays the correct amount of variables', () => {
expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length);
});
- it('displays the correct variable options', () => {
- expect(findOptionsValues(0)).toBe('Protected, Expanded');
- expect(findOptionsValues(1)).toBe('Masked');
+ it.each`
+ rowIndex | attributeIndex | text
+ ${0} | ${0} | ${'Protected'}
+ ${0} | ${1} | ${'Expanded'}
+ ${1} | ${0} | ${'File'}
+ ${1} | ${1} | ${'Masked'}
+ `(
+ 'displays variable attribute $text for row $rowIndex',
+ ({ rowIndex, attributeIndex, text }) => {
+ expect(findAttributeByIndex(rowIndex, attributeIndex)).toBe(text);
+ },
+ );
+
+ it('renders action buttons', () => {
+ expect(findRevealButton().exists()).toBe(true);
+ expect(findAddButton().exists()).toBe(true);
+ expect(findEditButton().exists()).toBe(true);
});
it('enables the Add Variable button', () => {
@@ -96,6 +125,55 @@ describe('Ci variable table', () => {
});
});
+ describe('When table has inherited CI variables', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { variables: mockInheritedVariables },
+ provide: { isInheritedGroupVars: true, ...provide },
+ });
+ });
+
+ it.each`
+ index | text
+ ${0} | ${'Key'}
+ ${1} | ${'Attributes'}
+ ${2} | ${'Environments'}
+ ${3} | ${'Group'}
+ `('renders the $text column', ({ index, text }) => {
+ expect(findTableColumnText(index)).toEqual(text);
+ });
+
+ it('does not render action buttons', () => {
+ expect(findRevealButton().exists()).toBe(false);
+ expect(findAddButton().exists()).toBe(false);
+ expect(findEditButton().exists()).toBe(false);
+ expect(findKeysetPagination().exists()).toBe(false);
+ });
+
+ it('displays the correct amount of variables', () => {
+ expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(mockInheritedVariables.length);
+ });
+
+ it.each`
+ rowIndex | attributeIndex | text
+ ${0} | ${0} | ${'Protected'}
+ ${0} | ${1} | ${'Masked'}
+ ${0} | ${2} | ${'Expanded'}
+ ${2} | ${0} | ${'File'}
+ ${2} | ${1} | ${'Protected'}
+ `(
+ 'displays variable attribute $text for row $rowIndex',
+ ({ rowIndex, attributeIndex, text }) => {
+ expect(findAttributeByIndex(rowIndex, attributeIndex)).toBe(text);
+ },
+ );
+
+ it('displays link to the group settings', () => {
+ expect(findGroupCiCdSettingsLink(0)).toBe(mockInheritedVariables[0].groupCiCdSettingsPath);
+ expect(findGroupCiCdSettingsLink(1)).toBe(mockInheritedVariables[1].groupCiCdSettingsPath);
+ });
+ });
+
describe('When variables have exceeded the max limit', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js
index f9450803308..9c9c99ad5ea 100644
--- a/spec/frontend/ci/ci_variable_list/mocks.js
+++ b/spec/frontend/ci/ci_variable_list/mocks.js
@@ -51,6 +51,45 @@ export const mockVariables = (kind) => {
];
};
+export const mockInheritedVariables = [
+ {
+ id: 'gid://gitlab/Ci::GroupVariable/120',
+ key: 'INHERITED_VAR_1',
+ variableType: 'ENV_VAR',
+ environmentScope: '*',
+ masked: true,
+ protected: true,
+ raw: false,
+ groupName: 'group-name',
+ groupCiCdSettingsPath: '/groups/group-name/-/settings/ci_cd',
+ __typename: 'InheritedCiVariable',
+ },
+ {
+ id: 'gid://gitlab/Ci::GroupVariable/121',
+ key: 'INHERITED_VAR_2',
+ variableType: 'ENV_VAR',
+ environmentScope: 'staging',
+ masked: false,
+ protected: false,
+ raw: true,
+ groupName: 'subgroup-name',
+ groupCiCdSettingsPath: '/groups/group-name/subgroup-name/-/settings/ci_cd',
+ __typename: 'InheritedCiVariable',
+ },
+ {
+ id: 'gid://gitlab/Ci::GroupVariable/122',
+ key: 'INHERITED_VAR_3',
+ variableType: 'FILE',
+ environmentScope: 'production',
+ masked: false,
+ protected: true,
+ raw: true,
+ groupName: 'subgroup-name',
+ groupCiCdSettingsPath: '/groups/group-name/subgroup-name/-/settings/ci_cd',
+ __typename: 'InheritedCiVariable',
+ },
+];
+
export const mockVariablesWithScopes = (kind) =>
mockVariables(kind).map((variable) => {
return { ...variable, environmentScope: '*' };
diff --git a/spec/frontend/ci/inherited_ci_variables/components/inherited_ci_variables_app_spec.js b/spec/frontend/ci/inherited_ci_variables/components/inherited_ci_variables_app_spec.js
new file mode 100644
index 00000000000..0af026cfec4
--- /dev/null
+++ b/spec/frontend/ci/inherited_ci_variables/components/inherited_ci_variables_app_spec.js
@@ -0,0 +1,114 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue';
+import InheritedCiVariablesApp, {
+ i18n,
+ FETCH_LIMIT,
+ VARIABLES_PER_FETCH,
+} from '~/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue';
+import getInheritedCiVariables from '~/ci/inherited_ci_variables/graphql/queries/inherited_ci_variables.query.graphql';
+import { mockInheritedCiVariables } from '../mocks';
+
+jest.mock('~/alert');
+Vue.use(VueApollo);
+
+describe('Inherited CI Variables Component', () => {
+ let wrapper;
+ let mockApollo;
+ let mockVariables;
+
+ const defaultProvide = {
+ projectPath: 'namespace/project',
+ projectId: '1',
+ };
+
+ const findCiTable = () => wrapper.findComponent(CiVariableTable);
+
+ // eslint-disable-next-line consistent-return
+ function createComponentWithApollo({ isLoading = false } = {}) {
+ const handlers = [[getInheritedCiVariables, mockVariables]];
+
+ mockApollo = createMockApollo(handlers);
+
+ wrapper = shallowMount(InheritedCiVariablesApp, {
+ provide: defaultProvide,
+ apolloProvider: mockApollo,
+ });
+
+ if (!isLoading) {
+ return waitForPromises();
+ }
+ }
+
+ beforeEach(() => {
+ mockVariables = jest.fn();
+ });
+
+ describe('while variables are being fetched', () => {
+ beforeEach(() => {
+ mockVariables.mockResolvedValue(mockInheritedCiVariables());
+ createComponentWithApollo({ isLoading: true });
+ });
+
+ it('shows a loading icon', () => {
+ expect(findCiTable().props('isLoading')).toBe(true);
+ });
+ });
+
+ describe('when there are more variables to fetch', () => {
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockInheritedCiVariables({ withNextPage: true }));
+
+ await createComponentWithApollo();
+ });
+
+ it('re-fetches the query up to <FETCH_LIMIT> times', () => {
+ expect(mockVariables).toHaveBeenCalledTimes(FETCH_LIMIT);
+ });
+
+ it('shows alert message when calls have exceeded FETCH_LIMIT', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: i18n.tooManyCallsError });
+ });
+ });
+
+ describe('when variables are fetched successfully', () => {
+ beforeEach(async () => {
+ mockVariables.mockResolvedValue(mockInheritedCiVariables());
+
+ await createComponentWithApollo();
+ });
+
+ it('query was called with the correct arguments', () => {
+ expect(mockVariables).toHaveBeenCalledWith({
+ first: VARIABLES_PER_FETCH,
+ fullPath: defaultProvide.projectPath,
+ });
+ });
+
+ it('passes down variables to the table component', () => {
+ expect(findCiTable().props('variables')).toEqual(
+ mockInheritedCiVariables().data.project.inheritedCiVariables.nodes,
+ );
+ });
+
+ it('createAlert was not called', () => {
+ expect(createAlert).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when fetch error occurs', () => {
+ beforeEach(async () => {
+ mockVariables.mockRejectedValue();
+
+ await createComponentWithApollo();
+ });
+
+ it('shows alert message with the expected error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: i18n.fetchError });
+ });
+ });
+});
diff --git a/spec/frontend/ci/inherited_ci_variables/mocks.js b/spec/frontend/ci/inherited_ci_variables/mocks.js
new file mode 100644
index 00000000000..841ba0a0043
--- /dev/null
+++ b/spec/frontend/ci/inherited_ci_variables/mocks.js
@@ -0,0 +1,44 @@
+export const mockInheritedCiVariables = ({ withNextPage = false } = {}) => ({
+ data: {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/38',
+ inheritedCiVariables: {
+ __typename: `InheritedCiVariableConnection`,
+ pageInfo: {
+ startCursor: 'adsjsd12kldpsa',
+ endCursor: 'adsjsd12kldpsa',
+ hasPreviousPage: withNextPage,
+ hasNextPage: withNextPage,
+ __typename: 'PageInfo',
+ },
+ nodes: [
+ {
+ __typename: `InheritedCiVariable`,
+ id: 'gid://gitlab/Ci::GroupVariable/1',
+ environmentScope: '*',
+ groupName: 'group_abc',
+ groupCiCdSettingsPath: '/groups/group_abc/-/settings/ci_cd',
+ key: 'GROUP_VAR',
+ masked: false,
+ protected: true,
+ raw: false,
+ variableType: 'ENV_VAR',
+ },
+ {
+ __typename: `InheritedCiVariable`,
+ id: 'gid://gitlab/Ci::GroupVariable/2',
+ environmentScope: '*',
+ groupName: 'subgroup_xyz',
+ groupCiCdSettingsPath: '/groups/group_abc/subgroup_xyz/-/settings/ci_cd',
+ key: 'SUB_GROUP_VAR',
+ masked: true,
+ protected: false,
+ raw: true,
+ variableType: 'ENV_VAR',
+ },
+ ],
+ },
+ },
+ },
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
index b07d63dd5d9..2845f76209b 100644
--- a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { GlDrawer } from '@gitlab/ui';
import PipelineEditorDrawer from '~/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
+import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants';
describe('Pipeline editor drawer', () => {
let wrapper;
@@ -14,10 +15,10 @@ describe('Pipeline editor drawer', () => {
it('emits close event when closing the drawer', () => {
createComponent();
- expect(wrapper.emitted('close-drawer')).toBeUndefined();
+ expect(wrapper.emitted('switch-drawer')).toBeUndefined();
findDrawer().vm.$emit('close');
- expect(wrapper.emitted('close-drawer')).toHaveLength(1);
+ expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]);
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
index f1a5c4169fb..f6247fb4a19 100644
--- a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
@@ -5,6 +5,8 @@ import CiEditorHeader from '~/ci/pipeline_editor/components/editor/ci_editor_hea
import {
pipelineEditorTrackingOptions,
TEMPLATE_REPOSITORY_URL,
+ EDITOR_APP_DRAWER_HELP,
+ EDITOR_APP_DRAWER_NONE,
} from '~/ci/pipeline_editor/constants';
describe('CI Editor Header', () => {
@@ -12,7 +14,7 @@ describe('CI Editor Header', () => {
let trackingSpy = null;
const createComponent = ({
- showDrawer = false,
+ showHelpDrawer = false,
showJobAssistantDrawer = false,
showAiAssistantDrawer = false,
aiChatAvailable = false,
@@ -27,7 +29,7 @@ describe('CI Editor Header', () => {
},
},
propsData: {
- showDrawer,
+ showHelpDrawer,
showJobAssistantDrawer,
showAiAssistantDrawer,
},
@@ -116,15 +118,15 @@ describe('CI Editor Header', () => {
describe('when pipeline editor drawer is closed', () => {
beforeEach(() => {
- createComponent({ showDrawer: false });
+ createComponent({ showHelpDrawer: false });
});
- it('emits open drawer event when clicked', () => {
- expect(wrapper.emitted('open-drawer')).toBeUndefined();
+ it('emits switch drawer event when clicked', () => {
+ expect(wrapper.emitted('switch-drawer')).toBeUndefined();
findHelpBtn().vm.$emit('click');
- expect(wrapper.emitted('open-drawer')).toHaveLength(1);
+ expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_HELP]]);
});
it('tracks open help drawer action', () => {
@@ -136,15 +138,15 @@ describe('CI Editor Header', () => {
describe('when pipeline editor drawer is open', () => {
beforeEach(() => {
- createComponent({ showDrawer: true });
+ createComponent({ showHelpDrawer: true });
});
it('emits close drawer event when clicked', () => {
- expect(wrapper.emitted('close-drawer')).toBeUndefined();
+ expect(wrapper.emitted('switch-drawer')).toBeUndefined();
findHelpBtn().vm.$emit('click');
- expect(wrapper.emitted('close-drawer')).toHaveLength(1);
+ expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]);
});
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
index b8526e569ec..29759f828e4 100644
--- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
@@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
-import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
+import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants';
import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data';
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
index 8ca88472bf1..9d93ba332e9 100644
--- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
@@ -6,6 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineStatus, { i18n } from '~/ci/pipeline_editor/components/header/pipeline_status.vue';
import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql';
+import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue';
import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue';
import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data';
@@ -16,7 +17,7 @@ describe('Pipeline Status', () => {
let mockApollo;
let mockPipelineQuery;
- const createComponentWithApollo = () => {
+ const createComponentWithApollo = ({ ciGraphqlPipelineMiniGraph = false } = {}) => {
const handlers = [[getPipelineQuery, mockPipelineQuery]];
mockApollo = createMockApollo(handlers);
@@ -26,6 +27,9 @@ describe('Pipeline Status', () => {
commitSha: mockCommitSha,
},
provide: {
+ glFeatures: {
+ ciGraphqlPipelineMiniGraph,
+ },
projectFullPath: mockProjectFullPath,
},
stubs: { GlLink, GlSprintf },
@@ -34,6 +38,7 @@ describe('Pipeline Status', () => {
const findIcon = () => wrapper.findComponent(GlIcon);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findGraphqlPipelineMiniGraph = () => wrapper.findComponent(GraphqlPipelineMiniGraph);
const findPipelineEditorMiniGraph = () => wrapper.findComponent(PipelineEditorMiniGraph);
const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]');
const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
@@ -128,4 +133,28 @@ describe('Pipeline Status', () => {
});
});
});
+
+ describe('feature flag behavior', () => {
+ beforeEach(() => {
+ mockPipelineQuery.mockResolvedValue({
+ data: { project: mockProjectPipeline() },
+ });
+ });
+
+ it.each`
+ state | provide | showPipelineMiniGraph | showGraphqlPipelineMiniGraph
+ ${true} | ${{ ciGraphqlPipelineMiniGraph: true }} | ${false} | ${true}
+ ${false} | ${{}} | ${true} | ${false}
+ `(
+ 'renders the correct component when the feature flag is set to $state',
+ async ({ provide, showPipelineMiniGraph, showGraphqlPipelineMiniGraph }) => {
+ createComponentWithApollo(provide);
+
+ await waitForPromises();
+
+ expect(findPipelineEditorMiniGraph().exists()).toBe(showPipelineMiniGraph);
+ expect(findGraphqlPipelineMiniGraph().exists()).toBe(showGraphqlPipelineMiniGraph);
+ },
+ );
+ });
});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js
index 9046be4a45e..b30a8e64f87 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item_spec.js
@@ -1,10 +1,15 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
import ArtifactsAndCacheItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/artifacts_and_cache_item.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+import {
+ JOB_TEMPLATE,
+ HELP_PATHS,
+} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
describe('Artifacts and cache item', () => {
let wrapper;
+ const findLinks = () => wrapper.findAllComponents(GlLink);
const findArtifactsPathsInputByIndex = (index) =>
wrapper.findByTestId(`artifacts-paths-input-${index}`);
const findArtifactsExcludeInputByIndex = (index) =>
@@ -31,9 +36,19 @@ describe('Artifacts and cache item', () => {
propsData: {
job,
},
+ stubs: {
+ GlSprintf,
+ },
});
};
+ it('should render help links with correct hrefs', () => {
+ createComponent();
+
+ const hrefs = findLinks().wrappers.map((w) => w.attributes('href'));
+ expect(hrefs).toEqual([HELP_PATHS.artifactsHelpPath, HELP_PATHS.cacheHelpPath]);
+ });
+
it('should emit update job event when filling inputs', () => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js
index f99d7277612..5625b2577e3 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js
@@ -1,10 +1,15 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+import {
+ HELP_PATHS,
+ JOB_TEMPLATE,
+} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
describe('Image item', () => {
let wrapper;
+ const findLink = () => wrapper.findComponent(GlLink);
const findImageNameInput = () => wrapper.findByTestId('image-name-input');
const findImageEntrypointInput = () => wrapper.findByTestId('image-entrypoint-input');
@@ -16,6 +21,9 @@ describe('Image item', () => {
propsData: {
job,
},
+ stubs: {
+ GlSprintf,
+ },
});
};
@@ -23,6 +31,12 @@ describe('Image item', () => {
createComponent();
});
+ it('should render help link with correct href', () => {
+ createComponent();
+
+ expect(findLink().attributes('href')).toEqual(HELP_PATHS.imageHelpPath);
+ });
+
it('should emit update job event when filling inputs', () => {
expect(wrapper.emitted('update-job')).toBeUndefined();
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js
index 659ccb25996..edaa96a197a 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item_spec.js
@@ -1,14 +1,17 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
import RulesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/rules_item.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
JOB_TEMPLATE,
JOB_RULES_WHEN,
JOB_RULES_START_IN,
+ HELP_PATHS,
} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
describe('Rules item', () => {
let wrapper;
+ const findLink = () => wrapper.findComponent(GlLink);
const findRulesWhenSelect = () => wrapper.findByTestId('rules-when-select');
const findRulesStartInNumberInput = () => wrapper.findByTestId('rules-start-in-number-input');
const findRulesStartInUnitSelect = () => wrapper.findByTestId('rules-start-in-unit-select');
@@ -25,6 +28,9 @@ describe('Rules item', () => {
isStartValid: true,
job: JSON.parse(JSON.stringify(JOB_TEMPLATE)),
},
+ stubs: {
+ GlSprintf,
+ },
});
};
@@ -32,6 +38,12 @@ describe('Rules item', () => {
createComponent();
});
+ it('should render help link with correct href', () => {
+ createComponent();
+
+ expect(findLink().attributes('href')).toEqual(HELP_PATHS.rulesHelpPath);
+ });
+
it('should emit update job event when filling inputs', () => {
expect(wrapper.emitted('update-job')).toBeUndefined();
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js
index 284d639c77f..f664547bbcc 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item_spec.js
@@ -1,10 +1,15 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
import ServicesItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/services_item.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+import {
+ HELP_PATHS,
+ JOB_TEMPLATE,
+} from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
describe('Services item', () => {
let wrapper;
+ const findLink = () => wrapper.findComponent(GlLink);
const findServiceNameInputByIndex = (index) =>
wrapper.findByTestId(`service-name-input-${index}`);
const findServiceEntrypointInputByIndex = (index) =>
@@ -21,9 +26,18 @@ describe('Services item', () => {
propsData: {
job,
},
+ stubs: {
+ GlSprintf,
+ },
});
};
+ it('should render help links with correct hrefs', () => {
+ createComponent();
+
+ expect(findLink().attributes('href')).toEqual(HELP_PATHS.servicesHelpPath);
+ });
+
it('should emit update job event when filling inputs', () => {
createComponent();
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
index 0258a1a8c7f..cf2797c255f 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
@@ -15,6 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
+import { EDITOR_APP_DRAWER_NONE } from '~/ci/pipeline_editor/constants';
import { mockRunnersTagsQueryResponse, mockLintResponse, mockCiYml } from '../../mock_data';
Vue.use(VueApollo);
@@ -96,20 +97,20 @@ describe('Job assistant drawer', () => {
expect(findRulesItem().exists()).toBe(true);
});
- it('should emit close job assistant drawer event when closing the drawer', () => {
- expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined();
+ it('should emit switch drawer event when closing the drawer', () => {
+ expect(wrapper.emitted('switch-drawer')).toBeUndefined();
findDrawer().vm.$emit('close');
- expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1);
+ expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]);
});
- it('should emit close job assistant drawer event when click cancel button', () => {
- expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined();
+ it('should emit switch drawer event when click cancel button', () => {
+ expect(wrapper.emitted('switch-drawer')).toBeUndefined();
findCancelButton().trigger('click');
- expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1);
+ expect(wrapper.emitted('switch-drawer')).toEqual([[EDITOR_APP_DRAWER_NONE]]);
});
it('should block submit if job name is empty', async () => {
diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
index 471b033913b..77252a5c0b6 100644
--- a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -1,5 +1,3 @@
-// TODO
-
import { GlAlert, GlBadge, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
@@ -55,7 +53,7 @@ describe('Pipeline editor tabs component', () => {
ciFileContent: mockCiYml,
currentTab: CREATE_TAB,
isNewCiConfigFile: true,
- showDrawer: false,
+ showHelpDrawer: false,
showJobAssistantDrawer: false,
showAiAssistantDrawer: false,
...props,
diff --git a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
index 2349816fa86..f2818277c59 100644
--- a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
@@ -1,15 +1,20 @@
+import Vue from 'vue';
import { GlAlert, GlDisclosureDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui';
-import { nextTick } from 'vue';
-import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
+import MockAdapter from 'axios-mock-adapter';
+
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue';
import CiValidate, { i18n } from '~/ci/pipeline_editor/components/validate/ci_validate.vue';
import ValidatePipelinePopover from '~/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue';
import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql';
-import lintCIMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql';
import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants';
import {
mockBlobContentQueryResponse,
@@ -17,68 +22,45 @@ import {
mockCiYml,
mockSimulatePipelineHelpPagePath,
} from '../../mock_data';
-import { mockLintDataError, mockLintDataValid } from '../../../ci_lint/mock_data';
+import {
+ mockLintDataError,
+ mockLintDataValid,
+ mockLintDataErrorRest,
+ mockLintDataValidRest,
+} from '../../../ci_lint/mock_data';
+
+let mockAxios;
+
+Vue.use(VueApollo);
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+const defaultProvide = {
+ ciConfigPath: '/path/to/ci-config',
+ ciLintPath: mockCiLintPath,
+ currentBranch: 'main',
+ projectFullPath: '/path/to/project',
+ validateTabIllustrationPath: '/path/to/img',
+ simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath,
+};
describe('Pipeline Editor Validate Tab', () => {
let wrapper;
- let mockApollo;
let mockBlobContentData;
let trackingSpy;
- const createComponent = ({
- props,
- stubs,
- options,
- isBlobLoading = false,
- isSimulationLoading = false,
- } = {}) => {
+ const createComponent = ({ props, stubs } = {}) => {
+ const handlers = [[getBlobContent, mockBlobContentData]];
+ const mockApollo = createMockApollo(handlers, resolvers);
+
wrapper = shallowMountExtended(CiValidate, {
propsData: {
ciFileContent: mockCiYml,
...props,
},
- provide: {
- ciConfigPath: '/path/to/ci-config',
- ciLintPath: mockCiLintPath,
- currentBranch: 'main',
- projectFullPath: '/path/to/project',
- validateTabIllustrationPath: '/path/to/img',
- simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath,
- },
- stubs,
- mocks: {
- $apollo: {
- queries: {
- initialBlobContent: {
- loading: isBlobLoading,
- },
- },
- mutations: {
- lintCiMutation: {
- loading: isSimulationLoading,
- },
- },
- },
- },
- ...options,
- });
- };
-
- const createComponentWithApollo = ({ props, stubs } = {}) => {
- const handlers = [[getBlobContent, mockBlobContentData]];
- mockApollo = createMockApollo(handlers);
-
- createComponent({
- props,
stubs,
- options: {
- localVue,
- apolloProvider: mockApollo,
- mocks: {},
+ provide: {
+ ...defaultProvide,
},
+ apolloProvider: mockApollo,
});
};
@@ -96,12 +78,21 @@ describe('Pipeline Editor Validate Tab', () => {
const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button');
beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ mockAxios.onPost(defaultProvide.ciLintPath).reply(HTTP_STATUS_OK, mockLintDataValidRest);
+
mockBlobContentData = jest.fn();
});
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
describe('while initial CI content is loading', () => {
beforeEach(() => {
- createComponent({ isBlobLoading: true });
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+
+ createComponent();
});
it('renders disabled CTA with tooltip', () => {
@@ -113,7 +104,7 @@ describe('Pipeline Editor Validate Tab', () => {
describe('after initial CI content is loaded', () => {
beforeEach(async () => {
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
- await createComponentWithApollo({ stubs: { GlPopover, ValidatePipelinePopover } });
+ await createComponent({ stubs: { GlPopover, ValidatePipelinePopover } });
});
it('renders disabled pipeline source dropdown', () => {
@@ -137,10 +128,9 @@ describe('Pipeline Editor Validate Tab', () => {
describe('simulating the pipeline', () => {
beforeEach(async () => {
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
- await createComponentWithApollo();
+ await createComponent();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
});
afterEach(() => {
@@ -158,32 +148,32 @@ describe('Pipeline Editor Validate Tab', () => {
});
it('renders loading state while simulation is ongoing', async () => {
- findCta().vm.$emit('click');
- await nextTick();
+ await findCta().vm.$emit('click');
expect(findLoadingIcon().exists()).toBe(true);
expect(findCancelBtn().exists()).toBe(true);
expect(findCta().props('loading')).toBe(true);
});
- it('calls mutation with the correct input', async () => {
- await findCta().vm.$emit('click');
+ it('calls endpoint with the correct input', async () => {
+ findCta().vm.$emit('click');
+
+ await waitForPromises();
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: lintCIMutation,
- variables: {
- dry: true,
+ expect(mockAxios.history.post).toHaveLength(1);
+ expect(mockAxios.history.post[0].data).toBe(
+ JSON.stringify({
content: mockCiYml,
- endpoint: mockCiLintPath,
- },
- });
+ dry_run: true,
+ }),
+ );
});
describe('when results are successful', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
- await findCta().vm.$emit('click');
+ findCta().vm.$emit('click');
+
+ await waitForPromises();
});
it('renders success alert', () => {
@@ -210,8 +200,10 @@ describe('Pipeline Editor Validate Tab', () => {
describe('when results have errors', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataError);
- await findCta().vm.$emit('click');
+ mockAxios.onPost(defaultProvide.ciLintPath).reply(HTTP_STATUS_OK, mockLintDataErrorRest);
+ findCta().vm.$emit('click');
+
+ await waitForPromises();
});
it('renders error alert', () => {
@@ -236,11 +228,11 @@ describe('Pipeline Editor Validate Tab', () => {
describe('when CI content has changed after a simulation', () => {
beforeEach(async () => {
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
- await createComponentWithApollo();
+ await createComponent();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
- await findCta().vm.$emit('click');
+ findCta().vm.$emit('click');
+ await waitForPromises();
});
afterEach(() => {
@@ -267,25 +259,26 @@ describe('Pipeline Editor Validate Tab', () => {
});
it('calls mutation with new content', async () => {
- await wrapper.setProps({ ciFileContent: 'new yaml content' });
- await findResultsCta().vm.$emit('click');
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2);
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: lintCIMutation,
- variables: {
- dry: true,
- content: 'new yaml content',
- endpoint: mockCiLintPath,
- },
- });
+ const newContent = 'new yaml content';
+ await wrapper.setProps({ ciFileContent: newContent });
+ findResultsCta().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(mockAxios.history.post).toHaveLength(2);
+ expect(mockAxios.history.post[1].data).toBe(
+ JSON.stringify({
+ content: newContent,
+ dry_run: true,
+ }),
+ );
});
});
describe('canceling a simulation', () => {
beforeEach(async () => {
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
- await createComponentWithApollo();
+ await createComponent();
});
it('returns to init state', async () => {
@@ -294,9 +287,7 @@ describe('Pipeline Editor Validate Tab', () => {
expect(findCiLintResults().exists()).toBe(false);
// mutations should have successful results
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
- findCta().vm.$emit('click');
- await nextTick();
+ await findCta().vm.$emit('click');
// cancel before simulation succeeds
expect(findCancelBtn().exists()).toBe(true);
diff --git a/spec/frontend/ci/pipeline_editor/index_spec.js b/spec/frontend/ci/pipeline_editor/index_spec.js
new file mode 100644
index 00000000000..530a441bde1
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/index_spec.js
@@ -0,0 +1,27 @@
+import { initPipelineEditor } from '~/ci/pipeline_editor';
+import * as optionsCE from '~/ci/pipeline_editor/options';
+
+describe('initPipelineEditor', () => {
+ let el;
+ const selector = 'SELECTOR';
+
+ beforeEach(() => {
+ jest.spyOn(optionsCE, 'createAppOptions').mockReturnValue({ option: 2 });
+
+ el = document.createElement('div');
+ el.id = selector;
+ document.body.appendChild(el);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(el);
+ });
+
+ it('returns null if there are no elements found', () => {
+ expect(initPipelineEditor()).toBeNull();
+ });
+
+ it('returns an object if there is an element found', () => {
+ expect(initPipelineEditor(`#${selector}`)).toMatchObject({});
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js
index 865dd34fbfe..a3294cdc269 100644
--- a/spec/frontend/ci/pipeline_editor/mock_data.js
+++ b/spec/frontend/ci/pipeline_editor/mock_data.js
@@ -1,6 +1,42 @@
import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
+export const commonOptions = {
+ ciConfigPath: '/ci/config',
+ ciExamplesHelpPagePath: 'help/ci/examples',
+ ciHelpPagePath: 'help/ci/',
+ ciLintPath: 'ci/lint',
+ ciTroubleshootingPath: 'help/troubleshoot',
+ defaultBranch: 'main',
+ emptyStateIllustrationPath: 'illustrations/svg',
+ helpPaths: '/ads',
+ includesHelpPagePath: 'help/includes',
+ needsHelpPagePath: 'help/ci/needs',
+ newMergeRequestPath: 'merge_request/new',
+ pipelinePagePath: '/pipelines/1',
+ projectFullPath: 'root/my-project',
+ projectNamespace: 'root',
+ simulatePipelineHelpPagePath: 'help/ci/simulate',
+ totalBranches: '10',
+ usesExternalConfig: 'false',
+ validateTabIllustrationPath: 'illustrations/tab',
+ ymlHelpPagePath: 'help/ci/yml',
+ aiChatAvailable: 'true',
+};
+
+export const editorDatasetOptions = {
+ initialBranchName: 'production',
+ pipelineEtag: 'pipelineEtag',
+ ...commonOptions,
+};
+
+export const expectedInjectValues = {
+ ...commonOptions,
+ aiChatAvailable: true,
+ usesExternalConfig: false,
+ totalBranches: 10,
+};
+
export const mockProjectNamespace = 'user1';
export const mockProjectPath = 'project1';
export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`;
@@ -43,7 +79,7 @@ job_build:
export const mockCiTemplateQueryResponse = {
data: {
project: {
- id: 'project-1',
+ id: 'gid://gitlab/Project/1',
ciTemplate: {
content: mockCiYml,
},
@@ -54,7 +90,7 @@ export const mockCiTemplateQueryResponse = {
export const mockBlobContentQueryResponse = {
data: {
project: {
- id: 'project-1',
+ id: 'gid://gitlab/Project/1',
repository: { blobs: { nodes: [{ id: 'blob-1', rawBlob: mockCiYml }] } },
},
},
@@ -62,13 +98,13 @@ export const mockBlobContentQueryResponse = {
export const mockBlobContentQueryResponseNoCiFile = {
data: {
- project: { id: 'project-1', repository: { blobs: { nodes: [] } } },
+ project: { id: 'gid://gitlab/Project/1', repository: { blobs: { nodes: [] } } },
},
};
export const mockBlobContentQueryResponseEmptyCiFile = {
data: {
- project: { id: 'project-1', repository: { blobs: { nodes: [{ rawBlob: '' }] } } },
+ project: { id: 'gid://gitlab/Project/1', repository: { blobs: { nodes: [{ rawBlob: '' }] } } },
},
};
diff --git a/spec/frontend/ci/pipeline_editor/options_spec.js b/spec/frontend/ci/pipeline_editor/options_spec.js
new file mode 100644
index 00000000000..b8f4105c923
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/options_spec.js
@@ -0,0 +1,27 @@
+import { createAppOptions } from '~/ci/pipeline_editor/options';
+import { editorDatasetOptions, expectedInjectValues } from './mock_data';
+
+describe('createAppOptions', () => {
+ let el;
+
+ const createElement = () => {
+ el = document.createElement('div');
+
+ document.body.appendChild(el);
+ Object.entries(editorDatasetOptions).forEach(([k, v]) => {
+ el.dataset[k] = v;
+ });
+ };
+
+ afterEach(() => {
+ el = null;
+ });
+
+ it("extracts the properties from the element's dataset", () => {
+ createElement();
+ const options = createAppOptions(el);
+ Object.entries(expectedInjectValues).forEach(([key, value]) => {
+ expect(options.provide).toMatchObject({ [key]: value });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
index cc4a022c2df..89ce3a2e18c 100644
--- a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
+++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
@@ -1,5 +1,6 @@
+import Vue from 'vue';
import { GlAlert, GlButton, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
@@ -53,9 +54,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-
const defaultProvide = {
ciConfigPath: mockCiConfigPath,
defaultBranch: mockDefaultBranch,
@@ -74,24 +72,10 @@ describe('Pipeline editor app component', () => {
let mockLatestCommitShaQuery;
let mockPipelineQuery;
- const createComponent = ({
- blobLoading = false,
- options = {},
- provide = {},
- stubs = {},
- } = {}) => {
+ const createComponent = ({ options = {}, provide = {}, stubs = {} } = {}) => {
wrapper = shallowMount(PipelineEditorApp, {
provide: { ...defaultProvide, ...provide },
stubs,
- mocks: {
- $apollo: {
- queries: {
- initialCiFileContent: {
- loading: blobLoading,
- },
- },
- },
- },
...options,
});
};
@@ -101,6 +85,8 @@ describe('Pipeline editor app component', () => {
stubs = {},
withUndefinedBranch = false,
} = {}) => {
+ Vue.use(VueApollo);
+
const handlers = [
[getBlobContent, mockBlobContentData],
[getCiConfigData, mockCiConfigData],
@@ -137,7 +123,6 @@ describe('Pipeline editor app component', () => {
});
const options = {
- localVue,
mocks: {},
apolloProvider: mockApollo,
};
@@ -164,7 +149,7 @@ describe('Pipeline editor app component', () => {
describe('loading state', () => {
it('displays a loading icon if the blob query is loading', () => {
- createComponent({ blobLoading: true });
+ createComponentWithApollo();
expect(findLoadingIcon().exists()).toBe(true);
expect(findEditorHome().exists()).toBe(false);
@@ -246,10 +231,6 @@ describe('Pipeline editor app component', () => {
describe('when file exists', () => {
beforeEach(async () => {
await createComponentWithApollo();
-
- jest
- .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
- .mockImplementation(jest.fn());
});
it('shows pipeline editor home component', () => {
@@ -268,8 +249,8 @@ describe('Pipeline editor app component', () => {
});
});
- it('does not poll for the commit sha', () => {
- expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
+ it('calls once and does not start poll for the commit sha', () => {
+ expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(1);
});
});
@@ -281,10 +262,6 @@ describe('Pipeline editor app component', () => {
PipelineEditorEmptyState,
},
});
-
- jest
- .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
- .mockImplementation(jest.fn());
});
it('shows an empty state and does not show editor home component', () => {
@@ -293,8 +270,8 @@ describe('Pipeline editor app component', () => {
expect(findEditorHome().exists()).toBe(false);
});
- it('does not poll for the commit sha', () => {
- expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
+ it('calls once and does not start poll for the commit sha', () => {
+ expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(1);
});
describe('because of a fetching error', () => {
@@ -381,38 +358,27 @@ describe('Pipeline editor app component', () => {
});
it('polls for commit sha while pipeline data is not yet available for current branch', async () => {
- jest
- .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
- .mockImplementation(jest.fn());
-
- // simulate a commit to the current branch
findEditorHome().vm.$emit('updateCommitSha');
await waitForPromises();
- expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(1);
+ expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2);
});
it('stops polling for commit sha when pipeline data is available for newly committed branch', async () => {
- jest
- .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
- .mockImplementation(jest.fn());
-
mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
- await wrapper.vm.$apollo.queries.commitSha.refetch();
+ await waitForPromises();
+
+ await findEditorHome().vm.$emit('updateCommitSha');
- expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
+ expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2);
});
it('stops polling for commit sha when pipeline data is available for current branch', async () => {
- jest
- .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
- .mockImplementation(jest.fn());
-
mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults);
findEditorHome().vm.$emit('updateCommitSha');
await waitForPromises();
- expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
+ expect(mockLatestCommitShaQuery).toHaveBeenCalledTimes(2);
});
});
@@ -497,15 +463,12 @@ describe('Pipeline editor app component', () => {
it('refetches blob content', async () => {
await createComponentWithApollo();
- jest
- .spyOn(wrapper.vm.$apollo.queries.initialCiFileContent, 'refetch')
- .mockImplementation(jest.fn());
- expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(0);
+ expect(mockBlobContentData).toHaveBeenCalledTimes(1);
- await wrapper.vm.refetchContent();
+ findEditorHome().vm.$emit('refetchContent');
- expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(1);
+ expect(mockBlobContentData).toHaveBeenCalledTimes(2);
});
it('hides start screen when refetch fetches CI file', async () => {
@@ -516,7 +479,8 @@ describe('Pipeline editor app component', () => {
expect(findEditorHome().exists()).toBe(false);
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
- await wrapper.vm.$apollo.queries.initialCiFileContent.refetch();
+ findEmptyState().vm.$emit('refetchContent');
+ await waitForPromises();
expect(findEmptyState().exists()).toBe(false);
expect(findEditorHome().exists()).toBe(true);
@@ -573,10 +537,6 @@ describe('Pipeline editor app component', () => {
mockGetTemplate.mockResolvedValue(mockCiTemplateQueryResponse);
await createComponentWithApollo();
-
- jest
- .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
- .mockImplementation(jest.fn());
});
it('skips empty state and shows editor home component', () => {
diff --git a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
index 4c56dd74f1a..75bca68b888 100644
--- a/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
+++ b/spec/frontend/ci/runner/admin_new_runner_app/admin_new_runner_app_spec.js
@@ -16,14 +16,14 @@ import {
WINDOWS_PLATFORM,
} from '~/ci/runner/constants';
import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl } from '~/lib/utils/url_utility';
import { runnerCreateResult } from '../mock_data';
jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
- redirectTo: jest.fn(),
+ visitUrl: jest.fn(),
}));
const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
@@ -87,7 +87,7 @@ describe('AdminNewRunnerApp', () => {
it('redirects to the registration page', () => {
const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`;
- expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(url);
});
});
@@ -100,7 +100,7 @@ describe('AdminNewRunnerApp', () => {
it('redirects to the registration page with the platform', () => {
const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`;
- expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(url);
});
});
diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
index 9787b1ef83f..c4ed6d1bdb5 100644
--- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
+++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js
@@ -5,7 +5,7 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/ci/runner/components/runner_header.vue';
@@ -26,11 +26,15 @@ import { runnerData } from '../mock_data';
jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
-jest.mock('~/lib/utils/url_utility');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
const mockRunner = runnerData.data.runner;
const mockRunnerGraphqlId = mockRunner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
+const mockRunnerSha = mockRunner.shortSha;
const mockRunnersPath = '/admin/runners';
Vue.use(VueApollo);
@@ -86,7 +90,7 @@ describe('AdminRunnerShowApp', () => {
});
it('displays the runner header', () => {
- expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
+ expect(findRunnerHeader().text()).toContain(`#${mockRunnerId} (${mockRunnerSha})`);
});
it('displays the runner edit and pause buttons', () => {
@@ -180,7 +184,7 @@ describe('AdminRunnerShowApp', () => {
message: 'Runner deleted',
variant: VARIANT_SUCCESS,
});
- expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(mockRunnersPath);
});
});
diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js
index c3d33c88422..fc74e2947b6 100644
--- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js
@@ -84,7 +84,7 @@ const COUNT_QUERIES = TAB_COUNT_QUERIES + STATUS_COUNT_QUERIES;
describe('AdminRunnersApp', () => {
let wrapper;
- let showToast;
+ const showToast = jest.fn();
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
@@ -122,11 +122,14 @@ describe('AdminRunnersApp', () => {
staleTimeoutSecs,
...provide,
},
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
...options,
});
- showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
-
return waitForPromises();
};
@@ -153,7 +156,9 @@ describe('AdminRunnersApp', () => {
await createComponent({ mountFn: mountExtended });
});
- it('fetches counts', () => {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/414975
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip('fetches counts', () => {
expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
});
diff --git a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js
index c435dd57de2..88d4398aa70 100644
--- a/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js
+++ b/spec/frontend/ci/runner/components/cells/runner_status_cell_spec.js
@@ -24,7 +24,7 @@ describe('RunnerStatusCell', () => {
propsData: {
runner: {
runnerType: INSTANCE_TYPE,
- active: true,
+ paused: false,
status: STATUS_ONLINE,
jobExecutionStatus: JOB_STATUS_IDLE,
...runner,
@@ -59,7 +59,7 @@ describe('RunnerStatusCell', () => {
it('Displays paused status', () => {
createComponent({
runner: {
- active: false,
+ paused: true,
status: STATUS_ONLINE,
},
});
diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js
index 64e9c11a584..cda3876f9b2 100644
--- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js
+++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js
@@ -3,6 +3,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import RunnerManagersBadge from '~/ci/runner/components/runner_managers_badge.vue';
import RunnerTags from '~/ci/runner/components/runner_tags.vue';
import RunnerSummaryField from '~/ci/runner/components/cells/runner_summary_field.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -23,6 +24,7 @@ const mockRunner = allRunnersWithCreatorData.data.runners.nodes[0];
describe('RunnerTypeCell', () => {
let wrapper;
+ const findRunnerManagersBadge = () => wrapper.findComponent(RunnerManagersBadge);
const findLockIcon = () => wrapper.findByTestId('lock-icon');
const findRunnerTags = () => wrapper.findComponent(RunnerTags);
const findRunnerSummaryField = (icon) =>
@@ -54,6 +56,18 @@ describe('RunnerTypeCell', () => {
);
});
+ it('Displays no runner manager count', () => {
+ createComponent({
+ managers: { count: 0 },
+ });
+
+ expect(findRunnerManagersBadge().html()).toBe('');
+ });
+
+ it('Displays runner manager count', () => {
+ expect(findRunnerManagersBadge().text()).toBe('2');
+ });
+
it('Does not display the locked icon', () => {
expect(findLockIcon().exists()).toBe(false);
});
diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js
index bfdde922e17..db54bf0c80e 100644
--- a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js
+++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js
@@ -33,6 +33,8 @@ describe('RegistrationTokenResetDropdownItem', () => {
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
const createComponent = ({ props, provide = {} } = {}) => {
+ showToast = jest.fn();
+
wrapper = shallowMount(RegistrationTokenResetDropdownItem, {
provide,
propsData: {
@@ -45,9 +47,12 @@ describe('RegistrationTokenResetDropdownItem', () => {
directives: {
GlModal: createMockDirective('gl-modal'),
},
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
});
-
- showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
};
beforeEach(() => {
diff --git a/spec/frontend/ci/runner/components/runner_create_form_spec.js b/spec/frontend/ci/runner/components/runner_create_form_spec.js
index 329dd2f73ee..c452e32b0e4 100644
--- a/spec/frontend/ci/runner/components/runner_create_form_spec.js
+++ b/spec/frontend/ci/runner/components/runner_create_form_spec.js
@@ -11,6 +11,7 @@ import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
+ I18N_CREATE_ERROR,
} from '~/ci/runner/constants';
import runnerCreateMutation from '~/ci/runner/graphql/new/runner_create.mutation.graphql';
import { captureException } from '~/ci/runner/sentry_utils';
@@ -21,12 +22,14 @@ jest.mock('~/ci/runner/sentry_utils');
const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
const defaultRunnerModel = {
+ runnerType: INSTANCE_TYPE,
description: '',
accessLevel: DEFAULT_ACCESS_LEVEL,
paused: false,
maintenanceNote: '',
maximumTimeout: '',
runUntagged: false,
+ locked: false,
tagList: '',
};
@@ -81,6 +84,7 @@ describe('RunnerCreateForm', () => {
findRunnerFormFields().vm.$emit('input', {
...defaultRunnerModel,
+ runnerType: props.runnerType,
description: 'My runner',
maximumTimeout: 0,
tagList: 'tag1, tag2',
@@ -123,8 +127,8 @@ describe('RunnerCreateForm', () => {
expect(wrapper.emitted('saved')[0]).toEqual([mockCreatedRunner]);
});
- it('does not show a saving state', () => {
- expect(findSubmitBtn().props('loading')).toBe(false);
+ it('maintains a saving state before navigating away', () => {
+ expect(findSubmitBtn().props('loading')).toBe(true);
});
});
@@ -185,5 +189,37 @@ describe('RunnerCreateForm', () => {
expect(captureException).not.toHaveBeenCalled();
});
});
+
+ describe('when no runner information is returned', () => {
+ beforeEach(async () => {
+ runnerCreateHandler.mockResolvedValue({
+ data: {
+ runnerCreate: {
+ errors: [],
+ runner: null,
+ },
+ },
+ });
+
+ findForm().vm.$emit('submit', { preventDefault });
+ await waitForPromises();
+ });
+
+ it('emits "error" result', () => {
+ expect(wrapper.emitted('error')[0]).toEqual([new TypeError(I18N_CREATE_ERROR)]);
+ });
+
+ it('does not show a saving state', () => {
+ expect(findSubmitBtn().props('loading')).toBe(false);
+ });
+
+ it('reports error', () => {
+ expect(captureException).toHaveBeenCalledTimes(1);
+ expect(captureException).toHaveBeenCalledWith({
+ component: 'RunnerCreateForm',
+ error: new Error(I18N_CREATE_ERROR),
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/ci/runner/components/runner_delete_button_spec.js b/spec/frontend/ci/runner/components/runner_delete_button_spec.js
index 3123f2894fb..3b3f3b1770d 100644
--- a/spec/frontend/ci/runner/components/runner_delete_button_spec.js
+++ b/spec/frontend/ci/runner/components/runner_delete_button_spec.js
@@ -236,7 +236,7 @@ describe('RunnerDeleteButton', () => {
createComponent({
props: {
runner: {
- active: true,
+ paused: false,
},
compact: true,
},
diff --git a/spec/frontend/ci/runner/components/runner_delete_modal_spec.js b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js
index f2fb0206763..606cc46c018 100644
--- a/spec/frontend/ci/runner/components/runner_delete_modal_spec.js
+++ b/spec/frontend/ci/runner/components/runner_delete_modal_spec.js
@@ -20,25 +20,50 @@ describe('RunnerDeleteModal', () => {
});
};
- it('Displays title', () => {
- createComponent();
+ describe.each([null, 0, 1])('for %o runners', (managersCount) => {
+ beforeEach(() => {
+ createComponent({ props: { managersCount } });
+ });
- expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?');
- });
+ it('Displays title', () => {
+ expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?');
+ });
- it('Displays buttons', () => {
- createComponent();
+ it('Displays buttons', () => {
+ expect(findGlModal().props('actionPrimary')).toMatchObject({
+ text: 'Permanently delete runner',
+ });
+ expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' });
+ });
- expect(findGlModal().props('actionPrimary')).toMatchObject({ text: 'Delete runner' });
- expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' });
+ it('Displays contents', () => {
+ expect(findGlModal().text()).toContain(
+ 'The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+ );
+ });
});
- it('Displays contents', () => {
- createComponent();
+ describe('for 2 runners', () => {
+ beforeEach(() => {
+ createComponent({ props: { managersCount: 2 } });
+ });
+
+ it('Displays title', () => {
+ expect(findGlModal().props('title')).toBe('Delete 2 runners?');
+ });
- expect(findGlModal().html()).toContain(
- 'The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
- );
+ it('Displays buttons', () => {
+ expect(findGlModal().props('actionPrimary')).toMatchObject({
+ text: 'Permanently delete 2 runners',
+ });
+ expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' });
+ });
+
+ it('Displays contents', () => {
+ expect(findGlModal().text()).toContain(
+ '2 runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+ );
+ });
});
describe('When modal is confirmed by the user', () => {
diff --git a/spec/frontend/ci/runner/components/runner_details_spec.js b/spec/frontend/ci/runner/components/runner_details_spec.js
index c2d9e86aa91..cc91340655b 100644
--- a/spec/frontend/ci/runner/components/runner_details_spec.js
+++ b/spec/frontend/ci/runner/components/runner_details_spec.js
@@ -1,4 +1,5 @@
import { GlSprintf, GlIntersperse } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { useFakeDate } from 'helpers/fake_date';
@@ -10,6 +11,7 @@ import RunnerDetail from '~/ci/runner/components/runner_detail.vue';
import RunnerGroups from '~/ci/runner/components/runner_groups.vue';
import RunnerTags from '~/ci/runner/components/runner_tags.vue';
import RunnerTag from '~/ci/runner/components/runner_tag.vue';
+import RunnerManagersDetail from '~/ci/runner/components/runner_managers_detail.vue';
import { runnerData, runnerWithGroupData } from '../mock_data';
@@ -24,6 +26,9 @@ describe('RunnerDetails', () => {
useFakeDate(mockNow);
const findDetailGroups = () => wrapper.findComponent(RunnerGroups);
+ const findRunnerManagersDetail = () => wrapper.findComponent(RunnerManagersDetail);
+
+ const findDdContent = (label) => findDd(label, wrapper).text().replace(/\s+/g, ' ');
const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerDetails, {
@@ -61,6 +66,7 @@ describe('RunnerDetails', () => {
${'Maximum job timeout'} | ${{ maximumTimeout: 10 * 60 + 5 }} | ${'10 minutes 5 seconds'}
${'Token expiry'} | ${{ tokenExpiresAt: mockOneHourAgo }} | ${'1 hour ago'}
${'Token expiry'} | ${{ tokenExpiresAt: null }} | ${'Never expires'}
+ ${'Runners'} | ${{ managers: { count: 2 } }} | ${`2 ${__('Show details')}`}
`('"$field" field', ({ field, runner, expectedValue }) => {
beforeEach(() => {
createComponent({
@@ -74,12 +80,13 @@ describe('RunnerDetails', () => {
GlIntersperse,
GlSprintf,
TimeAgo,
+ RunnerManagersDetail,
},
});
});
it(`displays expected value "${expectedValue}"`, () => {
- expect(findDd(field, wrapper).text()).toBe(expectedValue);
+ expect(findDdContent(field)).toBe(expectedValue);
});
});
@@ -94,7 +101,7 @@ describe('RunnerDetails', () => {
stubs,
});
- expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('tag-1 tag-2');
+ expect(findDdContent(s__('Runners|Tags'))).toBe('tag-1 tag-2');
});
it('displays "None" when runner has no tags', () => {
@@ -105,7 +112,19 @@ describe('RunnerDetails', () => {
stubs,
});
- expect(findDd('Tags', wrapper).text().replace(/\s+/g, ' ')).toBe('None');
+ expect(findDdContent(s__('Runners|Tags'))).toBe('None');
+ });
+ });
+
+ describe('"Runners" field', () => {
+ it('displays runner managers count of $count', () => {
+ createComponent({
+ props: {
+ runner: mockRunner,
+ },
+ });
+
+ expect(findRunnerManagersDetail().props('runner')).toEqual(mockRunner);
});
});
diff --git a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js
index a59c5a21377..689d0575726 100644
--- a/spec/frontend/ci/runner/components/runner_details_tabs_spec.js
+++ b/spec/frontend/ci/runner/components/runner_details_tabs_spec.js
@@ -16,9 +16,17 @@ import { runnerData } from '../mock_data';
// Vue Test Utils `stubs` option does not stub components mounted
// in <router-view>. Use mocking instead:
jest.mock('~/ci/runner/components/runner_jobs.vue', () => {
- const ActualRunnerJobs = jest.requireActual('~/ci/runner/components/runner_jobs.vue').default;
+ const { props } = jest.requireActual('~/ci/runner/components/runner_jobs.vue').default;
return {
- props: ActualRunnerJobs.props,
+ props,
+ render() {},
+ };
+});
+
+jest.mock('~/ci/runner/components/runner_managers_detail.vue', () => {
+ const { props } = jest.requireActual('~/ci/runner/components/runner_managers_detail.vue').default;
+ return {
+ props,
render() {},
};
});
diff --git a/spec/frontend/ci/runner/components/runner_form_fields_spec.js b/spec/frontend/ci/runner/components/runner_form_fields_spec.js
index 5b429645d17..93be4d9d35e 100644
--- a/spec/frontend/ci/runner/components/runner_form_fields_spec.js
+++ b/spec/frontend/ci/runner/components/runner_form_fields_spec.js
@@ -1,71 +1,158 @@
import { nextTick } from 'vue';
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { s__ } from '~/locale';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
-import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '~/ci/runner/constants';
+import {
+ ACCESS_LEVEL_NOT_PROTECTED,
+ ACCESS_LEVEL_REF_PROTECTED,
+ PROJECT_TYPE,
+} from '~/ci/runner/constants';
const mockDescription = 'My description';
+const mockNewDescription = 'My new description';
const mockMaxTimeout = 60;
const mockTags = 'tag, tag2';
describe('RunnerFormFields', () => {
let wrapper;
+ const findInputByLabel = (label) => wrapper.findByLabelText(label);
const findInput = (name) => wrapper.find(`input[name="${name}"]`);
- const createComponent = ({ runner } = {}) => {
+ const expectRendersFields = () => {
+ expect(wrapper.text()).toContain(s__('Runners|Tags'));
+ expect(wrapper.text()).toContain(s__('Runners|Details'));
+ expect(wrapper.text()).toContain(s__('Runners|Configuration'));
+
+ expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(0);
+ expect(wrapper.findAll('input')).toHaveLength(6);
+ };
+
+ const createComponent = ({ ...props } = {}) => {
wrapper = mountExtended(RunnerFormFields, {
propsData: {
- value: runner,
+ ...props,
},
});
};
+ describe('when runner is loading', () => {
+ beforeEach(() => {
+ createComponent({ loading: true });
+ });
+
+ it('renders a loading frame', () => {
+ expect(wrapper.text()).toContain(s__('Runners|Tags'));
+ expect(wrapper.text()).toContain(s__('Runners|Details'));
+ expect(wrapper.text()).toContain(s__('Runners|Configuration'));
+
+ expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(3);
+ expect(wrapper.findAll('input')).toHaveLength(0);
+ });
+
+ describe('and then is loaded', () => {
+ beforeEach(() => {
+ wrapper.setProps({ loading: false, value: { description: mockDescription } });
+ });
+
+ it('renders fields', () => {
+ expectRendersFields();
+ });
+ });
+ });
+
+ it('when runner is loaded, renders fields', () => {
+ createComponent({
+ value: { description: mockDescription },
+ });
+
+ expectRendersFields();
+ });
+
+ it('when runner is updated with the same value, only emits when changed (avoids infinite loop)', async () => {
+ createComponent({ value: null, loading: true });
+ await wrapper.setProps({ value: { description: mockDescription }, loading: false });
+ await wrapper.setProps({ value: { description: mockDescription }, loading: false });
+
+ expect(wrapper.emitted('input')).toHaveLength(1);
+ });
+
it('updates runner fields', async () => {
- createComponent();
+ createComponent({
+ value: { description: mockDescription },
+ });
expect(wrapper.emitted('input')).toBe(undefined);
- findInput('description').setValue(mockDescription);
+ findInputByLabel(s__('Runners|Runner description')).setValue(mockNewDescription);
findInput('max-timeout').setValue(mockMaxTimeout);
- findInput('paused').setChecked(true);
- findInput('protected').setChecked(true);
- findInput('run-untagged').setChecked(true);
findInput('tags').setValue(mockTags);
await nextTick();
- expect(wrapper.emitted('input')[0][0]).toMatchObject({
- description: mockDescription,
- maximumTimeout: mockMaxTimeout,
- tagList: mockTags,
- });
+ expect(wrapper.emitted('input').at(-1)).toEqual([
+ {
+ description: mockNewDescription,
+ maximumTimeout: mockMaxTimeout,
+ tagList: mockTags,
+ },
+ ]);
});
it('checks checkbox fields', async () => {
createComponent({
- runner: {
+ value: {
+ runUntagged: false,
paused: false,
accessLevel: ACCESS_LEVEL_NOT_PROTECTED,
- runUntagged: false,
},
});
+ findInput('run-untagged').setChecked(true);
findInput('paused').setChecked(true);
findInput('protected').setChecked(true);
- findInput('run-untagged').setChecked(true);
await nextTick();
- expect(wrapper.emitted('input')[0][0]).toEqual({
- paused: true,
- accessLevel: ACCESS_LEVEL_REF_PROTECTED,
- runUntagged: true,
+ expect(wrapper.emitted('input').at(-1)).toEqual([
+ {
+ runUntagged: true,
+ paused: true,
+ accessLevel: ACCESS_LEVEL_REF_PROTECTED,
+ },
+ ]);
+ });
+
+ it('locked checkbox is not shown', () => {
+ createComponent();
+
+ expect(findInput('locked').exists()).toBe(false);
+ });
+
+ it('when runner is of project type, locked checkbox can be checked', async () => {
+ createComponent({
+ value: {
+ runnerType: PROJECT_TYPE,
+ locked: false,
+ },
});
+
+ findInput('locked').setChecked(true);
+
+ await nextTick();
+
+ expect(wrapper.emitted('input').at(-1)).toEqual([
+ {
+ runnerType: PROJECT_TYPE,
+ locked: true,
+ },
+ ]);
});
it('unchecks checkbox fields', async () => {
createComponent({
- runner: {
+ value: {
paused: true,
accessLevel: ACCESS_LEVEL_REF_PROTECTED,
runUntagged: true,
@@ -78,10 +165,12 @@ describe('RunnerFormFields', () => {
await nextTick();
- expect(wrapper.emitted('input')[0][0]).toEqual({
- paused: false,
- accessLevel: ACCESS_LEVEL_NOT_PROTECTED,
- runUntagged: false,
- });
+ expect(wrapper.emitted('input').at(-1)).toEqual([
+ {
+ paused: false,
+ accessLevel: ACCESS_LEVEL_NOT_PROTECTED,
+ runUntagged: false,
+ },
+ ]);
});
});
diff --git a/spec/frontend/ci/runner/components/runner_header_spec.js b/spec/frontend/ci/runner/components/runner_header_spec.js
index c851966431d..f5091226eaa 100644
--- a/spec/frontend/ci/runner/components/runner_header_spec.js
+++ b/spec/frontend/ci/runner/components/runner_header_spec.js
@@ -17,6 +17,7 @@ import RunnerStatusBadge from '~/ci/runner/components/runner_status_badge.vue';
import { runnerData } from '../mock_data';
const mockRunner = runnerData.data.runner;
+const mockRunnerSha = mockRunner.shortSha;
describe('RunnerHeader', () => {
let wrapper;
@@ -71,7 +72,7 @@ describe('RunnerHeader', () => {
},
});
- expect(wrapper.text()).toContain('Runner #99');
+ expect(wrapper.text()).toContain(`#99 (${mockRunnerSha})`);
});
it('displays the runner locked icon', () => {
@@ -100,7 +101,7 @@ describe('RunnerHeader', () => {
},
});
- expect(wrapper.text()).toContain('Runner #99');
+ expect(wrapper.text()).toContain(`#99 (${mockRunnerSha})`);
expect(wrapper.text()).not.toMatch(/created .+/);
expect(findTimeAgo().exists()).toBe(false);
});
diff --git a/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js
index 59c9383cb31..b2dfc77bd99 100644
--- a/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js
+++ b/spec/frontend/ci/runner/components/runner_jobs_empty_state_spec.js
@@ -1,4 +1,4 @@
-import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/pipelines_empty.svg?url';
+import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url';
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
diff --git a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js
index 0de2759ea8a..22797433b58 100644
--- a/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js
+++ b/spec/frontend/ci/runner/components/runner_list_empty_state_spec.js
@@ -1,27 +1,46 @@
-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 EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url';
+import FILTERED_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg?url';
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
-import { s__ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
-
-import { mockRegistrationToken, newRunnerPath } from 'jest/ci/runner/mock_data';
+import {
+ I18N_GET_STARTED,
+ I18N_RUNNERS_ARE_AGENTS,
+ I18N_CREATE_RUNNER_LINK,
+ I18N_STILL_USING_REGISTRATION_TOKENS,
+ I18N_CONTACT_ADMIN_TO_REGISTER,
+ I18N_FOLLOW_REGISTRATION_INSTRUCTIONS,
+ I18N_NO_RESULTS,
+ I18N_EDIT_YOUR_SEARCH,
+} from '~/ci/runner/constants';
+
+import {
+ mockRegistrationToken,
+ newRunnerPath as mockNewRunnerPath,
+} from 'jest/ci/runner/mock_data';
import RunnerListEmptyState from '~/ci/runner/components/runner_list_empty_state.vue';
describe('RunnerListEmptyState', () => {
let wrapper;
+ let glFeatures;
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findLinks = () => wrapper.findAllComponents(GlLink);
const findLink = () => wrapper.findComponent(GlLink);
const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal);
- const createComponent = ({ props, mountFn = shallowMountExtended, ...options } = {}) => {
+ const expectTitleToBe = (title) => {
+ expect(findEmptyState().find('h1').text()).toBe(title);
+ };
+ const expectDescriptionToBe = (sentences) => {
+ expect(findEmptyState().find('p').text()).toMatchInterpolatedText(sentences.join(' '));
+ };
+
+ const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerListEmptyState, {
propsData: {
- registrationToken: mockRegistrationToken,
- newRunnerPath,
...props,
},
directives: {
@@ -30,109 +49,146 @@ describe('RunnerListEmptyState', () => {
stubs: {
GlEmptyState,
GlSprintf,
- GlLink,
},
- ...options,
+ provide: { glFeatures },
});
};
- describe('when search is not filtered', () => {
- const title = s__('Runners|Get started with runners');
+ beforeEach(() => {
+ glFeatures = null;
+ });
- describe('when there is a registration token', () => {
+ describe('when search is not filtered', () => {
+ describe.each([
+ { createRunnerWorkflowForAdmin: true },
+ { createRunnerWorkflowForNamespace: true },
+ ])('when createRunnerWorkflow is enabled by %o', (currentGlFeatures) => {
beforeEach(() => {
- createComponent();
- });
-
- it('renders an illustration', () => {
- expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
- });
-
- it('displays "no results" text with instructions', () => {
- const desc = s__(
- 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
- );
-
- expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`);
+ glFeatures = currentGlFeatures;
});
- describe.each([
- { createRunnerWorkflowForAdmin: true },
- { createRunnerWorkflowForNamespace: true },
- ])('when %o', (glFeatures) => {
- describe('when newRunnerPath is defined', () => {
+ describe.each`
+ newRunnerPath | registrationToken | expectedMessages
+ ${mockNewRunnerPath} | ${mockRegistrationToken} | ${[I18N_CREATE_RUNNER_LINK, I18N_STILL_USING_REGISTRATION_TOKENS]}
+ ${mockNewRunnerPath} | ${null} | ${[I18N_CREATE_RUNNER_LINK]}
+ ${null} | ${mockRegistrationToken} | ${[I18N_STILL_USING_REGISTRATION_TOKENS]}
+ ${null} | ${null} | ${[I18N_CONTACT_ADMIN_TO_REGISTER]}
+ `(
+ 'when newRunnerPath is $newRunnerPath and registrationToken is $registrationToken',
+ ({ newRunnerPath, registrationToken, expectedMessages }) => {
beforeEach(() => {
createComponent({
- provide: {
- glFeatures,
+ props: {
+ newRunnerPath,
+ registrationToken,
},
});
});
- it('shows a link to the new runner page', () => {
- expect(findLink().attributes('href')).toBe(newRunnerPath);
+ it('shows title', () => {
+ expectTitleToBe(I18N_GET_STARTED);
});
- });
- describe('when newRunnerPath not defined', () => {
- beforeEach(() => {
- createComponent({
- props: {
- newRunnerPath: null,
- },
- provide: {
- glFeatures,
- },
- });
+ it('renders an illustration', () => {
+ expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
});
- it('opens a runner registration instructions modal with a link', () => {
- const { value } = getBinding(findLink().element, 'gl-modal');
+ it(`shows description: "${expectedMessages.join(' ')}"`, () => {
+ expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, ...expectedMessages]);
+ });
+ },
+ );
- expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
+ describe('with newRunnerPath and registration token', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ registrationToken: mockRegistrationToken,
+ newRunnerPath: mockNewRunnerPath,
+ },
});
});
+
+ it('shows links to the new runner page and registration instructions', () => {
+ expect(findLinks().at(0).attributes('href')).toBe(mockNewRunnerPath);
+
+ const { value } = getBinding(findLinks().at(1).element, 'gl-modal');
+ expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
+ });
});
- describe.each([
- { createRunnerWorkflowForAdmin: false },
- { createRunnerWorkflowForNamespace: false },
- ])('when %o', (glFeatures) => {
+ describe('with newRunnerPath and no registration token', () => {
beforeEach(() => {
createComponent({
- provide: {
- glFeatures,
+ props: {
+ registrationToken: mockRegistrationToken,
+ newRunnerPath: null,
},
});
});
it('opens a runner registration instructions modal with a link', () => {
const { value } = getBinding(findLink().element, 'gl-modal');
-
expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
});
});
- });
- describe('when there is no registration token', () => {
- beforeEach(() => {
- createComponent({ props: { registrationToken: null } });
- });
+ describe('with no newRunnerPath nor registration token', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ registrationToken: null,
+ newRunnerPath: null,
+ },
+ });
+ });
- it('renders an illustration', () => {
- expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
+ it('has no link', () => {
+ expect(findLink().exists()).toBe(false);
+ });
});
+ });
+
+ describe('when createRunnerWorkflow is disabled', () => {
+ describe('when there is a registration token', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ registrationToken: mockRegistrationToken,
+ },
+ });
+ });
+
+ it('renders an illustration', () => {
+ expect(findEmptyState().props('svgPath')).toBe(EMPTY_STATE_SVG_URL);
+ });
+
+ it('opens a runner registration instructions modal with a link', () => {
+ const { value } = getBinding(findLink().element, 'gl-modal');
+ expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
+ });
- it('displays "no results" text', () => {
- const desc = s__(
- 'Runners|Runners are the agents that run your CI/CD jobs. To register new runners, please contact your administrator.',
- );
+ it('displays text with registration instructions', () => {
+ expectTitleToBe(I18N_GET_STARTED);
- expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`);
+ expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_FOLLOW_REGISTRATION_INSTRUCTIONS]);
+ });
});
- it('has no registration instructions link', () => {
- expect(findLink().exists()).toBe(false);
+ describe('when there is no registration token', () => {
+ beforeEach(() => {
+ createComponent({ props: { registrationToken: null } });
+ });
+
+ it('displays "contact admin" text', () => {
+ expectTitleToBe(I18N_GET_STARTED);
+
+ expectDescriptionToBe([I18N_RUNNERS_ARE_AGENTS, I18N_CONTACT_ADMIN_TO_REGISTER]);
+ });
+
+ it('has no registration instructions link', () => {
+ expect(findLink().exists()).toBe(false);
+ });
});
});
});
@@ -147,8 +203,9 @@ describe('RunnerListEmptyState', () => {
});
it('displays "no filtered results" text', () => {
- expect(findEmptyState().text()).toContain(s__('Runners|No results found'));
- expect(findEmptyState().text()).toContain(s__('Runners|Edit your search and try again'));
+ expectTitleToBe(I18N_NO_RESULTS);
+
+ expectDescriptionToBe([I18N_EDIT_YOUR_SEARCH]);
});
});
});
diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js
index 0f4ec717c3e..9da640afeb7 100644
--- a/spec/frontend/ci/runner/components/runner_list_spec.js
+++ b/spec/frontend/ci/runner/components/runner_list_spec.js
@@ -18,7 +18,6 @@ import { I18N_PROJECT_TYPE, I18N_STATUS_NEVER_CONTACTED } from '~/ci/runner/cons
import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data';
const mockRunners = allRunnersData.data.runners.nodes;
-const mockActiveRunnersCount = mockRunners.length;
describe('RunnerList', () => {
let wrapper;
@@ -44,7 +43,6 @@ describe('RunnerList', () => {
apolloProvider: createMockApollo([], {}, cacheConfig),
propsData: {
runners: mockRunners,
- activeRunnersCount: mockActiveRunnersCount,
...props,
},
provide: {
diff --git a/spec/frontend/ci/runner/components/runner_managers_badge_spec.js b/spec/frontend/ci/runner/components/runner_managers_badge_spec.js
new file mode 100644
index 00000000000..185172ba02b
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_managers_badge_spec.js
@@ -0,0 +1,57 @@
+import { GlBadge } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RunnerManagersBadge from '~/ci/runner/components/runner_managers_badge.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+const mockCount = 2;
+
+describe('RunnerTypeBadge', () => {
+ let wrapper;
+
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const getTooltip = () => getBinding(findBadge()?.element, 'gl-tooltip');
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMount(RunnerManagersBadge, {
+ propsData: {
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ });
+ };
+
+ it.each([null, 0, 1])('renders no badge when count is %s', (count) => {
+ createComponent({ props: { count } });
+
+ expect(findBadge().exists()).toBe(false);
+ });
+
+ it('renders badge with tooltip', () => {
+ createComponent({ props: { count: mockCount } });
+
+ expect(findBadge().text()).toBe(`${mockCount}`);
+ expect(getTooltip().value).toContain(`${mockCount}`);
+ });
+
+ it('renders badge with icon and variant', () => {
+ createComponent({ props: { count: mockCount } });
+
+ expect(findBadge().props('icon')).toBe('container-image');
+ expect(findBadge().props('variant')).toBe('muted');
+ });
+
+ it('renders badge and tooltip with formatted count', () => {
+ createComponent({ props: { count: 1000 } });
+
+ expect(findBadge().text()).toBe('1,000');
+ expect(getTooltip().value).toContain('1,000');
+ });
+
+ it('passes arbitrary attributes to badge', () => {
+ createComponent({ props: { count: 2, size: 'sm' } });
+
+ expect(findBadge().props('size')).toBe('sm');
+ });
+});
diff --git a/spec/frontend/ci/runner/components/runner_managers_detail_spec.js b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js
new file mode 100644
index 00000000000..3435292394f
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js
@@ -0,0 +1,169 @@
+import { GlCollapse, GlSkeletonLoader, GlTableLite } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { __ } from '~/locale';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import RunnerManagersDetail from '~/ci/runner/components/runner_managers_detail.vue';
+import RunnerManagersTable from '~/ci/runner/components/runner_managers_table.vue';
+
+import runnerManagersQuery from '~/ci/runner/graphql/show/runner_managers.query.graphql';
+import { runnerData, runnerManagersData } from '../mock_data';
+
+jest.mock('~/alert');
+jest.mock('~/ci/runner/sentry_utils');
+
+const mockRunner = runnerData.data.runner;
+const mockRunnerManagers = runnerManagersData.data.runner.managers.nodes;
+
+Vue.use(VueApollo);
+
+describe('RunnerJobs', () => {
+ let wrapper;
+ let mockRunnerManagersHandler;
+
+ const findShowDetails = () => wrapper.findByText(__('Show details'));
+ const findHideDetails = () => wrapper.findByText(__('Hide details'));
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const findCollapse = () => wrapper.findComponent(GlCollapse);
+ const findRunnerManagersTable = () => wrapper.findComponent(RunnerManagersTable);
+
+ const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => {
+ wrapper = mountFn(RunnerManagersDetail, {
+ apolloProvider: createMockApollo([[runnerManagersQuery, mockRunnerManagersHandler]]),
+ propsData: {
+ runner: mockRunner,
+ ...props,
+ },
+ stubs: {
+ GlTableLite,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mockRunnerManagersHandler = jest.fn();
+ });
+
+ afterEach(() => {
+ mockRunnerManagersHandler.mockReset();
+ });
+
+ describe('Runners count', () => {
+ it.each`
+ count | expected
+ ${0} | ${'0'}
+ ${1} | ${'1'}
+ ${1000} | ${'1,000'}
+ `('displays runner managers count of $count', ({ count, expected }) => {
+ createComponent({
+ props: {
+ runner: {
+ ...mockRunner,
+ managers: {
+ count,
+ },
+ },
+ },
+ });
+
+ expect(wrapper.text()).toContain(expected);
+ });
+ });
+
+ describe('Expand and collapse', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows link to expand', () => {
+ expect(findShowDetails().exists()).toBe(true);
+ expect(findHideDetails().exists()).toBe(false);
+ });
+
+ it('is collapsed', () => {
+ expect(findCollapse().attributes('visible')).toBeUndefined();
+ });
+
+ describe('when expanded', () => {
+ beforeEach(() => {
+ findShowDetails().vm.$emit('click');
+ });
+
+ it('shows link to collapse', () => {
+ expect(findShowDetails().exists()).toBe(false);
+ expect(findHideDetails().exists()).toBe(true);
+ });
+
+ it('shows loading state', () => {
+ expect(findCollapse().attributes('visible')).toBe('true');
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('fetches data', () => {
+ expect(mockRunnerManagersHandler).toHaveBeenCalledTimes(1);
+ expect(mockRunnerManagersHandler).toHaveBeenCalledWith({
+ runnerId: mockRunner.id,
+ });
+ });
+ });
+ });
+
+ describe('Prefetches data upon user interation', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('does not fetch initially', () => {
+ expect(mockRunnerManagersHandler).not.toHaveBeenCalled();
+ });
+
+ describe.each(['focus', 'mouseover'])('fetches data after %s', (event) => {
+ beforeEach(() => {
+ findShowDetails().vm.$emit(event);
+ });
+
+ it('fetches data', () => {
+ expect(mockRunnerManagersHandler).toHaveBeenCalledTimes(1);
+ expect(mockRunnerManagersHandler).toHaveBeenCalledWith({
+ runnerId: mockRunner.id,
+ });
+ });
+
+ it('fetches data only once', async () => {
+ findShowDetails().vm.$emit(event);
+ await waitForPromises();
+
+ expect(mockRunnerManagersHandler).toHaveBeenCalledTimes(1);
+ expect(mockRunnerManagersHandler).toHaveBeenCalledWith({
+ runnerId: mockRunner.id,
+ });
+ });
+ });
+ });
+
+ describe('Shows data', () => {
+ beforeEach(async () => {
+ mockRunnerManagersHandler.mockResolvedValue(runnerManagersData);
+
+ createComponent({ mountFn: mountExtended });
+
+ await findShowDetails().trigger('click');
+ });
+
+ it('shows rows', () => {
+ expect(findCollapse().attributes('visible')).toBe('true');
+ expect(findRunnerManagersTable().props('items')).toEqual(mockRunnerManagers);
+ });
+
+ it('collapses when clicked', async () => {
+ await findHideDetails().trigger('click');
+
+ expect(findCollapse().attributes('visible')).toBeUndefined();
+ });
+ });
+});
diff --git a/spec/frontend/ci/runner/components/runner_managers_table_spec.js b/spec/frontend/ci/runner/components/runner_managers_table_spec.js
new file mode 100644
index 00000000000..cde6ee6eea0
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_managers_table_spec.js
@@ -0,0 +1,144 @@
+import { GlTableLite } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+
+import RunnerManagersTable from '~/ci/runner/components/runner_managers_table.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { I18N_STATUS_NEVER_CONTACTED } from '~/ci/runner/constants';
+
+import { runnerManagersData } from '../mock_data';
+
+jest.mock('~/alert');
+jest.mock('~/ci/runner/sentry_utils');
+
+const mockItems = runnerManagersData.data.runner.managers.nodes;
+
+describe('RunnerJobs', () => {
+ let wrapper;
+
+ const findHeaders = () => wrapper.findAll('thead th');
+ const findRows = () => wrapper.findAll('tbody tr');
+ const findCell = ({ field, i }) => extendedWrapper(findRows().at(i)).findByTestId(`td-${field}`);
+ const findCellText = (opts) => findCell(opts).text().replace(/\s+/g, ' ');
+
+ const createComponent = ({ item } = {}) => {
+ const [mockItem, ...otherItems] = mockItems;
+
+ wrapper = mountExtended(RunnerManagersTable, {
+ propsData: {
+ items: [{ ...mockItem, ...item }, ...otherItems],
+ },
+ stubs: {
+ GlTableLite,
+ },
+ });
+ };
+
+ it('shows headers', () => {
+ createComponent();
+ expect(findHeaders().wrappers.map((w) => w.text())).toEqual([
+ expect.stringContaining(s__('Runners|System ID')),
+ s__('Runners|Status'),
+ s__('Runners|Version'),
+ s__('Runners|IP Address'),
+ s__('Runners|Executor'),
+ s__('Runners|Arch/Platform'),
+ s__('Runners|Last contact'),
+ ]);
+ });
+
+ it('shows rows', () => {
+ createComponent();
+ expect(findRows()).toHaveLength(2);
+ });
+
+ it('shows system id', () => {
+ createComponent();
+ expect(findCellText({ field: 'systemId', i: 0 })).toBe(mockItems[0].systemId);
+ expect(findCellText({ field: 'systemId', i: 1 })).toBe(mockItems[1].systemId);
+ });
+
+ it('shows status', () => {
+ createComponent();
+ expect(findCellText({ field: 'status', i: 0 })).toBe(s__('Runners|Online'));
+ expect(findCellText({ field: 'status', i: 1 })).toBe(s__('Runners|Online'));
+ });
+
+ it('shows version', () => {
+ createComponent({
+ item: { version: '1.0' },
+ });
+
+ expect(findCellText({ field: 'version', i: 0 })).toBe('1.0');
+ });
+
+ it('shows version with revision', () => {
+ createComponent({
+ item: { version: '1.0', revision: '123456' },
+ });
+
+ expect(findCellText({ field: 'version', i: 0 })).toBe('1.0 (123456)');
+ });
+
+ it('shows revision without version', () => {
+ createComponent({
+ item: { version: null, revision: '123456' },
+ });
+
+ expect(findCellText({ field: 'version', i: 0 })).toBe('(123456)');
+ });
+
+ it('shows ip address', () => {
+ createComponent({
+ item: { ipAddress: '127.0.0.1' },
+ });
+
+ expect(findCellText({ field: 'ipAddress', i: 0 })).toBe('127.0.0.1');
+ });
+
+ it('shows executor', () => {
+ createComponent({
+ item: { executorName: 'shell' },
+ });
+
+ expect(findCellText({ field: 'executorName', i: 0 })).toBe('shell');
+ });
+
+ it('shows architecture', () => {
+ createComponent({
+ item: { architectureName: 'x64' },
+ });
+
+ expect(findCellText({ field: 'architecturePlatform', i: 0 })).toBe('x64');
+ });
+
+ it('shows platform', () => {
+ createComponent({
+ item: { platformName: 'darwin' },
+ });
+
+ expect(findCellText({ field: 'architecturePlatform', i: 0 })).toBe('darwin');
+ });
+
+ it('shows architecture and platform', () => {
+ createComponent({
+ item: { architectureName: 'x64', platformName: 'darwin' },
+ });
+
+ expect(findCellText({ field: 'architecturePlatform', i: 0 })).toBe('x64/darwin');
+ });
+
+ it('shows contacted at', () => {
+ createComponent();
+ expect(findCell({ field: 'contactedAt', i: 0 }).findComponent(TimeAgo).props('time')).toBe(
+ mockItems[0].contactedAt,
+ );
+ });
+
+ it('shows missing contacted at', () => {
+ createComponent({
+ item: { contactedAt: null },
+ });
+ expect(findCellText({ field: 'contactedAt', i: 0 })).toBe(I18N_STATUS_NEVER_CONTACTED);
+ });
+});
diff --git a/spec/frontend/ci/runner/components/runner_pause_button_spec.js b/spec/frontend/ci/runner/components/runner_pause_button_spec.js
index 350d029f3fc..1ea870e004a 100644
--- a/spec/frontend/ci/runner/components/runner_pause_button_spec.js
+++ b/spec/frontend/ci/runner/components/runner_pause_button_spec.js
@@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
-import runnerToggleActiveMutation from '~/ci/runner/graphql/shared/runner_toggle_active.mutation.graphql';
+import runnerTogglePausedMutation from '~/ci/runner/graphql/shared/runner_toggle_paused.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/ci/runner/sentry_utils';
import { createAlert } from '~/alert';
@@ -27,7 +27,7 @@ jest.mock('~/ci/runner/sentry_utils');
describe('RunnerPauseButton', () => {
let wrapper;
- let runnerToggleActiveHandler;
+ let runnerTogglePausedHandler;
const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
const findBtn = () => wrapper.findComponent(GlButton);
@@ -39,12 +39,12 @@ describe('RunnerPauseButton', () => {
propsData: {
runner: {
id: mockRunner.id,
- active: mockRunner.active,
+ paused: mockRunner.paused,
...runner,
},
...propsData,
},
- apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]),
+ apolloProvider: createMockApollo([[runnerTogglePausedMutation, runnerTogglePausedHandler]]),
directives: {
GlTooltip: createMockDirective('gl-tooltip'),
},
@@ -57,13 +57,13 @@ describe('RunnerPauseButton', () => {
};
beforeEach(() => {
- runnerToggleActiveHandler = jest.fn().mockImplementation(({ input }) => {
+ runnerTogglePausedHandler = jest.fn().mockImplementation(({ input }) => {
return Promise.resolve({
data: {
runnerUpdate: {
runner: {
id: input.id,
- active: input.active,
+ paused: !input.paused,
},
errors: [],
},
@@ -76,15 +76,15 @@ describe('RunnerPauseButton', () => {
describe('Pause/Resume action', () => {
describe.each`
- runnerState | icon | content | tooltip | isActive | newActiveValue
- ${'paused'} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} | ${false} | ${true}
- ${'active'} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} | ${true} | ${false}
- `('When the runner is $runnerState', ({ icon, content, tooltip, isActive, newActiveValue }) => {
+ runnerState | icon | content | tooltip | isPaused | newPausedValue
+ ${'paused'} | ${'play'} | ${I18N_RESUME} | ${I18N_RESUME_TOOLTIP} | ${true} | ${false}
+ ${'active'} | ${'pause'} | ${I18N_PAUSE} | ${I18N_PAUSE_TOOLTIP} | ${false} | ${true}
+ `('When the runner is $runnerState', ({ icon, content, tooltip, isPaused, newPausedValue }) => {
beforeEach(() => {
createComponent({
props: {
runner: {
- active: isActive,
+ paused: isPaused,
},
},
});
@@ -106,7 +106,7 @@ describe('RunnerPauseButton', () => {
describe(`Before the ${icon} button is clicked`, () => {
it('The mutation has not been called', () => {
- expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(0);
+ expect(runnerTogglePausedHandler).not.toHaveBeenCalled();
});
});
@@ -134,12 +134,12 @@ describe('RunnerPauseButton', () => {
await clickAndWait();
});
- it(`The mutation to that sets active to ${newActiveValue} is called`, () => {
- expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1);
- expect(runnerToggleActiveHandler).toHaveBeenCalledWith({
+ it(`The mutation to that sets "paused" to ${newPausedValue} is called`, () => {
+ expect(runnerTogglePausedHandler).toHaveBeenCalledTimes(1);
+ expect(runnerTogglePausedHandler).toHaveBeenCalledWith({
input: {
id: mockRunner.id,
- active: newActiveValue,
+ paused: newPausedValue,
},
});
});
@@ -158,7 +158,7 @@ describe('RunnerPauseButton', () => {
const mockErrorMsg = 'Update error!';
beforeEach(async () => {
- runnerToggleActiveHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
+ runnerTogglePausedHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await clickAndWait();
});
@@ -180,12 +180,12 @@ describe('RunnerPauseButton', () => {
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
- runnerToggleActiveHandler.mockResolvedValueOnce({
+ runnerTogglePausedHandler.mockResolvedValueOnce({
data: {
runnerUpdate: {
runner: {
id: mockRunner.id,
- active: isActive,
+ paused: isPaused,
},
errors: [mockErrorMsg, mockErrorMsg2],
},
@@ -215,7 +215,7 @@ describe('RunnerPauseButton', () => {
createComponent({
props: {
runner: {
- active: true,
+ paused: false,
},
compact: true,
},
diff --git a/spec/frontend/ci/runner/components/runner_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_status_badge_spec.js
index e1eb81f2d23..781193d8afa 100644
--- a/spec/frontend/ci/runner/components/runner_status_badge_spec.js
+++ b/spec/frontend/ci/runner/components/runner_status_badge_spec.js
@@ -21,13 +21,11 @@ describe('RunnerTypeBadge', () => {
const findBadge = () => wrapper.findComponent(GlBadge);
const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip');
- const createComponent = (props = {}) => {
+ const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(RunnerStatusBadge, {
propsData: {
- runner: {
- contactedAt: '2020-12-31T23:59:00Z',
- status: STATUS_ONLINE,
- },
+ contactedAt: '2020-12-31T23:59:00Z',
+ status: STATUS_ONLINE,
...props,
},
directives: {
@@ -55,7 +53,7 @@ describe('RunnerTypeBadge', () => {
it('renders never contacted state', () => {
createComponent({
- runner: {
+ props: {
contactedAt: null,
status: STATUS_NEVER_CONTACTED,
},
@@ -68,7 +66,7 @@ describe('RunnerTypeBadge', () => {
it('renders offline state', () => {
createComponent({
- runner: {
+ props: {
contactedAt: '2020-12-31T00:00:00Z',
status: STATUS_OFFLINE,
},
@@ -81,7 +79,7 @@ describe('RunnerTypeBadge', () => {
it('renders stale state', () => {
createComponent({
- runner: {
+ props: {
contactedAt: '2020-01-01T00:00:00Z',
status: STATUS_STALE,
},
@@ -94,7 +92,7 @@ describe('RunnerTypeBadge', () => {
it('renders stale state with no contact time', () => {
createComponent({
- runner: {
+ props: {
contactedAt: null,
status: STATUS_STALE,
},
@@ -108,7 +106,7 @@ describe('RunnerTypeBadge', () => {
describe('does not fail when data is missing', () => {
it('contacted_at is missing', () => {
createComponent({
- runner: {
+ props: {
contactedAt: null,
status: STATUS_ONLINE,
},
@@ -120,7 +118,7 @@ describe('RunnerTypeBadge', () => {
it('status is missing', () => {
createComponent({
- runner: {
+ props: {
status: null,
},
});
diff --git a/spec/frontend/ci/runner/components/runner_update_form_spec.js b/spec/frontend/ci/runner/components/runner_update_form_spec.js
index db4c236bfff..5851078a8d3 100644
--- a/spec/frontend/ci/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/ci/runner/components/runner_update_form_spec.js
@@ -1,20 +1,17 @@
-import Vue, { nextTick } from 'vue';
-import { GlForm, GlSkeletonLoader } from '@gitlab/ui';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlForm } from '@gitlab/ui';
import { __ } from '~/locale';
+import { createAlert, VARIANT_SUCCESS } from '~/alert';
+import { visitUrl } from '~/lib/utils/url_utility';
+
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+
+import { runnerToModel } from 'ee_else_ce/ci/runner/runner_update_form_utils';
+import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
import RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue';
-import {
- INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
- ACCESS_LEVEL_REF_PROTECTED,
- ACCESS_LEVEL_NOT_PROTECTED,
-} from '~/ci/runner/constants';
import runnerUpdateMutation from '~/ci/runner/graphql/edit/runner_update.mutation.graphql';
import { captureException } from '~/ci/runner/sentry_utils';
import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage';
@@ -23,7 +20,10 @@ import { runnerFormData } from '../mock_data';
jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
-jest.mock('~/lib/utils/url_utility');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
const mockRunner = runnerFormData.data.runner;
const mockRunnerPath = '/admin/runners/1';
@@ -35,16 +35,7 @@ describe('RunnerUpdateForm', () => {
let runnerUpdateHandler;
const findForm = () => wrapper.findComponent(GlForm);
- const findPausedCheckbox = () => wrapper.findByTestId('runner-field-paused');
- const findProtectedCheckbox = () => wrapper.findByTestId('runner-field-protected');
- const findRunUntaggedCheckbox = () => wrapper.findByTestId('runner-field-run-untagged');
- const findLockedCheckbox = () => wrapper.findByTestId('runner-field-locked');
- const findFields = () => wrapper.findAll('[data-testid^="runner-field"');
-
- const findDescriptionInput = () => wrapper.findByTestId('runner-field-description').find('input');
- const findMaxJobTimeoutInput = () =>
- wrapper.findByTestId('runner-field-max-timeout').find('input');
- const findTagsInput = () => wrapper.findByTestId('runner-field-tags').find('input');
+ const findRunnerFormFields = () => wrapper.findComponent(RunnerFormFields);
const findSubmit = () => wrapper.find('[type="submit"]');
const findSubmitDisabledAttr = () => findSubmit().attributes('disabled');
@@ -52,21 +43,10 @@ describe('RunnerUpdateForm', () => {
const submitForm = () => findForm().trigger('submit');
const submitFormAndWait = () => submitForm().then(waitForPromises);
- const getFieldsModel = () => ({
- active: !findPausedCheckbox().element.checked,
- accessLevel: findProtectedCheckbox().element.checked
- ? ACCESS_LEVEL_REF_PROTECTED
- : ACCESS_LEVEL_NOT_PROTECTED,
- runUntagged: findRunUntaggedCheckbox().element.checked,
- locked: findLockedCheckbox().element?.checked || false,
- maximumTimeout: findMaxJobTimeoutInput().element.value || null,
- tagList: findTagsInput().element.value.split(',').filter(Boolean),
- });
-
const createComponent = ({ props } = {}) => {
wrapper = mountExtended(RunnerUpdateForm, {
propsData: {
- runner: mockRunner,
+ runner: null,
runnerPath: mockRunnerPath,
...props,
},
@@ -86,7 +66,7 @@ describe('RunnerUpdateForm', () => {
variant: VARIANT_SUCCESS,
}),
);
- expect(redirectTo).toHaveBeenCalledWith(mockRunnerPath); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(mockRunnerPath);
};
beforeEach(() => {
@@ -103,141 +83,82 @@ describe('RunnerUpdateForm', () => {
},
});
});
+ });
+ it('form has fields, submit and cancel buttons', () => {
createComponent();
- });
- it('Form has a submit button', () => {
+ expect(findRunnerFormFields().exists()).toBe(true);
expect(findSubmit().exists()).toBe(true);
- });
-
- it('Form fields match data', () => {
- expect(mockRunner).toMatchObject(getFieldsModel());
- });
-
- it('Form shows a cancel button', () => {
- expect(runnerUpdateHandler).not.toHaveBeenCalled();
expect(findCancelBtn().attributes('href')).toBe(mockRunnerPath);
});
- it('Form prevent multiple submissions', async () => {
- await submitForm();
-
- expect(findSubmitDisabledAttr()).toBe('disabled');
- });
-
- it('Updates runner with no changes', async () => {
- await submitFormAndWait();
-
- // Some read-only fields are not submitted
- const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner;
-
- expectToHaveSubmittedRunnerContaining(submitted);
- });
-
describe('When data is being loaded', () => {
beforeEach(() => {
createComponent({ props: { loading: true } });
});
- it('Form skeleton is shown', () => {
- expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
- expect(findFields()).toHaveLength(0);
+ it('form has no runner', () => {
+ expect(findRunnerFormFields().props('value')).toBe(null);
});
- it('Form cannot be submitted', () => {
+ it('form cannot be submitted', () => {
expect(findSubmit().props('loading')).toBe(true);
});
+ });
+
+ describe('When runner has loaded', () => {
+ beforeEach(async () => {
+ createComponent({ props: { loading: true } });
- it('Form is updated when data loads', async () => {
- wrapper.setProps({
+ await wrapper.setProps({
loading: false,
+ runner: mockRunner,
});
-
- await nextTick();
-
- expect(findFields()).not.toHaveLength(0);
- expect(mockRunner).toMatchObject(getFieldsModel());
});
- });
- it.each`
- runnerType | exists | outcome
- ${INSTANCE_TYPE} | ${false} | ${'hidden'}
- ${GROUP_TYPE} | ${false} | ${'hidden'}
- ${PROJECT_TYPE} | ${true} | ${'shown'}
- `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => {
- const runner = { ...mockRunner, runnerType };
- createComponent({ props: { runner } });
+ it('shows runner fields', () => {
+ expect(findRunnerFormFields().props('value')).toEqual(runnerToModel(mockRunner));
+ });
- expect(findLockedCheckbox().exists()).toBe(exists);
- });
+ it('form has not been submitted', () => {
+ expect(runnerUpdateHandler).not.toHaveBeenCalled();
+ });
- describe('On submit, runner gets updated', () => {
- it.each`
- test | initialValue | findCheckbox | checked | submitted
- ${'pauses'} | ${{ active: true }} | ${findPausedCheckbox} | ${true} | ${{ active: false }}
- ${'activates'} | ${{ active: false }} | ${findPausedCheckbox} | ${false} | ${{ active: true }}
- ${'unprotects'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }} | ${findProtectedCheckbox} | ${true} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }}
- ${'protects'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED }} | ${findProtectedCheckbox} | ${false} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED }}
- ${'"runs untagged jobs"'} | ${{ runUntagged: true }} | ${findRunUntaggedCheckbox} | ${false} | ${{ runUntagged: false }}
- ${'"runs tagged jobs"'} | ${{ runUntagged: false }} | ${findRunUntaggedCheckbox} | ${true} | ${{ runUntagged: true }}
- ${'locks'} | ${{ runnerType: PROJECT_TYPE, locked: true }} | ${findLockedCheckbox} | ${false} | ${{ locked: false }}
- ${'unlocks'} | ${{ runnerType: PROJECT_TYPE, locked: false }} | ${findLockedCheckbox} | ${true} | ${{ locked: true }}
- `('Checkbox $test runner', async ({ initialValue, findCheckbox, checked, submitted }) => {
- const runner = { ...mockRunner, ...initialValue };
- createComponent({ props: { runner } });
-
- await findCheckbox().setChecked(checked);
- await submitFormAndWait();
+ it('Form prevents multiple submissions', async () => {
+ await submitForm();
- expectToHaveSubmittedRunnerContaining({
- id: runner.id,
- ...submitted,
- });
+ expect(findSubmitDisabledAttr()).toBe('disabled');
});
- it.each`
- test | initialValue | findInput | value | submitted
- ${'description'} | ${{ description: 'Desc. 1' }} | ${findDescriptionInput} | ${'Desc. 2'} | ${{ description: 'Desc. 2' }}
- ${'max timeout'} | ${{ maximumTimeout: 36000 }} | ${findMaxJobTimeoutInput} | ${'40000'} | ${{ maximumTimeout: 40000 }}
- ${'tags'} | ${{ tagList: ['tag1'] }} | ${findTagsInput} | ${'tag2, tag3'} | ${{ tagList: ['tag2', 'tag3'] }}
- `("Field updates runner's $test", async ({ initialValue, findInput, value, submitted }) => {
- const runner = { ...mockRunner, ...initialValue };
- createComponent({ props: { runner } });
-
- await findInput().setValue(value);
+ it('Updates runner with no changes', async () => {
await submitFormAndWait();
- expectToHaveSubmittedRunnerContaining({
- id: runner.id,
- ...submitted,
- });
+ // Some read-only fields are not submitted
+ const { __typename, shortSha, runnerType, createdAt, status, ...submitted } = mockRunner;
+
+ expectToHaveSubmittedRunnerContaining(submitted);
});
- it.each`
- value | submitted
- ${''} | ${{ tagList: [] }}
- ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }}
- ${'with spaces'} | ${{ tagList: ['with spaces'] }}
- ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }}
- `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => {
- const runner = { ...mockRunner, tagList: ['tag1'] };
- createComponent({ props: { runner } });
-
- await findTagsInput().setValue(value);
+ it('Updates runner with changes', async () => {
+ findRunnerFormFields().vm.$emit(
+ 'input',
+ runnerToModel({ ...mockRunner, description: 'A new description' }),
+ );
await submitFormAndWait();
- expectToHaveSubmittedRunnerContaining({
- id: runner.id,
- ...submitted,
- });
+ expectToHaveSubmittedRunnerContaining({ description: 'A new description' });
});
});
describe('On error', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent();
+
+ await wrapper.setProps({
+ loading: false,
+ runner: mockRunner,
+ });
});
it('On network error, error message is shown', async () => {
@@ -278,7 +199,7 @@ describe('RunnerUpdateForm', () => {
expect(captureException).not.toHaveBeenCalled();
expect(saveAlertToLocalStorage).not.toHaveBeenCalled();
- expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js
index 1c052b00fc3..177fd9bcd9a 100644
--- a/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js
+++ b/spec/frontend/ci/runner/group_new_runner_app/group_new_runner_app_spec.js
@@ -16,7 +16,7 @@ import {
WINDOWS_PLATFORM,
} from '~/ci/runner/constants';
import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl } from '~/lib/utils/url_utility';
import { runnerCreateResult } from '../mock_data';
const mockGroupId = 'gid://gitlab/Group/72';
@@ -25,7 +25,7 @@ jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
- redirectTo: jest.fn(),
+ visitUrl: jest.fn(),
}));
const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
@@ -92,7 +92,7 @@ describe('GroupRunnerRunnerApp', () => {
it('redirects to the registration page', () => {
const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`;
- expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(url);
});
});
@@ -105,7 +105,7 @@ describe('GroupRunnerRunnerApp', () => {
it('redirects to the registration page with the platform', () => {
const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`;
- expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(url);
});
});
diff --git a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js
index 0c594e8005c..120388900b5 100644
--- a/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js
+++ b/spec/frontend/ci/runner/group_runner_show/group_runner_show_app_spec.js
@@ -5,7 +5,7 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/ci/runner/components/runner_header.vue';
@@ -26,11 +26,15 @@ import { runnerData } from '../mock_data';
jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
-jest.mock('~/lib/utils/url_utility');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
const mockRunner = runnerData.data.runner;
const mockRunnerGraphqlId = mockRunner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
+const mockRunnerSha = mockRunner.shortSha;
const mockRunnersPath = '/groups/group1/-/runners';
const mockEditGroupRunnerPath = `/groups/group1/-/runners/${mockRunnerId}/edit`;
@@ -88,7 +92,7 @@ describe('GroupRunnerShowApp', () => {
});
it('displays the runner header', () => {
- expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
+ expect(findRunnerHeader().text()).toContain(`#${mockRunnerId} (${mockRunnerSha})`);
});
it('displays the runner edit and pause buttons', () => {
@@ -185,7 +189,7 @@ describe('GroupRunnerShowApp', () => {
message: 'Runner deleted',
variant: VARIANT_SUCCESS,
});
- expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(mockRunnersPath);
});
});
});
diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js
index 41be72b1645..74eeb864cd8 100644
--- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js
@@ -82,6 +82,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('GroupRunnersApp', () => {
let wrapper;
+ const showToast = jest.fn();
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
@@ -123,6 +124,11 @@ describe('GroupRunnersApp', () => {
staleTimeoutSecs,
...provide,
},
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
...options,
});
@@ -250,8 +256,6 @@ describe('GroupRunnersApp', () => {
});
describe('Single runner row', () => {
- let showToast;
-
const { webUrl, editUrl, node } = mockGroupRunnersEdges[0];
const { id: graphqlId, shortSha, jobExecutionStatus } = node;
const id = getIdFromGraphQLId(graphqlId);
@@ -260,7 +264,6 @@ describe('GroupRunnersApp', () => {
beforeEach(async () => {
await createComponent({ mountFn: mountExtended });
- showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
});
it('Shows job status and links to jobs', () => {
diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js
index 223a156795c..d72f93ad574 100644
--- a/spec/frontend/ci/runner/mock_data.js
+++ b/spec/frontend/ci/runner/mock_data.js
@@ -18,6 +18,7 @@ import runnerData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphq
import runnerWithGroupData from 'test_fixtures/graphql/ci/runner/show/runner.query.graphql.with_group.json';
import runnerProjectsData from 'test_fixtures/graphql/ci/runner/show/runner_projects.query.graphql.json';
import runnerJobsData from 'test_fixtures/graphql/ci/runner/show/runner_jobs.query.graphql.json';
+import runnerManagersData from 'test_fixtures/graphql/ci/runner/show/runner_managers.query.graphql.json';
// Edit runner queries
import runnerFormData from 'test_fixtures/graphql/ci/runner/edit/runner_form.query.graphql.json';
@@ -336,6 +337,7 @@ export {
runnerWithGroupData,
runnerProjectsData,
runnerJobsData,
+ runnerManagersData,
runnerFormData,
runnerCreateResult,
runnerForRegistration,
diff --git a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js
index 5bfbbfdc074..22d8e243f7b 100644
--- a/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js
+++ b/spec/frontend/ci/runner/project_new_runner_app/project_new_runner_app_spec.js
@@ -16,7 +16,7 @@ import {
WINDOWS_PLATFORM,
} from '~/ci/runner/constants';
import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue';
-import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { visitUrl } from '~/lib/utils/url_utility';
import { runnerCreateResult, mockRegistrationToken } from '../mock_data';
const mockProjectId = 'gid://gitlab/Project/72';
@@ -25,7 +25,7 @@ jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage');
jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
- redirectTo: jest.fn(),
+ visitUrl: jest.fn(),
}));
const mockCreatedRunner = runnerCreateResult.data.runnerCreate.runner;
@@ -93,7 +93,7 @@ describe('ProjectRunnerRunnerApp', () => {
it('redirects to the registration page', () => {
const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${DEFAULT_PLATFORM}`;
- expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(url);
});
});
@@ -106,7 +106,7 @@ describe('ProjectRunnerRunnerApp', () => {
it('redirects to the registration page with the platform', () => {
const url = `${mockCreatedRunner.ephemeralRegisterUrl}?${PARAM_KEY_PLATFORM}=${WINDOWS_PLATFORM}`;
- expect(redirectTo).toHaveBeenCalledWith(url); // eslint-disable-line import/no-deprecated
+ expect(visitUrl).toHaveBeenCalledWith(url);
});
});
diff --git a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js
index 79bbf95f8f0..ee4bd9ccc92 100644
--- a/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js
+++ b/spec/frontend/ci/runner/runner_edit/runner_edit_app_spec.js
@@ -21,6 +21,7 @@ jest.mock('~/ci/runner/sentry_utils');
const mockRunner = runnerFormData.data.runner;
const mockRunnerGraphqlId = mockRunner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
+const mockRunnerSha = mockRunner.shortSha;
const mockRunnerPath = `/admin/runners/${mockRunnerId}`;
Vue.use(VueApollo);
@@ -62,7 +63,7 @@ describe('RunnerEditApp', () => {
it('displays the runner id and creation date', async () => {
await createComponentWithApollo({ mountFn: mount });
- expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
+ expect(findRunnerHeader().text()).toContain(`#${mockRunnerId} (${mockRunnerSha})`);
expect(findRunnerHeader().text()).toContain('created');
});
diff --git a/spec/frontend/ci/runner/runner_update_form_utils_spec.js b/spec/frontend/ci/runner/runner_update_form_utils_spec.js
index b2f7bbc49a9..80c492bb431 100644
--- a/spec/frontend/ci/runner/runner_update_form_utils_spec.js
+++ b/spec/frontend/ci/runner/runner_update_form_utils_spec.js
@@ -12,7 +12,7 @@ const mockRunner = {
description: mockDescription,
maximumTimeout: 100,
accessLevel: ACCESS_LEVEL_NOT_PROTECTED,
- active: true,
+ paused: false,
locked: true,
runUntagged: true,
tagList: ['tag-1', 'tag-2'],
@@ -79,7 +79,7 @@ describe('~/ci/runner/runner_update_form_utils', () => {
${',,,,, commas'} | ${['commas']}
${'more ,,,,, commas'} | ${['more', 'commas']}
${' trimmed , trimmed2 '} | ${['trimmed', 'trimmed2']}
- `('collect tags separated by commas for "$value"', ({ tagList, tagListInput }) => {
+ `('collect comma-separated tags "$tagList" as $tagListInput', ({ tagList, tagListInput }) => {
const variables = modelToUpdateMutationVariables({
...mockModel,
tagList,
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
index 0f68a69458e..71a56eba22a 100644
--- a/spec/frontend/clusters_list/components/agent_table_spec.js
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -1,4 +1,4 @@
-import { GlLink, GlIcon } from '@gitlab/ui';
+import { GlLink, GlIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui';
import { sprintf } from '~/locale';
import AgentTable from '~/clusters_list/components/agent_table.vue';
import DeleteAgentButton from '~/clusters_list/components/delete_agent_button.vue';
@@ -17,6 +17,7 @@ const provideData = {
};
const defaultProps = {
agents: clusterAgents,
+ maxAgents: null,
};
const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, {
@@ -39,7 +40,11 @@ describe('AgentTable', () => {
const findAgentId = (at) => wrapper.findAllByTestId('cluster-agent-id').at(at);
const findConfiguration = (at) =>
wrapper.findAllByTestId('cluster-agent-configuration-link').at(at);
- const findDeleteAgentButton = () => wrapper.findAllComponents(DeleteAgentButton);
+ const findDeleteAgentButtons = () => wrapper.findAllComponents(DeleteAgentButton);
+ const findTableRow = (at) => wrapper.findComponent(GlTable).find('tbody').findAll('tr').at(at);
+ const findSharedBadgeByRow = (at) => findTableRow(at).findComponent(GlBadge);
+ const findDeleteAgentButtonByRow = (at) => findTableRow(at).findComponent(DeleteAgentButton);
+ const findPagination = () => wrapper.findComponent(GlPagination);
const createWrapper = ({ provide = provideData, propsData = defaultProps } = {}) => {
wrapper = mountExtended(AgentTable, {
@@ -64,6 +69,11 @@ describe('AgentTable', () => {
`('displays agent link for $agentName', ({ agentName, link, lineNumber }) => {
expect(findAgentLink(lineNumber).text()).toBe(agentName);
expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
+ expect(findSharedBadgeByRow(lineNumber).exists()).toBe(false);
+ });
+
+ it('displays "shared" badge if the agent is shared', () => {
+ expect(findSharedBadgeByRow(9).text()).toBe(I18N_AGENT_TABLE.sharedBadgeText);
});
it.each`
@@ -116,8 +126,9 @@ describe('AgentTable', () => {
},
);
- it('displays actions menu for each agent', () => {
- expect(findDeleteAgentButton()).toHaveLength(clusterAgents.length);
+ it('displays actions menu for each agent except the shared agents', () => {
+ expect(findDeleteAgentButtons()).toHaveLength(clusterAgents.length - 1);
+ expect(findDeleteAgentButtonByRow(9).exists()).toBe(false);
});
});
@@ -132,6 +143,7 @@ describe('AgentTable', () => {
${6} | ${'14.8.0'} | ${'15.0.0'} | ${false} | ${true} | ${outdatedTitle}
${7} | ${'14.8.0'} | ${'15.0.0-rc1'} | ${false} | ${true} | ${outdatedTitle}
${8} | ${'14.8.0'} | ${'14.8.10'} | ${false} | ${false} | ${''}
+ ${9} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''}
`(
'when agent version is "$agentVersion", KAS version is "$kasVersion" and version mismatch is "$versionMismatch"',
({ agentMockIdx, agentVersion, kasVersion, versionMismatch, versionOutdated, title }) => {
@@ -181,5 +193,32 @@ describe('AgentTable', () => {
}
},
);
+
+ describe('pagination', () => {
+ it('should not render pagination buttons when there are no additional pages', () => {
+ createWrapper();
+
+ expect(findPagination().exists()).toBe(false);
+ });
+
+ it('should render pagination buttons when there are additional pages', () => {
+ createWrapper({
+ propsData: { agents: [...clusterAgents, ...clusterAgents, ...clusterAgents] },
+ });
+
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('should not render pagination buttons when maxAgents is passed from the parent component', () => {
+ createWrapper({
+ propsData: {
+ agents: [...clusterAgents, ...clusterAgents, ...clusterAgents],
+ maxAgents: 6,
+ },
+ });
+
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
});
});
diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js
index d91245ba9b4..d6ede01fac4 100644
--- a/spec/frontend/clusters_list/components/agents_spec.js
+++ b/spec/frontend/clusters_list/components/agents_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlBanner } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue, { nextTick } from 'vue';
@@ -19,6 +19,7 @@ Vue.use(VueApollo);
describe('Agents', () => {
let wrapper;
+ let testDate = new Date();
const defaultProps = {
defaultBranchName: 'default',
@@ -31,9 +32,9 @@ describe('Agents', () => {
props = {},
glFeatures = {},
agents = [],
- pageInfo = null,
+ ciAccessAuthorizedAgentsNodes = [],
+ userAccessAuthorizedAgentsNodes = [],
trees = [],
- count = 0,
queryResponse = null,
}) => {
const provide = provideData;
@@ -43,12 +44,16 @@ describe('Agents', () => {
id: '1',
clusterAgents: {
nodes: agents,
- pageInfo,
connections: { nodes: [] },
tokens: { nodes: [] },
- count,
},
- repository: { tree: { trees: { nodes: trees, pageInfo } } },
+ ciAccessAuthorizedAgents: {
+ nodes: ciAccessAuthorizedAgentsNodes,
+ },
+ userAccessAuthorizedAgents: {
+ nodes: userAccessAuthorizedAgentsNodes,
+ },
+ repository: { tree: { trees: { nodes: trees } } },
},
},
};
@@ -78,7 +83,6 @@ describe('Agents', () => {
const findAgentTable = () => wrapper.findComponent(AgentTable);
const findEmptyState = () => wrapper.findComponent(AgentEmptyState);
- const findPaginationButtons = () => wrapper.findComponent(GlKeysetPagination);
const findAlert = () => wrapper.findComponent(GlAlert);
const findBanner = () => wrapper.findComponent(GlBanner);
@@ -87,13 +91,13 @@ describe('Agents', () => {
});
describe('when there is a list of agents', () => {
- let testDate = new Date();
const agents = [
{
__typename: 'ClusterAgent',
id: '1',
name: 'agent-1',
webPath: '/agent-1',
+ createdAt: testDate,
connections: null,
tokens: null,
},
@@ -102,6 +106,7 @@ describe('Agents', () => {
id: '2',
name: 'agent-2',
webPath: '/agent-2',
+ createdAt: testDate,
connections: null,
tokens: {
nodes: [
@@ -113,8 +118,26 @@ describe('Agents', () => {
},
},
];
-
- const count = 2;
+ const ciAccessAuthorizedAgentsNodes = [
+ {
+ agent: {
+ __typename: 'ClusterAgent',
+ id: '3',
+ name: 'ci-agent-1',
+ webPath: 'shared-project/agent-1',
+ createdAt: testDate,
+ connections: null,
+ tokens: null,
+ },
+ },
+ ];
+ const userAccessAuthorizedAgentsNodes = [
+ {
+ agent: {
+ ...agents[0],
+ },
+ },
+ ];
const trees = [
{
@@ -156,10 +179,26 @@ describe('Agents', () => {
],
},
},
+ {
+ id: '3',
+ name: 'ci-agent-1',
+ configFolder: undefined,
+ webPath: 'shared-project/agent-1',
+ status: 'unused',
+ isShared: true,
+ lastContact: null,
+ connections: null,
+ tokens: null,
+ },
];
beforeEach(() => {
- return createWrapper({ agents, count, trees });
+ return createWrapper({
+ agents,
+ ciAccessAuthorizedAgentsNodes,
+ userAccessAuthorizedAgentsNodes,
+ trees,
+ });
});
it('should render agent table', () => {
@@ -172,7 +211,7 @@ describe('Agents', () => {
});
it('should emit agents count to the parent component', () => {
- expect(wrapper.emitted().onAgentsLoad).toEqual([[count]]);
+ expect(wrapper.emitted().onAgentsLoad).toEqual([[expectedAgentsList.length]]);
});
describe.each`
@@ -192,7 +231,7 @@ describe('Agents', () => {
localStorage.setItem(AGENT_FEEDBACK_KEY, true);
}
- return createWrapper({ glFeatures, agents, count, trees });
+ return createWrapper({ glFeatures, agents, trees });
});
it(`should ${bannerShown ? 'show' : 'hide'} the feedback banner`, () => {
@@ -206,7 +245,7 @@ describe('Agents', () => {
showGitlabAgentFeedback: true,
};
beforeEach(() => {
- return createWrapper({ glFeatures, agents, count, trees });
+ return createWrapper({ glFeatures, agents, trees });
});
it('should render the correct title', () => {
@@ -238,51 +277,6 @@ describe('Agents', () => {
expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList);
});
});
-
- it('should not render pagination buttons when there are no additional pages', () => {
- expect(findPaginationButtons().exists()).toBe(false);
- });
-
- describe('when the list has additional pages', () => {
- const pageInfo = {
- hasNextPage: true,
- hasPreviousPage: false,
- startCursor: 'prev',
- endCursor: 'next',
- };
-
- beforeEach(() => {
- return createWrapper({
- agents,
- pageInfo: {
- ...pageInfo,
- __typename: 'PageInfo',
- },
- });
- });
-
- it('should render pagination buttons', () => {
- expect(findPaginationButtons().exists()).toBe(true);
- });
-
- it('should pass pageInfo to the pagination component', () => {
- expect(findPaginationButtons().props()).toMatchObject(pageInfo);
- });
-
- describe('when limit is passed from the parent component', () => {
- beforeEach(() => {
- return createWrapper({
- props: { limit: 6 },
- agents,
- pageInfo,
- });
- });
-
- it('should not render pagination buttons', () => {
- expect(findPaginationButtons().exists()).toBe(false);
- });
- });
- });
});
describe('when the agent list is empty', () => {
@@ -302,7 +296,10 @@ describe('Agents', () => {
describe('when agents query has errored', () => {
beforeEach(() => {
- return createWrapper({ agents: null });
+ createWrapper({
+ queryResponse: jest.fn().mockRejectedValue({}),
+ });
+ return waitForPromises();
});
it('displays an alert message', () => {
diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
index 02b455d0b61..1ec8764705c 100644
--- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
+++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
@@ -20,7 +20,6 @@ describe('AvailableAgentsDropdown', () => {
propsData,
stubs: { GlCollapsibleListbox },
});
- wrapper.vm.$refs.dropdown.closeAndFocus = jest.fn();
};
describe('there are agents available', () => {
diff --git a/spec/frontend/clusters_list/components/delete_agent_button_spec.js b/spec/frontend/clusters_list/components/delete_agent_button_spec.js
index 2c9a6b11671..8bbb5ec92a7 100644
--- a/spec/frontend/clusters_list/components/delete_agent_button_spec.js
+++ b/spec/frontend/clusters_list/components/delete_agent_button_spec.js
@@ -8,7 +8,7 @@ import deleteAgentMutation from '~/clusters_list/graphql/mutations/delete_agent.
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DeleteAgentButton from '~/clusters_list/components/delete_agent_button.vue';
-import { MAX_LIST_COUNT, DELETE_AGENT_BUTTON } from '~/clusters_list/constants';
+import { DELETE_AGENT_BUTTON } from '~/clusters_list/constants';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { getAgentResponse, mockDeleteResponse, mockErrorDeleteResponse } from '../mocks/apollo';
@@ -16,7 +16,6 @@ Vue.use(VueApollo);
const projectPath = 'path/to/project';
const defaultBranchName = 'default';
-const maxAgents = MAX_LIST_COUNT;
const agent = {
id: 'agent-id',
name: 'agent-name',
@@ -53,8 +52,6 @@ describe('DeleteAgentButton', () => {
variables: {
projectPath,
defaultBranchName,
- first: maxAgents,
- last: null,
},
data: getAgentResponse.data,
});
@@ -71,7 +68,6 @@ describe('DeleteAgentButton', () => {
};
const propsData = {
defaultBranchName,
- maxAgents,
agent,
};
diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js
index af1fb496118..161ea4566e1 100644
--- a/spec/frontend/clusters_list/components/mock_data.js
+++ b/spec/frontend/clusters_list/components/mock_data.js
@@ -205,4 +205,14 @@ export const clusterAgents = [
],
},
},
+ {
+ name: 'ci-agent-1',
+ id: '3',
+ webPath: 'shared-project/agent-1',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ isShared: true,
+ connections: null,
+ tokens: null,
+ },
];
diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js
index 3467b4c665c..c0e25d174ae 100644
--- a/spec/frontend/clusters_list/mocks/apollo.js
+++ b/spec/frontend/clusters_list/mocks/apollo.js
@@ -3,6 +3,7 @@ const agent = {
id: 'agent-id',
name: 'agent-name',
webPath: 'agent-webPath',
+ createdAt: new Date(),
};
const token = {
id: 'token-id',
@@ -14,13 +15,6 @@ const tokens = {
const connections = {
nodes: [],
};
-const pageInfo = {
- endCursor: '',
- hasNextPage: false,
- hasPreviousPage: false,
- startCursor: '',
-};
-const count = 1;
export const createAgentResponse = {
data: {
@@ -73,10 +67,12 @@ export const getAgentResponse = {
project: {
__typename: 'Project',
id: 'project-1',
- clusterAgents: { nodes: [{ ...agent, connections, tokens }], pageInfo, count },
+ clusterAgents: { nodes: [{ ...agent, connections, tokens }] },
+ ciAccessAuthorizedAgents: { nodes: [] },
+ userAccessAuthorizedAgents: { nodes: [] },
repository: {
tree: {
- trees: { nodes: [{ ...agent, path: null }], pageInfo },
+ trees: { nodes: [{ ...agent, path: null }] },
},
},
},
diff --git a/spec/frontend/code_review/signals_spec.js b/spec/frontend/code_review/signals_spec.js
index 03c3580860e..3758dd1222b 100644
--- a/spec/frontend/code_review/signals_spec.js
+++ b/spec/frontend/code_review/signals_spec.js
@@ -1,5 +1,4 @@
import { start } from '~/code_review/signals';
-
import diffsEventHub from '~/diffs/event_hub';
import { EVT_MR_PREPARED } from '~/diffs/constants';
import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request';
@@ -90,6 +89,20 @@ describe('~/code_review', () => {
expect(apolloSubscribeSpy).not.toHaveBeenCalled();
});
+ describe('when the project does not exist', () => {
+ beforeEach(() => {
+ querySpy.mockResolvedValue({
+ data: { project: null },
+ });
+ });
+
+ it('does not fail and quits silently', () => {
+ expect(async () => {
+ await start(callArgs);
+ }).not.toThrow();
+ });
+ });
+
describe('if the merge request is still asynchronously preparing', () => {
beforeEach(() => {
querySpy.mockResolvedValue({
diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
index 0f158df6c05..8cad483e27e 100644
--- a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
+++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap
@@ -58,7 +58,7 @@ exports[`Comment templates list item component renders list item 1`] = `
</button>
<div
- class="gl-new-dropdown-panel gl-w-31"
+ class="gl-new-dropdown-panel gl-w-31!"
data-testid="base-dropdown-menu"
id="base-dropdown-7"
>
diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
index 7be68df61de..7983f8fddf5 100644
--- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
+++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
@@ -7,10 +7,11 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue';
+import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import { COMMIT_BOX_POLL_INTERVAL } from '~/projects/commit_box/info/constants';
-import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
-import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql';
+import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
+import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql';
import * as sharedGraphQlUtils from '~/graphql_shared/utils';
import {
mockDownstreamQueryResponse,
@@ -28,6 +29,7 @@ describe('Commit box pipeline mini graph', () => {
let wrapper;
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findGraphqlPipelineMiniGraph = () => wrapper.findComponent(GraphqlPipelineMiniGraph);
const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph);
const downstreamHandler = jest.fn().mockResolvedValue(mockDownstreamQueryResponse);
@@ -52,7 +54,7 @@ describe('Commit box pipeline mini graph', () => {
return createMockApollo(requestHandlers);
};
- const createComponent = (handler) => {
+ const createComponent = ({ handler, ciGraphqlPipelineMiniGraph = false } = {}) => {
wrapper = extendedWrapper(
shallowMount(CommitBoxPipelineMiniGraph, {
propsData: {
@@ -63,6 +65,9 @@ describe('Commit box pipeline mini graph', () => {
iid,
dataMethod: 'graphql',
graphqlResourceEtag: '/api/graphql:pipelines/id/320',
+ glFeatures: {
+ ciGraphqlPipelineMiniGraph,
+ },
},
apolloProvider: createMockApolloProvider(handler),
}),
@@ -148,7 +153,7 @@ describe('Commit box pipeline mini graph', () => {
});
it('should pass the pipeline path prop for the counter badge', async () => {
- createComponent(downstreamHandler);
+ createComponent({ handler: downstreamHandler });
await waitForPromises();
@@ -159,7 +164,7 @@ describe('Commit box pipeline mini graph', () => {
});
it('should render an upstream pipeline only', async () => {
- createComponent(upstreamHandler);
+ createComponent({ handler: upstreamHandler });
await waitForPromises();
@@ -171,7 +176,7 @@ describe('Commit box pipeline mini graph', () => {
});
it('should render downstream and upstream pipelines', async () => {
- createComponent(upstreamDownstreamHandler);
+ createComponent({ handler: upstreamDownstreamHandler });
await waitForPromises();
@@ -255,4 +260,31 @@ describe('Commit box pipeline mini graph', () => {
);
});
});
+
+ describe('feature flag behavior', () => {
+ it.each`
+ state | provide | showPipelineMiniGraph | showGraphqlPipelineMiniGraph
+ ${true} | ${{ ciGraphqlPipelineMiniGraph: true }} | ${false} | ${true}
+ ${false} | ${{}} | ${true} | ${false}
+ `(
+ 'renders the correct component when the feature flag is set to $state',
+ async ({ provide, showPipelineMiniGraph, showGraphqlPipelineMiniGraph }) => {
+ createComponent(provide);
+
+ await waitForPromises();
+
+ expect(findPipelineMiniGraph().exists()).toBe(showPipelineMiniGraph);
+ expect(findGraphqlPipelineMiniGraph().exists()).toBe(showGraphqlPipelineMiniGraph);
+ },
+ );
+
+ it('skips queries when the feature flag is enabled', async () => {
+ createComponent({ ciGraphqlPipelineMiniGraph: true });
+
+ await waitForPromises();
+
+ expect(stagesHandler).not.toHaveBeenCalled();
+ expect(downstreamHandler).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/commit/components/commit_refs_spec.js b/spec/frontend/commit/components/commit_refs_spec.js
new file mode 100644
index 00000000000..380b2e07842
--- /dev/null
+++ b/spec/frontend/commit/components/commit_refs_spec.js
@@ -0,0 +1,97 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { createAlert } from '~/alert';
+import commitReferences from '~/projects/commit_box/info/graphql/queries/commit_references.query.graphql';
+import containingBranchesQuery from '~/projects/commit_box/info/graphql/queries/commit_containing_branches.query.graphql';
+import RefsList from '~/projects/commit_box/info/components/refs_list.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ FETCH_CONTAINING_REFS_EVENT,
+ FETCH_COMMIT_REFERENCES_ERROR,
+} from '~/projects/commit_box/info/constants';
+import CommitRefs from '~/projects/commit_box/info/components/commit_refs.vue';
+
+import {
+ mockCommitReferencesResponse,
+ mockOnlyBranchesResponse,
+ mockContainingBranchesResponse,
+ refsListPropsMock,
+} from '../mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/alert');
+
+describe('Commit references component', () => {
+ let wrapper;
+
+ const successQueryHandler = (mockResponse) => jest.fn().mockResolvedValue(mockResponse);
+ const failedQueryHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+ const containingBranchesQueryHandler = successQueryHandler(mockContainingBranchesResponse);
+ const findRefsLists = () => wrapper.findAllComponents(RefsList);
+ const branchesList = () => findRefsLists().at(0);
+
+ const createComponent = async (
+ commitReferencesQueryHandler = successQueryHandler(mockCommitReferencesResponse),
+ ) => {
+ wrapper = shallowMount(CommitRefs, {
+ apolloProvider: createMockApollo([
+ [commitReferences, commitReferencesQueryHandler],
+ [containingBranchesQuery, containingBranchesQueryHandler],
+ ]),
+ provide: {
+ fullPath: 'some/project',
+ commitSha: 'xxx',
+ },
+ });
+
+ await waitForPromises();
+ };
+
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ it('renders component correcrly', () => {
+ expect(findRefsLists()).toHaveLength(2);
+ });
+
+ it('passes props to refs list', () => {
+ expect(branchesList().props()).toEqual(refsListPropsMock);
+ });
+
+ it('shows alert when response fails', async () => {
+ await createComponent(failedQueryHandler);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: FETCH_COMMIT_REFERENCES_ERROR,
+ captureError: true,
+ });
+ });
+
+ it('fetches containing refs on the fetch event', async () => {
+ await createComponent();
+ branchesList().vm.$emit(FETCH_CONTAINING_REFS_EVENT);
+ await waitForPromises();
+ expect(containingBranchesQueryHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render list when there is no branches or tags', async () => {
+ await createComponent(successQueryHandler(mockOnlyBranchesResponse));
+ expect(findRefsLists()).toHaveLength(1);
+ });
+
+ describe('with relative url', () => {
+ beforeEach(async () => {
+ gon.relative_url_root = '/gitlab';
+ await createComponent();
+ });
+
+ it('passes correct urlPart prop to refList', () => {
+ expect(branchesList().props('urlPart')).toBe(
+ `${gon.relative_url_root}${refsListPropsMock.urlPart}`,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/commit/components/refs_list_spec.js b/spec/frontend/commit/components/refs_list_spec.js
new file mode 100644
index 00000000000..cc783dc3b58
--- /dev/null
+++ b/spec/frontend/commit/components/refs_list_spec.js
@@ -0,0 +1,77 @@
+import { GlCollapse, GlButton, GlBadge, GlSkeletonLoader } from '@gitlab/ui';
+import RefsList from '~/projects/commit_box/info/components/refs_list.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ CONTAINING_COMMIT,
+ FETCH_CONTAINING_REFS_EVENT,
+} from '~/projects/commit_box/info/constants';
+import { refsListPropsMock, containingBranchesMock } from '../mock_data';
+
+describe('Commit references component', () => {
+ let wrapper;
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(RefsList, {
+ propsData: {
+ ...refsListPropsMock,
+ ...props,
+ },
+ });
+ };
+
+ const findTitle = () => wrapper.findByTestId('title');
+ const findCollapseButton = () => wrapper.findComponent(GlButton);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findTippingRefs = () => wrapper.findAllComponents(GlBadge);
+ const findContainingRefs = () => wrapper.findComponent(GlCollapse);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the namespace passed', () => {
+ expect(findTitle().text()).toEqual(refsListPropsMock.namespace);
+ });
+
+ it('renders list of tipping branches or tags', () => {
+ expect(findTippingRefs()).toHaveLength(refsListPropsMock.tippingRefs.length);
+ });
+
+ it('does not render collapse with containing branches ot tags when there is no data', () => {
+ createComponent({ hasContainingRefs: false });
+ expect(findCollapseButton().exists()).toBe(false);
+ });
+
+ it('renders collapse component if commit has containing branches', () => {
+ expect(findCollapseButton().text()).toContain(CONTAINING_COMMIT);
+ });
+
+ it('emits event when collapse button is clicked', () => {
+ findCollapseButton().vm.$emit('click');
+ expect(wrapper.emitted()[FETCH_CONTAINING_REFS_EVENT]).toHaveLength(1);
+ });
+
+ it('renders the list of containing branches or tags when collapse is expanded', () => {
+ createComponent({ containingRefs: containingBranchesMock });
+ const containingRefsList = findContainingRefs();
+ expect(containingRefsList.findAllComponents(GlBadge)).toHaveLength(
+ containingBranchesMock.length,
+ );
+ });
+
+ it('renders links to refs', () => {
+ const index = 0;
+ const refBadge = findTippingRefs().at(index);
+ const refUrl = `${refsListPropsMock.urlPart}${refsListPropsMock.tippingRefs[index]}?ref_type=${refsListPropsMock.refType}`;
+ expect(refBadge.attributes('href')).toBe(refUrl);
+ });
+
+ it('does not reneder list of tipping branches or tags if there is no data', () => {
+ createComponent({ tippingRefs: [] });
+ expect(findTippingRefs().exists()).toBe(false);
+ });
+
+ it('renders skeleton loader when isLoading prop has true value', () => {
+ createComponent({ isLoading: true, containingRefs: [] });
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js
index 3b6971d9607..2a618e08c50 100644
--- a/spec/frontend/commit/mock_data.js
+++ b/spec/frontend/commit/mock_data.js
@@ -232,3 +232,62 @@ export const x509CertificateDetailsProp = {
subject: 'CN=gitlab@example.org,OU=Example,O=World',
subjectKeyIdentifier: 'BC BC BC BC BC BC BC BC',
};
+
+export const tippingBranchesMock = ['main', 'development'];
+
+export const containingBranchesMock = ['branch-1', 'branch-2', 'branch-3'];
+
+export const mockCommitReferencesResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/1',
+ commitReferences: {
+ containingBranches: { names: ['branch-1'], __typename: 'CommitParentNames' },
+ containingTags: { names: ['tag-1'], __typename: 'CommitParentNames' },
+ tippingBranches: { names: tippingBranchesMock, __typename: 'CommitParentNames' },
+ tippingTags: { names: ['tag-latest'], __typename: 'CommitParentNames' },
+ __typename: 'CommitReferences',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const mockOnlyBranchesResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/1',
+ commitReferences: {
+ containingBranches: { names: ['branch-1'], __typename: 'CommitParentNames' },
+ containingTags: { names: [], __typename: 'CommitParentNames' },
+ tippingBranches: { names: tippingBranchesMock, __typename: 'CommitParentNames' },
+ tippingTags: { names: [], __typename: 'CommitParentNames' },
+ __typename: 'CommitReferences',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const mockContainingBranchesResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/1',
+ commitReferences: {
+ containingBranches: { names: containingBranchesMock, __typename: 'CommitParentNames' },
+ __typename: 'CommitReferences',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const refsListPropsMock = {
+ hasContainingRefs: true,
+ containingRefs: [],
+ namespace: 'Branches',
+ tippingRefs: tippingBranchesMock,
+ isLoading: false,
+ urlPart: '/some/project/-/commits/',
+ refType: 'heads',
+};
diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
index 97716ce848c..85eafa9e85c 100644
--- a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js
@@ -64,12 +64,12 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => {
tippyOptions: expect.objectContaining({
onHidden: expect.any(Function),
onShow: expect.any(Function),
- appendTo: expect.any(Function),
+ strategy: 'fixed',
+ maxWidth: 'auto',
...tippyOptions,
}),
});
- expect(BubbleMenuPlugin.mock.calls[0][0].tippyOptions.appendTo()).toBe(document.body);
expect(tiptapEditor.registerPlugin).toHaveBeenCalledWith(pluginInitializationResult);
});
diff --git a/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js
new file mode 100644
index 00000000000..169f77dc054
--- /dev/null
+++ b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js
@@ -0,0 +1,247 @@
+import { GlLoadingIcon, GlListboxItem, GlCollapsibleListbox } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue';
+import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue';
+import { stubComponent } from 'helpers/stub_component';
+import Reference from '~/content_editor/extensions/reference';
+import { createTestEditor, emitEditorEvent, createDocBuilder } from '../../test_utils';
+
+const mockIssue = {
+ href: 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24',
+ text: '#24',
+ expandedText: 'Et fuga quos omnis enim dolores amet impedit. (#24)',
+ fullyExpandedText:
+ 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.',
+};
+const mockMergeRequest = {
+ href: 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2',
+ text: '!2',
+ expandedText: 'Qui possimus sit harum ut ipsam autem. (!2)',
+ fullyExpandedText: 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0',
+};
+const mockEpic = {
+ href: 'https://gitlab.com/groups/gitlab-org/-/epics/5',
+ text: '&5',
+ expandedText: 'Temporibus delectus distinctio quas sed non per... (&5)',
+};
+
+const supportedIssueDisplayFormats = ['Issue ID', 'Issue title', 'Issue summary'];
+
+const supportedMergeRequestDisplayFormats = [
+ 'Merge request ID',
+ 'Merge request title',
+ 'Merge request summary',
+];
+
+const supportedEpicDisplayFormats = ['Epic ID', 'Epic title'];
+
+describe('content_editor/components/bubble_menus/reference_bubble_menu', () => {
+ let wrapper;
+ let tiptapEditor;
+ let contentEditor;
+ let eventHub;
+ let doc;
+ let p;
+ let reference;
+
+ const buildExpectedDoc = (href, originalText, referenceType, text) =>
+ doc(p(reference({ className: 'gfm', href, originalText, referenceType, text })));
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor({ extensions: [Reference] });
+ contentEditor = { resolveReference: jest.fn().mockImplementation(() => new Promise(() => {})) };
+ eventHub = eventHubFactory();
+
+ ({
+ builders: { doc, p, reference },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ reference: { nodeType: Reference.name },
+ },
+ }));
+ };
+
+ const expectedDocs = {
+ issue: [
+ () =>
+ buildExpectedDoc(
+ 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24',
+ '#24',
+ 'issue',
+ '#24',
+ ),
+ () =>
+ buildExpectedDoc(
+ 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24',
+ '#24+',
+ 'issue',
+ 'Et fuga quos omnis enim dolores amet impedit. (#24)',
+ ),
+ () =>
+ buildExpectedDoc(
+ 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24',
+ '#24+s',
+ 'issue',
+ 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.',
+ ),
+ ],
+ merge_request: [
+ () =>
+ buildExpectedDoc(
+ 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2',
+ '!2',
+ 'merge_request',
+ '!2',
+ ),
+ () =>
+ buildExpectedDoc(
+ 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2',
+ '!2+',
+ 'merge_request',
+ 'Qui possimus sit harum ut ipsam autem. (!2)',
+ ),
+ () =>
+ buildExpectedDoc(
+ 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2',
+ '!2+s',
+ 'merge_request',
+ 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0',
+ ),
+ ],
+ epic: [
+ () => buildExpectedDoc('https://gitlab.com/groups/gitlab-org/-/epics/5', '&5', 'epic', '&5'),
+ () =>
+ buildExpectedDoc(
+ 'https://gitlab.com/groups/gitlab-org/-/epics/5',
+ '&5+',
+ 'epic',
+ 'Temporibus delectus distinctio quas sed non per... (&5)',
+ ),
+ ],
+ };
+
+ const buildWrapper = () => {
+ wrapper = mountExtended(ReferenceBubbleMenu, {
+ provide: {
+ tiptapEditor,
+ contentEditor,
+ eventHub,
+ },
+ stubs: {
+ BubbleMenu: stubComponent(BubbleMenu),
+ },
+ });
+ };
+
+ const showMenu = () => {
+ wrapper.findComponent(BubbleMenu).vm.$emit('show');
+ return nextTick();
+ };
+
+ const buildWrapperAndDisplayMenu = async () => {
+ buildWrapper();
+
+ await showMenu();
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+ };
+
+ beforeEach(() => {
+ buildEditor();
+
+ tiptapEditor
+ .chain()
+ .setContent(
+ '<a href="https://gitlab.com/gitlab-org/gitlab/issues/1" class="gfm" data-reference-type="issue" data-original="#1">#1</a>',
+ )
+ .setNodeSelection(1)
+ .run();
+ });
+
+ it('shows a loading indicator while the reference is being resolved', async () => {
+ await buildWrapperAndDisplayMenu();
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ describe.each`
+ referenceType | mockReference | supportedDisplayFormats
+ ${'issue'} | ${mockIssue} | ${supportedIssueDisplayFormats}
+ ${'merge_request'} | ${mockMergeRequest} | ${supportedMergeRequestDisplayFormats}
+ ${'epic'} | ${mockEpic} | ${supportedEpicDisplayFormats}
+ `(
+ 'for reference type $referenceType',
+ ({ referenceType, mockReference, supportedDisplayFormats }) => {
+ beforeEach(async () => {
+ tiptapEditor
+ .chain()
+ .setContent(
+ `<a href="${mockReference.href}" class="gfm" data-reference-type="${referenceType}" data-original="${mockReference.text}">${mockReference.text}</a>`,
+ )
+ .setNodeSelection(1)
+ .run();
+
+ contentEditor.resolveReference.mockImplementation(() => Promise.resolve(mockReference));
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+ });
+
+ it('shows a dropdown with supported display formats', async () => {
+ await buildWrapperAndDisplayMenu();
+
+ supportedDisplayFormats.forEach((format) => expect(wrapper.text()).toContain(format));
+ });
+
+ describe.each`
+ option | displayFormat | selectedValue
+ ${0} | ${supportedDisplayFormats[0]} | ${''}
+ ${1} | ${supportedDisplayFormats[1]} | ${'+'}
+ ${2} | ${supportedDisplayFormats[2]} | ${'+s'}
+ `('on selecting option $option', ({ option, displayFormat, selectedValue }) => {
+ if (!displayFormat) return;
+
+ const findDropdownItem = () => wrapper.findAllComponents(GlListboxItem).at(option);
+
+ beforeEach(async () => {
+ await buildWrapperAndDisplayMenu();
+
+ findDropdownItem().trigger('click');
+ });
+
+ it('selects the option', () => {
+ expect(wrapper.findComponent(GlCollapsibleListbox).props()).toMatchObject({
+ selected: selectedValue,
+ toggleText: displayFormat,
+ });
+ });
+
+ it('updates the reference in content editor', () => {
+ expect(tiptapEditor.getJSON()).toEqual(expectedDocs[referenceType][option]().toJSON());
+ });
+ });
+ },
+ );
+
+ describe('copy URL button', () => {
+ it('copies the reference link to clipboard', async () => {
+ jest.spyOn(navigator.clipboard, 'writeText');
+
+ await buildWrapperAndDisplayMenu();
+ await wrapper.findByTestId('copy-reference-url').trigger('click');
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ 'https://gitlab.com/gitlab-org/gitlab/issues/1',
+ );
+ });
+ });
+
+ describe('remove reference button', () => {
+ it('removes the reference', async () => {
+ await buildWrapperAndDisplayMenu();
+ await wrapper.findByTestId('remove-reference').trigger('click');
+
+ expect(tiptapEditor.getHTML()).toBe('<p></p>');
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 852c8a9591a..0b8321ba8eb 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -9,6 +9,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ
import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue';
import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue';
import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue';
+import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue';
import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue';
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
import waitForPromises from 'helpers/wait_for_promises';
@@ -94,7 +95,7 @@ describe('ContentEditor', () => {
it('renders footer containing quick actions help text if quick actions docs path is defined', () => {
createWrapper({ quickActionsDocsPath: '/foo/bar' });
- expect(findEditorElement().text()).toContain('For quick actions, type /');
+ expect(wrapper.text()).toContain('For quick actions, type /');
expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/foo/bar');
});
@@ -104,6 +105,18 @@ describe('ContentEditor', () => {
expect(findEditorElement().text()).not.toContain('For quick actions, type /');
});
+ it('displays an attachment button', () => {
+ createWrapper();
+
+ expect(wrapper.findComponent(FormattingToolbar).props().hideAttachmentButton).toBe(false);
+ });
+
+ it('hides the attachment button if attachments are disabled', () => {
+ createWrapper({ disableAttachments: true });
+
+ expect(wrapper.findComponent(FormattingToolbar).props().hideAttachmentButton).toBe(true);
+ });
+
describe('when setting initial content', () => {
it('displays loading indicator', async () => {
createWrapper();
@@ -267,7 +280,8 @@ describe('ContentEditor', () => {
${'link'} | ${LinkBubbleMenu}
${'media'} | ${MediaBubbleMenu}
${'codeBlock'} | ${CodeBlockBubbleMenu}
- `('renders formatting bubble menu', ({ component }) => {
+ ${'reference'} | ${ReferenceBubbleMenu}
+ `('renders $name bubble menu', ({ component }) => {
createWrapper();
expect(wrapper.findComponent(component).exists()).toBe(true);
diff --git a/spec/frontend/content_editor/components/formatting_toolbar_spec.js b/spec/frontend/content_editor/components/formatting_toolbar_spec.js
index e04c6a00765..9d835381ff4 100644
--- a/spec/frontend/content_editor/components/formatting_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/formatting_toolbar_spec.js
@@ -12,13 +12,14 @@ describe('content_editor/components/formatting_toolbar', () => {
let wrapper;
let trackingSpy;
- const buildWrapper = () => {
+ const buildWrapper = (props) => {
wrapper = shallowMountExtended(FormattingToolbar, {
stubs: {
GlTabs,
GlTab,
EditorModeSwitcher,
},
+ propsData: props,
});
};
@@ -73,4 +74,12 @@ describe('content_editor/components/formatting_toolbar', () => {
expect(wrapper.findComponent(EditorModeSwitcher).exists()).toBe(true);
});
+
+ describe('when attachment button is hidden', () => {
+ it('does not show the attachment button', () => {
+ buildWrapper({ hideAttachmentButton: true });
+
+ expect(wrapper.findByTestId('attachment').exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
index 35741971488..be6e47e067f 100644
--- a/spec/frontend/content_editor/components/toolbar_table_button_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlButton } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue';
import { stubComponent } from 'helpers/stub_component';
@@ -14,12 +14,13 @@ describe('content_editor/components/toolbar_table_button', () => {
tiptapEditor: editor,
},
stubs: {
- GlDropdown: stubComponent(GlDropdown),
+ GlDisclosureDropdown: stubComponent(GlDisclosureDropdown),
},
});
};
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findButton = (row, col) => wrapper.findComponent({ ref: `table-${row}-${col}` });
const getNumButtons = () => findDropdown().findAllComponents(GlButton).length;
beforeEach(() => {
@@ -32,32 +33,44 @@ describe('content_editor/components/toolbar_table_button', () => {
editor.destroy();
});
- it('renders a grid of 5x5 buttons to create a table', () => {
- expect(getNumButtons()).toBe(25); // 5x5
- });
-
describe.each`
row | col | numButtons | tableSize
- ${3} | ${4} | ${25} | ${'3x4'}
- ${4} | ${4} | ${25} | ${'4x4'}
- ${4} | ${5} | ${30} | ${'4x5'}
- ${5} | ${4} | ${30} | ${'5x4'}
- ${5} | ${5} | ${36} | ${'5x5'}
+ ${3} | ${4} | ${25} | ${'3×4'}
+ ${4} | ${4} | ${25} | ${'4×4'}
+ ${4} | ${5} | ${30} | ${'4×5'}
+ ${5} | ${4} | ${30} | ${'5×4'}
+ ${5} | ${5} | ${36} | ${'5×5'}
`('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => {
- describe('on mouse over', () => {
+ describe('a11y tests', () => {
+ it('is in its own gridcell', () => {
+ expect(findButton(row, col).element.parentElement.getAttribute('role')).toBe('gridcell');
+ });
+
+ it('has an aria-label', () => {
+ expect(findButton(row, col).attributes('aria-label')).toBe(`Insert a ${tableSize} table`);
+ });
+ });
+
+ describe.each`
+ event | triggerEvent
+ ${'mouseover'} | ${(button) => button.trigger('mouseover')}
+ ${'focus'} | ${(button) => button.element.dispatchEvent(new FocusEvent('focus'))}
+ `('on $event', ({ triggerEvent }) => {
beforeEach(async () => {
- const button = wrapper.findByTestId(`table-${row}-${col}`);
- await button.trigger('mouseover');
+ const button = wrapper.findComponent({ ref: `table-${row}-${col}` });
+ await triggerEvent(button);
});
it('marks all rows and cols before it as active', () => {
const prevRow = Math.max(1, row - 1);
const prevCol = Math.max(1, col - 1);
- expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass('active');
+ expect(wrapper.findComponent({ ref: `table-${prevRow}-${prevCol}` }).element).toHaveClass(
+ 'active',
+ );
});
it('shows a help text indicating the size of the table being inserted', () => {
- expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table.`);
+ expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table`);
});
it('adds another row and col of buttons to create a bigger table', () => {
@@ -71,7 +84,7 @@ describe('content_editor/components/toolbar_table_button', () => {
beforeEach(async () => {
commands = mockChainedCommands(editor, ['focus', 'insertTable', 'run']);
- const button = wrapper.findByTestId(`table-${row}-${col}`);
+ const button = wrapper.findComponent({ ref: `table-${row}-${col}` });
await button.trigger('mouseover');
await button.trigger('click');
});
@@ -95,8 +108,8 @@ describe('content_editor/components/toolbar_table_button', () => {
expect(getNumButtons()).toBe(i * i);
// eslint-disable-next-line no-await-in-loop
- await wrapper.findByTestId(`table-${i}-${i}`).trigger('mouseover');
- expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`);
+ await wrapper.findComponent({ ref: `table-${i}-${i}` }).trigger('mouseover');
+ expect(findDropdown().element).toHaveText(`Insert a ${i}×${i} table`);
}
expect(getNumButtons()).toBe(100); // 10x10 (and not 11x11)
@@ -105,10 +118,50 @@ describe('content_editor/components/toolbar_table_button', () => {
describe('a11y tests', () => {
it('sets text, title, and text-sr-only properties to the table button dropdown', () => {
expect(findDropdown().props()).toMatchObject({
- text: 'Insert table',
+ toggleText: 'Insert table',
textSrOnly: true,
});
- expect(findDropdown().attributes('title')).toBe('Insert table');
+ expect(findDropdown().attributes('aria-label')).toBe('Insert table');
+ });
+
+ it('renders a role=grid of 5x5 gridcells to create a table', () => {
+ expect(getNumButtons()).toBe(25); // 5x5
+ expect(wrapper.find('[role="grid"]').exists()).toBe(true);
+ wrapper.findAll('[role="row"]').wrappers.forEach((row) => {
+ expect(row.findAll('[role="gridcell"]')).toHaveLength(5);
+ });
+ });
+
+ it('sets aria-rowcount and aria-colcount on the dropdown contents', () => {
+ expect(wrapper.find('[role="grid"]').attributes()).toMatchObject({
+ 'aria-rowcount': '10',
+ 'aria-colcount': '10',
+ });
+ });
+
+ it('allows navigating the grid with the arrow keys', async () => {
+ const dispatchKeyboardEvent = (button, key) =>
+ button.element.dispatchEvent(new KeyboardEvent('keydown', { key }));
+
+ let button = findButton(3, 4);
+ await button.trigger('mouseover');
+ expect(button.element).toHaveClass('active');
+
+ button = findButton(3, 5);
+ await dispatchKeyboardEvent(button, 'ArrowRight');
+ expect(button.element).toHaveClass('active');
+
+ button = findButton(4, 5);
+ await dispatchKeyboardEvent(button, 'ArrowDown');
+ expect(button.element).toHaveClass('active');
+
+ button = findButton(4, 4);
+ await dispatchKeyboardEvent(button, 'ArrowLeft');
+ expect(button.element).toHaveClass('active');
+
+ button = findButton(3, 4);
+ await dispatchKeyboardEvent(button, 'ArrowUp');
+ expect(button.element).toHaveClass('active');
});
});
});
diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js
index 0d56280d630..275f48ea857 100644
--- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js
@@ -1,8 +1,8 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { NodeViewWrapper } from '@tiptap/vue-2';
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
import { selectedRect as getSelectedRect } from '@tiptap/pm/tables';
import { nextTick } from 'vue';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils';
@@ -15,32 +15,21 @@ describe('content/components/wrappers/table_cell_base', () => {
let node;
const createWrapper = (propsData = { cellType: 'td' }) => {
- wrapper = shallowMountExtended(TableCellBaseWrapper, {
+ wrapper = mountExtended(TableCellBaseWrapper, {
propsData: {
editor,
node,
+ getPos: () => 0,
...propsData,
},
stubs: {
- GlDropdown: stubComponent(GlDropdown, {
- methods: {
- hide: jest.fn(),
- },
- }),
+ NodeViewWrapper: stubComponent(NodeViewWrapper),
+ NodeViewContent: stubComponent(NodeViewContent),
},
});
};
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findDropdownItemWithLabel = (name) =>
- wrapper
- .findAllComponents(GlDropdownItem)
- .filter((dropdownItem) => dropdownItem.text().includes(name))
- .at(0);
- const findDropdownItemWithLabelExists = (name) =>
- wrapper
- .findAllComponents(GlDropdownItem)
- .filter((dropdownItem) => dropdownItem.text().includes(name)).length > 0;
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const setCurrentPositionInCell = () => {
const { $cursor } = editor.state.selection;
@@ -48,7 +37,9 @@ describe('content/components/wrappers/table_cell_base', () => {
};
beforeEach(() => {
- node = {};
+ node = {
+ attrs: {},
+ };
editor = createTestEditor({});
});
@@ -68,11 +59,10 @@ describe('content/components/wrappers/table_cell_base', () => {
category: 'tertiary',
icon: 'chevron-down',
size: 'small',
- split: false,
+ noCaret: true,
});
expect(findDropdown().attributes()).toMatchObject({
boundary: 'viewport',
- 'no-caret': '',
});
});
@@ -88,6 +78,10 @@ describe('content/components/wrappers/table_cell_base', () => {
beforeEach(async () => {
setCurrentPositionInCell();
getSelectedRect.mockReturnValue({
+ top: 0,
+ left: 0,
+ bottom: 1,
+ right: 1,
map: {
height: 1,
width: 1,
@@ -107,81 +101,176 @@ describe('content/components/wrappers/table_cell_base', () => {
${'Delete table'} | ${'deleteTable'}
`(
'executes $commandName when $dropdownItemLabel button is clicked',
- ({ commandName, dropdownItemLabel }) => {
+ async ({ dropdownItemLabel, commandName }) => {
const mocks = mockChainedCommands(editor, [commandName, 'run']);
- findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click');
+ await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click');
expect(mocks[commandName]).toHaveBeenCalled();
},
);
- it('does not allow deleting rows and columns', () => {
- expect(findDropdownItemWithLabelExists('Delete row')).toBe(false);
- expect(findDropdownItemWithLabelExists('Delete column')).toBe(false);
+ it.each`
+ dropdownItemLabel
+ ${'Delete row'}
+ ${'Delete column'}
+ ${'Split cell'}
+ ${'Merge'}
+ `('does not have option $dropdownItemLabel available', ({ dropdownItemLabel }) => {
+ expect(findDropdown().text()).not.toContain(dropdownItemLabel);
});
- it('allows deleting rows when there are more than 2 rows in the table', async () => {
- const mocks = mockChainedCommands(editor, ['deleteRow', 'run']);
+ it.each`
+ dropdownItemLabel | commandName
+ ${'Delete row'} | ${'deleteRow'}
+ ${'Delete column'} | ${'deleteColumn'}
+ `(
+ 'allows $dropdownItemLabel operation when there are more than 2 rows and 1 column in the table',
+ async ({ dropdownItemLabel, commandName }) => {
+ const mocks = mockChainedCommands(editor, [commandName, 'run']);
- getSelectedRect.mockReturnValue({
- map: {
- height: 3,
- },
- });
+ getSelectedRect.mockReturnValue({
+ top: 0,
+ left: 0,
+ bottom: 1,
+ right: 1,
+ map: {
+ height: 3,
+ width: 2,
+ },
+ });
- emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
+ emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
- await nextTick();
+ await nextTick();
+ await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click');
- findDropdownItemWithLabel('Delete row').vm.$emit('click');
+ expect(mocks[commandName]).toHaveBeenCalled();
+ },
+ );
- expect(mocks.deleteRow).toHaveBeenCalled();
- });
+ describe("when current row is the table's header", () => {
+ beforeEach(async () => {
+ // Remove 2 rows condition
+ getSelectedRect.mockReturnValue({
+ map: {
+ height: 3,
+ },
+ });
- it('allows deleting columns when there are more than 1 column in the table', async () => {
- const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']);
+ createWrapper({ cellType: 'th' });
- getSelectedRect.mockReturnValue({
- map: {
- width: 2,
- },
+ await nextTick();
});
- emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
+ it('does not allow adding a row before the header', () => {
+ expect(findDropdown().text()).not.toContain('Insert row before');
+ });
- await nextTick();
+ it('does not allow removing the header row', async () => {
+ createWrapper({ cellType: 'th' });
- findDropdownItemWithLabel('Delete column').vm.$emit('click');
+ await nextTick();
- expect(mocks.deleteColumn).toHaveBeenCalled();
+ expect(findDropdown().text()).not.toContain('Delete row');
+ });
});
- describe('when current row is the table’s header', () => {
- beforeEach(async () => {
- // Remove 2 rows condition
+ describe.each`
+ attrs | rect
+ ${{ rowspan: 2 }} | ${{ top: 0, left: 0, bottom: 2, right: 1 }}
+ ${{ colspan: 2 }} | ${{ top: 0, left: 0, bottom: 1, right: 2 }}
+ `('when selected cell has $attrs', ({ attrs, rect }) => {
+ beforeEach(() => {
+ node = { attrs };
+
getSelectedRect.mockReturnValue({
+ ...rect,
map: {
height: 3,
+ width: 2,
},
});
- createWrapper({ cellType: 'th' });
+ setCurrentPositionInCell();
+ });
+
+ it('allows splitting the cell', async () => {
+ const mocks = mockChainedCommands(editor, ['splitCell', 'run']);
+
+ createWrapper();
await nextTick();
+ await wrapper.findByRole('button', { name: 'Split cell' }).trigger('click');
+
+ expect(mocks.splitCell).toHaveBeenCalled();
});
+ });
- it('does not allow adding a row before the header', () => {
- expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false);
+ describe('when selected cell has rowspan=2 and colspan=2', () => {
+ beforeEach(() => {
+ node = { attrs: { rowspan: 2, colspan: 2 } };
+ const rect = { top: 1, left: 1, bottom: 3, right: 3 };
+
+ getSelectedRect.mockReturnValue({
+ ...rect,
+ map: { height: 5, width: 5 },
+ });
+
+ setCurrentPositionInCell();
});
- it('does not allow removing the header row', async () => {
- createWrapper({ cellType: 'th' });
+ it.each`
+ type | dropdownItemLabel | commandName
+ ${'rows'} | ${'Delete 2 rows'} | ${'deleteRow'}
+ ${'columns'} | ${'Delete 2 columns'} | ${'deleteColumn'}
+ `('shows correct label for deleting $type', async ({ dropdownItemLabel, commandName }) => {
+ const mocks = mockChainedCommands(editor, [commandName, 'run']);
+
+ createWrapper();
await nextTick();
+ await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click');
- expect(findDropdownItemWithLabelExists('Delete row')).toBe(false);
+ expect(mocks[commandName]).toHaveBeenCalled();
});
});
+
+ describe.each`
+ rows | cols | product
+ ${2} | ${1} | ${2}
+ ${1} | ${2} | ${2}
+ ${2} | ${2} | ${4}
+ `('when $rows x $cols ($product) cells are selected', ({ rows, cols, product }) => {
+ it.each`
+ dropdownItemLabel | commandName
+ ${`Merge ${product} cells`} | ${'mergeCells'}
+ ${rows === 1 ? 'Delete row' : `Delete ${rows} rows`} | ${'deleteRow'}
+ ${cols === 1 ? 'Delete column' : `Delete ${cols} columns`} | ${'deleteColumn'}
+ `(
+ 'executes $commandName when $dropdownItemLabel is clicked',
+ async ({ dropdownItemLabel, commandName }) => {
+ const mocks = mockChainedCommands(editor, [commandName, 'run']);
+
+ getSelectedRect.mockReturnValue({
+ top: 0,
+ left: 0,
+ bottom: rows,
+ right: cols,
+ map: {
+ height: 4,
+ width: 4,
+ },
+ });
+
+ emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
+
+ await nextTick();
+ await wrapper.findByRole('button', { name: dropdownItemLabel }).trigger('click');
+
+ expect(mocks[commandName]).toHaveBeenCalled();
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/content_editor/extensions/code_spec.js b/spec/frontend/content_editor/extensions/code_spec.js
index 0a54ac6a96b..4d8629a35c0 100644
--- a/spec/frontend/content_editor/extensions/code_spec.js
+++ b/spec/frontend/content_editor/extensions/code_spec.js
@@ -1,8 +1,60 @@
+import Bold from '~/content_editor/extensions/bold';
import Code from '~/content_editor/extensions/code';
-import { EXTENSION_PRIORITY_LOWER } from '~/content_editor/constants';
+import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/code', () => {
- it('has a lower loading priority', () => {
- expect(Code.config.priority).toBe(EXTENSION_PRIORITY_LOWER);
+ let tiptapEditor;
+ let doc;
+ let p;
+ let bold;
+ let code;
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({ extensions: [Bold, Code] });
+
+ ({
+ builders: { doc, p, bold, code },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ bold: { markType: Bold.name },
+ code: { markType: Code.name },
+ },
+ }));
+ });
+
+ it.each`
+ markOrder | description
+ ${['bold', 'code']} | ${'bold is toggled before code'}
+ ${['code', 'bold']} | ${'code is toggled before bold'}
+ `('has a lower loading priority, when $description', ({ markOrder }) => {
+ const initialDoc = doc(p('code block'));
+ const expectedDoc = doc(p(bold(code('code block'))));
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ tiptapEditor.commands.selectAll();
+ markOrder.forEach((mark) => tiptapEditor.commands.toggleMark(mark));
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
+
+ describe('shortcut: RightArrow', () => {
+ it('exits the code block', () => {
+ const initialDoc = doc(p('You can write ', code('java')));
+ const expectedDoc = doc(p('You can write ', code('javascript'), ' here'));
+ const pos = 25;
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ tiptapEditor.commands.setTextSelection(pos);
+
+ // insert 'script' after 'java' within the code block
+ tiptapEditor.commands.insertContent({ type: 'text', text: 'script' });
+
+ // insert ' here' after the code block
+ tiptapEditor.commands.keyboardShortcut('ArrowRight');
+ tiptapEditor.commands.insertContent({ type: 'text', text: 'here' });
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
});
});
diff --git a/spec/frontend/content_editor/extensions/description_item_spec.js b/spec/frontend/content_editor/extensions/description_item_spec.js
new file mode 100644
index 00000000000..02b80d93886
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/description_item_spec.js
@@ -0,0 +1,121 @@
+import DescriptionList from '~/content_editor/extensions/description_list';
+import DescriptionItem from '~/content_editor/extensions/description_item';
+import { createTestEditor, createDocBuilder, triggerKeyboardInput } from '../test_utils';
+
+describe('content_editor/extensions/description_item', () => {
+ let tiptapEditor;
+ let doc;
+ let p;
+ let descriptionList;
+ let descriptionItem;
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({ extensions: [DescriptionList, DescriptionItem] });
+
+ ({
+ builders: { doc, p, descriptionList, descriptionItem },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ descriptionList: { nodeType: DescriptionList.name },
+ descriptionItem: { nodeType: DescriptionItem.name },
+ },
+ }));
+ });
+
+ describe('shortcut: Enter', () => {
+ it('splits a description item into two items', () => {
+ const initialDoc = doc(descriptionList(descriptionItem(p('Description item'))));
+ const expectedDoc = doc(
+ descriptionList(descriptionItem(p('Descrip')), descriptionItem(p('tion item'))),
+ );
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ tiptapEditor.commands.setTextSelection(10);
+ tiptapEditor.commands.keyboardShortcut('Enter');
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+
+ describe('shortcut: Tab', () => {
+ it('converts a description term into a description details', () => {
+ const initialDoc = doc(descriptionList(descriptionItem(p('Description item'))));
+ const expectedDoc = doc(
+ descriptionList(descriptionItem({ isTerm: false }, p('Description item'))),
+ );
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ tiptapEditor.commands.setTextSelection(10);
+ tiptapEditor.commands.keyboardShortcut('Tab');
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
+
+ it('has no effect on a description details', () => {
+ const initialDoc = doc(
+ descriptionList(descriptionItem({ isTerm: false }, p('Description item'))),
+ );
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ tiptapEditor.commands.setTextSelection(10);
+ tiptapEditor.commands.keyboardShortcut('Tab');
+
+ expect(tiptapEditor.getJSON()).toEqual(initialDoc.toJSON());
+ });
+ });
+
+ describe('shortcut: Shift-Tab', () => {
+ it('converts a description details into a description term', () => {
+ const initialDoc = doc(
+ descriptionList(
+ descriptionItem({ isTerm: false }, p('Description item')),
+ descriptionItem(p('Description item')),
+ descriptionItem(p('Description item')),
+ ),
+ );
+ const expectedDoc = doc(
+ descriptionList(
+ descriptionItem(p('Description item')),
+ descriptionItem(p('Description item')),
+ descriptionItem(p('Description item')),
+ ),
+ );
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ tiptapEditor.commands.setTextSelection(10);
+ tiptapEditor.commands.keyboardShortcut('Shift-Tab');
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
+
+ it('lifts a description term', () => {
+ const initialDoc = doc(descriptionList(descriptionItem(p('Description item'))));
+ const expectedDoc = doc(p('Description item'));
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+ tiptapEditor.commands.setTextSelection(10);
+ tiptapEditor.commands.keyboardShortcut('Shift-Tab');
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+
+ describe('capturing keyboard events', () => {
+ it.each`
+ key | shiftKey | nodeActive | captured | description
+ ${'Tab'} | ${false} | ${true} | ${true} | ${'captures Tab key when cursor is inside a description item'}
+ ${'Tab'} | ${false} | ${false} | ${false} | ${'does not capture Tab key when cursor is not inside a description item'}
+ ${'Tab'} | ${true} | ${true} | ${true} | ${'captures Shift-Tab key when cursor is inside a description item'}
+ ${'Tab'} | ${true} | ${false} | ${false} | ${'does not capture Shift-Tab key when cursor is not inside a description item'}
+ `('$description', ({ key, shiftKey, nodeActive, captured }) => {
+ const initialDoc = doc(descriptionList(descriptionItem(p('Text content'))));
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+
+ jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(nodeActive);
+
+ expect(triggerKeyboardInput({ tiptapEditor, key, shiftKey })).toBe(captured);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/description_list_spec.js b/spec/frontend/content_editor/extensions/description_list_spec.js
new file mode 100644
index 00000000000..e46680956ec
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/description_list_spec.js
@@ -0,0 +1,36 @@
+import DescriptionList from '~/content_editor/extensions/description_list';
+import DescriptionItem from '~/content_editor/extensions/description_item';
+import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils';
+
+describe('content_editor/extensions/description_list', () => {
+ let tiptapEditor;
+ let doc;
+ let p;
+ let descriptionList;
+ let descriptionItem;
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({ extensions: [DescriptionList, DescriptionItem] });
+
+ ({
+ builders: { doc, p, descriptionList, descriptionItem },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ descriptionList: { nodeType: DescriptionList.name },
+ descriptionItem: { nodeType: DescriptionItem.name },
+ },
+ }));
+ });
+
+ it.each`
+ inputRuleText | insertedNode | insertedNodeType
+ ${'<dl>'} | ${() => descriptionList(descriptionItem(p()))} | ${'descriptionList'}
+ ${'<dl'} | ${() => p()} | ${'paragraph'}
+ ${'dl>'} | ${() => p()} | ${'paragraph'}
+ `('with input=$input, it inserts a $insertedNodeType node', ({ inputRuleText, insertedNode }) => {
+ triggerNodeInputRule({ tiptapEditor, inputRuleText });
+
+ expect(tiptapEditor.getJSON()).toEqual(doc(insertedNode()).toJSON());
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/details_content_spec.js b/spec/frontend/content_editor/extensions/details_content_spec.js
index 575f3bf65e4..02e2b51366a 100644
--- a/spec/frontend/content_editor/extensions/details_content_spec.js
+++ b/spec/frontend/content_editor/extensions/details_content_spec.js
@@ -1,6 +1,6 @@
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
-import { createTestEditor, createDocBuilder } from '../test_utils';
+import { createTestEditor, createDocBuilder, triggerKeyboardInput } from '../test_utils';
describe('content_editor/extensions/details_content', () => {
let tiptapEditor;
@@ -42,7 +42,6 @@ describe('content_editor/extensions/details_content', () => {
);
tiptapEditor.commands.setContent(initialDoc.toJSON());
-
tiptapEditor.commands.setTextSelection(10);
tiptapEditor.commands.keyboardShortcut('Enter');
@@ -66,11 +65,26 @@ describe('content_editor/extensions/details_content', () => {
);
tiptapEditor.commands.setContent(initialDoc.toJSON());
-
tiptapEditor.commands.setTextSelection(20);
tiptapEditor.commands.keyboardShortcut('Shift-Tab');
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
});
+
+ describe('capturing keyboard events', () => {
+ it.each`
+ key | shiftKey | nodeActive | captured | description
+ ${'Tab'} | ${true} | ${true} | ${true} | ${'captures Shift-Tab key when cursor is inside a details content'}
+ ${'Tab'} | ${true} | ${false} | ${false} | ${'does not capture Shift-Tab key when cursor is not inside a details content'}
+ `('$description', ({ key, shiftKey, nodeActive, captured }) => {
+ const initialDoc = doc(details(detailsContent(p('Text content'))));
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+
+ jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(nodeActive);
+
+ expect(triggerKeyboardInput({ tiptapEditor, key, shiftKey })).toBe(captured);
+ });
+ });
});
diff --git a/spec/frontend/content_editor/extensions/details_spec.js b/spec/frontend/content_editor/extensions/details_spec.js
index cd59943982f..ce97444ec19 100644
--- a/spec/frontend/content_editor/extensions/details_spec.js
+++ b/spec/frontend/content_editor/extensions/details_spec.js
@@ -1,6 +1,6 @@
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
-import { createTestEditor, createDocBuilder } from '../test_utils';
+import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils';
describe('content_editor/extensions/details', () => {
let tiptapEditor;
@@ -75,18 +75,13 @@ describe('content_editor/extensions/details', () => {
});
it.each`
- input | insertedNode
- ${'<details>'} | ${(...args) => details(detailsContent(p(...args)))}
- ${'<details'} | ${(...args) => p(...args)}
- ${'details>'} | ${(...args) => p(...args)}
- `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => {
- const { view } = tiptapEditor;
- const { selection } = view.state;
- const expectedDoc = doc(insertedNode());
-
- // Triggers the event handler that input rules listen to
- view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input));
-
- expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ inputRuleText | insertedNode | insertedNodeType
+ ${'<details>'} | ${() => details(detailsContent(p()))} | ${'details'}
+ ${'<details'} | ${() => p()} | ${'paragraph'}
+ ${'details>'} | ${() => p()} | ${'paragraph'}
+ `('with input=$input, it inserts a $insertedNodeType node', ({ inputRuleText, insertedNode }) => {
+ triggerNodeInputRule({ tiptapEditor, inputRuleText });
+
+ expect(tiptapEditor.getJSON()).toEqual(doc(insertedNode()).toJSON());
});
});
diff --git a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js
index 61dc164c99a..63ed08096b2 100644
--- a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js
+++ b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js
@@ -1,6 +1,5 @@
import DrawioDiagram from '~/content_editor/extensions/drawio_diagram';
import Image from '~/content_editor/extensions/image';
-import createAssetResolver from '~/content_editor/services/asset_resolver';
import { create } from '~/drawio/content_editor_facade';
import { launchDrawioEditor } from '~/drawio/drawio_editor';
import { createTestEditor, createDocBuilder } from '../test_utils';
@@ -19,12 +18,15 @@ describe('content_editor/extensions/drawio_diagram', () => {
let paragraph;
let image;
let drawioDiagram;
+ let assetResolver;
+
const uploadsPath = '/uploads';
- const renderMarkdown = () => {};
beforeEach(() => {
+ assetResolver = new (class {})();
+
tiptapEditor = createTestEditor({
- extensions: [Image, DrawioDiagram.configure({ uploadsPath, renderMarkdown })],
+ extensions: [Image, DrawioDiagram.configure({ uploadsPath, assetResolver })],
});
const { builders } = createDocBuilder({
tiptapEditor,
@@ -72,19 +74,12 @@ describe('content_editor/extensions/drawio_diagram', () => {
describe('createOrEditDiagram command', () => {
let editorFacade;
- let assetResolver;
beforeEach(() => {
editorFacade = {};
- assetResolver = {};
tiptapEditor.commands.createOrEditDiagram();
create.mockReturnValueOnce(editorFacade);
- createAssetResolver.mockReturnValueOnce(assetResolver);
- });
-
- it('creates a new instance of asset resolver', () => {
- expect(createAssetResolver).toHaveBeenCalledWith({ renderMarkdown });
});
it('creates a new instance of the content_editor_facade', () => {
diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
index c9997e3c58f..baf0919fec8 100644
--- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js
+++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
@@ -4,24 +4,28 @@ import Diagram from '~/content_editor/extensions/diagram';
import Frontmatter from '~/content_editor/extensions/frontmatter';
import Heading from '~/content_editor/extensions/heading';
import Bold from '~/content_editor/extensions/bold';
+import Italic from '~/content_editor/extensions/italic';
import { VARIANT_DANGER } from '~/alert';
import eventHubFactory from '~/helpers/event_hub_factory';
import { ALERT_EVENT } from '~/content_editor/constants';
import waitForPromises from 'helpers/wait_for_promises';
+import MarkdownSerializer from '~/content_editor/services/markdown_serializer';
import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils';
const CODE_BLOCK_HTML = '<pre class="js-syntax-highlight" lang="javascript">var a = 2;</pre>';
const DIAGRAM_HTML =
'<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">';
const FRONTMATTER_HTML = '<pre lang="yaml" data-lang-params="frontmatter">key: value</pre>';
-const PARAGRAPH_HTML = '<p>Just a regular paragraph</p>';
+const PARAGRAPH_HTML = '<p>Some text with <strong>bold</strong> and <em>italic</em> text.</p>';
describe('content_editor/extensions/paste_markdown', () => {
let tiptapEditor;
let doc;
let p;
let bold;
+ let italic;
let heading;
+ let codeBlock;
let renderMarkdown;
let eventHub;
const defaultData = { 'text/plain': '**bold text**' };
@@ -35,28 +39,36 @@ describe('content_editor/extensions/paste_markdown', () => {
tiptapEditor = createTestEditor({
extensions: [
Bold,
+ Italic,
CodeBlockHighlight,
Diagram,
Frontmatter,
Heading,
- PasteMarkdown.configure({ renderMarkdown, eventHub }),
+ PasteMarkdown.configure({ renderMarkdown, eventHub, serializer: new MarkdownSerializer() }),
],
});
({
- builders: { doc, p, bold, heading },
+ builders: { doc, p, bold, italic, heading, codeBlock },
} = createDocBuilder({
tiptapEditor,
names: {
bold: { markType: Bold.name },
+ italic: { markType: Italic.name },
heading: { nodeType: Heading.name },
+ codeBlock: { nodeType: CodeBlockHighlight.name },
},
}));
});
- const buildClipboardEvent = ({ data = {}, types = ['text/plain'] } = {}) => {
- return Object.assign(new Event('paste'), {
- clipboardData: { types, getData: jest.fn((type) => data[type] || defaultData[type]) },
+ const buildClipboardEvent = ({ eventName = 'paste', data = {}, types = ['text/plain'] } = {}) => {
+ return Object.assign(new Event(eventName), {
+ clipboardData: {
+ types,
+ getData: jest.fn((type) => data[type] || defaultData[type]),
+ setData: jest.fn(),
+ clearData: jest.fn(),
+ },
});
};
@@ -80,13 +92,13 @@ describe('content_editor/extensions/paste_markdown', () => {
};
it.each`
- types | data | handled | desc
- ${['text/plain']} | ${{}} | ${true} | ${'handles plain text'}
- ${['text/plain', 'text/html']} | ${{}} | ${false} | ${'doesn’t handle html format'}
- ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${true} | ${'handles vscode markdown'}
- ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${false} | ${'doesn’t vscode code snippet'}
- `('$desc', async ({ types, handled, data }) => {
- expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled);
+ types | data | formatDesc
+ ${['text/plain']} | ${{}} | ${'plain text'}
+ ${['text/plain', 'text/html']} | ${{}} | ${'html format'}
+ ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${'vscode markdown'}
+ ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${'vscode snippet'}
+ `('handles $formatDesc', async ({ types, data }) => {
+ expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(true);
});
it.each`
@@ -101,6 +113,45 @@ describe('content_editor/extensions/paste_markdown', () => {
expect(await triggerPasteEventHandler(buildClipboardEvent())).toBe(handled);
});
+ describe.each`
+ eventName | expectedDoc
+ ${'cut'} | ${() => doc(p())}
+ ${'copy'} | ${() => doc(p('Some text with ', bold('bold'), ' and ', italic('italic'), ' text.'))}
+ `('when $eventName event is triggered', ({ eventName, expectedDoc }) => {
+ let event;
+ beforeEach(() => {
+ event = buildClipboardEvent({ eventName });
+
+ jest.spyOn(event, 'preventDefault');
+ jest.spyOn(event, 'stopPropagation');
+
+ tiptapEditor.commands.insertContent(PARAGRAPH_HTML);
+ tiptapEditor.commands.selectAll();
+ tiptapEditor.view.dispatchEvent(event);
+ });
+
+ it('prevents default', () => {
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ });
+
+ it('sets the clipboard data', () => {
+ expect(event.clipboardData.setData).toHaveBeenCalledWith(
+ 'text/plain',
+ 'Some text with bold and italic text.',
+ );
+ expect(event.clipboardData.setData).toHaveBeenCalledWith('text/html', PARAGRAPH_HTML);
+ expect(event.clipboardData.setData).toHaveBeenCalledWith(
+ 'text/x-gfm',
+ 'Some text with **bold** and _italic_ text.',
+ );
+ });
+
+ it('modifies the document', () => {
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc().toJSON());
+ });
+ });
+
describe('when pasting raw markdown source', () => {
describe('when rendering markdown succeeds', () => {
beforeEach(() => {
@@ -162,6 +213,97 @@ describe('content_editor/extensions/paste_markdown', () => {
});
});
+ describe('when pasting html content', () => {
+ it('strips out any stray div, pre, span tags', async () => {
+ renderMarkdown.mockResolvedValueOnce(
+ '<div><span dir="auto"><strong>bold text</strong></span></div><pre><code>some code</code></pre>',
+ );
+
+ const expectedDoc = doc(p(bold('bold text')), p('some code'));
+
+ await triggerPasteEventHandlerAndWaitForTransaction(
+ buildClipboardEvent({
+ types: ['text/html'],
+ data: {
+ 'text/html':
+ '<div><span dir="auto"><strong>bold text</strong></span></div><pre><code>some code</code></pre>',
+ },
+ }),
+ );
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+
+ describe('when pasting text/x-gfm', () => {
+ it('processes the content as markdown, even if html content exists', async () => {
+ renderMarkdown.mockResolvedValueOnce('<strong>bold text</strong>');
+
+ const expectedDoc = doc(p(bold('bold text')));
+
+ await triggerPasteEventHandlerAndWaitForTransaction(
+ buildClipboardEvent({
+ types: ['text/x-gfm'],
+ data: {
+ 'text/x-gfm': '**bold text**',
+ 'text/plain': 'irrelevant text',
+ 'text/html': '<div>some random irrelevant html</div>',
+ },
+ }),
+ );
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+
+ describe('when pasting vscode-editor-data', () => {
+ it('pastes the content as a code block', async () => {
+ renderMarkdown.mockResolvedValueOnce(
+ '<div class="gl-relative markdown-code-block js-markdown-code">&#x000A;<pre data-sourcepos="1:1-3:3" data-canonical-lang="ruby" class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="nb">puts</span> <span class="s2">"Hello World"</span></span></code></pre>&#x000A;<copy-code></copy-code>&#x000A;</div>',
+ );
+
+ const expectedDoc = doc(
+ codeBlock(
+ { language: 'ruby', class: 'code highlight js-syntax-highlight language-ruby' },
+ 'puts "Hello World"',
+ ),
+ );
+
+ await triggerPasteEventHandlerAndWaitForTransaction(
+ buildClipboardEvent({
+ types: ['vscode-editor-data', 'text/plain', 'text/html'],
+ data: {
+ 'vscode-editor-data': '{ "version": 1, "mode": "ruby" }',
+ 'text/plain': 'puts "Hello World"',
+ 'text/html':
+ '<meta charset=\'utf-8\'><div style="color: #d4d4d4;background-color: #1e1e1e;font-family: \'Fira Code\', Menlo, Monaco, \'Courier New\', monospace, Menlo, Monaco, \'Courier New\', monospace;font-weight: normal;font-size: 14px;line-height: 21px;white-space: pre;"><div><span style="color: #dcdcaa;">puts</span><span style="color: #d4d4d4;"> </span><span style="color: #ce9178;">"Hello world"</span></div></div>',
+ },
+ }),
+ );
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+
+ it('pastes as regular markdown if language is markdown', async () => {
+ renderMarkdown.mockResolvedValueOnce('<p><strong>bold text</strong></p>');
+
+ const expectedDoc = doc(p(bold('bold text')));
+
+ await triggerPasteEventHandlerAndWaitForTransaction(
+ buildClipboardEvent({
+ types: ['vscode-editor-data', 'text/plain', 'text/html'],
+ data: {
+ 'vscode-editor-data': '{ "version": 1, "mode": "markdown" }',
+ 'text/plain': '**bold text**',
+ 'text/html': '<p><strong>bold text</strong></p>',
+ },
+ }),
+ );
+
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+
describe('when rendering markdown fails', () => {
beforeEach(() => {
renderMarkdown.mockRejectedValueOnce();
diff --git a/spec/frontend/content_editor/extensions/reference_spec.js b/spec/frontend/content_editor/extensions/reference_spec.js
new file mode 100644
index 00000000000..c25c7c41d75
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/reference_spec.js
@@ -0,0 +1,162 @@
+import Reference from '~/content_editor/extensions/reference';
+import AssetResolver from '~/content_editor/services/asset_resolver';
+import {
+ RESOLVED_ISSUE_HTML,
+ RESOLVED_MERGE_REQUEST_HTML,
+ RESOLVED_EPIC_HTML,
+} from '../test_constants';
+import {
+ createTestEditor,
+ createDocBuilder,
+ triggerNodeInputRule,
+ waitUntilTransaction,
+} from '../test_utils';
+
+describe('content_editor/extensions/reference', () => {
+ let tiptapEditor;
+ let doc;
+ let p;
+ let reference;
+ let renderMarkdown;
+ let assetResolver;
+
+ beforeEach(() => {
+ renderMarkdown = jest.fn().mockImplementation(() => new Promise(() => {}));
+ assetResolver = new AssetResolver({ renderMarkdown });
+
+ tiptapEditor = createTestEditor({
+ extensions: [Reference.configure({ assetResolver })],
+ });
+
+ ({
+ builders: { doc, p, reference },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ reference: { nodeType: Reference.name },
+ },
+ }));
+ });
+
+ describe('when typing a valid reference input rule', () => {
+ const buildExpectedDoc = (href, originalText, referenceType, text) =>
+ doc(p(reference({ className: null, href, originalText, referenceType, text }), ' '));
+
+ it.each`
+ inputRuleText | mockReferenceHtml | expectedDoc
+ ${'#1 '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1', 'issue', '#1 (closed)')}
+ ${'#1+ '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+', 'issue', '500 error on MR approvers edit page (#1 - closed)')}
+ ${'#1+s '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+s', 'issue', '500 error on MR approvers edit page (#1 - closed) • Unassigned')}
+ ${'!1 '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1', 'merge_request', '!1 (merged)')}
+ ${'!1+ '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged)')}
+ ${'!1+s '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+s', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged) • John Doe')}
+ ${'&1 '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1', 'epic', '&1')}
+ ${'&1+ '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1+', 'epic', 'Approvals in merge request list (&1)')}
+ `(
+ 'replaces the input rule ($inputRuleText) with a reference node',
+ async ({ inputRuleText, mockReferenceHtml, expectedDoc }) => {
+ await waitUntilTransaction({
+ number: 2,
+ tiptapEditor,
+ action() {
+ renderMarkdown.mockResolvedValueOnce(mockReferenceHtml);
+
+ tiptapEditor.commands.insertContent({ type: 'text', text: inputRuleText });
+ triggerNodeInputRule({ tiptapEditor, inputRuleText });
+ },
+ });
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc().toJSON());
+ },
+ );
+
+ it('resolves multiple references in the same paragraph correctly', async () => {
+ await waitUntilTransaction({
+ number: 2,
+ tiptapEditor,
+ action() {
+ renderMarkdown.mockResolvedValueOnce(RESOLVED_ISSUE_HTML);
+
+ tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' });
+ triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' });
+ },
+ });
+
+ await waitUntilTransaction({
+ number: 2,
+ tiptapEditor,
+ action() {
+ renderMarkdown.mockResolvedValueOnce(RESOLVED_MERGE_REQUEST_HTML);
+
+ tiptapEditor.commands.insertContent({ type: 'text', text: 'was resolved with !1+ ' });
+ triggerNodeInputRule({ tiptapEditor, inputRuleText: 'was resolved with !1+ ' });
+ },
+ });
+
+ expect(tiptapEditor.getJSON()).toEqual(
+ doc(
+ p(
+ reference({
+ referenceType: 'issue',
+ originalText: '#1+',
+ text: '500 error on MR approvers edit page (#1 - closed)',
+ href: '/gitlab-org/gitlab/-/issues/1',
+ }),
+ ' was resolved with ',
+ reference({
+ referenceType: 'merge_request',
+ originalText: '!1+',
+ text: 'Enhance the LDAP group synchronization (!1 - merged)',
+ href: '/gitlab-org/gitlab/-/merge_requests/1',
+ }),
+ ' ',
+ ),
+ ).toJSON(),
+ );
+ });
+
+ it('resolves the input rule lazily in the correct position if the user makes a change before the request resolves', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ renderMarkdown.mockImplementation(() => promise);
+
+ tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' });
+ triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' });
+
+ // insert a new paragraph at a random location
+ tiptapEditor.commands.insertContentAt(0, {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Hello' }],
+ });
+
+ // update selection
+ tiptapEditor.commands.selectAll();
+
+ await waitUntilTransaction({
+ number: 1,
+ tiptapEditor,
+ action() {
+ resolvePromise(RESOLVED_ISSUE_HTML);
+ },
+ });
+
+ expect(tiptapEditor.state.doc).toEqual(
+ doc(
+ p('Hello'),
+ p(
+ reference({
+ referenceType: 'issue',
+ originalText: '#1+',
+ text: '500 error on MR approvers edit page (#1 - closed)',
+ href: '/gitlab-org/gitlab/-/issues/1',
+ }),
+ ' ',
+ ),
+ ),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js
index 359e69c083a..927a7d59899 100644
--- a/spec/frontend/content_editor/remark_markdown_processing_spec.js
+++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js
@@ -30,7 +30,7 @@ import TaskList from '~/content_editor/extensions/task_list';
import TaskItem from '~/content_editor/extensions/task_item';
import Video from '~/content_editor/extensions/video';
import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
-import markdownSerializer from '~/content_editor/services/markdown_serializer';
+import MarkdownSerializer from '~/content_editor/services/markdown_serializer';
import { SAFE_VIDEO_EXT, SAFE_AUDIO_EXT, DIAGRAM_LANGUAGES } from '~/content_editor/constants';
import { createTestEditor, createDocBuilder } from './test_utils';
@@ -158,7 +158,7 @@ describe('Client side Markdown processing', () => {
};
const serialize = (document) =>
- markdownSerializer({}).serialize({
+ new MarkdownSerializer().serialize({
doc: document,
pristineDoc: document,
});
diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js
index 0a99f823be3..292eec6db77 100644
--- a/spec/frontend/content_editor/services/asset_resolver_spec.js
+++ b/spec/frontend/content_editor/services/asset_resolver_spec.js
@@ -1,4 +1,9 @@
-import createAssetResolver from '~/content_editor/services/asset_resolver';
+import AssetResolver from '~/content_editor/services/asset_resolver';
+import {
+ RESOLVED_ISSUE_HTML,
+ RESOLVED_MERGE_REQUEST_HTML,
+ RESOLVED_EPIC_HTML,
+} from '../test_constants';
describe('content_editor/services/asset_resolver', () => {
let renderMarkdown;
@@ -6,7 +11,7 @@ describe('content_editor/services/asset_resolver', () => {
beforeEach(() => {
renderMarkdown = jest.fn();
- assetResolver = createAssetResolver({ renderMarkdown });
+ assetResolver = new AssetResolver({ renderMarkdown });
});
describe('resolveUrl', () => {
@@ -21,6 +26,65 @@ describe('content_editor/services/asset_resolver', () => {
});
});
+ describe('resolveReference', () => {
+ const resolvedEpic = {
+ expandedText: 'Approvals in merge request list (&1)',
+ fullyExpandedText: 'Approvals in merge request list (&1)',
+ href: '/groups/gitlab-org/-/epics/1',
+ text: '&1',
+ };
+
+ const resolvedIssue = {
+ expandedText: '500 error on MR approvers edit page (#1 - closed)',
+ fullyExpandedText: '500 error on MR approvers edit page (#1 - closed) • Unassigned',
+ href: '/gitlab-org/gitlab/-/issues/1',
+ text: '#1 (closed)',
+ };
+
+ const resolvedMergeRequest = {
+ expandedText: 'Enhance the LDAP group synchronization (!1 - merged)',
+ fullyExpandedText: 'Enhance the LDAP group synchronization (!1 - merged) • John Doe',
+ href: '/gitlab-org/gitlab/-/merge_requests/1',
+ text: '!1 (merged)',
+ };
+
+ describe.each`
+ referenceType | referenceId | sentMarkdown | returnedHtml | resolvedReference
+ ${'issue'} | ${'#1'} | ${'#1 #1+ #1+s'} | ${RESOLVED_ISSUE_HTML} | ${resolvedIssue}
+ ${'merge_request'} | ${'!1'} | ${'!1 !1+ !1+s'} | ${RESOLVED_MERGE_REQUEST_HTML} | ${resolvedMergeRequest}
+ ${'epic'} | ${'&1'} | ${'&1 &1+ &1+s'} | ${RESOLVED_EPIC_HTML} | ${resolvedEpic}
+ `(
+ 'for reference type $referenceType',
+ ({ referenceType, referenceId, sentMarkdown, returnedHtml, resolvedReference }) => {
+ it(`resolves ${referenceType} reference to href, text, title and summary`, async () => {
+ renderMarkdown.mockResolvedValue(returnedHtml);
+
+ expect(await assetResolver.resolveReference(referenceId)).toEqual(resolvedReference);
+ });
+
+ it.each`
+ suffix
+ ${''}
+ ${'+'}
+ ${'+s'}
+ `('strips suffix ("$suffix") before resolving', ({ suffix }) => {
+ assetResolver.resolveReference(referenceId + suffix);
+ expect(renderMarkdown).toHaveBeenCalledWith(sentMarkdown);
+ });
+ },
+ );
+
+ it.each`
+ case | sentMarkdown | returnedHtml
+ ${'no html is returned'} | ${''} | ${''}
+ ${'html contains no anchor tags'} | ${'no anchor tags'} | ${'<p>no anchor tags</p>'}
+ `('returns an empty object if $case', async ({ sentMarkdown, returnedHtml }) => {
+ renderMarkdown.mockResolvedValue(returnedHtml);
+
+ expect(await assetResolver.resolveReference(sentMarkdown)).toEqual({});
+ });
+ });
+
describe('renderDiagram', () => {
it('resolves a diagram code to a url containing the diagram image', async () => {
renderMarkdown.mockResolvedValue(
diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js
index 53cd51b8c5f..b9a9c3ccd17 100644
--- a/spec/frontend/content_editor/services/create_content_editor_spec.js
+++ b/spec/frontend/content_editor/services/create_content_editor_spec.js
@@ -2,6 +2,7 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants
import { createContentEditor } from '~/content_editor/services/create_content_editor';
import createGlApiMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer';
import createRemarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
+import AssetResolver from '~/content_editor/services/asset_resolver';
import { createTestContentEditorExtension } from '../test_utils';
jest.mock('~/emoji');
@@ -89,7 +90,7 @@ describe('content_editor/services/create_content_editor', () => {
.options,
).toMatchObject({
uploadsPath,
- renderMarkdown,
+ assetResolver: expect.any(AssetResolver),
});
});
});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index 3729b303cc6..4521822042c 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -26,6 +26,8 @@ import Link from '~/content_editor/extensions/link';
import ListItem from '~/content_editor/extensions/list_item';
import OrderedList from '~/content_editor/extensions/ordered_list';
import Paragraph from '~/content_editor/extensions/paragraph';
+import Reference from '~/content_editor/extensions/reference';
+import ReferenceLabel from '~/content_editor/extensions/reference_label';
import ReferenceDefinition from '~/content_editor/extensions/reference_definition';
import Sourcemap from '~/content_editor/extensions/sourcemap';
import Strike from '~/content_editor/extensions/strike';
@@ -35,7 +37,7 @@ import TableHeader from '~/content_editor/extensions/table_header';
import TableRow from '~/content_editor/extensions/table_row';
import TaskItem from '~/content_editor/extensions/task_item';
import TaskList from '~/content_editor/extensions/task_list';
-import markdownSerializer from '~/content_editor/services/markdown_serializer';
+import MarkdownSerializer from '~/content_editor/services/markdown_serializer';
import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import { createTiptapEditor, createDocBuilder } from '../test_utils';
@@ -43,6 +45,8 @@ jest.mock('~/emoji');
const tiptapEditor = createTiptapEditor([Sourcemap]);
+const text = (val) => tiptapEditor.state.schema.text(val);
+
const {
builders: {
audio,
@@ -76,6 +80,8 @@ const {
orderedList,
paragraph,
referenceDefinition,
+ reference,
+ referenceLabel,
strike,
table,
tableCell,
@@ -116,6 +122,8 @@ const {
orderedList: { nodeType: OrderedList.name },
paragraph: { nodeType: Paragraph.name },
referenceDefinition: { nodeType: ReferenceDefinition.name },
+ reference: { nodeType: Reference.name },
+ referenceLabel: { nodeType: ReferenceLabel.name },
strike: { markType: Strike.name },
table: { nodeType: Table.name },
tableCell: { nodeType: TableCell.name },
@@ -134,7 +142,7 @@ const {
});
const serialize = (...content) =>
- markdownSerializer({}).serialize({
+ new MarkdownSerializer().serialize({
doc: doc(...content),
});
@@ -148,14 +156,18 @@ describe('markdownSerializer', () => {
});
it('correctly serializes code blocks wrapped by italics and bold marks', () => {
- const text = 'code block';
-
- expect(serialize(paragraph(italic(code(text))))).toBe(`_\`${text}\`_`);
- expect(serialize(paragraph(code(italic(text))))).toBe(`_\`${text}\`_`);
- expect(serialize(paragraph(bold(code(text))))).toBe(`**\`${text}\`**`);
- expect(serialize(paragraph(code(bold(text))))).toBe(`**\`${text}\`**`);
- expect(serialize(paragraph(strike(code(text))))).toBe(`~~\`${text}\`~~`);
- expect(serialize(paragraph(code(strike(text))))).toBe(`~~\`${text}\`~~`);
+ const codeBlockContent = 'code block';
+
+ expect(serialize(paragraph(italic(code(codeBlockContent))))).toBe(`_\`${codeBlockContent}\`_`);
+ expect(serialize(paragraph(code(italic(codeBlockContent))))).toBe(`_\`${codeBlockContent}\`_`);
+ expect(serialize(paragraph(bold(code(codeBlockContent))))).toBe(`**\`${codeBlockContent}\`**`);
+ expect(serialize(paragraph(code(bold(codeBlockContent))))).toBe(`**\`${codeBlockContent}\`**`);
+ expect(serialize(paragraph(strike(code(codeBlockContent))))).toBe(
+ `~~\`${codeBlockContent}\`~~`,
+ );
+ expect(serialize(paragraph(code(strike(codeBlockContent))))).toBe(
+ `~~\`${codeBlockContent}\`~~`,
+ );
});
it('correctly serializes inline diff', () => {
@@ -166,7 +178,7 @@ describe('markdownSerializer', () => {
inlineDiff({ type: 'deletion' }, '-10 lines'),
),
),
- ).toBe('{++30 lines+}{--10 lines-}');
+ ).toBe('{+\\+30 lines+}{-\\-10 lines-}');
});
it('correctly serializes highlight', () => {
@@ -199,6 +211,12 @@ hi
);
});
+ it('escapes < and > in a paragraph', () => {
+ expect(
+ serialize(paragraph(text("some prose: <this> and </this> looks like code, but isn't"))),
+ ).toBe("some prose: \\<this\\> and \\</this\\> looks like code, but isn't");
+ });
+
it('correctly serializes a line break', () => {
expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld');
});
@@ -281,6 +299,90 @@ hi
).toBe('![GitLab][gitlab-url]');
});
+ it('correctly serializes references', () => {
+ expect(
+ serialize(
+ paragraph(
+ reference({
+ referenceType: 'issue',
+ originalText: '#123',
+ href: '/gitlab-org/gitlab-test/-/issues/123',
+ text: '#123',
+ }),
+ ),
+ ),
+ ).toBe('#123');
+ });
+
+ it('correctly renders a reference label', () => {
+ expect(
+ serialize(
+ paragraph(
+ referenceLabel({
+ referenceType: 'label',
+ originalText: '~foo',
+ href: '/gitlab-org/gitlab-test/-/labels/foo',
+ text: '~foo',
+ }),
+ ),
+ ),
+ ).toBe('~foo');
+ });
+
+ it('correctly renders a reference label without originalText', () => {
+ expect(
+ serialize(
+ paragraph(
+ referenceLabel({
+ referenceType: 'label',
+ href: '/gitlab-org/gitlab-test/-/labels/foo',
+ text: 'Foo Bar',
+ }),
+ ),
+ ),
+ ).toBe('~"Foo Bar"');
+ });
+
+ it('ensures spaces between multiple references', () => {
+ expect(
+ serialize(
+ paragraph(
+ reference({
+ referenceType: 'issue',
+ originalText: '#123',
+ href: '/gitlab-org/gitlab-test/-/issues/123',
+ text: '#123',
+ }),
+ referenceLabel({
+ referenceType: 'label',
+ originalText: '~foo',
+ href: '/gitlab-org/gitlab-test/-/labels/foo',
+ text: '~foo',
+ }),
+ reference({
+ referenceType: 'issue',
+ originalText: '#456',
+ href: '/gitlab-org/gitlab-test/-/issues/456',
+ text: '#456',
+ }),
+ ),
+ paragraph(
+ reference({
+ referenceType: 'command',
+ originalText: '/assign_reviewer',
+ text: '/assign_reviewer',
+ }),
+ reference({
+ referenceType: 'user',
+ originalText: '@johndoe',
+ href: '/johndoe',
+ text: '@johndoe',
+ }),
+ ),
+ ),
+ ).toBe('#123 ~foo #456\n\n/assign_reviewer @johndoe');
+ });
+
it.each`
src
${'data:image/png;base64,iVBORw0KGgoAAAAN'}
@@ -789,7 +891,8 @@ content 2
expect(
serialize(
details(
- detailsContent(paragraph('dream level 1')),
+ // if paragraph contains special characters, it should be escaped and rendered as block
+ detailsContent(paragraph('dream level 1*')),
detailsContent(
details(
detailsContent(paragraph('dream level 2')),
@@ -806,7 +909,10 @@ content 2
).toBe(
`
<details>
-<summary>dream level 1</summary>
+<summary>
+
+dream level 1\\*
+</summary>
<details>
<summary>dream level 2</summary>
@@ -912,6 +1018,31 @@ _An elephant at sunset_
);
});
+ it('correctly serializes a table with a pipe in a cell', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(
+ tableHeader(paragraph('header')),
+ tableHeader(paragraph('header')),
+ tableHeader(paragraph('header')),
+ ),
+ tableRow(
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell | cell')),
+ tableCell(paragraph(bold('a|b|c'))),
+ ),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+| header | header | header |
+|--------|--------|--------|
+| cell | cell \\| cell | **a\\|b\\|c** |
+ `.trim(),
+ );
+ });
+
it('correctly renders a table with checkboxes', () => {
expect(
serialize(
@@ -1022,7 +1153,8 @@ _An elephant at sunset_
table(
tableRow(
tableHeader(paragraph('examples of')),
- tableHeader(paragraph('block content')),
+ // if a node contains special characters, it should be escaped and rendered as block
+ tableHeader(paragraph('block content*')),
tableHeader(paragraph('in tables')),
tableHeader(paragraph('in content editor')),
),
@@ -1079,7 +1211,10 @@ _An elephant at sunset_
<table>
<tr>
<th>examples of</th>
-<th>block content</th>
+<th>
+
+block content\\*
+</th>
<th>in tables</th>
<th>in content editor</th>
</tr>
@@ -1425,9 +1560,6 @@ paragraph
${'link'} | ${'link(https://www.gitlab.com)'} | ${'modified link(https://www.gitlab.com)'} | ${prependContentEditAction}
${'link'} | ${'link(engineering@gitlab.com)'} | ${'modified link(engineering@gitlab.com)'} | ${prependContentEditAction}
${'link'} | ${'link <https://www.gitlab.com>'} | ${'modified link <https://www.gitlab.com>'} | ${prependContentEditAction}
- ${'link'} | ${'link [https://www.gitlab.com>'} | ${'modified link \\[https://www.gitlab.com>'} | ${prependContentEditAction}
- ${'link'} | ${'link <https://www.gitlab.com'} | ${'modified link <https://www.gitlab.com'} | ${prependContentEditAction}
- ${'link'} | ${'link https://www.gitlab.com>'} | ${'modified link https://www.gitlab.com>'} | ${prependContentEditAction}
${'link'} | ${'link https://www.gitlab.com/path'} | ${'modified link https://www.gitlab.com/path'} | ${prependContentEditAction}
${'link'} | ${'link https://www.gitlab.com?query=search'} | ${'modified link https://www.gitlab.com?query=search'} | ${prependContentEditAction}
${'link'} | ${'link https://www.gitlab.com/#fragment'} | ${'modified link https://www.gitlab.com/#fragment'} | ${prependContentEditAction}
@@ -1460,7 +1592,7 @@ paragraph
editAction(document);
- const serialized = markdownSerializer({}).serialize({
+ const serialized = new MarkdownSerializer().serialize({
pristineDoc: document,
doc: tiptapEditor.state.doc,
});
diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js
index 749f1234de0..cbd4f555e97 100644
--- a/spec/frontend/content_editor/test_constants.js
+++ b/spec/frontend/content_editor/test_constants.js
@@ -35,3 +35,12 @@ export const PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML = `<p data-sourcepos="1
export const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto">
<a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a>
</p>`;
+
+export const RESOLVED_ISSUE_HTML =
+ '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">#1 (closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+s" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed) • Unassigned</a></p>';
+
+export const RESOLVED_MERGE_REQUEST_HTML =
+ '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">!1 (merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+s" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged) • John Doe</a></p>';
+
+export const RESOLVED_EPIC_HTML =
+ '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;amp;1" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">&amp;1</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;amp;1+" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&amp;1)</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;amp;1+s" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+s" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&amp;1)</a></p>';
diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js
index 1f4a367e46c..2184a829cf0 100644
--- a/spec/frontend/content_editor/test_utils.js
+++ b/spec/frontend/content_editor/test_utils.js
@@ -37,6 +37,8 @@ import Link from '~/content_editor/extensions/link';
import ListItem from '~/content_editor/extensions/list_item';
import OrderedList from '~/content_editor/extensions/ordered_list';
import ReferenceDefinition from '~/content_editor/extensions/reference_definition';
+import Reference from '~/content_editor/extensions/reference';
+import ReferenceLabel from '~/content_editor/extensions/reference_label';
import Strike from '~/content_editor/extensions/strike';
import Table from '~/content_editor/extensions/table';
import TableCell from '~/content_editor/extensions/table_cell';
@@ -192,6 +194,15 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => {
);
};
+export const triggerKeyboardInput = ({ tiptapEditor, key, shiftKey = false }) => {
+ let isCaptured = false;
+ tiptapEditor.view.someProp('handleKeyDown', (f) => {
+ isCaptured = f(tiptapEditor.view, new KeyboardEvent('keydown', { key, shiftKey }));
+ return isCaptured;
+ });
+ return isCaptured;
+};
+
/**
* Executes an action that triggers a transaction in the
* tiptap Editor. Returns a promise that resolves
@@ -212,6 +223,22 @@ export const waitUntilNextDocTransaction = ({ tiptapEditor, action = () => {} })
});
};
+export const waitUntilTransaction = ({ tiptapEditor, number, action }) => {
+ return new Promise((resolve) => {
+ let counter = 0;
+ const handleTransaction = () => {
+ counter += 1;
+ if (counter === number) {
+ tiptapEditor.off('update', handleTransaction);
+ resolve();
+ }
+ };
+
+ tiptapEditor.on('update', handleTransaction);
+ action();
+ });
+};
+
export const expectDocumentAfterTransaction = ({ tiptapEditor, number, expectedDoc, action }) => {
return new Promise((resolve) => {
let counter = 0;
@@ -266,6 +293,8 @@ export const createTiptapEditor = (extensions = []) =>
ListItem,
OrderedList,
ReferenceDefinition,
+ Reference,
+ ReferenceLabel,
Strike,
Table,
TableCell,
diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js
new file mode 100644
index 00000000000..6672d3eb18b
--- /dev/null
+++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_approved_spec.js
@@ -0,0 +1,47 @@
+import events from 'test_fixtures/controller/users/activity.json';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants';
+import ContributionEventApproved from '~/contribution_events/components/contribution_event/contribution_event_approved.vue';
+import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue';
+import TargetLink from '~/contribution_events/components/target_link.vue';
+import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue';
+
+const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED);
+
+describe('ContributionEventApproved', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = mountExtended(ContributionEventApproved, {
+ propsData: {
+ event: eventApproved,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders `ContributionEventBase`', () => {
+ expect(wrapper.findComponent(ContributionEventBase).props()).toEqual({
+ event: eventApproved,
+ iconName: 'approval-solid',
+ iconClass: 'gl-text-green-500',
+ });
+ });
+
+ it('renders message', () => {
+ expect(wrapper.findByTestId('event-body').text()).toBe(
+ `Approved merge request ${eventApproved.target.reference_link_text} in ${eventApproved.resource_parent.full_name}.`,
+ );
+ });
+
+ it('renders target link', () => {
+ expect(wrapper.findComponent(TargetLink).props('event')).toEqual(eventApproved);
+ });
+
+ it('renders resource parent link', () => {
+ expect(wrapper.findComponent(ResourceParentLink).props('event')).toEqual(eventApproved);
+ });
+});
diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js
new file mode 100644
index 00000000000..8c951e20bed
--- /dev/null
+++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_base_spec.js
@@ -0,0 +1,62 @@
+import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
+import events from 'test_fixtures/controller/users/activity.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+const [event] = events;
+
+describe('ContributionEventBase', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ event,
+ iconName: 'approval-solid',
+ iconClass: 'gl-text-green-500',
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ContributionEventBase, {
+ propsData: defaultPropsData,
+ scopedSlots: {
+ default: '<div data-testid="default-slot"></div>',
+ 'additional-info': '<div data-testid="additional-info-slot"></div>',
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders avatar', () => {
+ const avatarLink = wrapper.findComponent(GlAvatarLink);
+
+ expect(avatarLink.attributes('href')).toBe(event.author.web_url);
+ expect(avatarLink.findComponent(GlAvatarLabeled).attributes()).toMatchObject({
+ label: event.author.name,
+ sublabel: `@${event.author.username}`,
+ src: event.author.avatar_url,
+ size: '32',
+ });
+ });
+
+ it('renders time ago tooltip', () => {
+ expect(wrapper.findComponent(TimeAgoTooltip).props('time')).toBe(event.created_at);
+ });
+
+ it('renders icon', () => {
+ const icon = wrapper.findComponent(GlIcon);
+
+ expect(icon.props('name')).toBe(defaultPropsData.iconName);
+ expect(icon.classes()).toContain(defaultPropsData.iconClass);
+ });
+
+ it('renders `default` slot', () => {
+ expect(wrapper.findByTestId('default-slot').exists()).toBe(true);
+ });
+
+ it('renders `additional-info` slot', () => {
+ expect(wrapper.findByTestId('additional-info-slot').exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/contribution_events/components/contribution_events_spec.js b/spec/frontend/contribution_events/components/contribution_events_spec.js
new file mode 100644
index 00000000000..4bc354c393f
--- /dev/null
+++ b/spec/frontend/contribution_events/components/contribution_events_spec.js
@@ -0,0 +1,31 @@
+import events from 'test_fixtures/controller/users/activity.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants';
+import ContributionEvents from '~/contribution_events/components/contribution_events.vue';
+import ContributionEventApproved from '~/contribution_events/components/contribution_event/contribution_event_approved.vue';
+
+const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED);
+
+describe('ContributionEvents', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ContributionEvents, {
+ propsData: {
+ events,
+ },
+ });
+ };
+
+ it.each`
+ expectedComponent | expectedEvent
+ ${ContributionEventApproved} | ${eventApproved}
+ `(
+ 'renders `$expectedComponent.name` component and passes expected event',
+ ({ expectedComponent, expectedEvent }) => {
+ createComponent();
+
+ expect(wrapper.findComponent(expectedComponent).props('event')).toEqual(expectedEvent);
+ },
+ );
+});
diff --git a/spec/frontend/contribution_events/components/resource_parent_link_spec.js b/spec/frontend/contribution_events/components/resource_parent_link_spec.js
new file mode 100644
index 00000000000..8d586db2a30
--- /dev/null
+++ b/spec/frontend/contribution_events/components/resource_parent_link_spec.js
@@ -0,0 +1,30 @@
+import { GlLink } from '@gitlab/ui';
+import events from 'test_fixtures/controller/users/activity.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants';
+import ResourceParentLink from '~/contribution_events/components/resource_parent_link.vue';
+
+const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED);
+
+describe('ResourceParentLink', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ResourceParentLink, {
+ propsData: {
+ event: eventApproved,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders link', () => {
+ const link = wrapper.findComponent(GlLink);
+
+ expect(link.attributes('href')).toBe(eventApproved.resource_parent.web_url);
+ expect(link.text()).toBe(eventApproved.resource_parent.full_name);
+ });
+});
diff --git a/spec/frontend/contribution_events/components/target_link_spec.js b/spec/frontend/contribution_events/components/target_link_spec.js
new file mode 100644
index 00000000000..7944375487b
--- /dev/null
+++ b/spec/frontend/contribution_events/components/target_link_spec.js
@@ -0,0 +1,33 @@
+import { GlLink } from '@gitlab/ui';
+import events from 'test_fixtures/controller/users/activity.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { EVENT_TYPE_APPROVED } from '~/contribution_events/constants';
+import TargetLink from '~/contribution_events/components/target_link.vue';
+
+const eventApproved = events.find((event) => event.action === EVENT_TYPE_APPROVED);
+
+describe('TargetLink', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(TargetLink, {
+ propsData: {
+ event: eventApproved,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders link', () => {
+ const link = wrapper.findComponent(GlLink);
+
+ expect(link.attributes()).toMatchObject({
+ href: eventApproved.target.web_url,
+ title: eventApproved.target.title,
+ });
+ expect(link.text()).toBe(eventApproved.target.reference_link_text);
+ });
+});
diff --git a/spec/frontend/design_management/components/design_description/description_form_spec.js b/spec/frontend/design_management/components/design_description/description_form_spec.js
new file mode 100644
index 00000000000..8c01023b1a8
--- /dev/null
+++ b/spec/frontend/design_management/components/design_description/description_form_spec.js
@@ -0,0 +1,299 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+
+import { GlAlert } from '@gitlab/ui';
+
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import DescriptionForm from '~/design_management/components/design_description/description_form.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+import updateDesignDescriptionMutation from '~/design_management/graphql/mutations/update_design_description.mutation.graphql';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { renderGFM } from '~/behaviors/markdown/render_gfm';
+
+import { designFactory, designUpdateFactory } from '../../mock_data/apollo_mock';
+
+jest.mock('~/behaviors/markdown/render_gfm');
+
+Vue.use(VueApollo);
+
+describe('Design description form', () => {
+ const formFieldProps = {
+ id: 'design-description',
+ name: 'design-description',
+ placeholder: 'Write a comment or drag your files here…',
+ 'aria-label': 'Design description',
+ };
+ const mockDesign = designFactory();
+ const mockDesignVariables = {
+ fullPath: '',
+ iid: '1',
+ filenames: ['test.jpg'],
+ atVersion: null,
+ };
+
+ const mockDesignResponse = designUpdateFactory();
+ const mockDesignUpdateMutationHandler = jest.fn().mockResolvedValue(mockDesignResponse);
+ let wrapper;
+ let mockApollo;
+
+ const createComponent = ({
+ design = mockDesign,
+ descriptionText = '',
+ showEditor = false,
+ isSubmitting = false,
+ designVariables = mockDesignVariables,
+ contentEditorOnIssues = false,
+ designUpdateMutationHandler = mockDesignUpdateMutationHandler,
+ } = {}) => {
+ mockApollo = createMockApollo([[updateDesignDescriptionMutation, designUpdateMutationHandler]]);
+ wrapper = mountExtended(DescriptionForm, {
+ propsData: {
+ design,
+ markdownPreviewPath: '/gitlab-org/gitlab-test/preview_markdown?target_type=Issue',
+ designVariables,
+ },
+ provide: {
+ glFeatures: {
+ contentEditorOnIssues,
+ },
+ },
+ apolloProvider: mockApollo,
+ data() {
+ return {
+ formFieldProps,
+ descriptionText,
+ showEditor,
+ isSubmitting,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ mockApollo = null;
+ });
+
+ const findDesignContent = () => wrapper.findByTestId('design-description-content');
+ const findDesignNoneBlock = () => wrapper.findByTestId('design-description-none');
+ const findEditDescriptionButton = () => wrapper.findByTestId('edit-description');
+ const findSaveDescriptionButton = () => wrapper.findByTestId('save-description');
+ const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
+ const findTextarea = () => wrapper.find('textarea');
+ const findCheckboxAtIndex = (index) => wrapper.findAll('input[type="checkbox"]').at(index);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ describe('user has updateDesign permission', () => {
+ const ctrlKey = {
+ ctrlKey: true,
+ };
+ const metaKey = {
+ metaKey: true,
+ };
+ const mockDescription = 'Hello world';
+ const errorMessage = 'Could not update description. Please try again.';
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders description content with the edit button', () => {
+ expect(findDesignContent().text()).toEqual('Test description');
+ expect(findEditDescriptionButton().exists()).toBe(true);
+ });
+
+ it('renders none when description is empty', () => {
+ createComponent({ design: designFactory({ description: '', descriptionHtml: '' }) });
+
+ expect(findDesignNoneBlock().text()).toEqual('None');
+ });
+
+ it('renders save button when editor is open', () => {
+ createComponent({
+ design: designFactory({ description: '', descriptionHtml: '' }),
+ showEditor: true,
+ });
+
+ expect(findSaveDescriptionButton().exists()).toBe(true);
+ expect(findSaveDescriptionButton().attributes('disabled')).toBeUndefined();
+ });
+
+ it('renders the markdown editor with default props', () => {
+ createComponent({
+ showEditor: true,
+ descriptionText: 'Test description',
+ });
+
+ expect(findMarkdownEditor().exists()).toBe(true);
+ expect(findMarkdownEditor().props()).toMatchObject({
+ value: 'Test description',
+ renderMarkdownPath: '/gitlab-org/gitlab-test/preview_markdown?target_type=Issue',
+ enableContentEditor: false,
+ formFieldProps,
+ autofocus: true,
+ enableAutocomplete: true,
+ supportsQuickActions: false,
+ autosaveKey: `Issue/${getIdFromGraphQLId(mockDesign.issue.id)}/Design/${getIdFromGraphQLId(
+ mockDesign.id,
+ )}`,
+ markdownDocsPath: '/help/user/markdown',
+ quickActionsDocsPath: '/help/user/project/quick_actions',
+ });
+ });
+
+ it.each`
+ isKeyEvent | assertionName | key | keyData
+ ${true} | ${'Ctrl + Enter keypress'} | ${'ctrl'} | ${ctrlKey}
+ ${true} | ${'Meta + Enter keypress'} | ${'meta'} | ${metaKey}
+ ${false} | ${'Save button click'} | ${''} | ${null}
+ `(
+ 'hides form and calls mutation when form is submitted via $assertionName',
+ async ({ isKeyEvent, keyData }) => {
+ const mockDesignUpdateResponseHandler = jest.fn().mockResolvedValue(
+ designUpdateFactory({
+ description: mockDescription,
+ descriptionHtml: `<p data-sourcepos="1:1-1:16" dir="auto">${mockDescription}</p>`,
+ }),
+ );
+
+ createComponent({
+ showEditor: true,
+ designUpdateMutationHandler: mockDesignUpdateResponseHandler,
+ });
+
+ findMarkdownEditor().vm.$emit('input', 'Hello world');
+ if (isKeyEvent) {
+ findTextarea().trigger('keydown.enter', keyData);
+ } else {
+ findSaveDescriptionButton().vm.$emit('click');
+ }
+
+ await nextTick();
+
+ expect(mockDesignUpdateResponseHandler).toHaveBeenCalledWith({
+ input: {
+ description: 'Hello world',
+ id: 'gid::/gitlab/Design/1',
+ },
+ });
+
+ await waitForPromises();
+
+ expect(findMarkdownEditor().exists()).toBe(false);
+ },
+ );
+
+ it('shows error message when mutation fails', async () => {
+ const failureHandler = jest.fn().mockRejectedValue(new Error(errorMessage));
+ createComponent({
+ showEditor: true,
+ descriptionText: 'Hello world',
+ designUpdateMutationHandler: failureHandler,
+ });
+
+ findMarkdownEditor().vm.$emit('input', 'Hello world');
+ findSaveDescriptionButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(errorMessage);
+ });
+ });
+
+ describe('content has checkboxes', () => {
+ const mockCheckboxDescription = '- [x] todo 1\n- [ ] todo 2';
+ const mockCheckboxDescriptionHtml = `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0">
+ <li class="task-list-item" data-sourcepos="1:1-2:15">
+ <input checked="" class="task-list-item-checkbox" type="checkbox"> todo 1</li>
+ <li class="task-list-item" data-sourcepos="2:1-2:15">
+ <input class="task-list-item-checkbox" type="checkbox"> todo 2</li>
+ </ul>`;
+ const checkboxDesignDescription = designFactory({
+ updateDesign: true,
+ description: mockCheckboxDescription,
+ descriptionHtml: mockCheckboxDescriptionHtml,
+ });
+ const mockCheckedDescriptionUpdateResponseHandler = jest.fn().mockResolvedValue(
+ designUpdateFactory({
+ description: '- [x] todo 1\n- [x] todo 2',
+ descriptionHtml: `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0">
+ <li class="task-list-item" data-sourcepos="1:1-2:15">
+ <input checked="" class="task-list-item-checkbox" type="checkbox"> todo 1</li>
+ <li class="task-list-item" data-sourcepos="2:1-2:15">
+ <input class="task-list-item-checkbox" type="checkbox"> todo 2</li>
+ </ul>`,
+ }),
+ );
+ const mockUnCheckedDescriptionUpdateResponseHandler = jest.fn().mockResolvedValue(
+ designUpdateFactory({
+ description: '- [ ] todo 1\n- [ ] todo 2',
+ descriptionHtml: `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0">
+ <li class="task-list-item" data-sourcepos="1:1-2:15">
+ <input class="task-list-item-checkbox" type="checkbox"> todo 1</li>
+ <li class="task-list-item" data-sourcepos="2:1-2:15">
+ <input class="task-list-item-checkbox" type="checkbox"> todo 2</li>
+ </ul>`,
+ }),
+ );
+
+ it.each`
+ assertionName | mockDesignUpdateResponseHandler | checkboxIndex | checked | expectedDesignDescription
+ ${'checked'} | ${mockCheckedDescriptionUpdateResponseHandler} | ${1} | ${true} | ${'- [x] todo 1\n- [x] todo 2'}
+ ${'unchecked'} | ${mockUnCheckedDescriptionUpdateResponseHandler} | ${0} | ${false} | ${'- [ ] todo 1\n- [ ] todo 2'}
+ `(
+ 'updates the store object when checkbox is $assertionName',
+ async ({
+ mockDesignUpdateResponseHandler,
+ checkboxIndex,
+ checked,
+ expectedDesignDescription,
+ }) => {
+ createComponent({
+ design: checkboxDesignDescription,
+ descriptionText: mockCheckboxDescription,
+ designUpdateMutationHandler: mockDesignUpdateResponseHandler,
+ });
+
+ findCheckboxAtIndex(checkboxIndex).setChecked(checked);
+
+ expect(mockDesignUpdateResponseHandler).toHaveBeenCalledWith({
+ input: {
+ description: expectedDesignDescription,
+ id: 'gid::/gitlab/Design/1',
+ },
+ });
+
+ await waitForPromises();
+
+ expect(renderGFM).toHaveBeenCalled();
+ },
+ );
+
+ it('disables checkbox while updating', () => {
+ createComponent({
+ design: checkboxDesignDescription,
+ descriptionText: mockCheckboxDescription,
+ });
+
+ findCheckboxAtIndex(1).setChecked();
+
+ expect(findCheckboxAtIndex(1).attributes().disabled).toBeDefined();
+ });
+ });
+
+ describe('user has no updateDesign permission', () => {
+ beforeEach(() => {
+ const designWithNoUpdateUserPermission = designFactory({
+ updateDesign: false,
+ });
+ createComponent({ design: designWithNoUpdateUserPermission });
+ });
+
+ it('does not render edit button', () => {
+ expect(findEditDescriptionButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
index 3b407d11041..9bb85ecf569 100644
--- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
@@ -1,15 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design note component should match the snapshot 1`] = `
-<timeline-entry-item-stub
+<timelineentryitem-stub
class="design-note note-form"
id="note_123"
>
- <gl-avatar-link-stub
+ <glavatarlink-stub
class="gl-float-left gl-mr-3"
href="https://gitlab.com/user"
>
- <gl-avatar-stub
+ <glavatar-stub
alt="avatar"
entityid="0"
entityname="foo-bar"
@@ -17,13 +17,13 @@ exports[`Design note component should match the snapshot 1`] = `
size="32"
src="https://gitlab.com/avatar"
/>
- </gl-avatar-link-stub>
+ </glavatarlink-stub>
<div
class="gl-display-flex gl-justify-content-space-between"
>
<div>
- <gl-link-stub
+ <gllink-stub
class="js-user-link"
data-testid="user-link"
data-user-id="1"
@@ -43,7 +43,7 @@ exports[`Design note component should match the snapshot 1`] = `
>
@foo-bar
</span>
- </gl-link-stub>
+ </gllink-stub>
<span
class="note-headline-light note-headline-meta"
@@ -52,22 +52,22 @@ exports[`Design note component should match the snapshot 1`] = `
class="system-note-message"
/>
- <gl-link-stub
- class="note-timestamp system-note-separator gl-display-block gl-mb-2"
+ <gllink-stub
+ class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm"
href="#note_123"
>
- <time-ago-tooltip-stub
+ <timeagotooltip-stub
cssclass=""
datetimeformat="DATE_WITH_TIME_FORMAT"
time="2019-07-26T15:02:20Z"
tooltipplacement="bottom"
/>
- </gl-link-stub>
+ </gllink-stub>
</span>
</div>
<div
- class="gl-display-flex gl-align-items-baseline"
+ class="gl-display-flex gl-align-items-baseline gl-mt-n2 gl-mr-n2"
>
<!---->
@@ -82,5 +82,5 @@ exports[`Design note component should match the snapshot 1`] = `
data-testid="note-text"
/>
-</timeline-entry-item-stub>
+</timelineentryitem-stub>
`;
diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
index a6ab147884f..664a0974549 100644
--- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
@@ -1,4 +1,4 @@
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlFormCheckbox } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
@@ -36,7 +36,7 @@ describe('Design discussions component', () => {
const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]');
const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]');
const findResolveLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]');
+ const findResolveCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const registerPath = '/users/sign_up?redirect_to_referer=yes';
const signInPath = '/users/sign_in?redirect_to_referer=yes';
diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js
index 6f5b282fa3b..661d1ac4087 100644
--- a/spec/frontend/design_management/components/design_notes/design_note_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js
@@ -1,7 +1,7 @@
import { ApolloMutation } from 'vue-apollo';
import { nextTick } from 'vue';
-import { GlAvatar, GlAvatarLink, GlDropdown } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { GlAvatar, GlAvatarLink, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import DesignNote from '~/design_management/components/design_notes/design_note.vue';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -38,11 +38,13 @@ describe('Design note component', () => {
const findReplyForm = () => wrapper.findComponent(DesignReplyForm);
const findEditButton = () => wrapper.findByTestId('note-edit');
const findNoteContent = () => wrapper.findByTestId('note-text');
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-button"]');
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findDropdownItems = () => findDropdown().findAllComponents(GlDisclosureDropdownItem);
+ const findEditDropdownItem = () => findDropdownItems().at(0);
+ const findDeleteDropdownItem = () => findDropdownItems().at(1);
function createComponent(props = {}, data = { isEditing: false }) {
- wrapper = shallowMountExtended(DesignNote, {
+ wrapper = mountExtended(DesignNote, {
propsData: {
note: {},
noteableId: 'gid://gitlab/DesignManagement::Design/6',
@@ -61,6 +63,13 @@ describe('Design note component', () => {
},
stubs: {
ApolloMutation,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ TimelineEntryItem: true,
+ TimeAgoTooltip: true,
+ GlAvatarLink: true,
+ GlAvatar: true,
+ GlLink: true,
},
});
}
@@ -151,6 +160,23 @@ describe('Design note component', () => {
);
});
+ it('should open an edit form on edit button click', async () => {
+ createComponent({
+ note: {
+ ...note,
+ userPermissions: {
+ adminNote: true,
+ },
+ },
+ });
+
+ findEditDropdownItem().find('button').trigger('click');
+
+ await nextTick();
+ expect(findReplyForm().exists()).toBe(true);
+ expect(findNoteContent().exists()).toBe(false);
+ });
+
it('should not render note content and should render reply form', () => {
expect(findNoteContent().exists()).toBe(false);
expect(findReplyForm().exists()).toBe(true);
@@ -174,7 +200,7 @@ describe('Design note component', () => {
});
});
- describe('when user has a permission to delete note', () => {
+ describe('when user has admin permissions', () => {
it('should display a dropdown', () => {
createComponent({
note: {
@@ -186,6 +212,9 @@ describe('Design note component', () => {
});
expect(findDropdown().exists()).toBe(true);
+ expect(findEditDropdownItem().exists()).toBe(true);
+ expect(findDeleteDropdownItem().exists()).toBe(true);
+ expect(findDropdown().props('items')[0].extraAttrs.class).toBe('gl-sm-display-none!');
});
});
@@ -203,7 +232,7 @@ describe('Design note component', () => {
},
});
- findDeleteNoteButton().vm.$emit('click');
+ findDeleteDropdownItem().find('button').trigger('click');
expect(wrapper.emitted()).toEqual({ 'delete-note': [[{ ...payload }]] });
});
diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js
index 90424175417..e3f056df4c6 100644
--- a/spec/frontend/design_management/components/design_sidebar_spec.js
+++ b/spec/frontend/design_management/components/design_sidebar_spec.js
@@ -26,6 +26,13 @@ const $route = {
},
};
+const mockDesignVariables = {
+ fullPath: 'project-path',
+ iid: '1',
+ filenames: ['gid::/gitlab/Design/1'],
+ atVersion: null,
+};
+
const mutate = jest.fn().mockResolvedValue();
describe('Design management design sidebar component', () => {
@@ -47,6 +54,7 @@ describe('Design management design sidebar component', () => {
resolvedDiscussionsExpanded: false,
markdownPreviewPath: '',
isLoading: false,
+ designVariables: mockDesignVariables,
...props,
},
mocks: {
diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
index 9451f35ac5b..0bbb44bb517 100644
--- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
+++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap
@@ -11,13 +11,13 @@ exports[`Design management list item component when item appears in view after i
exports[`Design management list item component with notes renders item with multiple comments 1`] = `
<router-link-stub
ariacurrentvalue="page"
- class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0"
+ class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0"
event="click"
tag="a"
to="[object Object]"
>
<div
- class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
+ class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base"
>
<!---->
@@ -91,13 +91,13 @@ exports[`Design management list item component with notes renders item with mult
exports[`Design management list item component with notes renders item with single comment 1`] = `
<router-link-stub
ariacurrentvalue="page"
- class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0"
+ class="card gl-cursor-pointer text-plain js-design-list-item design-list-item gl-mb-0"
event="click"
tag="a"
to="[object Object]"
>
<div
- class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
+ class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative gl-rounded-top-base"
>
<!---->
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index 18e08ecd729..063df9366e9 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -119,6 +119,8 @@ export const reorderedDesigns = [
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
+ description: '',
+ descriptionHtml: '',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
@@ -132,6 +134,8 @@ export const reorderedDesigns = [
notesCount: 3,
image: 'image-1',
imageV432x230: 'image-1',
+ description: '',
+ descriptionHtml: '',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
@@ -145,6 +149,8 @@ export const reorderedDesigns = [
notesCount: 1,
image: 'image-3',
imageV432x230: 'image-3',
+ description: '',
+ descriptionHtml: '',
currentUserTodos: {
__typename: 'ToDo',
nodes: [],
@@ -320,3 +326,59 @@ export const mockCreateImageNoteDiffResponse = {
},
},
};
+
+export const designFactory = ({
+ updateDesign = true,
+ discussions = {},
+ description = 'Test description',
+ descriptionHtml = '<p data-sourcepos="1:1-1:16" dir="auto">Test description</p>',
+} = {}) => ({
+ id: 'gid::/gitlab/Design/1',
+ iid: 1,
+ filename: 'test.jpg',
+ fullPath: 'full-design-path',
+ image: 'test.jpg',
+ description,
+ descriptionHtml,
+ updatedAt: '01-01-2019',
+ updatedBy: {
+ name: 'test',
+ },
+ issue: {
+ id: 'gid::/gitlab/Issue/1',
+ title: 'My precious issue',
+ webPath: 'full-issue-path',
+ webUrl: 'full-issue-url',
+ participants: {
+ nodes: [
+ {
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'link-to-author',
+ avatarUrl: 'link-to-avatar',
+ __typename: 'UserCore',
+ },
+ ],
+ __typename: 'UserCoreConnection',
+ },
+ userPermissions: {
+ updateDesign,
+ __typename: 'IssuePermissions',
+ },
+ __typename: 'Issue',
+ },
+ discussions,
+ __typename: 'Design',
+});
+
+export const designUpdateFactory = (options) => {
+ return {
+ data: {
+ designManagementUpdate: {
+ errors: [],
+ design: designFactory(options),
+ },
+ __typename: 'DesignManagementUpdatePayload',
+ },
+ };
+};
diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js
index f2a3a800969..8379408b27c 100644
--- a/spec/frontend/design_management/mock_data/design.js
+++ b/spec/frontend/design_management/mock_data/design.js
@@ -3,6 +3,8 @@ export default {
filename: 'test.jpg',
fullPath: 'full-design-path',
image: 'test.jpg',
+ description: 'Test description',
+ descriptionHtml: 'Test description',
updatedAt: '01-01-2019',
updatedBy: {
name: 'test',
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
deleted file mode 100644
index 7da0652faba..00000000000
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ /dev/null
@@ -1,60 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management index page designs renders error 1`] = `
-<div
- class="gl-mt-4"
- data-testid="designs-root"
->
- <!---->
-
- <!---->
-
- <div
- class="gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5"
- >
- <gl-alert-stub
- dismisslabel="Dismiss"
- primarybuttonlink=""
- primarybuttontext=""
- secondarybuttonlink=""
- secondarybuttontext=""
- showicon="true"
- title=""
- variant="danger"
- >
-
- An error occurred while loading designs. Please try again.
-
- </gl-alert-stub>
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
-
-exports[`Design management index page designs renders loading icon 1`] = `
-<div
- class="gl-mt-4"
- data-testid="designs-root"
->
- <!---->
-
- <!---->
-
- <div
- class="gl-bg-gray-10 gl-border gl-border-t-0 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-5"
- >
- <gl-loading-icon-stub
- color="dark"
- label="Loading"
- size="lg"
- />
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
index 18b63082e4a..bd37d917faa 100644
--- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -61,6 +61,12 @@ exports[`Design management design index page renders design index 1`] = `
ull-issue-path
</a>
+ <description-form-stub
+ design="[object Object]"
+ designvariables="[object Object]"
+ markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
+ />
+
<participants-stub
class="gl-mb-4"
lazy="true"
@@ -192,6 +198,12 @@ exports[`Design management design index page with error GlAlert is rendered in c
ull-issue-path
</a>
+ <description-form-stub
+ design="[object Object]"
+ designvariables="[object Object]"
+ markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
+ />
+
<participants-stub
class="gl-mb-4"
lazy="true"
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
index fcb03ea3700..6cddb0cbbf1 100644
--- a/spec/frontend/design_management/pages/design/index_spec.js
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -188,6 +188,12 @@ describe('Design management design index page', () => {
markdownPreviewPath: '/project-path/preview_markdown?target_type=Issue',
resolvedDiscussionsExpanded: false,
isLoading: false,
+ designVariables: {
+ fullPath: 'project-path',
+ iid: '1',
+ filenames: ['gid::/gitlab/Design/1'],
+ atVersion: null,
+ },
});
});
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 1a6403d3b87..961ea27f0f4 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -1,4 +1,4 @@
-import { GlEmptyState } from '@gitlab/ui';
+import { GlEmptyState, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo, { ApolloMutation } from 'vue-apollo';
@@ -16,7 +16,7 @@ import DesignDestroyer from '~/design_management/components/design_destroyer.vue
import Design from '~/design_management/components/list/item.vue';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
import uploadDesignMutation from '~/design_management/graphql/mutations/upload_design.mutation.graphql';
-import Index from '~/design_management/pages/index.vue';
+import Index, { i18n } from '~/design_management/pages/index.vue';
import createRouter from '~/design_management/router';
import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
import * as utils from '~/design_management/utils/design_management_utils';
@@ -117,6 +117,8 @@ describe('Design management index page', () => {
const findDesignUploadButton = () => wrapper.findByTestId('design-upload-button');
const findDesignToolbarWrapper = () => wrapper.findByTestId('design-toolbar-wrapper');
const findDesignUpdateAlert = () => wrapper.findByTestId('design-update-alert');
+ const findLoadinIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAlert = () => wrapper.findComponent(GlAlert);
async function moveDesigns(localWrapper) {
await waitForPromises();
@@ -177,13 +179,14 @@ describe('Design management index page', () => {
function createComponentWithApollo({
permissionsHandler = jest.fn().mockResolvedValue(getPermissionsQueryResponse()),
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
+ getDesignListHandler = jest.fn().mockResolvedValue(getDesignListQueryResponse()),
}) {
Vue.use(VueApollo);
permissionsQueryHandler = permissionsHandler;
moveDesignHandler = moveHandler;
const requestHandlers = [
- [getDesignListQuery, jest.fn().mockResolvedValue(getDesignListQueryResponse())],
+ [getDesignListQuery, getDesignListHandler],
[permissionsQuery, permissionsQueryHandler],
[moveDesignMutation, moveDesignHandler],
];
@@ -203,24 +206,12 @@ describe('Design management index page', () => {
describe('designs', () => {
it('renders loading icon', () => {
createComponent({ loading: true });
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders error', async () => {
- createComponent();
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ error: true });
-
- await nextTick();
- expect(wrapper.element).toMatchSnapshot();
+ expect(findLoadinIcon().exists()).toBe(true);
});
it('renders a toolbar with buttons when there are designs', () => {
createComponent({ allVersions: [mockVersion] });
-
+ expect(findLoadinIcon().exists()).toBe(false);
expect(findToolbar().exists()).toBe(true);
});
@@ -236,7 +227,6 @@ describe('Design management index page', () => {
it('has correct classes applied to design dropzone', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion] });
expect(dropzoneClasses()).toContain('design-list-item');
- expect(dropzoneClasses()).toContain('design-list-item-new');
});
it('has correct classes applied to dropzone wrapper', () => {
@@ -262,7 +252,6 @@ describe('Design management index page', () => {
it('has correct classes applied to design dropzone', () => {
expect(dropzoneClasses()).not.toContain('design-list-item');
- expect(dropzoneClasses()).not.toContain('design-list-item-new');
});
it('has correct classes applied to dropzone wrapper', () => {
@@ -319,6 +308,8 @@ describe('Design management index page', () => {
},
image: '',
imageV432x230: '',
+ description: '',
+ descriptionHtml: '',
filename: 'test',
fullPath: '',
event: 'NONE',
@@ -362,7 +353,6 @@ describe('Design management index page', () => {
expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]);
expect(wrapper.vm.isSaving).toBe(true);
expect(dropzoneClasses()).toContain('design-list-item');
- expect(dropzoneClasses()).toContain('design-list-item-new');
});
it('sets isSaving', async () => {
@@ -382,9 +372,8 @@ describe('Design management index page', () => {
it('updates state appropriately after upload complete', async () => {
createComponent({ stubs: { GlEmptyState } });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
+ const designDropzone = findFirstDropzoneWithDesign();
+ designDropzone.vm.$emit('change', 'test');
wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse);
await nextTick();
@@ -396,10 +385,8 @@ describe('Design management index page', () => {
it('updates state appropriately after upload error', async () => {
createComponent({ stubs: { GlEmptyState } });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ filesToBeSaved: [{ name: 'test' }] });
-
+ const designDropzone = findFirstDropzoneWithDesign();
+ designDropzone.vm.$emit('change', 'test');
wrapper.vm.onUploadDesignError();
await nextTick();
expect(wrapper.vm.filesToBeSaved).toEqual([]);
@@ -752,6 +739,16 @@ describe('Design management index page', () => {
});
describe('with mocked Apollo client', () => {
+ it('renders error', async () => {
+ // eslint-disable-next-line no-console
+ console.error = jest.fn();
+
+ createComponentWithApollo({
+ getDesignListHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')),
+ });
+ await waitForPromises();
+ expect(findAlert().text()).toBe(i18n.designLoadingError);
+ });
it('has a design with id 1 as a first one', async () => {
createComponentWithApollo({});
await waitForPromises();
diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js
index dc6056badb9..cbfe8e3a243 100644
--- a/spec/frontend/design_management/utils/design_management_utils_spec.js
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -89,6 +89,8 @@ describe('optimistic responses', () => {
id: -1,
image: '',
imageV432x230: '',
+ description: '',
+ descriptionHtml: '',
filename: 'test',
fullPath: '',
notesCount: 0,
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 42eec0af961..b69452069c0 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -43,7 +43,7 @@ describe('diffs/components/app', () => {
let wrapper;
let mock;
- function createComponent(props = {}, extendStore = () => {}, provisions = {}) {
+ function createComponent(props = {}, extendStore = () => {}, provisions = {}, baseConfig = {}) {
const provide = {
...provisions,
glFeatures: {
@@ -57,20 +57,24 @@ describe('diffs/components/app', () => {
extendStore(store);
+ store.dispatch('diffs/setBaseConfig', {
+ endpoint: TEST_ENDPOINT,
+ endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`,
+ endpointBatch: `${TEST_HOST}/diff/endpointBatch`,
+ endpointDiffForPath: TEST_ENDPOINT,
+ projectPath: 'namespace/project',
+ dismissEndpoint: '',
+ showSuggestPopover: true,
+ mrReviews: {},
+ ...baseConfig,
+ });
+
wrapper = shallowMount(App, {
propsData: {
- endpoint: TEST_ENDPOINT,
- endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`,
- endpointBatch: `${TEST_HOST}/diff/endpointBatch`,
- endpointDiffForPath: TEST_ENDPOINT,
endpointCoverage: `${TEST_HOST}/diff/endpointCoverage`,
endpointCodequality: '',
- projectPath: 'namespace/project',
currentUser: {},
changesEmptyStateIllustration: '',
- dismissEndpoint: '',
- showSuggestPopover: true,
- fileByFileUserPreference: false,
...props,
},
provide,
@@ -653,13 +657,18 @@ describe('diffs/components/app', () => {
describe('file-by-file', () => {
it('renders a single diff', async () => {
- createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.treeEntries = {
- 123: { type: 'blob', fileHash: '123' },
- 312: { type: 'blob', fileHash: '312' },
- };
- state.diffs.diffFiles.push({ file_hash: '312' });
- });
+ createComponent(
+ undefined,
+ ({ state }) => {
+ state.diffs.treeEntries = {
+ 123: { type: 'blob', fileHash: '123' },
+ 312: { type: 'blob', fileHash: '312' },
+ };
+ state.diffs.diffFiles.push({ file_hash: '312' });
+ },
+ undefined,
+ { viewDiffsFileByFile: true },
+ );
await nextTick();
@@ -671,12 +680,17 @@ describe('diffs/components/app', () => {
const paginator = () => fileByFileNav().findComponent(GlPagination);
it('sets previous button as disabled', async () => {
- createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.treeEntries = {
- 123: { type: 'blob', fileHash: '123' },
- 312: { type: 'blob', fileHash: '312' },
- };
- });
+ createComponent(
+ undefined,
+ ({ state }) => {
+ state.diffs.treeEntries = {
+ 123: { type: 'blob', fileHash: '123' },
+ 312: { type: 'blob', fileHash: '312' },
+ };
+ },
+ undefined,
+ { viewDiffsFileByFile: true },
+ );
await nextTick();
@@ -685,13 +699,18 @@ describe('diffs/components/app', () => {
});
it('sets next button as disabled', async () => {
- createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.treeEntries = {
- 123: { type: 'blob', fileHash: '123' },
- 312: { type: 'blob', fileHash: '312' },
- };
- state.diffs.currentDiffFileId = '312';
- });
+ createComponent(
+ undefined,
+ ({ state }) => {
+ state.diffs.treeEntries = {
+ 123: { type: 'blob', fileHash: '123' },
+ 312: { type: 'blob', fileHash: '312' },
+ };
+ state.diffs.currentDiffFileId = '312';
+ },
+ undefined,
+ { viewDiffsFileByFile: true },
+ );
await nextTick();
@@ -700,10 +719,15 @@ describe('diffs/components/app', () => {
});
it("doesn't display when there's fewer than 2 files", async () => {
- createComponent({ fileByFileUserPreference: true }, ({ state }) => {
- state.diffs.treeEntries = { 123: { type: 'blob', fileHash: '123' } };
- state.diffs.currentDiffFileId = '123';
- });
+ createComponent(
+ undefined,
+ ({ state }) => {
+ state.diffs.treeEntries = { 123: { type: 'blob', fileHash: '123' } };
+ state.diffs.currentDiffFileId = '123';
+ },
+ undefined,
+ { viewDiffsFileByFile: true },
+ );
await nextTick();
@@ -711,14 +735,14 @@ describe('diffs/components/app', () => {
});
it.each`
- currentDiffFileId | targetFile | newFileByFile
- ${'123'} | ${2} | ${false}
- ${'312'} | ${1} | ${true}
+ currentDiffFileId | targetFile
+ ${'123'} | ${2}
+ ${'312'} | ${1}
`(
'calls navigateToDiffFileIndex with $index when $link is clicked',
- async ({ currentDiffFileId, targetFile, newFileByFile }) => {
+ async ({ currentDiffFileId, targetFile }) => {
createComponent(
- { fileByFileUserPreference: true },
+ undefined,
({ state }) => {
state.diffs.treeEntries = {
123: { type: 'blob', fileHash: '123', filePaths: { old: '1234', new: '123' } },
@@ -726,11 +750,8 @@ describe('diffs/components/app', () => {
};
state.diffs.currentDiffFileId = currentDiffFileId;
},
- {
- glFeatures: {
- singleFileFileByFile: newFileByFile,
- },
- },
+ undefined,
+ { viewDiffsFileByFile: true },
);
await nextTick();
@@ -741,10 +762,7 @@ describe('diffs/components/app', () => {
await nextTick();
- expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith({
- index: targetFile - 1,
- singleFile: newFileByFile,
- });
+ expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(targetFile - 1);
},
);
});
diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js
index 47a266c2e36..cbbfd88260b 100644
--- a/spec/frontend/diffs/components/compare_versions_spec.js
+++ b/spec/frontend/diffs/components/compare_versions_spec.js
@@ -1,15 +1,14 @@
import { mount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import Vuex from 'vuex';
+import { nextTick } from 'vue';
import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { trimText } from 'helpers/text_helper';
import CompareVersionsComponent from '~/diffs/components/compare_versions.vue';
-import { createStore } from '~/mr_notes/stores';
+import store from '~/mr_notes/stores';
import diffsMockData from '../mock_data/merge_request_diffs';
-Vue.use(Vuex);
+jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
const NEXT_COMMIT_URL = `${TEST_HOST}/?commit_id=next`;
const PREV_COMMIT_URL = `${TEST_HOST}/?commit_id=prev`;
@@ -20,8 +19,6 @@ beforeEach(() => {
describe('CompareVersions', () => {
let wrapper;
- let store;
- let dispatchMock;
const targetBranchName = 'tmp-wine-dev';
const { commit } = getDiffWithCommit;
@@ -30,10 +27,10 @@ describe('CompareVersions', () => {
store.state.diffs.commit = { ...store.state.diffs.commit, ...commitArgs };
}
- dispatchMock = jest.spyOn(store, 'dispatch');
-
wrapper = mount(CompareVersionsComponent, {
- store,
+ mocks: {
+ $store: store,
+ },
propsData: {
mergeRequestDiffs: diffsMockData,
diffFilesCountText: '1',
@@ -50,8 +47,25 @@ describe('CompareVersions', () => {
getCommitNavButtonsElement().find('.btn-group > *:first-child');
beforeEach(() => {
- store = createStore();
+ store.reset();
+
const mergeRequestDiff = diffsMockData[0];
+ const version = {
+ ...mergeRequestDiff,
+ href: `${TEST_HOST}/latest/version`,
+ versionName: 'latest version',
+ };
+ store.getters['diffs/diffCompareDropdownSourceVersions'] = [version];
+ store.getters['diffs/diffCompareDropdownTargetVersions'] = [
+ {
+ ...version,
+ selected: true,
+ versionName: targetBranchName,
+ },
+ ];
+ store.getters['diffs/whichCollapsedTypes'] = { any: false };
+ store.getters['diffs/isInlineView'] = false;
+ store.getters['diffs/isParallelView'] = false;
store.state.diffs.addedLines = 10;
store.state.diffs.removedLines = 20;
@@ -104,7 +118,6 @@ describe('CompareVersions', () => {
it('should not render Tree List toggle button when there are no changes', () => {
createWrapper();
-
const treeListBtn = wrapper.find('.js-toggle-tree-list');
expect(treeListBtn.exists()).toBe(false);
@@ -118,7 +131,10 @@ describe('CompareVersions', () => {
const viewTypeBtn = wrapper.find('#inline-diff-btn');
viewTypeBtn.trigger('click');
- expect(window.location.toString()).toContain('?view=inline');
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/setInlineDiffViewType',
+ expect.any(MouseEvent),
+ );
});
});
@@ -128,13 +144,16 @@ describe('CompareVersions', () => {
const viewTypeBtn = wrapper.find('#parallel-diff-btn');
viewTypeBtn.trigger('click');
- expect(window.location.toString()).toContain('?view=parallel');
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/setParallelDiffViewType',
+ expect.any(MouseEvent),
+ );
});
});
describe('commit', () => {
beforeEach(() => {
- store.state.diffs.commit = getDiffWithCommit.commit;
+ store.state.diffs.commit = commit;
createWrapper();
});
@@ -218,7 +237,7 @@ describe('CompareVersions', () => {
link.trigger('click');
await nextTick();
- expect(dispatchMock).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', {
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', {
direction: 'previous',
});
});
@@ -248,7 +267,7 @@ describe('CompareVersions', () => {
link.trigger('click');
await nextTick();
- expect(dispatchMock).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', {
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/moveToNeighboringCommit', {
direction: 'next',
});
});
diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js
index 3524973278c..39d9255aaf9 100644
--- a/spec/frontend/diffs/components/diff_content_spec.js
+++ b/spec/frontend/diffs/components/diff_content_spec.js
@@ -115,6 +115,35 @@ describe('DiffContent', () => {
});
});
+ describe('with whitespace only change', () => {
+ afterEach(() => {
+ [isParallelViewGetterMock, isInlineViewGetterMock].forEach((m) => m.mockRestore());
+ });
+
+ const textDiffFile = {
+ ...defaultProps.diffFile,
+ viewer: { name: diffViewerModes.text, whitespace_only: true },
+ };
+
+ it('should render empty state', () => {
+ createComponent({
+ props: { diffFile: textDiffFile },
+ });
+
+ expect(wrapper.find('[data-testid="diff-whitespace-only-state"]').exists()).toBe(true);
+ });
+
+ it('emits load-file event when clicking show changes button', () => {
+ createComponent({
+ props: { diffFile: textDiffFile },
+ });
+
+ wrapper.find('[data-testid="diff-load-file-button"]').vm.$emit('click');
+
+ expect(wrapper.emitted('load-file')).toEqual([[{ w: '0' }]]);
+ });
+ });
+
describe('with empty files', () => {
const emptyDiffFile = {
...defaultProps.diffFile,
@@ -147,7 +176,12 @@ describe('DiffContent', () => {
getCommentFormForDiffFileGetterMock.mockReturnValue(() => true);
createComponent({
props: {
- diffFile: { ...imageDiffFile, discussions: [{ name: 'discussion-stub ' }] },
+ diffFile: {
+ ...imageDiffFile,
+ discussions: [
+ { name: 'discussion-stub', position: { position_type: IMAGE_DIFF_POSITION_TYPE } },
+ ],
+ },
},
});
@@ -157,7 +191,12 @@ describe('DiffContent', () => {
it('emits saveDiffDiscussion when note-form emits `handleFormUpdate`', () => {
const noteStub = {};
getCommentFormForDiffFileGetterMock.mockReturnValue(() => true);
- const currentDiffFile = { ...imageDiffFile, discussions: [{ name: 'discussion-stub ' }] };
+ const currentDiffFile = {
+ ...imageDiffFile,
+ discussions: [
+ { name: 'discussion-stub', position: { position_type: IMAGE_DIFF_POSITION_TYPE } },
+ ],
+ };
createComponent({
props: {
diffFile: currentDiffFile,
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
index 900aa8d1469..3f75b086368 100644
--- a/spec/frontend/diffs/components/diff_file_header_spec.js
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -18,7 +18,10 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import testAction from '../../__helpers__/vuex_action_helper';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
-jest.mock('~/lib/utils/common_utils');
+jest.mock('~/lib/utils/common_utils', () => ({
+ scrollToElement: jest.fn(),
+ isLoggedIn: () => true,
+}));
const diffFile = Object.freeze(
Object.assign(diffDiscussionsMockData.diff_file, {
@@ -47,6 +50,9 @@ describe('DiffFileHeader component', () => {
const diffHasDiscussionsResultMock = jest.fn();
const defaultMockStoreConfig = {
state: {},
+ getters: {
+ getNoteableData: () => ({ current_user: { can_create_note: true } }),
+ },
modules: {
diffs: {
namespaced: true,
@@ -637,4 +643,23 @@ describe('DiffFileHeader component', () => {
},
);
});
+
+ it.each`
+ commentOnFiles | exists | existsText
+ ${false} | ${false} | ${'does not'}
+ ${true} | ${true} | ${'does'}
+ `(
+ '$existsText render comment on files button when commentOnFiles is $commentOnFiles',
+ ({ commentOnFiles, exists }) => {
+ window.gon = { current_user_id: 1 };
+ createComponent({
+ props: {
+ addMergeRequestButtons: true,
+ },
+ options: { provide: { glFeatures: { commentOnFiles } } },
+ });
+
+ expect(wrapper.find('[data-testid="comment-files-button"]').exists()).toEqual(exists);
+ },
+ );
});
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 389b192a515..d9c57ed1470 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -553,4 +553,69 @@ describe('DiffFile', () => {
expect(wrapper.find('[data-testid="conflictsAlert"]').exists()).toBe(true);
});
});
+
+ describe('file discussions', () => {
+ it.each`
+ extraProps | exists | existsText
+ ${{}} | ${false} | ${'does not'}
+ ${{ hasCommentForm: false }} | ${false} | ${'does not'}
+ ${{ hasCommentForm: true }} | ${true} | ${'does'}
+ ${{ discussions: [{ id: 1, position: { position_type: 'file' } }] }} | ${true} | ${'does'}
+ ${{ drafts: [{ id: 1 }] }} | ${true} | ${'does'}
+ `(
+ 'discussions wrapper $existsText exist for file with $extraProps',
+ ({ extraProps, exists }) => {
+ const file = {
+ ...getReadableFile(),
+ ...extraProps,
+ };
+
+ ({ wrapper, store } = createComponent({
+ file,
+ options: { provide: { glFeatures: { commentOnFiles: true } } },
+ }));
+
+ expect(wrapper.find('[data-testid="file-discussions"]').exists()).toEqual(exists);
+ },
+ );
+
+ it.each`
+ hasCommentForm | exists | existsText
+ ${false} | ${false} | ${'does not'}
+ ${true} | ${true} | ${'does'}
+ `(
+ 'comment form $existsText exist for hasCommentForm with $hasCommentForm',
+ ({ hasCommentForm, exists }) => {
+ const file = {
+ ...getReadableFile(),
+ hasCommentForm,
+ };
+
+ ({ wrapper, store } = createComponent({
+ file,
+ options: { provide: { glFeatures: { commentOnFiles: true } } },
+ }));
+
+ expect(wrapper.find('[data-testid="file-note-form"]').exists()).toEqual(exists);
+ },
+ );
+
+ it.each`
+ discussions | exists | existsText
+ ${[]} | ${false} | ${'does not'}
+ ${[{ id: 1, position: { position_type: 'file' } }]} | ${true} | ${'does'}
+ `('discussions $existsText exist for $discussions', ({ discussions, exists }) => {
+ const file = {
+ ...getReadableFile(),
+ discussions,
+ };
+
+ ({ wrapper, store } = createComponent({
+ file,
+ options: { provide: { glFeatures: { commentOnFiles: true } } },
+ }));
+
+ expect(wrapper.find('[data-testid="diff-file-discussions"]').exists()).toEqual(exists);
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js
index eb895bd9057..e42b98e4d68 100644
--- a/spec/frontend/diffs/components/diff_line_note_form_spec.js
+++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js
@@ -1,8 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import Vuex from 'vuex';
import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue';
-import { createModules } from '~/mr_notes/stores';
+import store from '~/mr_notes/stores';
import NoteForm from '~/notes/components/note_form.vue';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
@@ -10,51 +9,25 @@ import { noteableDataMock } from 'jest/notes/mock_data';
import { getDiffFileMock } from '../mock_data/diff_file';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
describe('DiffLineNoteForm', () => {
let wrapper;
let diffFile;
let diffLines;
- let actions;
- let store;
- const getSelectedLine = () => {
- const lineCode = diffLines[1].line_code;
- return diffFile.highlighted_diff_lines.find((l) => l.line_code === lineCode);
- };
-
- const createStore = (state) => {
- const modules = createModules();
- modules.diffs.actions = {
- ...modules.diffs.actions,
- saveDiffDiscussion: jest.fn(() => Promise.resolve()),
- };
- modules.diffs.getters = {
- ...modules.diffs.getters,
- diffCompareDropdownTargetVersions: jest.fn(),
- diffCompareDropdownSourceVersions: jest.fn(),
- selectedSourceIndex: jest.fn(),
- };
- modules.notes.getters = {
- ...modules.notes.getters,
- noteableType: jest.fn(),
- };
- actions = modules.diffs.actions;
+ beforeEach(() => {
+ diffFile = getDiffFileMock();
+ diffLines = diffFile.highlighted_diff_lines;
- store = new Vuex.Store({ modules });
- store.state.notes.userData.id = 1;
store.state.notes.noteableData = noteableDataMock;
- store.replaceState({ ...store.state, ...state });
- };
+ store.getters.isLoggedIn = jest.fn().mockReturnValue(true);
+ store.getters['diffs/getDiffFileByHash'] = jest.fn().mockReturnValue(diffFile);
+ });
- const createComponent = ({ props, state } = {}) => {
+ const createComponent = ({ props } = {}) => {
wrapper?.destroy();
- diffFile = getDiffFileMock();
- diffLines = diffFile.highlighted_diff_lines;
-
- createStore(state);
- store.state.diffs.diffFiles = [diffFile];
const propsData = {
diffFileHash: diffFile.file_hash,
@@ -66,7 +39,9 @@ describe('DiffLineNoteForm', () => {
};
wrapper = shallowMount(DiffLineNoteForm, {
- store,
+ mocks: {
+ $store: store,
+ },
propsData,
});
};
@@ -129,7 +104,10 @@ describe('DiffLineNoteForm', () => {
expect(confirmAction).toHaveBeenCalled();
await nextTick();
- expect(getSelectedLine().hasForm).toBe(false);
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/cancelCommentForm', {
+ lineCode: diffLines[1].line_code,
+ fileHash: diffFile.file_hash,
+ });
});
});
@@ -157,6 +135,10 @@ describe('DiffLineNoteForm', () => {
});
describe('saving note', () => {
+ beforeEach(() => {
+ store.getters.noteableType = 'merge-request';
+ });
+
it('should save original line', async () => {
const lineRange = {
start: {
@@ -172,20 +154,65 @@ describe('DiffLineNoteForm', () => {
old_line: null,
},
};
- await findNoteForm().vm.$emit('handleFormUpdate', 'note body');
- expect(actions.saveDiffDiscussion.mock.calls[0][1].formData).toMatchObject({
- lineRange,
+
+ const noteBody = 'note body';
+ await findNoteForm().vm.$emit('handleFormUpdate', noteBody);
+
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/saveDiffDiscussion', {
+ note: noteBody,
+ formData: {
+ noteableData: noteableDataMock,
+ noteableType: store.getters.noteableType,
+ noteTargetLine: diffLines[1],
+ diffViewType: store.state.diffs.diffViewType,
+ diffFile,
+ linePosition: '',
+ lineRange,
+ },
+ });
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/cancelCommentForm', {
+ lineCode: diffLines[1].line_code,
+ fileHash: diffFile.file_hash,
});
});
it('should save selected line from the store', async () => {
const lineCode = 'test';
store.state.notes.selectedCommentPosition = { start: { line_code: lineCode } };
- createComponent({ state: store.state });
- await findNoteForm().vm.$emit('handleFormUpdate', 'note body');
- expect(actions.saveDiffDiscussion.mock.calls[0][1].formData.lineRange.start.line_code).toBe(
- lineCode,
- );
+ createComponent();
+ const noteBody = 'note body';
+
+ await findNoteForm().vm.$emit('handleFormUpdate', noteBody);
+
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/saveDiffDiscussion', {
+ note: noteBody,
+ formData: {
+ noteableData: noteableDataMock,
+ noteableType: store.getters.noteableType,
+ noteTargetLine: diffLines[1],
+ diffViewType: store.state.diffs.diffViewType,
+ diffFile,
+ linePosition: '',
+ lineRange: {
+ start: {
+ line_code: lineCode,
+ new_line: undefined,
+ old_line: undefined,
+ type: undefined,
+ },
+ end: {
+ line_code: diffLines[1].line_code,
+ new_line: diffLines[1].new_line,
+ old_line: diffLines[1].old_line,
+ type: diffLines[1].type,
+ },
+ },
+ },
+ });
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/cancelCommentForm', {
+ lineCode: diffLines[1].line_code,
+ fileHash: diffFile.file_hash,
+ });
});
});
});
diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js
index cfc80e61b30..8778683c135 100644
--- a/spec/frontend/diffs/components/diff_view_spec.js
+++ b/spec/frontend/diffs/components/diff_view_spec.js
@@ -1,10 +1,14 @@
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
+import { throttle } from 'lodash';
import DiffView from '~/diffs/components/diff_view.vue';
import DiffLine from '~/diffs/components/diff_line.vue';
import { diffCodeQuality } from '../mock_data/diff_code_quality';
+jest.mock('lodash/throttle', () => jest.fn((fn) => fn));
+const lodash = jest.requireActual('lodash');
+
describe('DiffView', () => {
const DiffExpansionCell = { template: `<div/>` };
const DiffRow = { template: `<div/>` };
@@ -51,6 +55,14 @@ describe('DiffView', () => {
return shallowMount(DiffView, { propsData, store, stubs });
};
+ beforeEach(() => {
+ throttle.mockImplementation(lodash.throttle);
+ });
+
+ afterEach(() => {
+ throttle.mockReset();
+ });
+
it('does not render a diff-line component when there is no finding', () => {
const wrapper = createWrapper();
expect(wrapper.findComponent(DiffLine).exists()).toBe(false);
@@ -138,5 +150,18 @@ describe('DiffView', () => {
expect(wrapper.vm.idState.dragStart).toBeNull();
expect(showCommentForm).toHaveBeenCalled();
});
+
+ it('throttles multiple calls to enterdragging', () => {
+ const wrapper = createWrapper({ diffLines: [{}] });
+ const diffRow = getDiffRow(wrapper);
+
+ diffRow.$emit('startdragging', { line: { chunk: 1, index: 1 } });
+ diffRow.$emit('enterdragging', { chunk: 1, index: 2 });
+ diffRow.$emit('enterdragging', { chunk: 1, index: 2 });
+
+ jest.runOnlyPendingTimers();
+
+ expect(setSelectedCommentPosition).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js
index e637b1dd43d..fd89d52a59e 100644
--- a/spec/frontend/diffs/components/no_changes_spec.js
+++ b/spec/frontend/diffs/components/no_changes_spec.js
@@ -1,55 +1,53 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
import NoChanges from '~/diffs/components/no_changes.vue';
-import { createStore } from '~/mr_notes/stores';
+import store from '~/mr_notes/stores';
import diffsMockData from '../mock_data/merge_request_diffs';
-Vue.use(Vuex);
+jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
const TEST_TARGET_BRANCH = 'foo';
const TEST_SOURCE_BRANCH = 'dev/update';
+const latestVersionNumber = Math.max(...diffsMockData.map((version) => version.version_index));
describe('Diff no changes empty state', () => {
- let wrapper;
- let store;
-
- function createComponent(mountFn = shallowMount) {
- wrapper = mountFn(NoChanges, {
- store,
+ const createComponent = (mountFn = shallowMount) =>
+ mountFn(NoChanges, {
+ mocks: {
+ $store: store,
+ },
propsData: {
changesEmptyStateIllustration: '',
},
});
- }
beforeEach(() => {
- store = createStore();
- store.state.diffs.mergeRequestDiff = {};
- store.state.notes.noteableData = {
+ store.reset();
+
+ store.getters.getNoteableData = {
target_branch: TEST_TARGET_BRANCH,
source_branch: TEST_SOURCE_BRANCH,
};
- store.state.diffs.mergeRequestDiffs = diffsMockData;
+ store.getters['diffs/diffCompareDropdownSourceVersions'] = [];
+ store.getters['diffs/diffCompareDropdownTargetVersions'] = [];
});
- const findMessage = () => wrapper.find('[data-testid="no-changes-message"]');
+ const findMessage = (wrapper) => wrapper.find('[data-testid="no-changes-message"]');
it('prevents XSS', () => {
- store.state.notes.noteableData = {
+ store.getters.getNoteableData = {
source_branch: '<script>alert("test");</script>',
target_branch: '<script>alert("test");</script>',
};
- createComponent();
+ const wrapper = createComponent();
expect(wrapper.find('script').exists()).toBe(false);
});
describe('Renders', () => {
it('Show create commit button', () => {
- createComponent();
+ const wrapper = createComponent();
expect(wrapper.findComponent(GlButton).exists()).toBe(true);
});
@@ -64,15 +62,28 @@ describe('Diff no changes empty state', () => {
'renders text "$expectedText" (sourceIndex=$sourceIndex and targetIndex=$targetIndex)',
({ expectedText, targetIndex, sourceIndex }) => {
if (targetIndex !== null) {
- store.state.diffs.startVersion = { version_index: targetIndex };
+ store.getters['diffs/diffCompareDropdownTargetVersions'] = [
+ {
+ selected: true,
+ version_index: targetIndex,
+ versionName: `version ${targetIndex}`,
+ },
+ ];
}
if (sourceIndex !== null) {
- store.state.diffs.mergeRequestDiff.version_index = sourceIndex;
+ store.getters['diffs/diffCompareDropdownSourceVersions'] = [
+ {
+ isLatestVersion: sourceIndex === latestVersionNumber,
+ selected: true,
+ version_index: targetIndex,
+ versionName: `version ${sourceIndex}`,
+ },
+ ];
}
- createComponent(mount);
+ const wrapper = createComponent(mount);
- expect(findMessage().text()).toBe(expectedText);
+ expect(findMessage(wrapper).text()).toBe(expectedText);
},
);
});
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index 3d2bbe43746..cbd2ae3e525 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -5,44 +5,34 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import SettingsDropdown from '~/diffs/components/settings_dropdown.vue';
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants';
import eventHub from '~/diffs/event_hub';
+import store from '~/mr_notes/stores';
-import createDiffsStore from '../create_diffs_store';
+jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
describe('Diff settings dropdown component', () => {
- let wrapper;
- let store;
-
- function createComponent(extendStore = () => {}) {
- store = createDiffsStore();
-
- extendStore(store);
-
- wrapper = extendedWrapper(
+ const createComponent = () =>
+ extendedWrapper(
mount(SettingsDropdown, {
- store,
+ mocks: {
+ $store: store,
+ },
}),
);
- }
function getFileByFileCheckbox(vueWrapper) {
return vueWrapper.findByTestId('file-by-file');
}
- function setup({ storeUpdater } = {}) {
- createComponent(storeUpdater);
- jest.spyOn(store, 'dispatch').mockImplementation(() => {});
- }
-
beforeEach(() => {
- setup();
- });
+ store.reset();
- afterEach(() => {
- store.dispatch.mockRestore();
+ store.getters['diffs/isInlineView'] = false;
+ store.getters['diffs/isParallelView'] = false;
});
describe('tree view buttons', () => {
it('list view button dispatches setRenderTreeList with false', () => {
+ const wrapper = createComponent();
wrapper.find('.js-list-view').trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', {
@@ -51,6 +41,7 @@ describe('Diff settings dropdown component', () => {
});
it('tree view button dispatches setRenderTreeList with true', () => {
+ const wrapper = createComponent();
wrapper.find('.js-tree-view').trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', {
@@ -59,19 +50,18 @@ describe('Diff settings dropdown component', () => {
});
it('sets list button as selected when renderTreeList is false', () => {
- setup({
- storeUpdater: (origStore) =>
- Object.assign(origStore.state.diffs, { renderTreeList: false }),
- });
+ store.state.diffs = { renderTreeList: false };
+
+ const wrapper = createComponent();
expect(wrapper.find('.js-list-view').classes('selected')).toBe(true);
expect(wrapper.find('.js-tree-view').classes('selected')).toBe(false);
});
it('sets tree button as selected when renderTreeList is true', () => {
- setup({
- storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { renderTreeList: true }),
- });
+ store.state.diffs = { renderTreeList: true };
+
+ const wrapper = createComponent();
expect(wrapper.find('.js-list-view').classes('selected')).toBe(false);
expect(wrapper.find('.js-tree-view').classes('selected')).toBe(true);
@@ -80,32 +70,36 @@ describe('Diff settings dropdown component', () => {
describe('compare changes', () => {
it('sets inline button as selected', () => {
- setup({
- storeUpdater: (origStore) =>
- Object.assign(origStore.state.diffs, { diffViewType: INLINE_DIFF_VIEW_TYPE }),
- });
+ store.state.diffs = { diffViewType: INLINE_DIFF_VIEW_TYPE };
+ store.getters['diffs/isInlineView'] = true;
+
+ const wrapper = createComponent();
expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(true);
expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(false);
});
it('sets parallel button as selected', () => {
- setup({
- storeUpdater: (origStore) =>
- Object.assign(origStore.state.diffs, { diffViewType: PARALLEL_DIFF_VIEW_TYPE }),
- });
+ store.state.diffs = { diffViewType: PARALLEL_DIFF_VIEW_TYPE };
+ store.getters['diffs/isParallelView'] = true;
+
+ const wrapper = createComponent();
expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(false);
expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(true);
});
it('calls setInlineDiffViewType when clicking inline button', () => {
+ const wrapper = createComponent();
+
wrapper.find('.js-inline-diff-button').trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('diffs/setInlineDiffViewType', expect.anything());
});
it('calls setParallelDiffViewType when clicking parallel button', () => {
+ const wrapper = createComponent();
+
wrapper.find('.js-parallel-diff-button').trigger('click');
expect(store.dispatch).toHaveBeenCalledWith(
@@ -117,23 +111,23 @@ describe('Diff settings dropdown component', () => {
describe('whitespace toggle', () => {
it('does not set as checked when showWhitespace is false', () => {
- setup({
- storeUpdater: (origStore) =>
- Object.assign(origStore.state.diffs, { showWhitespace: false }),
- });
+ store.state.diffs = { showWhitespace: false };
+
+ const wrapper = createComponent();
expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(false);
});
it('sets as checked when showWhitespace is true', () => {
- setup({
- storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { showWhitespace: true }),
- });
+ store.state.diffs = { showWhitespace: true };
+
+ const wrapper = createComponent();
expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(true);
});
it('calls setShowWhitespace on change', async () => {
+ const wrapper = createComponent();
const checkbox = wrapper.findByTestId('show-whitespace');
const { checked } = checkbox.element;
@@ -157,10 +151,9 @@ describe('Diff settings dropdown component', () => {
`(
'sets the checkbox to { checked: $checked } if the fileByFile setting is $fileByFile',
({ fileByFile, checked }) => {
- setup({
- storeUpdater: (origStore) =>
- Object.assign(origStore.state.diffs, { viewDiffsFileByFile: fileByFile }),
- });
+ store.state.diffs = { viewDiffsFileByFile: fileByFile };
+
+ const wrapper = createComponent();
expect(getFileByFileCheckbox(wrapper).element.checked).toBe(checked);
},
@@ -173,11 +166,9 @@ describe('Diff settings dropdown component', () => {
`(
'when the file by file setting starts as $start, toggling the checkbox should call setFileByFile with $setting',
async ({ start, setting }) => {
- setup({
- storeUpdater: (origStore) =>
- Object.assign(origStore.state.diffs, { viewDiffsFileByFile: start }),
- });
+ store.state.diffs = { viewDiffsFileByFile: start };
+ const wrapper = createComponent();
await getFileByFileCheckbox(wrapper).setChecked(setting);
expect(store.dispatch).toHaveBeenCalledWith('diffs/setFileByFile', {
diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js
index 87c638d065a..1ec8547d325 100644
--- a/spec/frontend/diffs/components/tree_list_spec.js
+++ b/spec/frontend/diffs/components/tree_list_spec.js
@@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import TreeList from '~/diffs/components/tree_list.vue';
import createStore from '~/diffs/store/modules';
+import batchComments from '~/batch_comments/stores/modules/batch_comments';
import DiffFileRow from '~/diffs/components//diff_file_row.vue';
import { stubComponent } from 'helpers/stub_component';
@@ -38,6 +39,7 @@ describe('Diffs tree list component', () => {
store = new Vuex.Store({
modules: {
diffs: createStore(),
+ batchComments: batchComments(),
},
});
diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js
index e0e5778e0d5..eef68100378 100644
--- a/spec/frontend/diffs/mock_data/diff_file.js
+++ b/spec/frontend/diffs/mock_data/diff_file.js
@@ -334,5 +334,6 @@ export const getDiffFileMock = () => ({
},
],
discussions: [],
+ drafts: [],
renderingLines: false,
});
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index f883aea764f..7534fe741e7 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -707,6 +707,7 @@ describe('DiffsStoreActions', () => {
[{ type: types.SET_DIFF_VIEW_TYPE, payload: INLINE_DIFF_VIEW_TYPE }],
[],
);
+ expect(window.location.toString()).toContain('?view=inline');
expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE);
});
});
@@ -720,6 +721,7 @@ describe('DiffsStoreActions', () => {
[{ type: types.SET_DIFF_VIEW_TYPE, payload: PARALLEL_DIFF_VIEW_TYPE }],
[],
);
+ expect(window.location.toString()).toContain('?view=parallel');
expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE);
});
});
@@ -788,7 +790,7 @@ describe('DiffsStoreActions', () => {
mock.onGet(file.loadCollapsedDiffUrl).reply(HTTP_STATUS_OK, data);
return diffActions
- .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file)
+ .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, { file })
.then(() => {
expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data });
});
@@ -802,13 +804,28 @@ describe('DiffsStoreActions', () => {
jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} }));
- diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file);
+ diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file });
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
params: { commit_id: null, w: '0' },
});
});
+ it('should pass through params', () => {
+ const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' };
+ const getters = {
+ commitId: null,
+ };
+
+ jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} }));
+
+ diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file, params: { w: '1' } });
+
+ expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
+ params: { commit_id: null, w: '1' },
+ });
+ });
+
it('should fetch data with commit ID', () => {
const file = { load_collapsed_diff_url: '/load/collapsed/diff/url' };
const getters = {
@@ -817,7 +834,7 @@ describe('DiffsStoreActions', () => {
jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} }));
- diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file);
+ diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file });
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
params: { commit_id: '123', w: '0' },
@@ -841,7 +858,7 @@ describe('DiffsStoreActions', () => {
});
it('fetches the data when there is no mergeRequestDiff', () => {
- diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file);
+ diffActions.loadCollapsedDiff({ commit() {}, getters, state }, { file });
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
params: expect.any(Object),
@@ -859,7 +876,7 @@ describe('DiffsStoreActions', () => {
diffActions.loadCollapsedDiff(
{ commit() {}, getters, state: { mergeRequestDiff: { version_path: versionPath } } },
- file,
+ { file },
);
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
@@ -1115,67 +1132,50 @@ describe('DiffsStoreActions', () => {
});
describe('when the app is in fileByFile mode', () => {
- describe('when the singleFileFileByFile feature flag is enabled', () => {
- it('commits SET_CURRENT_DIFF_FILE', () => {
- diffActions.goToFile(
- { state, commit, dispatch, getters },
- { path: file.path, singleFile: true },
- );
+ it('commits SET_CURRENT_DIFF_FILE', () => {
+ diffActions.goToFile({ state, commit, dispatch, getters }, file);
- expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash);
- });
+ expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash);
+ });
- it('does nothing more if the path has already been loaded', () => {
- getters.isTreePathLoaded = () => true;
+ it('does nothing more if the path has already been loaded', () => {
+ getters.isTreePathLoaded = () => true;
- diffActions.goToFile(
- { state, dispatch, getters, commit },
- { path: file.path, singleFile: true },
- );
+ diffActions.goToFile({ state, dispatch, getters, commit }, file);
- expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash);
- expect(dispatch).toHaveBeenCalledTimes(0);
- });
+ expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash);
+ expect(dispatch).toHaveBeenCalledTimes(0);
+ });
- describe('when the tree entry has not been loaded', () => {
- it('updates location hash', () => {
- diffActions.goToFile(
- { state, commit, getters, dispatch },
- { path: file.path, singleFile: true },
- );
+ describe('when the tree entry has not been loaded', () => {
+ it('updates location hash', () => {
+ diffActions.goToFile({ state, commit, getters, dispatch }, file);
- expect(document.location.hash).toBe('#test');
- });
+ expect(document.location.hash).toBe('#test');
+ });
- it('loads the file and then scrolls to it', async () => {
- diffActions.goToFile(
- { state, commit, getters, dispatch },
- { path: file.path, singleFile: true },
- );
+ it('loads the file and then scrolls to it', async () => {
+ diffActions.goToFile({ state, commit, getters, dispatch }, file);
- // Wait for the fetchFileByFile dispatch to return, to trigger scrollToFile
- await waitForPromises();
+ // Wait for the fetchFileByFile dispatch to return, to trigger scrollToFile
+ await waitForPromises();
- expect(dispatch).toHaveBeenCalledWith('fetchFileByFile');
- expect(dispatch).toHaveBeenCalledWith('scrollToFile', file);
- expect(dispatch).toHaveBeenCalledTimes(2);
- });
+ expect(dispatch).toHaveBeenCalledWith('fetchFileByFile');
+ expect(dispatch).toHaveBeenCalledWith('scrollToFile', file);
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ });
- it('shows an alert when there was an error fetching the file', async () => {
- dispatch = jest.fn().mockRejectedValue();
+ it('shows an alert when there was an error fetching the file', async () => {
+ dispatch = jest.fn().mockRejectedValue();
- diffActions.goToFile(
- { state, commit, getters, dispatch },
- { path: file.path, singleFile: true },
- );
+ diffActions.goToFile({ state, commit, getters, dispatch }, file);
- // Wait for the fetchFileByFile dispatch to return, to trigger the catch
- await waitForPromises();
+ // Wait for the fetchFileByFile dispatch to return, to trigger the catch
+ await waitForPromises();
- expect(createAlert).toHaveBeenCalledTimes(1);
- expect(createAlert).toHaveBeenCalledWith({
- message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED),
- });
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED),
});
});
});
@@ -1796,17 +1796,17 @@ describe('DiffsStoreActions', () => {
it('commits SET_CURRENT_DIFF_FILE', () => {
return testAction(
diffActions.navigateToDiffFileIndex,
- { index: 0, singleFile: false },
+ 0,
{ flatBlobsList: [{ fileHash: '123' }] },
[{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }],
[],
);
});
- it('dispatches the fetchFileByFile action when the state value viewDiffsFileByFile is true and the single-file file-by-file feature flag is enabled', () => {
+ it('dispatches the fetchFileByFile action when the state value viewDiffsFileByFile is true', () => {
return testAction(
diffActions.navigateToDiffFileIndex,
- { index: 0, singleFile: true },
+ 0,
{ viewDiffsFileByFile: true, flatBlobsList: [{ fileHash: '123' }] },
[{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }],
[{ type: 'fetchFileByFile' }],
@@ -1889,4 +1889,28 @@ describe('DiffsStoreActions', () => {
},
);
});
+
+ describe('toggleFileCommentForm', () => {
+ it('commits TOGGLE_FILE_COMMENT_FORM', () => {
+ return testAction(
+ diffActions.toggleFileCommentForm,
+ 'path',
+ {},
+ [{ type: types.TOGGLE_FILE_COMMENT_FORM, payload: 'path' }],
+ [],
+ );
+ });
+ });
+
+ describe('addDraftToFile', () => {
+ it('commits ADD_DRAFT_TO_FILE', () => {
+ return testAction(
+ diffActions.addDraftToFile,
+ { filePath: 'path', draft: 'draft' },
+ {},
+ [{ type: types.ADD_DRAFT_TO_FILE, payload: { filePath: 'path', draft: 'draft' } }],
+ [],
+ );
+ });
+ });
});
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index ed7b6699e2c..8097f0976f6 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -188,6 +188,24 @@ describe('Diffs Module Getters', () => {
expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true);
});
+ it('returns true when file discussion is expanded', () => {
+ const diffFile = {
+ discussions: [{ ...discussionMock, expanded: true }],
+ highlighted_diff_lines: [],
+ };
+
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true);
+ });
+
+ it('returns false when file discussion is expanded', () => {
+ const diffFile = {
+ discussions: [{ ...discussionMock, expanded: false }],
+ highlighted_diff_lines: [],
+ };
+
+ expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false);
+ });
+
it('returns false when there are no discussions', () => {
const diffFile = {
parallel_diff_lines: [],
@@ -231,6 +249,15 @@ describe('Diffs Module Getters', () => {
expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true);
});
+ it('returns true when file has discussions', () => {
+ const diffFile = {
+ discussions: [discussionMock, discussionMock],
+ highlighted_diff_lines: [],
+ };
+
+ expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true);
+ });
+
it('returns false when getDiffFileDiscussions returns no discussions', () => {
const diffFile = {
parallel_diff_lines: [],
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index ed8d7397bbc..b089cf22b14 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -269,6 +269,53 @@ describe('DiffsStoreMutations', () => {
expect(state.diffFiles[0][INLINE_DIFF_LINES_KEY][0].discussions[0].id).toEqual(1);
});
+ it('should add discussions to the given file', () => {
+ const diffPosition = {
+ base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
+ head_sha: 'b921914f9a834ac47e6fd9420f78db0f83559130',
+ new_line: null,
+ new_path: '500-lines-4.txt',
+ old_line: 5,
+ old_path: '500-lines-4.txt',
+ start_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
+ type: 'file',
+ };
+
+ const state = {
+ latestDiff: true,
+ diffFiles: [
+ {
+ file_hash: 'ABC',
+ [INLINE_DIFF_LINES_KEY]: [],
+ discussions: [],
+ },
+ ],
+ };
+ const discussion = {
+ id: 1,
+ line_code: 'ABC_1',
+ diff_discussion: true,
+ resolvable: true,
+ original_position: diffPosition,
+ position: diffPosition,
+ diff_file: {
+ file_hash: state.diffFiles[0].file_hash,
+ },
+ };
+
+ const diffPositionByLineCode = {
+ ABC_1: diffPosition,
+ };
+
+ mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
+ discussion,
+ diffPositionByLineCode,
+ });
+
+ expect(state.diffFiles[0].discussions.length).toEqual(1);
+ expect(state.diffFiles[0].discussions[0].id).toEqual(1);
+ });
+
it('should not duplicate discussions on line', () => {
const diffPosition = {
base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
@@ -957,4 +1004,25 @@ describe('DiffsStoreMutations', () => {
expect(state.mrReviews).toStrictEqual(newReviews);
});
});
+
+ describe('TOGGLE_FILE_COMMENT_FORM', () => {
+ it('toggles diff files hasCommentForm', () => {
+ const state = { diffFiles: [{ file_path: 'path', hasCommentForm: false }] };
+
+ mutations[types.TOGGLE_FILE_COMMENT_FORM](state, 'path');
+
+ expect(state.diffFiles[0].hasCommentForm).toEqual(true);
+ });
+ });
+
+ describe('ADD_DRAFT_TO_FILE', () => {
+ it('adds draft to diff file', () => {
+ const state = { diffFiles: [{ file_path: 'path', drafts: [] }] };
+
+ mutations[types.ADD_DRAFT_TO_FILE](state, { filePath: 'path', draft: 'test' });
+
+ expect(state.diffFiles[0].drafts.length).toEqual(1);
+ expect(state.diffFiles[0].drafts[0]).toEqual('test');
+ });
+ });
});
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 4760a8b7166..888df06d6b9 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -140,6 +140,7 @@ describe('DiffsStoreUtils', () => {
old_line: options.noteTargetLine.old_line,
new_line: options.noteTargetLine.new_line,
line_range: options.lineRange,
+ ignore_whitespace_change: true,
});
const postData = {
@@ -198,6 +199,7 @@ describe('DiffsStoreUtils', () => {
position_type: TEXT_DIFF_POSITION_TYPE,
old_line: options.noteTargetLine.old_line,
new_line: options.noteTargetLine.new_line,
+ ignore_whitespace_change: true,
});
const postData = {
@@ -713,6 +715,14 @@ describe('DiffsStoreUtils', () => {
).toBe('mode_changed');
});
+ it('returns no_preview if key has no match', () => {
+ expect(
+ utils.getDiffMode({
+ viewer: { name: 'no_preview' },
+ }),
+ ).toBe('no_preview');
+ });
+
it('defaults to replaced', () => {
expect(utils.getDiffMode({})).toBe('replaced');
});
diff --git a/spec/frontend/drawio/drawio_editor_spec.js b/spec/frontend/drawio/drawio_editor_spec.js
index d7d75922e1e..4d93908b757 100644
--- a/spec/frontend/drawio/drawio_editor_spec.js
+++ b/spec/frontend/drawio/drawio_editor_spec.js
@@ -1,6 +1,5 @@
import { launchDrawioEditor } from '~/drawio/drawio_editor';
import {
- DRAWIO_EDITOR_URL,
DRAWIO_FRAME_ID,
DIAGRAM_BACKGROUND_COLOR,
DRAWIO_IFRAME_TIMEOUT,
@@ -8,6 +7,10 @@ import {
} from '~/drawio/constants';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
+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';
+const DRAWIO_EDITOR_ORIGIN = new URL(DRAWIO_EDITOR_URL).origin;
+
jest.mock('~/alert');
jest.useFakeTimers();
@@ -59,6 +62,7 @@ describe('drawio/drawio_editor', () => {
updateDiagram: jest.fn(),
};
drawioIFrameReceivedMessages = [];
+ gon.diagramsnet_url = DRAWIO_EDITOR_ORIGIN;
});
afterEach(() => {
@@ -356,7 +360,11 @@ describe('drawio/drawio_editor', () => {
const TEST_FILENAME = 'diagram.drawio.svg';
beforeEach(() => {
- launchDrawioEditor({ editorFacade, filename: TEST_FILENAME });
+ launchDrawioEditor({
+ editorFacade,
+ filename: TEST_FILENAME,
+ drawioUrl: DRAWIO_EDITOR_ORIGIN,
+ });
});
it('displays loading spinner in the draw.io editor', async () => {
diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
index b5944a52af7..1e592f435e4 100644
--- a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
+++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
@@ -7,6 +7,7 @@ import { buildButton } from './helpers';
describe('Source Editor Toolbar button', () => {
let wrapper;
const defaultBtn = buildButton();
+ const tertiaryBtnWithIcon = buildButton({ category: 'tertiary' });
const findButton = () => wrapper.findComponent(GlButton);
@@ -41,6 +42,16 @@ describe('Source Editor Toolbar button', () => {
const btn = findButton();
expect(btn.exists()).toBe(true);
expect(btn.props()).toMatchObject(defaultProps);
+ expect(btn.text()).toBe('Foo Bar Button');
+ });
+
+ it('does not render button for tertiary button with icon', () => {
+ createComponent({
+ button: {
+ tertiaryBtnWithIcon,
+ },
+ });
+ expect(findButton().text()).toBe('');
});
it('renders a button based on the props passed', () => {
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml
index 996a48f7bc6..ba4b0db908d 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml
@@ -49,7 +49,7 @@ coverage-report-is-string:
coverage_report: cobertura
# invalid artifact:reports:performance
-# Superceded by: artifact:reports:browser_performance
+# Superseded by: artifact:reports:browser_performance
performance string path:
artifacts:
reports:
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml
index 6afd8baa0e8..56941fcc6d5 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml
@@ -1,3 +1,10 @@
+# invalid include:rules
+include:
+ - local: builds.yml
+ rules:
+ - if: '$INCLUDE_BUILDS == "true"'
+ when: on_success
+
# invalid trigger:include
trigger missing file property:
stage: prepare
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml
index c00ab0d464a..909911debf1 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml
@@ -5,8 +5,34 @@ stages:
include:
- local: builds.yml
rules:
- - if: '$INCLUDE_BUILDS == "true"'
+ - if: $DONT_INCLUDE_BUILDS == "true"
+ when: never
+ - local: builds.yml
+ rules:
+ - if: $INCLUDE_BUILDS == "true"
when: always
+ - local: deploys.yml
+ rules:
+ - if: $CI_COMMIT_BRANCH == "main"
+ - local: builds.yml
+ rules:
+ - exists:
+ - exception-file.md
+ when: never
+ - local: builds.yml
+ rules:
+ - exists:
+ - file.md
+ when: always
+ - local: builds.yml
+ rules:
+ - exists:
+ - file.md
+ when: null
+ - local: deploys.yml
+ rules:
+ - exists:
+ - file.md
# valid trigger:include
trigger:include accepts project and file properties:
diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js
index b1b8173188c..70bc1dee0ee 100644
--- a/spec/frontend/editor/source_editor_extension_base_spec.js
+++ b/spec/frontend/editor/source_editor_extension_base_spec.js
@@ -19,12 +19,12 @@ describe('The basis for an Source Editor extension', () => {
const findLine = (num) => {
return document.querySelector(`.${EXTENSION_BASE_LINE_NUMBERS_CLASS}:nth-child(${num})`);
};
- const generateLines = () => {
+ const generateFixture = () => {
let res = '';
for (let line = 1, lines = 5; line <= lines; line += 1) {
res += `<div class="${EXTENSION_BASE_LINE_NUMBERS_CLASS}">${line}</div>`;
}
- return res;
+ return `<span class="soft-wrap-toggle"></span>${res}`;
};
const generateEventMock = ({ line = defaultLine, el = null } = {}) => {
return {
@@ -51,7 +51,7 @@ describe('The basis for an Source Editor extension', () => {
};
beforeEach(() => {
- setHTMLFixture(generateLines());
+ setHTMLFixture(generateFixture());
event = generateEventMock();
});
@@ -156,12 +156,13 @@ describe('The basis for an Source Editor extension', () => {
describe('toggleSoftwrap', () => {
let instance;
-
beforeEach(() => {
instance = createInstance();
instance.toolbar = toolbar;
instance.use({ definition: SourceEditorExtension });
+
+ jest.spyOn(document.querySelector('.soft-wrap-toggle'), 'blur');
});
it.each`
@@ -183,6 +184,7 @@ describe('The basis for an Source Editor extension', () => {
expect(instance.toolbar.updateItem).toHaveBeenCalledWith(EXTENSION_SOFTWRAP_ID, {
selected: expectSelected,
});
+ expect(document.querySelector('.soft-wrap-toggle').blur).toHaveBeenCalled();
},
);
});
diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
index fb5fce92482..512b298bbbd 100644
--- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
@@ -206,9 +206,7 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
it('removes the registered buttons from the toolbar', () => {
expect(instance.toolbar.removeItems).not.toHaveBeenCalled();
instance.unuse(extension);
- expect(instance.toolbar.removeItems).toHaveBeenCalledWith([
- EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
- ]);
+ expect(instance.toolbar.removeItems).toHaveBeenCalledWith([]);
});
it('disposes the modelChange listener and does not fetch preview on content changes', () => {
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index 4e341b2bb2f..53fbe105ec6 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -1,6 +1,5 @@
/* eslint-disable import/no-commonjs, max-classes-per-file */
-const path = require('path');
const { TestEnvironment } = require('jest-environment-jsdom');
const { ErrorWithStack } = require('jest-util');
const {
@@ -10,8 +9,6 @@ const {
const { TEST_HOST } = require('./__helpers__/test_constants');
const { createGon } = require('./__helpers__/gon_helper');
-const ROOT_PATH = path.resolve(__dirname, '../..');
-
class CustomEnvironment extends TestEnvironment {
constructor({ globalConfig, projectConfig }, context) {
// Setup testURL so that window.location is setup properly
@@ -65,9 +62,6 @@ class CustomEnvironment extends TestEnvironment {
this.rejectedPromises.push(error);
};
- this.global.fixturesBasePath = `${ROOT_PATH}/tmp/tests/frontend/fixtures${IS_EE ? '-ee' : ''}`;
- this.global.staticFixturesBasePath = `${ROOT_PATH}/spec/frontend/fixtures`;
-
/**
* window.fetch() is required by the apollo-upload-client library otherwise
* a ReferenceError is generated: https://github.com/jaydenseric/apollo-upload-client/issues/100
diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js
index 34f338fabe6..f436c96f4a5 100644
--- a/spec/frontend/environments/edit_environment_spec.js
+++ b/spec/frontend/environments/edit_environment_spec.js
@@ -1,5 +1,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import EditEnvironment from '~/environments/components/edit_environment.vue';
@@ -7,99 +9,213 @@ import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
+import getEnvironment from '~/environments/graphql/queries/environment.query.graphql';
+import updateEnvironment from '~/environments/graphql/mutations/update_environment.mutation.graphql';
+import { __ } from '~/locale';
+import createMockApollo from '../__helpers__/mock_apollo_helper';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/alert');
-const DEFAULT_OPTS = {
- provide: {
- projectEnvironmentsPath: '/projects/environments',
- updateEnvironmentPath: '/proejcts/environments/1',
- protectedEnvironmentSettingsPath: '/projects/1/settings/ci_cd',
- },
- propsData: { environment: { id: '0', name: 'foo', external_url: 'https://foo.example.com' } },
+const newExternalUrl = 'https://google.ca';
+const environment = {
+ id: '1',
+ name: 'foo',
+ externalUrl: 'https://foo.example.com',
+ clusterAgent: null,
+};
+const resolvedEnvironment = { project: { id: '1', environment } };
+const environmentUpdate = {
+ environment: { id: '1', path: 'path/to/environment', clusterAgentId: null },
+ errors: [],
+};
+const environmentUpdateError = {
+ environment: null,
+ errors: [{ message: 'uh oh!' }],
+};
+
+const provide = {
+ projectEnvironmentsPath: '/projects/environments',
+ updateEnvironmentPath: '/projects/environments/1',
+ protectedEnvironmentSettingsPath: '/projects/1/settings/ci_cd',
+ projectPath: '/path/to/project',
};
describe('~/environments/components/edit.vue', () => {
let wrapper;
let mock;
- const createWrapper = (opts = {}) =>
- mountExtended(EditEnvironment, {
- ...DEFAULT_OPTS,
- ...opts,
+ const createMockApolloProvider = (mutationResult) => {
+ Vue.use(VueApollo);
+
+ const mocks = [
+ [getEnvironment, jest.fn().mockResolvedValue({ data: resolvedEnvironment })],
+ [
+ updateEnvironment,
+ jest.fn().mockResolvedValue({ data: { environmentUpdate: mutationResult } }),
+ ],
+ ];
+
+ return createMockApollo(mocks);
+ };
+
+ const createWrapper = () => {
+ wrapper = mountExtended(EditEnvironment, {
+ propsData: { environment: { id: '1', name: 'foo', external_url: 'https://foo.example.com' } },
+ provide,
});
+ };
- beforeEach(() => {
- mock = new MockAdapter(axios);
- wrapper = createWrapper();
- });
+ const createWrapperWithApollo = async ({ mutationResult = environmentUpdate } = {}) => {
+ wrapper = mountExtended(EditEnvironment, {
+ propsData: { environment: {} },
+ provide: {
+ ...provide,
+ glFeatures: {
+ environmentSettingsToGraphql: true,
+ },
+ },
+ apolloProvider: createMockApolloProvider(mutationResult),
+ });
- afterEach(() => {
- mock.restore();
- });
+ await waitForPromises();
+ };
- const findNameInput = () => wrapper.findByLabelText('Name');
- const findExternalUrlInput = () => wrapper.findByLabelText('External URL');
- const findForm = () => wrapper.findByRole('form', { name: 'Edit environment' });
+ const findNameInput = () => wrapper.findByLabelText(__('Name'));
+ const findExternalUrlInput = () => wrapper.findByLabelText(__('External URL'));
+ const findForm = () => wrapper.findByRole('form', { name: __('Edit environment') });
const showsLoading = () => wrapper.findComponent(GlLoadingIcon).exists();
- const submitForm = async (expected, response) => {
- mock
- .onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, {
- external_url: expected.url,
- id: '0',
- })
- .reply(...response);
- await findExternalUrlInput().setValue(expected.url);
-
+ const submitForm = async () => {
+ await findExternalUrlInput().setValue(newExternalUrl);
await findForm().trigger('submit');
- await waitForPromises();
};
- it('sets the title to Edit environment', () => {
- const header = wrapper.findByRole('heading', { name: 'Edit environment' });
- expect(header.exists()).toBe(true);
- });
+ describe('default', () => {
+ beforeEach(async () => {
+ await createWrapper();
+ });
- it('shows loader after form is submitted', async () => {
- const expected = { url: 'https://google.ca' };
+ it('sets the title to Edit environment', () => {
+ const header = wrapper.findByRole('heading', { name: __('Edit environment') });
+ expect(header.exists()).toBe(true);
+ });
- expect(showsLoading()).toBe(false);
+ it('renders a disabled "Name" field', () => {
+ const nameInput = findNameInput();
- await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]);
+ expect(nameInput.attributes().disabled).toBe('disabled');
+ expect(nameInput.element.value).toBe(environment.name);
+ });
- expect(showsLoading()).toBe(true);
+ it('renders an "External URL" field', () => {
+ const urlInput = findExternalUrlInput();
+
+ expect(urlInput.element.value).toBe(environment.externalUrl);
+ });
});
- it('submits the updated environment on submit', async () => {
- const expected = { url: 'https://google.ca' };
+ describe('when environmentSettingsToGraphql feature is enabled', () => {
+ describe('when mounted', () => {
+ beforeEach(() => {
+ createWrapperWithApollo();
+ });
+ it('renders loading icon when environment query is loading', () => {
+ expect(showsLoading()).toBe(true);
+ });
+ });
- await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]);
+ describe('when mutation successful', () => {
+ beforeEach(async () => {
+ await createWrapperWithApollo();
+ });
- expect(visitUrl).toHaveBeenCalledWith('/test');
- });
+ it('shows loader after form is submitted', async () => {
+ expect(showsLoading()).toBe(false);
- it('shows errors on error', async () => {
- const expected = { url: 'https://google.ca' };
+ await submitForm();
- await submitForm(expected, [HTTP_STATUS_BAD_REQUEST, { message: ['uh oh!'] }]);
+ expect(showsLoading()).toBe(true);
+ });
- expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' });
- expect(showsLoading()).toBe(false);
- });
+ it('submits the updated environment on submit', async () => {
+ await submitForm();
+ await waitForPromises();
+
+ expect(visitUrl).toHaveBeenCalledWith(environmentUpdate.environment.path);
+ });
+ });
+
+ describe('when mutation failed', () => {
+ beforeEach(async () => {
+ await createWrapperWithApollo({
+ mutationResult: environmentUpdateError,
+ });
+ });
- it('renders a disabled "Name" field', () => {
- const nameInput = findNameInput();
+ it('shows errors on error', async () => {
+ await submitForm();
+ await waitForPromises();
- expect(nameInput.attributes().disabled).toBe('disabled');
- expect(nameInput.element.value).toBe('foo');
+ expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' });
+ expect(showsLoading()).toBe(false);
+ });
+ });
});
- it('renders an "External URL" field', () => {
- const urlInput = findExternalUrlInput();
+ describe('when environmentSettingsToGraphql feature is disabled', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ createWrapper();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('shows loader after form is submitted', async () => {
+ expect(showsLoading()).toBe(false);
- expect(urlInput.element.value).toBe('https://foo.example.com');
+ mock
+ .onPut(provide.updateEnvironmentPath, {
+ external_url: newExternalUrl,
+ id: environment.id,
+ })
+ .reply(...[HTTP_STATUS_OK, { path: '/test' }]);
+
+ await submitForm();
+
+ expect(showsLoading()).toBe(true);
+ });
+
+ it('submits the updated environment on submit', async () => {
+ mock
+ .onPut(provide.updateEnvironmentPath, {
+ external_url: newExternalUrl,
+ id: environment.id,
+ })
+ .reply(...[HTTP_STATUS_OK, { path: '/test' }]);
+
+ await submitForm();
+ await waitForPromises();
+
+ expect(visitUrl).toHaveBeenCalledWith('/test');
+ });
+
+ it('shows errors on error', async () => {
+ mock
+ .onPut(provide.updateEnvironmentPath, {
+ external_url: newExternalUrl,
+ id: environment.id,
+ })
+ .reply(...[HTTP_STATUS_BAD_REQUEST, { message: ['uh oh!'] }]);
+
+ await submitForm();
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' });
+ expect(showsLoading()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js
index 530f9f55088..ea402f26426 100644
--- a/spec/frontend/environments/environment_delete_spec.js
+++ b/spec/frontend/environments/environment_delete_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -21,7 +21,7 @@ describe('External URL Component', () => {
});
};
- const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
+ const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
describe('event hub', () => {
beforeEach(() => {
@@ -30,13 +30,13 @@ describe('External URL Component', () => {
it('should render a dropdown item to delete the environment', () => {
expect(findDropdownItem().exists()).toBe(true);
- expect(wrapper.text()).toEqual('Delete environment');
- expect(findDropdownItem().attributes('variant')).toBe('danger');
+ expect(findDropdownItem().props('item').text).toBe('Delete environment');
+ expect(findDropdownItem().props('item').extraAttrs.variant).toBe('danger');
});
it('emits requestDeleteEnvironment in the event hub when button is clicked', () => {
jest.spyOn(eventHub, '$emit');
- findDropdownItem().vm.$emit('click');
+ findDropdownItem().vm.$emit('action');
expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', resolvedEnvironment);
});
});
@@ -55,13 +55,13 @@ describe('External URL Component', () => {
it('should render a dropdown item to delete the environment', () => {
expect(findDropdownItem().exists()).toBe(true);
- expect(wrapper.text()).toEqual('Delete environment');
- expect(findDropdownItem().attributes('variant')).toBe('danger');
+ expect(findDropdownItem().props('item').text).toBe('Delete environment');
+ expect(findDropdownItem().props('item').extraAttrs.variant).toBe('danger');
});
it('emits requestDeleteEnvironment in the event hub when button is clicked', () => {
jest.spyOn(mockApollo.defaultClient, 'mutate');
- findDropdownItem().vm.$emit('click');
+ findDropdownItem().vm.$emit('action');
expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({
mutation: setEnvironmentToDelete,
variables: { environment: resolvedEnvironment },
diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js
index 4716f807657..65c16697d44 100644
--- a/spec/frontend/environments/environment_folder_spec.js
+++ b/spec/frontend/environments/environment_folder_spec.js
@@ -35,7 +35,7 @@ describe('~/environments/components/environments_folder.vue', () => {
...propsData,
},
stubs: { transition: stubTransition() },
- provide: { helpPagePath: '/help', projectId: '1' },
+ provide: { helpPagePath: '/help', projectId: '1', projectPath: 'path/to/project' },
});
beforeEach(() => {
diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js
index 50e4e637aa3..db81c490747 100644
--- a/spec/frontend/environments/environment_form_spec.js
+++ b/spec/frontend/environments/environment_form_spec.js
@@ -1,6 +1,11 @@
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import EnvironmentForm from '~/environments/components/environment_form.vue';
+import getUserAuthorizedAgents from '~/environments/graphql/queries/user_authorized_agents.query.graphql';
+import createMockApollo from '../__helpers__/mock_apollo_helper';
jest.mock('~/lib/utils/csrf');
@@ -11,6 +16,10 @@ const DEFAULT_PROPS = {
};
const PROVIDE = { protectedEnvironmentSettingsPath: '/projects/not_real/settings/ci_cd' };
+const userAccessAuthorizedAgents = [
+ { agent: { id: '1', name: 'agent-1' } },
+ { agent: { id: '2', name: 'agent-2' } },
+];
describe('~/environments/components/form.vue', () => {
let wrapper;
@@ -25,6 +34,38 @@ describe('~/environments/components/form.vue', () => {
},
});
+ const createWrapperWithApollo = ({ propsData = {} } = {}) => {
+ Vue.use(VueApollo);
+
+ return mountExtended(EnvironmentForm, {
+ provide: {
+ ...PROVIDE,
+ glFeatures: {
+ environmentSettingsToGraphql: true,
+ },
+ },
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...propsData,
+ },
+ apolloProvider: createMockApollo([
+ [
+ getUserAuthorizedAgents,
+ jest.fn().mockResolvedValue({
+ data: {
+ project: {
+ id: '1',
+ userAccessAuthorizedAgents: { nodes: userAccessAuthorizedAgents },
+ },
+ },
+ }),
+ ],
+ ]),
+ });
+ };
+
+ const findAgentSelector = () => wrapper.findComponent(GlCollapsibleListbox);
+
describe('default', () => {
beforeEach(() => {
wrapper = createWrapper();
@@ -167,4 +208,83 @@ describe('~/environments/components/form.vue', () => {
expect(urlInput.element.value).toBe('https://example.com');
});
});
+
+ describe('when `environmentSettingsToGraphql feature flag is enabled', () => {
+ beforeEach(() => {
+ wrapper = createWrapperWithApollo();
+ });
+
+ it('renders an agent selector listbox', () => {
+ expect(findAgentSelector().props()).toMatchObject({
+ searchable: true,
+ toggleText: EnvironmentForm.i18n.agentHelpText,
+ headerText: EnvironmentForm.i18n.agentHelpText,
+ resetButtonLabel: EnvironmentForm.i18n.reset,
+ loading: false,
+ items: [],
+ });
+ });
+
+ it('sets the items prop of the agent selector after fetching the list', async () => {
+ findAgentSelector().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(findAgentSelector().props('items')).toEqual([
+ { value: '1', text: 'agent-1' },
+ { value: '2', text: 'agent-2' },
+ ]);
+ });
+
+ it('sets the loading prop of the agent selector while fetching the list', async () => {
+ await findAgentSelector().vm.$emit('shown');
+ expect(findAgentSelector().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findAgentSelector().props('loading')).toBe(false);
+ });
+
+ it('filters the agent list on user search', async () => {
+ findAgentSelector().vm.$emit('shown');
+ await waitForPromises();
+ await findAgentSelector().vm.$emit('search', 'agent-2');
+
+ expect(findAgentSelector().props('items')).toEqual([{ value: '2', text: 'agent-2' }]);
+ });
+
+ it('updates agent selector field with the name of selected agent', async () => {
+ findAgentSelector().vm.$emit('shown');
+ await waitForPromises();
+ await findAgentSelector().vm.$emit('select', '2');
+
+ expect(findAgentSelector().props('toggleText')).toBe('agent-2');
+ });
+
+ it('emits changes to the clusterAgentId', async () => {
+ findAgentSelector().vm.$emit('shown');
+ await waitForPromises();
+ await findAgentSelector().vm.$emit('select', '2');
+
+ expect(wrapper.emitted('change')).toEqual([
+ [{ name: '', externalUrl: '', clusterAgentId: '2' }],
+ ]);
+ });
+ });
+
+ describe('when environment has an associated agent', () => {
+ const environmentWithAgent = {
+ ...DEFAULT_PROPS.environment,
+ clusterAgent: { id: '1', name: 'agent-1' },
+ clusterAgentId: '1',
+ };
+ beforeEach(() => {
+ wrapper = createWrapperWithApollo({
+ propsData: { environment: environmentWithAgent },
+ });
+ });
+
+ it('updates agent selector field with the name of the associated agent', () => {
+ expect(findAgentSelector().props('toggleText')).toBe('agent-1');
+ });
+ });
});
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index e2b184adc8a..690db66efd1 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -51,7 +51,6 @@ describe('Environment item', () => {
const findUpcomingDeploymentAvatarLink = () =>
findUpcomingDeployment().findComponent(GlAvatarLink);
const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar);
- const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]');
describe('when item is not folder', () => {
it('should render environment name', () => {
@@ -435,25 +434,4 @@ describe('Environment item', () => {
});
});
});
-
- describe.each([true, false])(
- 'when `remove_monitor_metrics` flag is %p',
- (removeMonitorMetrics) => {
- beforeEach(() => {
- factory({
- propsData: {
- model: {
- metrics_path: 'http://0.0.0.0:3000/flightjs/Flight/-/metrics?environment=6',
- },
- tableData,
- },
- provide: { glFeatures: { removeMonitorMetrics } },
- });
- });
-
- it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => {
- expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics);
- });
- },
- );
});
diff --git a/spec/frontend/environments/environment_monitoring_spec.js b/spec/frontend/environments/environment_monitoring_spec.js
deleted file mode 100644
index 98dd9edd812..00000000000
--- a/spec/frontend/environments/environment_monitoring_spec.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import MonitoringComponent from '~/environments/components/environment_monitoring.vue';
-import { __ } from '~/locale';
-
-describe('Monitoring Component', () => {
- let wrapper;
-
- const monitoringUrl = 'https://gitlab.com';
-
- const createWrapper = () => {
- wrapper = mountExtended(MonitoringComponent, {
- propsData: {
- monitoringUrl,
- },
- });
- };
-
- beforeEach(() => {
- createWrapper();
- });
-
- it('should render a link to environment monitoring page', () => {
- const link = wrapper.findByRole('menuitem', { name: __('Monitoring') });
- expect(link.attributes('href')).toEqual(monitoringUrl);
- });
-});
diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js
index ee195b41bc8..bf371978d72 100644
--- a/spec/frontend/environments/environment_pin_spec.js
+++ b/spec/frontend/environments/environment_pin_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import cancelAutoStopMutation from '~/environments/graphql/mutations/cancel_auto_stop.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -18,6 +18,8 @@ describe('Pin Component', () => {
const autoStopUrl = '/root/auto-stop-env-test/-/environments/38/cancel_auto_stop';
+ const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
+
describe('without graphql', () => {
beforeEach(() => {
factory({
@@ -28,14 +30,13 @@ describe('Pin Component', () => {
});
it('should render the component with descriptive text', () => {
- expect(wrapper.text()).toBe('Prevent auto-stopping');
+ expect(findDropdownItem().props('item').text).toBe('Prevent auto-stopping');
});
it('should emit onPinClick when clicked', () => {
const eventHubSpy = jest.spyOn(eventHub, '$emit');
- const item = wrapper.findComponent(GlDropdownItem);
- item.vm.$emit('click');
+ findDropdownItem().vm.$emit('action');
expect(eventHubSpy).toHaveBeenCalledWith('cancelAutoStop', autoStopUrl);
});
@@ -57,14 +58,13 @@ describe('Pin Component', () => {
});
it('should render the component with descriptive text', () => {
- expect(wrapper.text()).toBe('Prevent auto-stopping');
+ expect(findDropdownItem().props('item').text).toBe('Prevent auto-stopping');
});
it('should emit onPinClick when clicked', () => {
jest.spyOn(mockApollo.defaultClient, 'mutate');
- const item = wrapper.findComponent(GlDropdownItem);
- item.vm.$emit('click');
+ findDropdownItem().vm.$emit('action');
expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({
mutation: cancelAutoStopMutation,
diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js
index 5d36209f8a6..653be6c1fde 100644
--- a/spec/frontend/environments/environment_rollback_spec.js
+++ b/spec/frontend/environments/environment_rollback_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RollbackComponent from '~/environments/components/environment_rollback.vue';
import eventHub from '~/environments/event_hub';
@@ -8,10 +8,14 @@ import setEnvironmentToRollback from '~/environments/graphql/mutations/set_envir
import createMockApollo from 'helpers/mock_apollo_helper';
describe('Rollback Component', () => {
+ let wrapper;
+
const retryUrl = 'https://gitlab.com/retry';
+ const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
+
it('Should render Re-deploy label when isLastDeployment is true', () => {
- const wrapper = shallowMount(RollbackComponent, {
+ wrapper = shallowMount(RollbackComponent, {
propsData: {
retryUrl,
isLastDeployment: true,
@@ -19,11 +23,11 @@ describe('Rollback Component', () => {
},
});
- expect(wrapper.text()).toBe('Re-deploy to environment');
+ expect(findDropdownItem().props('item').text).toBe('Re-deploy to environment');
});
it('Should render Rollback label when isLastDeployment is false', () => {
- const wrapper = shallowMount(RollbackComponent, {
+ wrapper = shallowMount(RollbackComponent, {
propsData: {
retryUrl,
isLastDeployment: false,
@@ -31,12 +35,12 @@ describe('Rollback Component', () => {
},
});
- expect(wrapper.text()).toBe('Rollback environment');
+ expect(findDropdownItem().props('item').text).toBe('Rollback environment');
});
it('should emit a "rollback" event on button click', () => {
const eventHubSpy = jest.spyOn(eventHub, '$emit');
- const wrapper = shallowMount(RollbackComponent, {
+ wrapper = shallowMount(RollbackComponent, {
propsData: {
retryUrl,
environment: {
@@ -44,9 +48,8 @@ describe('Rollback Component', () => {
},
},
});
- const button = wrapper.findComponent(GlDropdownItem);
- button.vm.$emit('click');
+ findDropdownItem().vm.$emit('action');
expect(eventHubSpy).toHaveBeenCalledWith('requestRollbackEnvironment', {
retryUrl,
@@ -63,7 +66,8 @@ describe('Rollback Component', () => {
const environment = {
name: 'test',
};
- const wrapper = shallowMount(RollbackComponent, {
+
+ wrapper = shallowMount(RollbackComponent, {
propsData: {
retryUrl,
graphql: true,
@@ -71,8 +75,8 @@ describe('Rollback Component', () => {
},
apolloProvider,
});
- const button = wrapper.findComponent(GlDropdownItem);
- button.vm.$emit('click');
+
+ findDropdownItem().vm.$emit('action');
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: setEnvironmentToRollback,
diff --git a/spec/frontend/environments/environment_terminal_button_spec.js b/spec/frontend/environments/environment_terminal_button_spec.js
index ab9f370595f..0a5ac96d26f 100644
--- a/spec/frontend/environments/environment_terminal_button_spec.js
+++ b/spec/frontend/environments/environment_terminal_button_spec.js
@@ -17,7 +17,7 @@ describe('Terminal Component', () => {
});
it('should render a link to open a web terminal with the provided path', () => {
- const link = wrapper.findByRole('menuitem', { name: __('Terminal') });
+ const link = wrapper.findByRole('link', { name: __('Terminal') });
expect(link.attributes('href')).toBe(terminalPath);
});
diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js
index 9464aeff028..5cbc16100be 100644
--- a/spec/frontend/environments/environments_detail_header_spec.js
+++ b/spec/frontend/environments/environments_detail_header_spec.js
@@ -1,6 +1,6 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { createMockDirective } from 'helpers/vue_mock_directive';
import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
import EnvironmentsDetailHeader from '~/environments/components/environments_detail_header.vue';
import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue';
@@ -11,7 +11,6 @@ import { createEnvironment } from './mock_data';
describe('Environments detail header component', () => {
const cancelAutoStopPath = '/my-environment/cancel/path';
const terminalPath = '/my-environment/terminal/path';
- const metricsPath = '/my-environment/metrics/path';
const updatePath = '/my-environment/edit/path';
let wrapper;
@@ -22,7 +21,6 @@ describe('Environments detail header component', () => {
const findCancelAutoStopAtForm = () => wrapper.findByTestId('cancel-auto-stop-form');
const findTerminalButton = () => wrapper.findByTestId('terminal-button');
const findExternalUrlButton = () => wrapper.findComponentByTestId('external-url-button');
- const findMetricsButton = () => wrapper.findByTestId('metrics-button');
const findEditButton = () => wrapper.findByTestId('edit-button');
const findStopButton = () => wrapper.findByTestId('stop-button');
const findDestroyButton = () => wrapper.findByTestId('destroy-button');
@@ -34,7 +32,6 @@ describe('Environments detail header component', () => {
['Cancel Auto Stop At', findCancelAutoStopAtButton],
['Terminal', findTerminalButton],
['External Url', findExternalUrlButton],
- ['Metrics', findMetricsButton],
['Edit', findEditButton],
['Stop', findStopButton],
['Destroy', findDestroyButton],
@@ -178,48 +175,6 @@ describe('Environments detail header component', () => {
});
});
- describe('when metrics are enabled', () => {
- beforeEach(() => {
- createWrapper({
- props: {
- environment: createEnvironment({ metricsUrl: 'my metrics url' }),
- metricsPath,
- },
- });
- });
-
- it('displays the metrics button with correct path', () => {
- expect(findMetricsButton().attributes('href')).toBe(metricsPath);
- });
-
- it('uses a gl tooltip for the title', () => {
- const button = findMetricsButton();
- const tooltip = getBinding(button.element, 'gl-tooltip');
-
- expect(tooltip).toBeDefined();
- expect(button.attributes('title')).toBe('See metrics');
- });
-
- describe.each([true, false])(
- 'and `remove_monitor_metrics` flag is %p',
- (removeMonitorMetrics) => {
- beforeEach(() => {
- createWrapper({
- props: {
- environment: createEnvironment({ metricsUrl: 'my metrics url' }),
- metricsPath,
- },
- glFeatures: { removeMonitorMetrics },
- });
- });
-
- it(`${removeMonitorMetrics ? 'does not render' : 'renders'} Metrics button`, () => {
- expect(findMetricsButton().exists()).toBe(!removeMonitorMetrics);
- });
- },
- );
- });
-
describe('when has all admin rights', () => {
beforeEach(() => {
createWrapper({
diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js
index addbf2c21dc..91268ade1e9 100644
--- a/spec/frontend/environments/graphql/mock_data.js
+++ b/spec/frontend/environments/graphql/mock_data.js
@@ -800,12 +800,14 @@ export const resolvedDeploymentDetails = {
};
export const agent = {
- project: 'agent-project',
id: 'gid://gitlab/ClusterAgent/1',
name: 'agent-name',
- kubernetesNamespace: 'agent-namespace',
+ webPath: 'path/to/agent-page',
+ tokens: { nodes: [] },
};
+export const kubernetesNamespace = 'agent-namespace';
+
const runningPod = { status: { phase: 'Running' } };
const pendingPod = { status: { phase: 'Pending' } };
const succeededPod = { status: { phase: 'Succeeded' } };
diff --git a/spec/frontend/environments/kubernetes_agent_info_spec.js b/spec/frontend/environments/kubernetes_agent_info_spec.js
index b1795065281..9169b9284f4 100644
--- a/spec/frontend/environments/kubernetes_agent_info_spec.js
+++ b/spec/frontend/environments/kubernetes_agent_info_spec.js
@@ -1,26 +1,14 @@
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { GlIcon, GlLink, GlSprintf, GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue';
import { AGENT_STATUSES, ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants';
-import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import getK8sClusterAgentQuery from '~/environments/graphql/queries/k8s_cluster_agent.query.graphql';
-Vue.use(VueApollo);
-
-const propsData = {
- agentName: 'my-agent',
- agentId: '1',
- agentProjectPath: 'path/to/agent-config-project',
-};
-
-const mockClusterAgent = {
- id: '1',
- name: 'token-1',
+const defaultClusterAgent = {
+ name: 'my-agent',
+ id: 'gid://gitlab/ClusterAgent/1',
webPath: 'path/to/agent-page',
};
@@ -29,27 +17,16 @@ const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNE
describe('~/environments/components/kubernetes_agent_info.vue', () => {
let wrapper;
- let agentQueryResponse;
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAgentLink = () => wrapper.findComponent(GlLink);
const findAgentStatus = () => wrapper.findByTestId('agent-status');
const findAgentStatusIcon = () => findAgentStatus().findComponent(GlIcon);
const findAgentLastUsedDate = () => wrapper.findByTestId('agent-last-used-date');
- const findAlert = () => wrapper.findComponent(GlAlert);
-
- const createWrapper = ({ tokens = [], queryResponse = null } = {}) => {
- const clusterAgent = { ...mockClusterAgent, tokens: { nodes: tokens } };
-
- agentQueryResponse =
- queryResponse ||
- jest.fn().mockResolvedValue({ data: { project: { id: 'project-1', clusterAgent } } });
- const apolloProvider = createMockApollo([[getK8sClusterAgentQuery, agentQueryResponse]]);
+ const createWrapper = ({ tokens = [] } = {}) => {
wrapper = extendedWrapper(
shallowMount(KubernetesAgentInfo, {
- apolloProvider,
- propsData,
+ propsData: { clusterAgent: { ...defaultClusterAgent, tokens: { nodes: tokens } } },
stubs: { TimeAgoTooltip, GlSprintf },
}),
);
@@ -60,28 +37,9 @@ describe('~/environments/components/kubernetes_agent_info.vue', () => {
createWrapper();
});
- it('shows loading icon while fetching the agent details', async () => {
- expect(findLoadingIcon().exists()).toBe(true);
- await waitForPromises();
- expect(findLoadingIcon().exists()).toBe(false);
- });
-
- it('sends expected params', async () => {
- await waitForPromises();
-
- const variables = {
- agentName: propsData.agentName,
- projectPath: propsData.agentProjectPath,
- };
-
- expect(agentQueryResponse).toHaveBeenCalledWith(variables);
- });
-
- it('renders the agent name with the link', async () => {
- await waitForPromises();
-
- expect(findAgentLink().attributes('href')).toBe(mockClusterAgent.webPath);
- expect(findAgentLink().text()).toContain(mockClusterAgent.id);
+ it('renders the agent name with the link', () => {
+ expect(findAgentLink().attributes('href')).toBe(defaultClusterAgent.webPath);
+ expect(findAgentLink().text()).toContain('1');
});
});
@@ -110,15 +68,4 @@ describe('~/environments/components/kubernetes_agent_info.vue', () => {
expect(findAgentLastUsedDate().text()).toBe(lastUsedText);
});
});
-
- describe('when the agent query has errored', () => {
- beforeEach(() => {
- createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() });
- return waitForPromises();
- });
-
- it('displays an alert message', () => {
- expect(findAlert().text()).toBe(KubernetesAgentInfo.i18n.loadingError);
- });
- });
});
diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js
index 394fd200edf..1c7ace00f48 100644
--- a/spec/frontend/environments/kubernetes_overview_spec.js
+++ b/spec/frontend/environments/kubernetes_overview_spec.js
@@ -5,14 +5,13 @@ import KubernetesOverview from '~/environments/components/kubernetes_overview.vu
import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info.vue';
import KubernetesPods from '~/environments/components/kubernetes_pods.vue';
import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue';
-import { agent } from './graphql/mock_data';
+import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue';
+import { agent, kubernetesNamespace } from './graphql/mock_data';
import { mockKasTunnelUrl } from './mock_data';
const propsData = {
- agentId: agent.id,
- agentName: agent.name,
- agentProjectPath: agent.project,
- namespace: agent.kubernetesNamespace,
+ clusterAgent: agent,
+ namespace: kubernetesNamespace,
};
const provide = {
@@ -23,6 +22,7 @@ const configuration = {
basePath: provide.kasTunnelUrl.replace(/\/$/, ''),
baseOptions: {
headers: { 'GitLab-Agent-Id': '1' },
+ withCredentials: true,
},
};
@@ -34,6 +34,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => {
const findAgentInfo = () => wrapper.findComponent(KubernetesAgentInfo);
const findKubernetesPods = () => wrapper.findComponent(KubernetesPods);
const findKubernetesTabs = () => wrapper.findComponent(KubernetesTabs);
+ const findKubernetesStatusBar = () => wrapper.findComponent(KubernetesStatusBar);
const findAlert = () => wrapper.findComponent(GlAlert);
const createWrapper = () => {
@@ -91,26 +92,65 @@ describe('~/environments/components/kubernetes_overview.vue', () => {
});
it('renders kubernetes agent info', () => {
- expect(findAgentInfo().props()).toEqual({
- agentName: agent.name,
- agentId: agent.id,
- agentProjectPath: agent.project,
- });
+ expect(findAgentInfo().props('clusterAgent')).toEqual(agent);
});
it('renders kubernetes pods', () => {
expect(findKubernetesPods().props()).toEqual({
- namespace: agent.kubernetesNamespace,
+ namespace: kubernetesNamespace,
configuration,
});
});
it('renders kubernetes tabs', () => {
expect(findKubernetesTabs().props()).toEqual({
- namespace: agent.kubernetesNamespace,
+ namespace: kubernetesNamespace,
configuration,
});
});
+
+ it('renders kubernetes status bar', () => {
+ expect(findKubernetesStatusBar().exists()).toBe(true);
+ });
+ });
+
+ describe('Kubernetes health status', () => {
+ beforeEach(() => {
+ createWrapper();
+ toggleCollapse();
+ });
+
+ it("doesn't set `clusterHealthStatus` when pods are still loading", async () => {
+ findKubernetesPods().vm.$emit('loading', true);
+ await nextTick();
+
+ expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('');
+ });
+
+ it("doesn't set `clusterHealthStatus` when workload types are still loading", async () => {
+ findKubernetesTabs().vm.$emit('loading', true);
+ await nextTick();
+
+ expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('');
+ });
+
+ it('sets `clusterHealthStatus` as error when pods emitted a failure', async () => {
+ findKubernetesPods().vm.$emit('failed');
+ await nextTick();
+
+ expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
+ });
+
+ it('sets `clusterHealthStatus` as error when workload types emitted a failure', async () => {
+ findKubernetesTabs().vm.$emit('failed');
+ await nextTick();
+
+ expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
+ });
+
+ it('sets `clusterHealthStatus` as success when data is loaded and no failures where emitted', () => {
+ expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('success');
+ });
});
describe('on cluster error', () => {
diff --git a/spec/frontend/environments/kubernetes_pods_spec.js b/spec/frontend/environments/kubernetes_pods_spec.js
index 137309d7853..0420d8df1a9 100644
--- a/spec/frontend/environments/kubernetes_pods_spec.js
+++ b/spec/frontend/environments/kubernetes_pods_spec.js
@@ -50,6 +50,14 @@ describe('~/environments/components/kubernetes_pods.vue', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
+ it('emits loading state', async () => {
+ createWrapper();
+ expect(wrapper.emitted('loading')[0]).toEqual([true]);
+
+ await waitForPromises();
+ expect(wrapper.emitted('loading')[1]).toEqual([false]);
+ });
+
it('hides the loading icon when the list of pods loaded', async () => {
createWrapper();
await waitForPromises();
@@ -84,6 +92,13 @@ describe('~/environments/components/kubernetes_pods.vue', () => {
});
},
);
+
+ it('emits a failed event when there are failed pods', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(wrapper.emitted('failed')).toHaveLength(1);
+ });
});
describe('when gets an error from the cluster_client API', () => {
diff --git a/spec/frontend/environments/kubernetes_status_bar_spec.js b/spec/frontend/environments/kubernetes_status_bar_spec.js
new file mode 100644
index 00000000000..2ebb30e2766
--- /dev/null
+++ b/spec/frontend/environments/kubernetes_status_bar_spec.js
@@ -0,0 +1,42 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue';
+import {
+ CLUSTER_STATUS_HEALTHY_TEXT,
+ CLUSTER_STATUS_UNHEALTHY_TEXT,
+} from '~/environments/constants';
+
+describe('~/environments/components/kubernetes_status_bar.vue', () => {
+ let wrapper;
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findHealthBadge = () => wrapper.findComponent(GlBadge);
+
+ const createWrapper = ({ clusterHealthStatus = '' } = {}) => {
+ wrapper = shallowMount(KubernetesStatusBar, {
+ propsData: { clusterHealthStatus },
+ });
+ };
+
+ describe('health badge', () => {
+ it('shows loading icon when cluster health is not present', () => {
+ createWrapper();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it.each([
+ ['success', 'success', CLUSTER_STATUS_HEALTHY_TEXT],
+ ['error', 'danger', CLUSTER_STATUS_UNHEALTHY_TEXT],
+ ])(
+ 'when clusterHealthStatus is %s shows health badge with variant %s and text %s',
+ (status, variant, text) => {
+ createWrapper({ clusterHealthStatus: status });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ expect(findHealthBadge().props('variant')).toBe(variant);
+ expect(findHealthBadge().text()).toBe(text);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/environments/kubernetes_summary_spec.js b/spec/frontend/environments/kubernetes_summary_spec.js
index 53b83079486..22c81f29f64 100644
--- a/spec/frontend/environments/kubernetes_summary_spec.js
+++ b/spec/frontend/environments/kubernetes_summary_spec.js
@@ -59,6 +59,14 @@ describe('~/environments/components/kubernetes_summary.vue', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
+ it('emits loading state', async () => {
+ createWrapper();
+ expect(wrapper.emitted('loading')[0]).toEqual([true]);
+
+ await waitForPromises();
+ expect(wrapper.emitted('loading')[1]).toEqual([false]);
+ });
+
describe('when workloads data is loaded', () => {
beforeEach(async () => {
await createWrapper();
@@ -94,6 +102,10 @@ describe('~/environments/components/kubernetes_summary.vue', () => {
);
});
+ it('emits a failed event when there are failed workload types', () => {
+ expect(wrapper.emitted('failed')).toHaveLength(1);
+ });
+
it('emits an error message when gets an error from the cluster_client API', async () => {
const error = new Error('Error from the cluster_client API');
const createErroredApolloProvider = () => {
diff --git a/spec/frontend/environments/kubernetes_tabs_spec.js b/spec/frontend/environments/kubernetes_tabs_spec.js
index 429f267347b..81b0bb86e0e 100644
--- a/spec/frontend/environments/kubernetes_tabs_spec.js
+++ b/spec/frontend/environments/kubernetes_tabs_spec.js
@@ -165,4 +165,23 @@ describe('~/environments/components/kubernetes_tabs.vue', () => {
expect(wrapper.emitted('cluster-error')).toEqual([[error]]);
});
});
+
+ describe('summary tab', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('emits loading event when gets it from the component', () => {
+ findKubernetesSummary().vm.$emit('loading', true);
+ expect(wrapper.emitted('loading')[0]).toEqual([true]);
+
+ findKubernetesSummary().vm.$emit('loading', false);
+ expect(wrapper.emitted('loading')[1]).toEqual([false]);
+ });
+
+ it('emits a failed event when gets it from the component', () => {
+ findKubernetesSummary().vm.$emit('failed');
+ expect(wrapper.emitted('failed')).toHaveLength(1);
+ });
+ });
});
diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js
index 5583e737dd8..eb6990ba8a8 100644
--- a/spec/frontend/environments/new_environment_item_spec.js
+++ b/spec/frontend/environments/new_environment_item_spec.js
@@ -3,6 +3,7 @@ import Vue from 'vue';
import { GlCollapse, GlIcon } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { stubTransition } from 'helpers/stub_transition';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
@@ -11,6 +12,7 @@ import EnvironmentActions from '~/environments/components/environment_actions.vu
import Deployment from '~/environments/components/deployment.vue';
import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue';
import KubernetesOverview from '~/environments/components/kubernetes_overview.vue';
+import getEnvironmentClusterAgent from '~/environments/graphql/queries/environment_cluster_agent.query.graphql';
import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data';
import { mockKasTunnelUrl } from './mock_data';
@@ -18,9 +20,24 @@ Vue.use(VueApollo);
describe('~/environments/components/new_environment_item.vue', () => {
let wrapper;
+ let queryResponseHandler;
- const createApolloProvider = () => {
- return createMockApollo();
+ const projectPath = '/1';
+
+ const createApolloProvider = (clusterAgent = null) => {
+ const response = {
+ data: {
+ project: {
+ id: '1',
+ environment: {
+ id: '1',
+ clusterAgent,
+ },
+ },
+ },
+ };
+ queryResponseHandler = jest.fn().mockResolvedValue(response);
+ return createMockApollo([[getEnvironmentClusterAgent, queryResponseHandler]]);
};
const createWrapper = ({ propsData = {}, provideData = {}, apolloProvider } = {}) =>
@@ -30,7 +47,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
provide: {
helpPagePath: '/help',
projectId: '1',
- projectPath: '/1',
+ projectPath,
kasTunnelUrl: mockKasTunnelUrl,
...provideData,
},
@@ -40,7 +57,6 @@ describe('~/environments/components/new_environment_item.vue', () => {
const findDeployment = () => wrapper.findComponent(Deployment);
const findActions = () => wrapper.findComponent(EnvironmentActions);
const findKubernetesOverview = () => wrapper.findComponent(KubernetesOverview);
- const findMonitoringLink = () => wrapper.find('[data-testid="environment-monitoring"]');
const expandCollapsedSection = async () => {
const button = wrapper.findByRole('button', { name: __('Expand') });
@@ -185,7 +201,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
it('shows the option to rollback/re-deploy if available', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
- const rollback = wrapper.findByRole('menuitem', {
+ const rollback = wrapper.findByRole('button', {
name: s__('Environments|Re-deploy to environment'),
});
@@ -198,7 +214,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
apolloProvider: createApolloProvider(),
});
- const rollback = wrapper.findByRole('menuitem', {
+ const rollback = wrapper.findByRole('button', {
name: s__('Environments|Re-deploy to environment'),
});
@@ -224,7 +240,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
});
it('shows the option to pin the environment if there is an autostop date', () => {
- const pin = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') });
+ const pin = wrapper.findByRole('button', { name: __('Prevent auto-stopping') });
expect(pin.exists()).toBe(true);
});
@@ -244,7 +260,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
it('does not show the option to pin the environment if there is no autostop date', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
- const pin = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') });
+ const pin = wrapper.findByRole('button', { name: __('Prevent auto-stopping') });
expect(pin.exists()).toBe(false);
});
@@ -279,7 +295,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
it('does not show the option to pin the environment if there is no autostop date', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
- const pin = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') });
+ const pin = wrapper.findByRole('button', { name: __('Prevent auto-stopping') });
expect(pin.exists()).toBe(false);
});
@@ -296,44 +312,6 @@ describe('~/environments/components/new_environment_item.vue', () => {
});
});
- describe('monitoring', () => {
- it('shows the link to monitoring if metrics are set up', () => {
- wrapper = createWrapper({
- propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } },
- apolloProvider: createApolloProvider(),
- });
-
- const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') });
-
- expect(rollback.exists()).toBe(true);
- });
-
- it('does not show the link to monitoring if metrics are not set up', () => {
- wrapper = createWrapper({ apolloProvider: createApolloProvider() });
-
- const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') });
-
- expect(rollback.exists()).toBe(false);
- });
-
- describe.each([true, false])(
- 'when `remove_monitor_metrics` flag is %p',
- (removeMonitorMetrics) => {
- beforeEach(() => {
- wrapper = createWrapper({
- propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } },
- apolloProvider: createApolloProvider(),
- provideData: { glFeatures: { removeMonitorMetrics } },
- });
- });
-
- it(`${removeMonitorMetrics ? 'does not render' : 'renders'} link to metrics`, () => {
- expect(findMonitoringLink().exists()).toBe(!removeMonitorMetrics);
- });
- },
- );
- });
-
describe('terminal', () => {
it('shows the link to the terminal if set up', () => {
wrapper = createWrapper({
@@ -341,17 +319,17 @@ describe('~/environments/components/new_environment_item.vue', () => {
apolloProvider: createApolloProvider(),
});
- const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') });
+ const terminal = wrapper.findByRole('link', { name: __('Terminal') });
- expect(rollback.exists()).toBe(true);
+ expect(terminal.exists()).toBe(true);
});
it('does not show the link to the terminal if not set up', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
- const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') });
+ const terminal = wrapper.findByRole('link', { name: __('Terminal') });
- expect(rollback.exists()).toBe(false);
+ expect(terminal.exists()).toBe(false);
});
});
@@ -364,21 +342,21 @@ describe('~/environments/components/new_environment_item.vue', () => {
apolloProvider: createApolloProvider(),
});
- const rollback = wrapper.findByRole('menuitem', {
+ const deleteTrigger = wrapper.findByRole('button', {
name: s__('Environments|Delete environment'),
});
- expect(rollback.exists()).toBe(true);
+ expect(deleteTrigger.exists()).toBe(true);
});
it('does not show the button to delete the environment if not possible', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
- const rollback = wrapper.findByRole('menuitem', {
+ const deleteTrigger = wrapper.findByRole('button', {
name: s__('Environments|Delete environment'),
});
- expect(rollback.exists()).toBe(false);
+ expect(deleteTrigger.exists()).toBe(false);
});
});
@@ -540,68 +518,69 @@ describe('~/environments/components/new_environment_item.vue', () => {
});
describe('kubernetes overview', () => {
- const environmentWithAgent = {
- ...resolvedEnvironment,
- agent,
- };
-
- it('should render if the feature flag is enabled and the environment has an agent object with the required data specified', () => {
+ it('should request agent data when the environment is visible if the feature flag is enabled', async () => {
wrapper = createWrapper({
- propsData: { environment: environmentWithAgent },
+ propsData: { environment: resolvedEnvironment },
provideData: {
glFeatures: {
kasUserAccessProject: true,
},
},
- apolloProvider: createApolloProvider(),
+ apolloProvider: createApolloProvider(agent),
});
- expandCollapsedSection();
+ await expandCollapsedSection();
- expect(findKubernetesOverview().props()).toMatchObject({
- agentProjectPath: agent.project,
- agentName: agent.name,
- agentId: agent.id,
- namespace: agent.kubernetesNamespace,
+ expect(queryResponseHandler).toHaveBeenCalledWith({
+ environmentName: resolvedEnvironment.name,
+ projectFullPath: projectPath,
});
});
- it('should not render if the feature flag is not enabled', () => {
+ it('should render if the feature flag is enabled and the environment has an agent associated', async () => {
wrapper = createWrapper({
- propsData: { environment: environmentWithAgent },
- apolloProvider: createApolloProvider(),
+ propsData: { environment: resolvedEnvironment },
+ provideData: {
+ glFeatures: {
+ kasUserAccessProject: true,
+ },
+ },
+ apolloProvider: createApolloProvider(agent),
});
- expandCollapsedSection();
+ await expandCollapsedSection();
+ await waitForPromises();
- expect(findKubernetesOverview().exists()).toBe(false);
+ expect(findKubernetesOverview().props()).toMatchObject({
+ clusterAgent: agent,
+ });
});
- it('should not render if the environment has no agent object', () => {
+ it('should not render if the feature flag is not enabled', async () => {
wrapper = createWrapper({
- apolloProvider: createApolloProvider(),
+ propsData: { environment: resolvedEnvironment },
+ apolloProvider: createApolloProvider(agent),
});
- expandCollapsedSection();
+ await expandCollapsedSection();
+ expect(queryResponseHandler).not.toHaveBeenCalled();
expect(findKubernetesOverview().exists()).toBe(false);
});
- it('should not render if the environment has an agent object without agent id specified', () => {
- const environment = {
- ...resolvedEnvironment,
- agent: {
- project: agent.project,
- name: agent.name,
- },
- };
-
+ it('should not render if the environment has no agent object', async () => {
wrapper = createWrapper({
- propsData: { environment },
+ propsData: { environment: resolvedEnvironment },
+ provideData: {
+ glFeatures: {
+ kasUserAccessProject: true,
+ },
+ },
apolloProvider: createApolloProvider(),
});
- expandCollapsedSection();
+ await expandCollapsedSection();
+ await waitForPromises();
expect(findKubernetesOverview().exists()).toBe(false);
});
diff --git a/spec/frontend/environments/new_environment_spec.js b/spec/frontend/environments/new_environment_spec.js
index 743f4ad6786..749e4e5caa4 100644
--- a/spec/frontend/environments/new_environment_spec.js
+++ b/spec/frontend/environments/new_environment_spec.js
@@ -1,103 +1,196 @@
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import NewEnvironment from '~/environments/components/new_environment.vue';
+import createEnvironment from '~/environments/graphql/mutations/create_environment.mutation.graphql';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import createMockApollo from '../__helpers__/mock_apollo_helper';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/alert');
-const DEFAULT_OPTS = {
- provide: {
- projectEnvironmentsPath: '/projects/environments',
- protectedEnvironmentSettingsPath: '/projects/not_real/settings/ci_cd',
- },
+const newName = 'test';
+const newExternalUrl = 'https://google.ca';
+
+const provide = {
+ projectEnvironmentsPath: '/projects/environments',
+ projectPath: '/path/to/project',
+};
+
+const environmentCreate = { environment: { id: '1', path: 'path/to/environment' }, errors: [] };
+const environmentCreateError = {
+ environment: null,
+ errors: [{ message: 'uh oh!' }],
};
describe('~/environments/components/new.vue', () => {
let wrapper;
let mock;
- let name;
- let url;
- let form;
-
- const createWrapper = (opts = {}) =>
- mountExtended(NewEnvironment, {
- ...DEFAULT_OPTS,
- ...opts,
+
+ const createMockApolloProvider = (mutationResult) => {
+ Vue.use(VueApollo);
+
+ return createMockApollo([
+ [
+ createEnvironment,
+ jest.fn().mockResolvedValue({ data: { environmentCreate: mutationResult } }),
+ ],
+ ]);
+ };
+
+ const createWrapperWithApollo = async (mutationResult = environmentCreate) => {
+ wrapper = mountExtended(NewEnvironment, {
+ provide: {
+ ...provide,
+ glFeatures: {
+ environmentSettingsToGraphql: true,
+ },
+ },
+ apolloProvider: createMockApolloProvider(mutationResult),
});
- beforeEach(() => {
- mock = new MockAdapter(axios);
- wrapper = createWrapper();
- name = wrapper.findByLabelText('Name');
- url = wrapper.findByLabelText('External URL');
- form = wrapper.findByRole('form', { name: 'New environment' });
- });
+ await waitForPromises();
+ };
- afterEach(() => {
- mock.restore();
- });
+ const createWrapperWithAxios = () => {
+ wrapper = mountExtended(NewEnvironment, {
+ provide: {
+ ...provide,
+ glFeatures: {
+ environmentSettingsToGraphql: false,
+ },
+ },
+ });
+ };
+ const findNameInput = () => wrapper.findByLabelText(__('Name'));
+ const findExternalUrlInput = () => wrapper.findByLabelText(__('External URL'));
+ const findForm = () => wrapper.findByRole('form', { name: __('New environment') });
const showsLoading = () => wrapper.findComponent(GlLoadingIcon).exists();
- const submitForm = async (expected, response) => {
- mock
- .onPost(DEFAULT_OPTS.provide.projectEnvironmentsPath, {
- name: expected.name,
- external_url: expected.url,
- })
- .reply(...response);
- await name.setValue(expected.name);
- await url.setValue(expected.url);
-
- await form.trigger('submit');
- await waitForPromises();
+ const submitForm = async () => {
+ await findNameInput().setValue('test');
+ await findExternalUrlInput().setValue('https://google.ca');
+
+ await findForm().trigger('submit');
};
- it('sets the title to New environment', () => {
- const header = wrapper.findByRole('heading', { name: 'New environment' });
- expect(header.exists()).toBe(true);
- });
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapperWithAxios();
+ });
+
+ it('sets the title to New environment', () => {
+ const header = wrapper.findByRole('heading', { name: 'New environment' });
+ expect(header.exists()).toBe(true);
+ });
- it.each`
- input | value
- ${() => name} | ${'test'}
- ${() => url} | ${'https://example.org'}
- `('changes the value of the input to $value', async ({ input, value }) => {
- await input().setValue(value);
+ it.each`
+ input | value
+ ${() => findNameInput()} | ${'test'}
+ ${() => findExternalUrlInput()} | ${'https://example.org'}
+ `('changes the value of the input to $value', ({ input, value }) => {
+ input().setValue(value);
- expect(input().element.value).toBe(value);
+ expect(input().element.value).toBe(value);
+ });
});
- it('shows loader after form is submitted', async () => {
- const expected = { name: 'test', url: 'https://google.ca' };
+ describe('when environmentSettingsToGraphql feature is enabled', () => {
+ describe('when mutation successful', () => {
+ beforeEach(() => {
+ createWrapperWithApollo();
+ });
- expect(showsLoading()).toBe(false);
+ it('shows loader after form is submitted', async () => {
+ expect(showsLoading()).toBe(false);
- await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]);
+ await submitForm();
- expect(showsLoading()).toBe(true);
- });
+ expect(showsLoading()).toBe(true);
+ });
- it('submits the new environment on submit', async () => {
- const expected = { name: 'test', url: 'https://google.ca' };
+ it('submits the new environment on submit', async () => {
+ submitForm();
+ await waitForPromises();
- await submitForm(expected, [HTTP_STATUS_OK, { path: '/test' }]);
+ expect(visitUrl).toHaveBeenCalledWith('path/to/environment');
+ });
+ });
- expect(visitUrl).toHaveBeenCalledWith('/test');
+ describe('when failed', () => {
+ beforeEach(async () => {
+ createWrapperWithApollo(environmentCreateError);
+ submitForm();
+ await waitForPromises();
+ });
+
+ it('shows errors on error', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: 'uh oh!' });
+ expect(showsLoading()).toBe(false);
+ });
+ });
});
- it('shows errors on error', async () => {
- const expected = { name: 'test', url: 'https://google.ca' };
+ describe('when environmentSettingsToGraphql feature is disabled', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ createWrapperWithAxios();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('shows loader after form is submitted', async () => {
+ expect(showsLoading()).toBe(false);
+
+ mock
+ .onPost(provide.projectEnvironmentsPath, {
+ name: newName,
+ external_url: newExternalUrl,
+ })
+ .reply(HTTP_STATUS_OK, { path: '/test' });
- await submitForm(expected, [HTTP_STATUS_BAD_REQUEST, { message: ['name taken'] }]);
+ await submitForm();
- expect(createAlert).toHaveBeenCalledWith({ message: 'name taken' });
- expect(showsLoading()).toBe(false);
+ expect(showsLoading()).toBe(true);
+ });
+
+ it('submits the new environment on submit', async () => {
+ mock
+ .onPost(provide.projectEnvironmentsPath, {
+ name: newName,
+ external_url: newExternalUrl,
+ })
+ .reply(HTTP_STATUS_OK, { path: '/test' });
+
+ await submitForm();
+ await waitForPromises();
+
+ expect(visitUrl).toHaveBeenCalledWith('/test');
+ });
+
+ it('shows errors on error', async () => {
+ mock
+ .onPost(provide.projectEnvironmentsPath, {
+ name: newName,
+ external_url: newExternalUrl,
+ })
+ .reply(HTTP_STATUS_BAD_REQUEST, { message: ['name taken'] });
+
+ await submitForm();
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: 'name taken' });
+ expect(showsLoading()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/error_tracking/components/error_details_info_spec.js b/spec/frontend/error_tracking/components/error_details_info_spec.js
index 4a741a4c31e..a3f4b0e0dd8 100644
--- a/spec/frontend/error_tracking/components/error_details_info_spec.js
+++ b/spec/frontend/error_tracking/components/error_details_info_spec.js
@@ -40,43 +40,45 @@ describe('ErrorDetails', () => {
});
it('should render a card with error counts', () => {
- expect(wrapper.findByTestId('error-count-card').text()).toContain('Events 12');
+ expect(wrapper.findByTestId('error-count-card').text()).toMatchInterpolatedText('Events 12');
});
it('should render a card with user counts', () => {
- expect(wrapper.findByTestId('user-count-card').text()).toContain('Users 2');
+ expect(wrapper.findByTestId('user-count-card').text()).toMatchInterpolatedText('Users 2');
});
- describe('release links', () => {
- it('if firstReleaseVersion is missing, does not render a card', () => {
+ describe('first seen card', () => {
+ it('if firstSeen is missing, does not render a card', () => {
+ mountComponent({
+ firstSeen: undefined,
+ });
expect(wrapper.findByTestId('first-release-card').exists()).toBe(false);
});
- describe('if firstReleaseVersion link exists', () => {
- it('renders the first release card', () => {
- mountComponent({
- firstReleaseVersion: 'first-release-version',
- });
- const card = wrapper.findByTestId('first-release-card');
- expect(card.exists()).toBe(true);
- expect(card.text()).toContain('First seen');
- expect(card.findComponent(GlLink).exists()).toBe(true);
- expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true);
+ it('if firstSeen exists renders a card', () => {
+ mountComponent({
+ firstSeen: '2017-05-26T13:32:48Z',
});
+ const card = wrapper.findByTestId('first-release-card');
+ expect(card.exists()).toBe(true);
+ expect(card.text()).toContain('First seen');
+ expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true);
+ expect(card.findComponent(TimeAgoTooltip).props('time')).toBe('2017-05-26T13:32:48Z');
+ });
- it('renders a link to the commit if error is integrated', () => {
+ describe('if firstReleaseVersion link exists', () => {
+ it('shows the shortened release tag as text, if error is integrated', () => {
mountComponent({
- externalBaseUrl: 'external-base-url',
firstReleaseVersion: 'first-release-version',
firstSeen: '2023-04-20T17:02:06+00:00',
integrated: true,
});
- expect(
- wrapper.findByTestId('first-release-card').findComponent(GlLink).attributes('href'),
- ).toBe('external-base-url/-/commit/first-release-version');
+ const card = wrapper.findByTestId('first-release-card');
+ expect(card.text()).toMatchInterpolatedText('First seen first-rele');
+ expect(card.findComponent(GlLink).exists()).toBe(false);
});
- it('renders a link to the release if error is not integrated', () => {
+ it('renders a link to the release, if error is not integrated', () => {
mountComponent({
externalBaseUrl: 'external-base-url',
firstReleaseVersion: 'first-release-version',
@@ -88,36 +90,40 @@ describe('ErrorDetails', () => {
).toBe('external-base-url/releases/first-release-version');
});
});
+ });
- it('if lastReleaseVersion is missing, does not render a card', () => {
+ describe('last seen card', () => {
+ it('if lastSeen is missing, does not render a card', () => {
+ mountComponent({
+ lastSeen: undefined,
+ });
expect(wrapper.findByTestId('last-release-card').exists()).toBe(false);
});
- describe('if lastReleaseVersion link exists', () => {
- it('renders the last release card', () => {
- mountComponent({
- lastReleaseVersion: 'last-release-version',
- });
- const card = wrapper.findByTestId('last-release-card');
- expect(card.exists()).toBe(true);
- expect(card.text()).toContain('Last seen');
- expect(card.findComponent(GlLink).exists()).toBe(true);
- expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true);
+ it('if lastSeen exists renders a card', () => {
+ mountComponent({
+ lastSeen: '2017-05-26T13:32:48Z',
});
+ const card = wrapper.findByTestId('last-release-card');
+ expect(card.exists()).toBe(true);
+ expect(card.text()).toContain('Last seen');
+ expect(card.findComponent(TimeAgoTooltip).exists()).toBe(true);
+ expect(card.findComponent(TimeAgoTooltip).props('time')).toBe('2017-05-26T13:32:48Z');
+ });
- it('renders a link to the commit if error is integrated', () => {
+ describe('if lastReleaseVersion link exists', () => {
+ it('shows the shortened release tag as text, if error is integrated', () => {
mountComponent({
- externalBaseUrl: 'external-base-url',
lastReleaseVersion: 'last-release-version',
lastSeen: '2023-04-20T17:02:06+00:00',
integrated: true,
});
- expect(
- wrapper.findByTestId('last-release-card').findComponent(GlLink).attributes('href'),
- ).toBe('external-base-url/-/commit/last-release-version');
+ const card = wrapper.findByTestId('last-release-card');
+ expect(card.text()).toMatchInterpolatedText('Last seen last-relea');
+ expect(card.findComponent(GlLink).exists()).toBe(false);
});
- it('renders a link to the release if error is integrated', () => {
+ it('renders a link to the release, if error is not integrated', () => {
mountComponent({
externalBaseUrl: 'external-base-url',
lastReleaseVersion: 'last-release-version',
diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js
index 8700301ef73..c9238c4b636 100644
--- a/spec/frontend/error_tracking/components/error_details_spec.js
+++ b/spec/frontend/error_tracking/components/error_details_spec.js
@@ -14,16 +14,13 @@ import { severityLevel, severityLevelVariant, errorStatus } from '~/error_tracki
import ErrorDetails from '~/error_tracking/components/error_details.vue';
import Stacktrace from '~/error_tracking/components/stacktrace.vue';
import ErrorDetailsInfo from '~/error_tracking/components/error_details_info.vue';
-import {
- trackErrorDetailsViewsOptions,
- trackErrorStatusUpdateOptions,
- trackCreateIssueFromError,
-} from '~/error_tracking/events_tracking';
import { createAlert, VARIANT_WARNING } from '~/alert';
import { __ } from '~/locale';
import Tracking from '~/tracking';
+import TimelineChart from '~/error_tracking/components/timeline_chart.vue';
jest.mock('~/alert');
+jest.mock('~/tracking');
Vue.use(Vuex);
@@ -33,7 +30,6 @@ describe('ErrorDetails', () => {
let actions;
let getters;
let mocks;
- const externalUrl = 'https://sentry.io/organizations/test-sentry-nk/issues/1/?project=1';
const findInput = (name) => {
const inputs = wrapper
@@ -48,7 +44,7 @@ describe('ErrorDetails', () => {
wrapper.find('[data-testid="update-resolve-status-btn"]');
const findAlert = () => wrapper.findComponent(GlAlert);
- function mountComponent() {
+ function mountComponent({ integratedErrorTrackingEnabled = false } = {}) {
wrapper = shallowMount(ErrorDetails, {
stubs: { GlButton, GlSprintf },
store,
@@ -61,6 +57,7 @@ describe('ErrorDetails', () => {
issueStackTracePath: '/stacktrace',
projectIssuesPath: '/test-project/issues/',
csrfToken: 'fakeToken',
+ integratedErrorTrackingEnabled,
},
});
}
@@ -163,6 +160,7 @@ describe('ErrorDetails', () => {
mocks.$apollo.queries.error.loading = false;
mountComponent();
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -187,6 +185,7 @@ describe('ErrorDetails', () => {
beforeEach(() => {
store.state.details.loadingStacktrace = false;
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -208,6 +207,7 @@ describe('ErrorDetails', () => {
describe('Badges', () => {
it('should show language and error level badges', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -220,6 +220,7 @@ describe('ErrorDetails', () => {
it('should NOT show the badge if the tag is not present', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -234,6 +235,7 @@ describe('ErrorDetails', () => {
'should set correct severity level variant for %s badge',
async (level) => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -249,6 +251,7 @@ describe('ErrorDetails', () => {
it('should fallback for ERROR severityLevelVariant when severityLevel is unknown', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -272,6 +275,32 @@ describe('ErrorDetails', () => {
});
});
+ describe('timeline chart', () => {
+ it('should not show timeline chart if frequency data does not exist', () => {
+ expect(wrapper.findComponent(TimelineChart).exists()).toBe(false);
+ expect(wrapper.text()).not.toContain('Last 24 hours');
+ });
+
+ it('should show timeline chart', async () => {
+ const mockFrequency = [
+ [0, 1],
+ [2, 3],
+ ];
+ // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
+ // eslint-disable-next-line no-restricted-syntax
+ wrapper.setData({
+ error: {
+ frequency: mockFrequency,
+ },
+ });
+ await nextTick();
+ expect(wrapper.findComponent(TimelineChart).exists()).toBe(true);
+ expect(wrapper.findComponent(TimelineChart).props('timelineData')).toEqual(mockFrequency);
+ expect(wrapper.text()).toContain('Last 24 hours');
+ });
+ });
+
describe('Stacktrace', () => {
it('should show stacktrace', async () => {
store.state.details.loadingStacktrace = false;
@@ -406,6 +435,7 @@ describe('ErrorDetails', () => {
it('should show alert with closed issueId', async () => {
const closedIssueId = 123;
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
isAlertVisible: true,
@@ -428,6 +458,7 @@ describe('ErrorDetails', () => {
describe('is present', () => {
beforeEach(() => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -452,6 +483,7 @@ describe('ErrorDetails', () => {
describe('is not present', () => {
beforeEach(() => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
error: {
@@ -477,37 +509,56 @@ describe('ErrorDetails', () => {
describe('Snowplow tracking', () => {
beforeEach(() => {
- jest.spyOn(Tracking, 'event');
mocks.$apollo.queries.error.loading = false;
- mountComponent();
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- error: { externalUrl },
- });
});
- it('should track detail page views', () => {
- const { category, action } = trackErrorDetailsViewsOptions;
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
- });
+ describe.each([true, false])(`when integratedErrorTracking is %s`, (integrated) => {
+ const category = 'Error Tracking';
- it('should track IGNORE status update', async () => {
- await findUpdateIgnoreStatusButton().trigger('click');
- const { category, action } = trackErrorStatusUpdateOptions('ignored');
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
- });
+ beforeEach(() => {
+ mountComponent({ integratedErrorTrackingEnabled: integrated });
+ // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // TODO remove setData usage https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2216
+ // eslint-disable-next-line no-restricted-syntax
+ wrapper.setData({
+ error: {},
+ });
+ });
- it('should track RESOLVE status update', async () => {
- await findUpdateResolveStatusButton().trigger('click');
- const { category, action } = trackErrorStatusUpdateOptions('resolved');
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
- });
+ it('should track detail page views', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'view_error_details', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
+
+ it('should track IGNORE status update', async () => {
+ await findUpdateIgnoreStatusButton().trigger('click');
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'update_ignored_status', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
+
+ it('should track RESOLVE status update', async () => {
+ await findUpdateResolveStatusButton().trigger('click');
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'update_resolved_status', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
- it('should track create issue button click', async () => {
- await wrapper.find('[data-qa-selector="create_issue_button"]').vm.$emit('click');
- const { category, action } = trackCreateIssueFromError;
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
+ it('should track create issue button click', async () => {
+ await wrapper.find('[data-qa-selector="create_issue_button"]').vm.$emit('click');
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'click_create_issue_from_error', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
});
});
});
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
index 6d4e92cf91f..49f365e8c60 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -1,20 +1,24 @@
-import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination, GlDropdown } from '@gitlab/ui';
+import {
+ GlEmptyState,
+ GlLoadingIcon,
+ GlFormInput,
+ GlPagination,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import stubChildren from 'helpers/stub_children';
import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
-import {
- trackErrorListViewsOptions,
- trackErrorStatusUpdateOptions,
- trackErrorStatusFilterOptions,
- trackErrorSortedByField,
-} from '~/error_tracking/events_tracking';
+import TimelineChart from '~/error_tracking/components/timeline_chart.vue';
import Tracking from '~/tracking';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import errorsList from './list_mock.json';
+jest.mock('~/tracking');
+
Vue.use(Vuex);
describe('ErrorTrackingList', () => {
@@ -37,6 +41,7 @@ describe('ErrorTrackingList', () => {
errorTrackingEnabled = true,
userCanEnableErrorTracking = true,
showIntegratedTrackingDisabledAlert = false,
+ integratedErrorTrackingEnabled = false,
stubs = {},
} = {}) {
wrapper = extendedWrapper(
@@ -49,6 +54,7 @@ describe('ErrorTrackingList', () => {
enableErrorTrackingLink: '/link',
userCanEnableErrorTracking,
errorTrackingEnabled,
+ integratedErrorTrackingEnabled,
showIntegratedTrackingDisabledAlert,
illustrationPath: 'illustration/path',
},
@@ -122,8 +128,6 @@ describe('ErrorTrackingList', () => {
mountComponent({
stubs: {
GlTable: false,
- GlDropdown: false,
- GlDropdownItem: false,
GlLink: false,
},
});
@@ -155,6 +159,30 @@ describe('ErrorTrackingList', () => {
});
});
+ describe('timeline graph', () => {
+ it('should show the timeline chart', () => {
+ findErrorListRows().wrappers.forEach((row, index) => {
+ expect(row.findComponent(TimelineChart).exists()).toBe(true);
+ const mockFrequency = errorsList[index].frequency;
+ expect(row.findComponent(TimelineChart).props('timelineData')).toEqual(mockFrequency);
+ });
+ });
+
+ it('should not show the timeline chart if frequency data does not exist', () => {
+ store.state.list.errors = errorsList.map((e) => ({ ...e, frequency: undefined }));
+ mountComponent({
+ stubs: {
+ GlTable: false,
+ GlLink: false,
+ },
+ });
+
+ findErrorListRows().wrappers.forEach((row) => {
+ expect(row.findComponent(TimelineChart).exists()).toBe(false);
+ });
+ });
+ });
+
describe('filtering', () => {
const findSearchBox = () => wrapper.findComponent(GlFormInput);
@@ -170,14 +198,14 @@ describe('ErrorTrackingList', () => {
});
it('sorts by fields', () => {
- const findSortItem = () => findSortDropdown().find('.dropdown-item');
- findSortItem().trigger('click');
+ const findSortItem = () => findSortDropdown().findComponent(GlDropdownItem);
+ findSortItem().vm.$emit('click');
expect(actions.sortByField).toHaveBeenCalled();
});
it('filters by status', () => {
- const findStatusFilter = () => findStatusFilterDropdown().find('.dropdown-item');
- findStatusFilter().trigger('click');
+ const findStatusFilter = () => findStatusFilterDropdown().findComponent(GlDropdownItem);
+ findStatusFilter().vm.$emit('click');
expect(actions.filterByStatus).toHaveBeenCalled();
});
});
@@ -244,9 +272,7 @@ describe('ErrorTrackingList', () => {
describe('when alert is dismissed', () => {
it('hides the alert box', async () => {
- findIntegratedDisabledAlert().vm.$emit('dismiss');
-
- await nextTick();
+ await findIntegratedDisabledAlert().vm.$emit('dismiss');
expect(findIntegratedDisabledAlert().exists()).toBe(false);
});
@@ -367,19 +393,12 @@ describe('ErrorTrackingList', () => {
const emptyStatePrimaryDescription = emptyStateComponent.find('span', {
exactText: 'Monitor your errors directly in GitLab.',
});
- const emptyStateSecondaryDescription = emptyStateComponent.find('span', {
- exactText: 'Error tracking is currently in',
- });
const emptyStateLinks = emptyStateComponent.findAll('a');
expect(emptyStateComponent.isVisible()).toBe(true);
expect(emptyStatePrimaryDescription.exists()).toBe(true);
- expect(emptyStateSecondaryDescription.exists()).toBe(true);
expect(emptyStateLinks.at(0).attributes('href')).toBe(
'/help/operations/error_tracking.html#integrated-error-tracking',
);
- expect(emptyStateLinks.at(1).attributes('href')).toBe(
- 'https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta',
- );
});
});
@@ -522,49 +541,67 @@ describe('ErrorTrackingList', () => {
describe('Snowplow tracking', () => {
beforeEach(() => {
- jest.spyOn(Tracking, 'event');
store.state.list.loading = false;
store.state.list.errors = errorsList;
- mountComponent({
- stubs: {
- GlTable: false,
- GlLink: false,
- GlDropdown: false,
- GlDropdownItem: false,
- },
- });
});
- it('should track list views', () => {
- const { category, action } = trackErrorListViewsOptions;
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
- });
+ describe.each([true, false])(`when integratedErrorTracking is %s`, (integrated) => {
+ const category = 'Error Tracking';
- it('should track status updates', async () => {
- const status = 'ignored';
- findErrorActions().vm.$emit('update-issue-status', {
- errorId: 1,
- status,
+ beforeEach(() => {
+ mountComponent({
+ stubs: {
+ GlTable: false,
+ GlLink: false,
+ },
+ integratedErrorTrackingEnabled: integrated,
+ });
});
- await nextTick();
+ it('should track list views', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'view_errors_list', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
- const { category, action } = trackErrorStatusUpdateOptions(status);
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
- });
+ it('should track status updates', async () => {
+ const status = 'ignored';
+ findErrorActions().vm.$emit('update-issue-status', {
+ errorId: 1,
+ status,
+ });
+ await nextTick();
- it('should track error filter', () => {
- const findStatusFilter = () => findStatusFilterDropdown().find('.dropdown-item');
- findStatusFilter().trigger('click');
- const { category, action } = trackErrorStatusFilterOptions('unresolved');
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
- });
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'update_ignored_status', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
+
+ it('should track error filter', () => {
+ const findStatusFilter = () => findStatusFilterDropdown().findComponent(GlDropdownItem);
+ findStatusFilter().vm.$emit('click');
- it('should track error sorting', () => {
- const findSortItem = () => findSortDropdown().find('.dropdown-item');
- findSortItem().trigger('click');
- const { category, action } = trackErrorSortedByField('last_seen');
- expect(Tracking.event).toHaveBeenCalledWith(category, action);
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'filter_unresolved_status', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
+
+ it('should track error sorting', () => {
+ const findSortItem = () => findSortDropdown().findComponent(GlDropdownItem);
+ findSortItem().vm.$emit('click');
+
+ expect(Tracking.event).toHaveBeenCalledWith(category, 'sort_by_last_seen', {
+ extra: {
+ variant: integrated ? 'integrated' : 'external',
+ },
+ });
+ });
});
});
});
diff --git a/spec/frontend/error_tracking/components/list_mock.json b/spec/frontend/error_tracking/components/list_mock.json
index 54ae0a4c7cf..f8addef324e 100644
--- a/spec/frontend/error_tracking/components/list_mock.json
+++ b/spec/frontend/error_tracking/components/list_mock.json
@@ -7,7 +7,17 @@
"count": "52",
"firstSeen": "2019-05-30T07:21:46Z",
"lastSeen": "2019-11-06T03:21:39Z",
- "status": "unresolved"
+ "status": "unresolved",
+ "frequency": [
+ [
+ 0,
+ 1
+ ],
+ [
+ 1,
+ 2
+ ]
+ ]
},
{
"id": "2",
@@ -17,7 +27,17 @@
"count": "12",
"firstSeen": "2019-10-19T03:53:56Z",
"lastSeen": "2019-11-05T03:51:54Z",
- "status": "unresolved"
+ "status": "unresolved",
+ "frequency": [
+ [
+ 0,
+ 1
+ ],
+ [
+ 1,
+ 2
+ ]
+ ]
},
{
"id": "3",
@@ -27,6 +47,16 @@
"count": "275",
"firstSeen": "2019-02-12T07:22:36Z",
"lastSeen": "2019-10-22T03:20:48Z",
- "status": "unresolved"
+ "status": "unresolved",
+ "frequency": [
+ [
+ 0,
+ 1
+ ],
+ [
+ 1,
+ 2
+ ]
+ ]
}
-] \ No newline at end of file
+]
diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
index 45fc1ad04ff..9bb68c6f277 100644
--- a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
+++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js
@@ -1,4 +1,4 @@
-import { GlSprintf, GlIcon } from '@gitlab/ui';
+import { GlSprintf, GlIcon, GlTruncate } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue';
@@ -44,6 +44,21 @@ describe('Stacktrace Entry', () => {
expect(wrapper.findAll('.line_content.old').length).toBe(1);
});
+ it('should render file information if filePath exists', () => {
+ mountComponent({ lines });
+ expect(wrapper.findComponent(FileIcon).exists()).toBe(true);
+ expect(wrapper.findComponent(ClipboardButton).exists()).toBe(true);
+ expect(wrapper.findComponent(GlTruncate).exists()).toBe(true);
+ expect(wrapper.findComponent(GlTruncate).props('text')).toBe('sidekiq/util.rb');
+ });
+
+ it('should not render file information if filePath does not exists', () => {
+ mountComponent({ lines, filePath: undefined });
+ expect(wrapper.findComponent(FileIcon).exists()).toBe(false);
+ expect(wrapper.findComponent(ClipboardButton).exists()).toBe(false);
+ expect(wrapper.findComponent(GlTruncate).exists()).toBe(false);
+ });
+
describe('entry caption', () => {
const findFileHeaderContent = () => wrapper.find('.file-header-content').text();
diff --git a/spec/frontend/error_tracking/components/stacktrace_spec.js b/spec/frontend/error_tracking/components/stacktrace_spec.js
index 29301c3e5ee..75c631617c3 100644
--- a/spec/frontend/error_tracking/components/stacktrace_spec.js
+++ b/spec/frontend/error_tracking/components/stacktrace_spec.js
@@ -14,6 +14,8 @@ describe('ErrorDetails', () => {
[25, ' watchdog(name, \u0026block)\n'],
],
lineNo: 24,
+ function: 'fn',
+ colNo: 1,
};
function mountComponent(entries) {
@@ -27,13 +29,33 @@ describe('ErrorDetails', () => {
describe('Stacktrace', () => {
it('should render single Stacktrace entry', () => {
mountComponent([stackTraceEntry]);
- expect(wrapper.findAllComponents(StackTraceEntry).length).toBe(1);
+ const allEntries = wrapper.findAllComponents(StackTraceEntry);
+ expect(allEntries.length).toBe(1);
+ const entry = allEntries.at(0);
+ expect(entry.props()).toEqual({
+ lines: stackTraceEntry.context,
+ filePath: stackTraceEntry.filename,
+ errorLine: stackTraceEntry.lineNo,
+ errorFn: stackTraceEntry.function,
+ errorColumn: stackTraceEntry.colNo,
+ expanded: true,
+ });
});
it('should render multiple Stacktrace entry', () => {
const entriesNum = 3;
mountComponent(new Array(entriesNum).fill(stackTraceEntry));
- expect(wrapper.findAllComponents(StackTraceEntry).length).toBe(entriesNum);
+ const entries = wrapper.findAllComponents(StackTraceEntry);
+ expect(entries.length).toBe(entriesNum);
+ expect(entries.at(0).props('expanded')).toBe(true);
+ expect(entries.at(1).props('expanded')).toBe(false);
+ expect(entries.at(2).props('expanded')).toBe(false);
+ });
+
+ it('should use the entry abs_path if filename is missing', () => {
+ mountComponent([{ ...stackTraceEntry, filename: undefined, abs_path: 'abs_path' }]);
+
+ expect(wrapper.findComponent(StackTraceEntry).props('filePath')).toBe('abs_path');
});
});
});
diff --git a/spec/frontend/error_tracking/components/timeline_chart_spec.js b/spec/frontend/error_tracking/components/timeline_chart_spec.js
new file mode 100644
index 00000000000..f864d11804c
--- /dev/null
+++ b/spec/frontend/error_tracking/components/timeline_chart_spec.js
@@ -0,0 +1,94 @@
+import { GlChart } from '@gitlab/ui/dist/charts';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import TimelineChart from '~/error_tracking/components/timeline_chart.vue';
+
+const MOCK_HEIGHT = 123;
+
+describe('TimelineChart', () => {
+ let wrapper;
+
+ function mountComponent(timelineData = []) {
+ wrapper = shallowMountExtended(TimelineChart, {
+ stubs: { GlChart: true },
+ propsData: {
+ timelineData: [...timelineData],
+ height: MOCK_HEIGHT,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('renders the component', () => {
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ it('does not render a chart if timelineData is missing', () => {
+ wrapper = shallowMountExtended(TimelineChart, {
+ stubs: { GlChart: true },
+ propsData: {
+ timelineData: undefined,
+ height: MOCK_HEIGHT,
+ },
+ });
+ expect(wrapper.findComponent(GlChart).exists()).toBe(false);
+ });
+
+ it('renders a gl-chart', () => {
+ expect(wrapper.findComponent(GlChart).exists()).toBe(true);
+ expect(wrapper.findComponent(GlChart).props('height')).toBe(MOCK_HEIGHT);
+ });
+
+ describe('timeline-data', () => {
+ describe.each([
+ {
+ mockItems: [
+ [1686218400, 1],
+ [1686222000, 2],
+ ],
+ expectedX: ['Jun 8, 2023 10:00am UTC', 'Jun 8, 2023 11:00am UTC'],
+ expectedY: [1, 2],
+ description: 'tuples with dates as timestamps in seconds',
+ },
+ {
+ mockItems: [
+ ['06-05-2023', 1],
+ ['06-06-2023', 2],
+ ],
+ expectedX: ['Jun 5, 2023 12:00am UTC', 'Jun 6, 2023 12:00am UTC'],
+ expectedY: [1, 2],
+ description: 'tuples with non-numeric dates',
+ },
+ {
+ mockItems: [
+ { time: 1686218400, count: 1 },
+ { time: 1686222000, count: 2 },
+ ],
+ expectedX: ['Jun 8, 2023 10:00am UTC', 'Jun 8, 2023 11:00am UTC'],
+ expectedY: [1, 2],
+ description: 'objects with dates as timestamps in seconds',
+ },
+ {
+ mockItems: [
+ { time: '06-05-2023', count: 1 },
+ { time: '06-06-2023', count: 2 },
+ ],
+ expectedX: ['Jun 5, 2023 12:00am UTC', 'Jun 6, 2023 12:00am UTC'],
+ expectedY: [1, 2],
+ description: 'objects with non-numeric dates',
+ },
+ ])('when timeline-data items are $description', ({ mockItems, expectedX, expectedY }) => {
+ it(`renders the chart correctly`, () => {
+ mountComponent(mockItems);
+
+ const chartOptions = wrapper.findComponent(GlChart).props('options');
+ const xData = chartOptions.xAxis.data;
+ const yData = chartOptions.series[0].data;
+ expect(xData).toEqual(expectedX);
+ expect(yData).toEqual(expectedY);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js
index 96b9434f3ec..133796df3e4 100644
--- a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
@@ -24,11 +24,10 @@ describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => {
propsData: { ...DEFAULT_PROPS, ...props },
});
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findGlListboxItem = () => wrapper.findAllComponents(GlListboxItem).at(0);
describe('with user lists', () => {
- const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
-
beforeEach(() => {
Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
wrapper = factory();
@@ -37,22 +36,19 @@ describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => {
it('should show the input for userListId with the correct value', () => {
const dropdownWrapper = findDropdown();
expect(dropdownWrapper.exists()).toBe(true);
- expect(dropdownWrapper.props('text')).toBe(userList.name);
+ expect(dropdownWrapper.props('toggleText')).toBe(userList.name);
});
it('should show a check for the selected list', () => {
- const itemWrapper = findDropdownItem();
- expect(itemWrapper.props('isChecked')).toBe(true);
+ expect(findGlListboxItem().props('isSelected')).toBe(true);
});
it('should display the name of the list in the drop;down', () => {
- const itemWrapper = findDropdownItem();
- expect(itemWrapper.text()).toBe(userList.name);
+ expect(findGlListboxItem().text()).toBe(userList.name);
});
it('should emit a change event when altering the userListId', () => {
- const inputWrapper = findDropdownItem();
- inputWrapper.vm.$emit('click');
+ findDropdown().vm.$emit('select', userList.id);
expect(wrapper.emitted('change')).toEqual([
[
{
@@ -63,25 +59,19 @@ describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => {
});
it('should search when the filter changes', async () => {
+ findDropdown().vm.$emit('search', 'new');
let r;
Api.searchFeatureFlagUserLists.mockReturnValue(
new Promise((resolve) => {
r = resolve;
}),
);
- const searchWrapper = wrapper.findComponent(GlSearchBoxByType);
- searchWrapper.vm.$emit('input', 'new');
- await nextTick();
- const loadingIcon = wrapper.findComponent(GlLoadingIcon);
- expect(loadingIcon.exists()).toBe(true);
expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'new');
r({ data: [userList] });
await nextTick();
-
- expect(loadingIcon.exists()).toBe(false);
});
});
diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb
index b6f6d149756..a1896a6470b 100644
--- a/spec/frontend/fixtures/merge_requests.rb
+++ b/spec/frontend/fixtures/merge_requests.rb
@@ -114,6 +114,10 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type:
let(:group) { create(:group) }
let(:description) { "@#{group.full_path} @all @#{user.username}" }
+ before do
+ stub_feature_flags(disable_all_mention: false)
+ end
+
it 'merge_requests/merge_request_with_mentions.html' do
render_merge_request(merge_request)
end
diff --git a/spec/frontend/fixtures/pipeline_details.rb b/spec/frontend/fixtures/pipeline_details.rb
new file mode 100644
index 00000000000..af9b11b0841
--- /dev/null
+++ b/spec/frontend/fixtures/pipeline_details.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "GraphQL Pipeline details", '(JavaScript fixtures)', type: :request, feature_category: :pipeline_composition do
+ include ApiHelpers
+ include GraphqlHelpers
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:admin) { project.first_owner }
+ let_it_be(:commit) { create(:commit, project: project) }
+ let_it_be(:pipeline) do
+ create(:ci_pipeline, project: project, sha: commit.id, ref: 'master', user: admin, status: :success)
+ end
+
+ let_it_be(:build_success) do
+ create(:ci_build, :dependent, name: 'build_my_app', pipeline: pipeline, stage: 'build', status: :success)
+ end
+
+ let_it_be(:build_test) { create(:ci_build, :dependent, name: 'test_my_app', pipeline: pipeline, stage: 'test') }
+ let_it_be(:build_deploy_failed) do
+ create(:ci_build, :dependent, name: 'deploy_my_app', status: :failed, pipeline: pipeline, stage: 'deploy')
+ end
+
+ let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline) }
+
+ let(:pipeline_details_query_path) { 'app/graphql/queries/pipelines/get_pipeline_details.query.graphql' }
+
+ it "pipelines/pipeline_details.json" do
+ query = get_graphql_query_as_string(pipeline_details_query_path, with_base_path: false)
+
+ post_graphql(query, current_user: admin, variables: { projectPath: project.full_path, iid: pipeline.iid })
+
+ expect_graphql_errors_to_be_empty
+ end
+end
diff --git a/spec/frontend/fixtures/pipeline_header.rb b/spec/frontend/fixtures/pipeline_header.rb
new file mode 100644
index 00000000000..3fdc45b1194
--- /dev/null
+++ b/spec/frontend/fixtures/pipeline_header.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :request, feature_category: :pipeline_composition do
+ include ApiHelpers
+ include GraphqlHelpers
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { project.first_owner }
+ let_it_be(:commit) { create(:commit, project: project) }
+
+ let(:query_path) { 'pipelines/graphql/queries/get_pipeline_header_data.query.graphql' }
+
+ context 'with successful pipeline' do
+ let_it_be(:pipeline) do
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: commit.id,
+ ref: 'master',
+ user: user,
+ status: :success,
+ duration: 7210,
+ created_at: 2.hours.ago,
+ started_at: 1.hour.ago,
+ finished_at: Time.current
+ )
+ end
+
+ it "graphql/pipelines/pipeline_header_success.json" do
+ query = get_graphql_query_as_string(query_path)
+
+ post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'with running pipeline' do
+ let_it_be(:pipeline) do
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: commit.id,
+ ref: 'master',
+ user: user,
+ status: :running,
+ created_at: 2.hours.ago,
+ started_at: 1.hour.ago
+ )
+ end
+
+ let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline, ref: 'master') }
+
+ it "graphql/pipelines/pipeline_header_running.json" do
+ query = get_graphql_query_as_string(query_path)
+
+ post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'with running pipeline and duration' do
+ let_it_be(:pipeline) do
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: commit.id,
+ ref: 'master',
+ user: user,
+ status: :running,
+ duration: 7210,
+ created_at: 2.hours.ago,
+ started_at: 1.hour.ago
+ )
+ end
+
+ let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline, ref: 'master') }
+
+ it "graphql/pipelines/pipeline_header_running_with_duration.json" do
+ query = get_graphql_query_as_string(query_path)
+
+ post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'with failed pipeline' do
+ let_it_be(:pipeline) do
+ create(
+ :ci_pipeline,
+ project: project,
+ sha: commit.id,
+ ref: 'master',
+ user: user,
+ status: :failed,
+ duration: 7210,
+ started_at: 1.hour.ago,
+ finished_at: Time.current
+ )
+ end
+
+ let_it_be(:build) { create(:ci_build, :canceled, pipeline: pipeline, ref: 'master') }
+
+ it "graphql/pipelines/pipeline_header_failed.json" do
+ query = get_graphql_query_as_string(query_path)
+
+ post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+end
diff --git a/spec/frontend/fixtures/project.rb b/spec/frontend/fixtures/project.rb
new file mode 100644
index 00000000000..6100248d0a5
--- /dev/null
+++ b/spec/frontend/fixtures/project.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Project (GraphQL fixtures)', feature_category: :groups_and_projects do
+ describe GraphQL::Query, type: :request do
+ include ApiHelpers
+ include GraphqlHelpers
+ include JavaScriptFixturesHelpers
+ include ProjectForksHelper
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:current_user) { create(:user) }
+
+ describe 'writable forks' do
+ writeable_forks_query_path = 'vue_shared/components/web_ide/get_writable_forks.query.graphql'
+
+ let(:query) { get_graphql_query_as_string(writeable_forks_query_path) }
+
+ subject { post_graphql(query, current_user: current_user, variables: { projectPath: project.full_path }) }
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'with none' do
+ it "graphql/#{writeable_forks_query_path}_none.json" do
+ subject
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'with some' do
+ let_it_be(:fork1) { fork_project(project, nil, repository: true) }
+ let_it_be(:fork2) { fork_project(project, nil, repository: true) }
+
+ before_all do
+ fork1.add_developer(current_user)
+ fork2.add_developer(current_user)
+ end
+
+ it "graphql/#{writeable_forks_query_path}_some.json" do
+ subject
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+ end
+ end
+end
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index 099df607487..a73a0dcbdd1 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -14,6 +14,9 @@ RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet d
let_it_be(:project_2) { create(:project, :repository, :public) }
let_it_be(:runner) { create(:ci_runner, :instance, description: 'My Runner', creator: admin, version: '1.0.0') }
+ let_it_be(:runner_manager_1) { create(:ci_runner_machine, runner: runner, contacted_at: Time.current) }
+ let_it_be(:runner_manager_2) { create(:ci_runner_machine, runner: runner, contacted_at: Time.current) }
+
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], version: '2.0.0') }
let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], version: '2.0.0') }
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], version: '2.0.0') }
@@ -137,6 +140,22 @@ RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet d
end
end
+ describe 'runner_managers.query.graphql', type: :request do
+ runner_managers_query = 'show/runner_managers.query.graphql'
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("#{query_path}#{runner_managers_query}")
+ end
+
+ it "#{fixtures_path}#{runner_managers_query}.json" do
+ post_graphql(query, current_user: admin, variables: {
+ runner_id: runner.to_global_id.to_s
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
describe 'runner_form.query.graphql', type: :request do
runner_jobs_query = 'edit/runner_form.query.graphql'
diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb
index 5b09e1c9495..83e02470321 100644
--- a/spec/frontend/fixtures/startup_css.rb
+++ b/spec/frontend/fixtures/startup_css.rb
@@ -40,11 +40,8 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
expect(response).to be_successful
end
- # This Feature Flag is off by default
# This ensures that the correct css is generated for super sidebar
- # When the feature flag is off, the general startup will capture it
it "startup_css/project-#{type}-super-sidebar.html" do
- stub_feature_flags(super_sidebar_nav: true)
user.update!(use_new_navigation: true)
get :show, params: {
diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html
index 3b4dbdf7d36..bc8a27c779f 100644
--- a/spec/frontend/fixtures/static/whats_new_notification.html
+++ b/spec/frontend/fixtures/static/whats_new_notification.html
@@ -1,5 +1,6 @@
<div class='whats-new-notification-fixture-root'>
<div class='app' data-version-digest='version-digest'></div>
+ <div data-testid='without-digest'></div>
<div class='header-help'>
<div class='js-whats-new-notification-count'></div>
</div>
diff --git a/spec/frontend/fixtures/users.rb b/spec/frontend/fixtures/users.rb
index 0e9d7475bf9..89bffea7e4c 100644
--- a/spec/frontend/fixtures/users.rb
+++ b/spec/frontend/fixtures/users.rb
@@ -2,18 +2,47 @@
require 'spec_helper'
-RSpec.describe 'Users (GraphQL fixtures)', feature_category: :user_profile do
+RSpec.describe 'Users (JavaScript fixtures)', feature_category: :user_profile do
+ include JavaScriptFixturesHelpers
+ include ApiHelpers
+
+ let_it_be(:followers) { create_list(:user, 5) }
+ let_it_be(:user) { create(:user, followers: followers) }
+
+ describe API::Users, '(JavaScript fixtures)', type: :request do
+ it 'api/users/followers/get.json' do
+ get api("/users/#{user.id}/followers", user)
+
+ expect(response).to be_successful
+ end
+ end
+
+ describe UsersController, '(JavaScript fixtures)', type: :controller do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project_empty_repo, group: group) }
+
+ include_context 'with user contribution events'
+
+ before do
+ group.add_owner(user)
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ it 'controller/users/activity.json' do
+ get :activity, params: { username: user.username, limit: 50 }, format: :json
+
+ expect(response).to be_successful
+ end
+ end
+
describe GraphQL::Query, type: :request do
- include ApiHelpers
include GraphqlHelpers
- include JavaScriptFixturesHelpers
-
- let_it_be(:user) { create(:user) }
context 'for user achievements' do
let_it_be(:group) { create(:group, :public) }
let_it_be(:private_group) { create(:group, :private) }
- let_it_be(:achievement1) { create(:achievement, namespace: group) }
+ let_it_be(:achievement1) { create(:achievement, namespace: group, name: 'Multiple') }
let_it_be(:achievement2) { create(:achievement, namespace: group) }
let_it_be(:achievement3) { create(:achievement, namespace: group) }
let_it_be(:achievement_from_private_group) { create(:achievement, namespace: private_group) }
@@ -65,6 +94,7 @@ RSpec.describe 'Users (GraphQL fixtures)', feature_category: :user_profile do
[achievement1, achievement2, achievement3, achievement_with_avatar_and_description].each do |achievement|
create(:user_achievement, user: user, achievement: achievement)
end
+ create(:user_achievement, user: user, achievement: achievement1)
post_graphql(query, current_user: user, variables: { id: user.to_global_id })
diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
deleted file mode 100644
index 9447e7daba8..00000000000
--- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
+++ /dev/null
@@ -1,110 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`grafana integration component default state to match the default snapshot 1`] = `
-<section
- class="settings no-animate js-grafana-integration"
- id="grafana"
->
- <div
- class="settings-header"
- >
- <h4
- class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
- >
-
- Grafana authentication
-
- </h4>
-
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- class="js-settings-toggle"
- icon=""
- size="medium"
- variant="default"
- >
- Expand
- </gl-button-stub>
-
- <p
- class="js-section-sub-header"
- >
-
- Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.
-
- <gl-link-stub>
- Learn more.
- </gl-link-stub>
- </p>
- </div>
-
- <div
- class="settings-content"
- >
- <form>
- <gl-form-group-stub
- label="Enable authentication"
- label-for="grafana-integration-enabled"
- labeldescription=""
- optionaltext="(optional)"
- >
- <gl-form-checkbox-stub
- id="grafana-integration-enabled"
- >
-
- Active
-
- </gl-form-checkbox-stub>
- </gl-form-group-stub>
-
- <gl-form-group-stub
- description="Enter the base URL of the Grafana instance."
- label="Grafana URL"
- label-for="grafana-url"
- labeldescription=""
- optionaltext="(optional)"
- >
- <gl-form-input-stub
- id="grafana-url"
- placeholder="https://my-grafana.example.com/"
- value="http://test.host"
- />
- </gl-form-group-stub>
-
- <gl-form-group-stub
- label="API token"
- label-for="grafana-token"
- labeldescription=""
- optionaltext="(optional)"
- >
- <gl-form-input-stub
- id="grafana-token"
- value="someToken"
- />
-
- <p
- class="form-text text-muted"
- >
- <gl-sprintf-stub
- message="Enter the %{docLinkStart}Grafana API token%{docLinkEnd}."
- />
- </p>
- </gl-form-group-stub>
-
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- data-testid="save-grafana-settings-button"
- icon=""
- size="medium"
- variant="confirm"
- >
-
- Save changes
-
- </gl-button-stub>
- </form>
- </div>
-</section>
-`;
diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js
deleted file mode 100644
index 540fc597aa9..00000000000
--- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { TEST_HOST } from 'helpers/test_constants';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { createAlert } from '~/alert';
-import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue';
-import { createStore } from '~/grafana_integration/store';
-import axios from '~/lib/utils/axios_utils';
-import { refreshCurrentPage } from '~/lib/utils/url_utility';
-
-jest.mock('~/lib/utils/url_utility');
-jest.mock('~/alert');
-
-describe('grafana integration component', () => {
- let wrapper;
- let store;
- const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`;
- const grafanaIntegrationUrl = `${TEST_HOST}`;
- const grafanaIntegrationToken = 'someToken';
-
- beforeEach(() => {
- store = createStore({
- operationsSettingsEndpoint,
- grafanaIntegrationUrl,
- grafanaIntegrationToken,
- });
- });
-
- afterEach(() => {
- createAlert.mockReset();
- refreshCurrentPage.mockReset();
- });
-
- describe('default state', () => {
- it('to match the default snapshot', () => {
- wrapper = shallowMount(GrafanaIntegration, { store });
-
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- it('renders header text', () => {
- wrapper = shallowMount(GrafanaIntegration, { store });
-
- expect(wrapper.find('.js-section-header').text()).toBe('Grafana authentication');
- });
-
- describe('expand/collapse button', () => {
- it('renders as an expand button by default', () => {
- wrapper = shallowMount(GrafanaIntegration, { store });
-
- const button = wrapper.findComponent(GlButton);
- expect(button.text()).toBe('Expand');
- });
- });
-
- describe('sub-header', () => {
- it('renders descriptive text', () => {
- wrapper = shallowMount(GrafanaIntegration, { store });
-
- expect(wrapper.find('.js-section-sub-header').text()).toContain(
- 'Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.\n Learn more.',
- );
- });
- });
-
- describe('form', () => {
- beforeEach(() => {
- jest.spyOn(axios, 'patch').mockImplementation();
- wrapper = mountExtended(GrafanaIntegration, { store });
- });
-
- afterEach(() => {
- axios.patch.mockReset();
- });
-
- describe('submit button', () => {
- const findSubmitButton = () => wrapper.findByTestId('save-grafana-settings-button');
-
- const endpointRequest = [
- operationsSettingsEndpoint,
- {
- project: {
- grafana_integration_attributes: {
- grafana_url: grafanaIntegrationUrl,
- token: grafanaIntegrationToken,
- enabled: false,
- },
- },
- },
- ];
-
- it('submits form on click', async () => {
- axios.patch.mockResolvedValue();
- findSubmitButton(wrapper).trigger('click');
-
- expect(axios.patch).toHaveBeenCalledWith(...endpointRequest);
- await nextTick();
- expect(refreshCurrentPage).toHaveBeenCalled();
- });
-
- it('creates alert banner on error', async () => {
- const message = 'mockErrorMessage';
- axios.patch.mockRejectedValue({ response: { data: { message } } });
-
- findSubmitButton().trigger('click');
-
- expect(axios.patch).toHaveBeenCalledWith(...endpointRequest);
-
- await nextTick();
- await jest.runAllTicks();
- expect(createAlert).toHaveBeenCalledWith({
- message: `There was an error saving your changes. ${message}`,
- });
- });
- });
- });
-});
diff --git a/spec/frontend/grafana_integration/store/mutations_spec.js b/spec/frontend/grafana_integration/store/mutations_spec.js
deleted file mode 100644
index 18e87394189..00000000000
--- a/spec/frontend/grafana_integration/store/mutations_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import mutations from '~/grafana_integration/store/mutations';
-import createState from '~/grafana_integration/store/state';
-
-describe('grafana integration mutations', () => {
- let localState;
-
- beforeEach(() => {
- localState = createState();
- });
-
- describe('SET_GRAFANA_URL', () => {
- it('sets grafanaUrl', () => {
- const mockUrl = 'mockUrl';
- mutations.SET_GRAFANA_URL(localState, mockUrl);
-
- expect(localState.grafanaUrl).toBe(mockUrl);
- });
- });
-
- describe('SET_GRAFANA_TOKEN', () => {
- it('sets grafanaToken', () => {
- const mockToken = 'mockToken';
- mutations.SET_GRAFANA_TOKEN(localState, mockToken);
-
- expect(localState.grafanaToken).toBe(mockToken);
- });
- });
- describe('SET_GRAFANA_ENABLED', () => {
- it('updates grafanaEnabled for integration', () => {
- mutations.SET_GRAFANA_ENABLED(localState, true);
-
- expect(localState.grafanaEnabled).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
index 7b42e50fee5..b474745790e 100644
--- a/spec/frontend/groups/components/app_spec.js
+++ b/spec/frontend/groups/components/app_spec.js
@@ -6,7 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import appComponent from '~/groups/components/app.vue';
import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
+import groupItemComponent from 'jh_else_ce/groups/components/group_item.vue';
import eventHub from '~/groups/event_hub';
import GroupsService from '~/groups/service/groups_service';
import GroupsStore from '~/groups/store/groups_store';
@@ -42,7 +42,7 @@ describe('AppComponent', () => {
let mock;
let getGroupsSpy;
- const store = new GroupsStore({ hideProjects: false });
+ const store = new GroupsStore({});
const service = new GroupsService(mockEndpoint);
const createShallowComponent = ({ propsData = {} } = {}) => {
@@ -51,7 +51,6 @@ describe('AppComponent', () => {
propsData: {
store,
service,
- hideProjects: false,
containerId: 'js-groups-tree',
...propsData,
},
diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js
index da31fb02f69..b274c01a43b 100644
--- a/spec/frontend/groups/components/group_folder_spec.js
+++ b/spec/frontend/groups/components/group_folder_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import GroupFolder from '~/groups/components/group_folder.vue';
-import GroupItem from '~/groups/components/group_item.vue';
+import GroupItem from 'jh_else_ce/groups/components/group_item.vue';
import { MAX_CHILDREN_COUNT } from '~/groups/constants';
import { mockGroups, mockParentGroupItem } from '../mock_data';
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
index 663dd341a58..94460de9dd6 100644
--- a/spec/frontend/groups/components/group_item_spec.js
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -1,7 +1,7 @@
import { GlPopover } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import GroupFolder from '~/groups/components/group_folder.vue';
-import GroupItem from '~/groups/components/group_item.vue';
+import GroupItem from 'jh_else_ce/groups/components/group_item.vue';
import ItemActions from '~/groups/components/item_actions.vue';
import eventHub from '~/groups/event_hub';
import { getGroupItemMicrodata } from '~/groups/store/utils';
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
index c04eaa501ba..3cdbd3e38be 100644
--- a/spec/frontend/groups/components/groups_spec.js
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -3,7 +3,7 @@ import { GlEmptyState } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import GroupFolderComponent from '~/groups/components/group_folder.vue';
-import GroupItemComponent from '~/groups/components/group_item.vue';
+import GroupItemComponent from 'jh_else_ce/groups/components/group_item.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import GroupsComponent from '~/groups/components/groups.vue';
import eventHub from '~/groups/event_hub';
diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js
index 101dd06d578..ca852f398d0 100644
--- a/spec/frontend/groups/components/overview_tabs_spec.js
+++ b/spec/frontend/groups/components/overview_tabs_spec.js
@@ -93,7 +93,6 @@ describe('OverviewTabs', () => {
action: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
store: new GroupsStore({ showSchemaMarkup: true }),
service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]),
- hideProjects: false,
});
await waitForPromises();
@@ -117,7 +116,6 @@ describe('OverviewTabs', () => {
action: ACTIVE_TAB_SHARED,
store: new GroupsStore(),
service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SHARED]),
- hideProjects: false,
});
expect(tabPanel.vm.$attrs.lazy).toBe(false);
@@ -143,7 +141,6 @@ describe('OverviewTabs', () => {
action: ACTIVE_TAB_ARCHIVED,
store: new GroupsStore(),
service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_ARCHIVED]),
- hideProjects: false,
});
expect(tabPanel.vm.$attrs.lazy).toBe(false);
diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js
index 9ccc6919b81..baf3c6f08b2 100644
--- a/spec/frontend/header_search/init_spec.js
+++ b/spec/frontend/header_search/init_spec.js
@@ -8,7 +8,7 @@ describe('Header Search EventListener', () => {
jest.restoreAllMocks();
setHTMLFixture(`
<div class="js-header-content">
- <div class="header-search" id="js-header-search" data-autocomplete-path="/search/autocomplete" data-issues-path="/dashboard/issues" data-mr-path="/dashboard/merge_requests" data-search-context="{}" data-search-path="/search">
+ <div class="header-search-form" id="js-header-search" data-autocomplete-path="/search/autocomplete" data-issues-path="/dashboard/issues" data-mr-path="/dashboard/merge_requests" data-search-context="{}" data-search-path="/search">
<input autocomplete="off" class="form-control gl-form-input gl-search-box-by-type-input" data-qa-selector="search_box" id="search" name="search" placeholder="Search GitLab" type="text">
</div>
</div>`);
diff --git a/spec/frontend/ide/stores/modules/file_templates/getters_spec.js b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js
index e237b167f96..02e0d55346e 100644
--- a/spec/frontend/ide/stores/modules/file_templates/getters_spec.js
+++ b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js
@@ -5,7 +5,7 @@ import createState from '~/ide/stores/state';
describe('IDE file templates getters', () => {
describe('templateTypes', () => {
it('returns list of template types', () => {
- expect(getters.templateTypes().length).toBe(5);
+ expect(getters.templateTypes().length).toBe(4);
});
});
diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js
index 4c6fee35389..103a3e4ddd1 100644
--- a/spec/frontend/import_entities/components/import_status_spec.js
+++ b/spec/frontend/import_entities/components/import_status_spec.js
@@ -1,5 +1,7 @@
import { GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { __, s__ } from '~/locale';
+
import ImportStatus from '~/import_entities/components/import_status.vue';
import { STATUSES } from '~/import_entities/constants';
@@ -25,7 +27,7 @@ describe('Import entities status component', () => {
createComponent({
status: STATUSES.FINISHED,
});
- expect(getStatusText()).toBe('Complete');
+ expect(getStatusText()).toBe(__('Complete'));
});
it('displays finished status as complete when all stats items were processed', () => {
@@ -37,7 +39,7 @@ describe('Import entities status component', () => {
},
});
- expect(getStatusText()).toBe('Complete');
+ expect(getStatusText()).toBe(__('Complete'));
expect(getStatusIcon()).toBe('status-success');
});
@@ -50,7 +52,7 @@ describe('Import entities status component', () => {
},
});
- expect(getStatusText()).toBe('Partially completed');
+ expect(getStatusText()).toBe(s__('Import|Partially completed'));
expect(getStatusIcon()).toBe('status-alert');
});
});
diff --git a/spec/frontend/integrations/edit/components/jira_auth_fields_spec.js b/spec/frontend/integrations/edit/components/jira_auth_fields_spec.js
new file mode 100644
index 00000000000..dcae2ceeeaa
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/jira_auth_fields_spec.js
@@ -0,0 +1,142 @@
+import { GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import JiraAuthFields from '~/integrations/edit/components/jira_auth_fields.vue';
+import { jiraAuthTypeFieldProps } from '~/integrations/constants';
+import { createStore } from '~/integrations/edit/store';
+
+import { mockJiraAuthFields } from '../mock_data';
+
+describe('JiraAuthFields', () => {
+ let wrapper;
+
+ const defaultProps = {
+ fields: mockJiraAuthFields,
+ };
+
+ const createComponent = ({ props } = {}) => {
+ const store = createStore();
+
+ wrapper = shallowMountExtended(JiraAuthFields, {
+ propsData: { ...defaultProps, ...props },
+ store,
+ });
+ };
+
+ const findAuthTypeRadio = () => wrapper.findComponent(GlFormRadioGroup);
+ const findAuthTypeOptions = () => wrapper.findAllComponents(GlFormRadio);
+ const findUsernameField = () => wrapper.findByTestId('jira-auth-username');
+ const findPasswordField = () => wrapper.findByTestId('jira-auth-password');
+
+ const selectRadioOption = (index) => findAuthTypeRadio().vm.$emit('input', index);
+
+ describe('template', () => {
+ const mockFieldsWithPasswordValue = [
+ mockJiraAuthFields[0],
+ mockJiraAuthFields[1],
+ {
+ ...mockJiraAuthFields[2],
+ value: 'hidden',
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders auth type as radio buttons with correct options', () => {
+ expect(findAuthTypeRadio().exists()).toBe(true);
+
+ findAuthTypeOptions().wrappers.forEach((option, index) => {
+ expect(option.text()).toBe(JiraAuthFields.authTypeOptions[index].text);
+ });
+ });
+
+ it('selects "Basic" authentication by default', () => {
+ expect(findAuthTypeRadio().attributes('checked')).toBe('0');
+ });
+
+ it('selects correct authentication when passed from backend', async () => {
+ createComponent({
+ props: {
+ fields: [
+ {
+ ...mockJiraAuthFields[0],
+ value: 1,
+ },
+ mockJiraAuthFields[1],
+ mockJiraAuthFields[2],
+ ],
+ },
+ });
+ await nextTick();
+
+ expect(findAuthTypeRadio().attributes('checked')).toBe('1');
+ });
+
+ describe('when "Basic" authentication is selected', () => {
+ it('renders username field as required', () => {
+ expect(findUsernameField().exists()).toBe(true);
+ expect(findUsernameField().props()).toMatchObject({
+ title: jiraAuthTypeFieldProps[0].username,
+ required: true,
+ });
+ });
+
+ it('renders password field with help', () => {
+ expect(findPasswordField().exists()).toBe(true);
+ expect(findPasswordField().props()).toMatchObject({
+ title: jiraAuthTypeFieldProps[0].password,
+ help: jiraAuthTypeFieldProps[0].passwordHelp,
+ });
+ });
+
+ it('renders new password title when value is present', () => {
+ createComponent({
+ props: {
+ fields: mockFieldsWithPasswordValue,
+ },
+ });
+
+ expect(findPasswordField().props('title')).toBe(jiraAuthTypeFieldProps[0].nonEmptyPassword);
+ });
+ });
+
+ describe('when "Jira personal access token" authentication is selected', () => {
+ beforeEach(() => {
+ createComponent();
+
+ selectRadioOption(1);
+ });
+
+ it('selects "Jira personal access token" authentication', () => {
+ expect(findAuthTypeRadio().attributes('checked')).toBe('1');
+ });
+
+ it('does not render username field', () => {
+ expect(findUsernameField().exists()).toBe(false);
+ });
+
+ it('renders password field without help', () => {
+ expect(findPasswordField().exists()).toBe(true);
+ expect(findPasswordField().props()).toMatchObject({
+ title: jiraAuthTypeFieldProps[1].password,
+ help: null,
+ });
+ });
+
+ it('renders new password title when value is present', async () => {
+ createComponent({
+ props: {
+ fields: mockFieldsWithPasswordValue,
+ },
+ });
+
+ await selectRadioOption(1);
+
+ expect(findPasswordField().props('title')).toBe(jiraAuthTypeFieldProps[1].nonEmptyPassword);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
index 2d1a6b3ace1..a528816971a 100644
--- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js
+++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlLink } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
@@ -27,14 +27,14 @@ describe('OverrideDropdown', () => {
};
const findGlLink = () => wrapper.findComponent(GlLink);
- const findGlDropdown = () => wrapper.findComponent(GlDropdown);
+ const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox);
describe('template', () => {
describe('override prop is true', () => {
it('renders GlToggle as disabled', () => {
createComponent();
- expect(findGlDropdown().props('text')).toBe('Use custom settings');
+ expect(findGlCollapsibleListbox().props('toggleText')).toBe('Use custom settings');
});
});
@@ -42,7 +42,7 @@ describe('OverrideDropdown', () => {
it('renders GlToggle as disabled', () => {
createComponent({ override: false });
- expect(findGlDropdown().props('text')).toBe('Use default settings');
+ expect(findGlCollapsibleListbox().props('toggleText')).toBe('Use default settings');
});
});
diff --git a/spec/frontend/integrations/edit/components/sections/connection_spec.js b/spec/frontend/integrations/edit/components/sections/connection_spec.js
index a24253d542d..7bd08a15ec1 100644
--- a/spec/frontend/integrations/edit/components/sections/connection_spec.js
+++ b/spec/frontend/integrations/edit/components/sections/connection_spec.js
@@ -1,15 +1,21 @@
import { shallowMount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
+import JiraAuthFields from '~/integrations/edit/components/jira_auth_fields.vue';
import { createStore } from '~/integrations/edit/store';
-import { mockIntegrationProps } from '../../mock_data';
+import { mockIntegrationProps, mockJiraAuthFields, mockField } from '../../mock_data';
describe('IntegrationSectionConnection', () => {
let wrapper;
+ const JiraAuthFieldsStub = stubComponent(JiraAuthFields, {
+ template: `<div />`,
+ });
+
const createComponent = ({ customStateProps = {}, props = {} } = {}) => {
const store = createStore({
customState: { ...mockIntegrationProps, ...customStateProps },
@@ -17,11 +23,15 @@ describe('IntegrationSectionConnection', () => {
wrapper = shallowMount(IntegrationSectionConnection, {
propsData: { ...props },
store,
+ stubs: {
+ JiraAuthFields: JiraAuthFieldsStub,
+ },
});
};
const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox);
const findAllDynamicFields = () => wrapper.findAllComponents(DynamicField);
+ const findJiraAuthFields = () => wrapper.findComponent(JiraAuthFields);
describe('template', () => {
describe('ActiveCheckbox', () => {
@@ -63,11 +73,42 @@ describe('IntegrationSectionConnection', () => {
});
});
- it('does not render DynamicField when field is empty', () => {
+ it('does not render DynamicField when fields is empty', () => {
createComponent();
expect(findAllDynamicFields()).toHaveLength(0);
});
});
+
+ describe('when integration is not Jira', () => {
+ it('does not render JiraAuthFields', () => {
+ createComponent();
+
+ expect(findJiraAuthFields().exists()).toBe(false);
+ });
+ });
+
+ describe('when integration is Jira', () => {
+ beforeEach(() => {
+ createComponent({
+ customStateProps: {
+ type: 'jira',
+ },
+ props: {
+ fields: [mockField, ...mockJiraAuthFields],
+ },
+ });
+ });
+
+ it('renders JiraAuthFields', () => {
+ expect(findJiraAuthFields().exists()).toBe(true);
+ expect(findJiraAuthFields().props('fields')).toEqual(mockJiraAuthFields);
+ });
+
+ it('filters out Jira auth fields for DynamicField', () => {
+ expect(findAllDynamicFields()).toHaveLength(1);
+ expect(findAllDynamicFields().at(0).props('name')).toBe(mockField.name);
+ });
+ });
});
});
diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js
index c276d2e7364..31526eddd36 100644
--- a/spec/frontend/integrations/edit/mock_data.js
+++ b/spec/frontend/integrations/edit/mock_data.js
@@ -26,6 +26,24 @@ export const mockJiraIssueTypes = [
{ id: '3', name: 'epic', description: 'epic' },
];
+export const mockJiraAuthFields = [
+ {
+ name: 'jira_auth_type',
+ type: 'select',
+ title: 'Authentication type',
+ },
+ {
+ name: 'username',
+ type: 'text',
+ help: 'Email for Jira Cloud or username for Jira Data Center and Jira Server',
+ },
+ {
+ name: 'password',
+ type: 'password',
+ help: 'API token for Jira Cloud or password for Jira Data Center and Jira Server',
+ },
+];
+
export const mockField = {
help: 'The URL of the project',
name: 'project_url',
diff --git a/spec/frontend/integrations/gitlab_slack_application/components/gitlab_slack_application_spec.js b/spec/frontend/integrations/gitlab_slack_application/components/gitlab_slack_application_spec.js
new file mode 100644
index 00000000000..64b3b47d741
--- /dev/null
+++ b/spec/frontend/integrations/gitlab_slack_application/components/gitlab_slack_application_spec.js
@@ -0,0 +1,105 @@
+import { GlButton, GlLink } from '@gitlab/ui';
+
+import { nextTick } from 'vue';
+import GitlabSlackApplication from '~/integrations/gitlab_slack_application/components/gitlab_slack_application.vue';
+import { addProjectToSlack } from '~/integrations/gitlab_slack_application/api';
+import { i18n } from '~/integrations/gitlab_slack_application/constants';
+import ProjectsDropdown from '~/integrations/gitlab_slack_application/components/projects_dropdown.vue';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
+import { mockProjects } from '../mock_data';
+
+jest.mock('~/integrations/gitlab_slack_application/api');
+jest.mock('~/lib/utils/url_utility');
+
+describe('GitlabSlackApplication', () => {
+ let wrapper;
+
+ const defaultProps = {
+ projects: [],
+ gitlabForSlackGifPath: '//gitlabForSlackGifPath',
+ signInPath: '//signInPath',
+ slackLinkPath: '//slackLinkPath',
+ docsPath: '//docsPath',
+ gitlabLogoPath: '//gitlabLogoPath',
+ slackLogoPath: '//slackLogoPath',
+ isSignedIn: true,
+ };
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(GitlabSlackApplication, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ const findGlButton = () => wrapper.findComponent(GlButton);
+ const findGlLink = () => wrapper.findComponent(GlLink);
+ const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdown);
+ const findAppContent = () => wrapper.findByTestId('gitlab-slack-content');
+
+ describe('template', () => {
+ describe('when user is not signed in', () => {
+ it('renders "Sign in" button', () => {
+ createComponent({
+ props: { isSignedIn: false },
+ });
+
+ expect(findGlButton().attributes('href')).toBe(defaultProps.signInPath);
+ });
+ });
+
+ describe('when user is signed in', () => {
+ describe('user does not have any projects', () => {
+ it('renders empty text', () => {
+ createComponent();
+
+ expect(findAppContent().text()).toContain(i18n.noProjects);
+ expect(findAppContent().text()).toContain(i18n.noProjectsDescription);
+ });
+
+ it('renders "Learn more" link', () => {
+ createComponent();
+
+ expect(findGlLink().text()).toBe(i18n.learnMore);
+ });
+ });
+
+ describe('user has projects', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ projects: mockProjects,
+ },
+ });
+ });
+
+ it('renders ProjectsDropdown', () => {
+ expect(findProjectsDropdown().props('projects')).toBe(mockProjects);
+ });
+
+ it('redirects to slackLinkPath when submitted', async () => {
+ const redirectLink = '//redirectLink';
+ const mockProject = mockProjects[1];
+ const addToSlackData = { data: { add_to_slack_link: redirectLink } };
+
+ addProjectToSlack.mockResolvedValue(addToSlackData);
+
+ findProjectsDropdown().vm.$emit('project-selected', mockProject);
+ await nextTick();
+
+ expect(findProjectsDropdown().props('selectedProject')).toBe(mockProject);
+ expect(findGlButton().props('disabled')).toBe(false);
+
+ findGlButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(redirectTo).toHaveBeenCalledWith(redirectLink); // eslint-disable-line import/no-deprecated
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/gitlab_slack_application/mock_data.js b/spec/frontend/integrations/gitlab_slack_application/mock_data.js
new file mode 100644
index 00000000000..9ada528d69e
--- /dev/null
+++ b/spec/frontend/integrations/gitlab_slack_application/mock_data.js
@@ -0,0 +1,14 @@
+export const mockProjects = [
+ {
+ id: 1,
+ name: 'Test',
+ avatar_url: 'avatar.jpg',
+ name_with_namespace: 'Test org / Test',
+ },
+ {
+ id: 2,
+ name: 'Shell',
+ avatar_url: 'avatar.jpg',
+ name_with_namespace: 'Test org / Shell',
+ },
+];
diff --git a/spec/frontend/invite_members/components/import_project_members_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
index 73634855850..224ebe18e2a 100644
--- a/spec/frontend/invite_members/components/import_project_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
@@ -6,19 +6,28 @@ import { BV_HIDE_MODAL } from '~/lib/utils/constants';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import * as ProjectsApi from '~/api/projects_api';
+import eventHub from '~/invite_members/event_hub';
import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
import ProjectSelect from '~/invite_members/components/project_select.vue';
import axios from '~/lib/utils/axios_utils';
+
import {
displaySuccessfulInvitationAlert,
reloadOnInvitationSuccess,
} from '~/invite_members/utils/trigger_successful_invite_alert';
+import {
+ IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY,
+ IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL,
+} from '~/invite_members/constants';
+
jest.mock('~/invite_members/utils/trigger_successful_invite_alert');
let wrapper;
let mock;
+let trackingSpy;
const projectId = '1';
const projectName = 'test name';
@@ -27,6 +36,18 @@ const $toast = {
show: jest.fn(),
};
+const expectTracking = (action) =>
+ expect(trackingSpy).toHaveBeenCalledWith(IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY, action, {
+ label: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_LABEL,
+ category: IMPORT_PROJECT_MEMBERS_MODAL_TRACKING_CATEGORY,
+ property: undefined,
+ });
+
+const triggerOpenModal = async () => {
+ eventHub.$emit('openProjectMembersModal');
+ await nextTick();
+};
+
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMountExtended(ImportProjectMembersModal, {
propsData: {
@@ -48,6 +69,8 @@ const createComponent = ({ props = {} } = {}) => {
$toast,
},
});
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
};
beforeEach(() => {
@@ -57,6 +80,7 @@ beforeEach(() => {
afterEach(() => {
mock.restore();
+ unmockTracking();
});
describe('ImportProjectMembersModal', () => {
@@ -106,6 +130,24 @@ describe('ImportProjectMembersModal', () => {
expect(findGlModal().props('actionPrimary').attributes.loading).toBe(true);
});
+
+ it('tracks render', async () => {
+ await triggerOpenModal();
+
+ expectTracking('render');
+ });
+
+ it('tracks cancel', () => {
+ findGlModal().vm.$emit('cancel');
+
+ expectTracking('click_cancel');
+ });
+
+ it('tracks close', () => {
+ findGlModal().vm.$emit('close');
+
+ expectTracking('click_x');
+ });
});
describe('submitting the import', () => {
@@ -145,6 +187,10 @@ describe('ImportProjectMembersModal', () => {
wrapper.vm.$options.toastOptions,
);
});
+
+ it('tracks successful import', () => {
+ expectTracking('invite_successful');
+ });
});
describe('when the import is successful', () => {
@@ -189,6 +235,10 @@ describe('ImportProjectMembersModal', () => {
it('sets isLoading to false after success', () => {
expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false);
});
+
+ it('tracks successful import', () => {
+ expectTracking('invite_successful');
+ });
});
describe('when the import fails', () => {
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index e080e665a3b..1a9b0fae52a 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -63,6 +63,7 @@ describe('InviteMembersModal', () => {
let wrapper;
let mock;
let trackingSpy;
+ const showToast = jest.fn();
const expectTracking = (action, label = undefined, property = undefined) =>
expect(trackingSpy).toHaveBeenCalledWith(INVITE_MEMBER_MODAL_TRACKING_CATEGORY, action, {
@@ -94,6 +95,11 @@ describe('InviteMembersModal', () => {
GlEmoji,
...stubs,
},
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
});
};
@@ -470,7 +476,6 @@ describe('InviteMembersModal', () => {
createComponent({ reloadPageOnSubmit: true });
await triggerMembersTokenSelect([user1, user2]);
- wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
clickInviteButton();
});
@@ -484,7 +489,7 @@ describe('InviteMembersModal', () => {
});
it('does not show the toast message', () => {
- expect(wrapper.vm.$toast.show).not.toHaveBeenCalled();
+ expect(showToast).not.toHaveBeenCalled();
});
});
@@ -493,7 +498,6 @@ describe('InviteMembersModal', () => {
createComponent();
await triggerMembersTokenSelect([user1, user2]);
- wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
});
@@ -507,7 +511,7 @@ describe('InviteMembersModal', () => {
});
it('displays the successful toastMessage', () => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
+ expect(showToast).toHaveBeenCalledWith('Members were successfully added');
});
it('does not call displaySuccessfulInvitationAlert on mount', () => {
@@ -630,7 +634,6 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user3]);
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: emailPostData });
});
@@ -644,7 +647,7 @@ describe('InviteMembersModal', () => {
});
it('displays the successful toastMessage', () => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
+ expect(showToast).toHaveBeenCalledWith('Members were successfully added');
});
it('does not call displaySuccessfulInvitationAlert on mount', () => {
@@ -858,7 +861,6 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user1, user3]);
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: singleUserPostData });
});
@@ -877,7 +879,7 @@ describe('InviteMembersModal', () => {
});
it('displays the successful toastMessage', () => {
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
+ expect(showToast).toHaveBeenCalledWith('Members were successfully added');
});
it('does not call displaySuccessfulInvitationAlert on mount', () => {
diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js
index c7e9905dee3..ff0313cc49e 100644
--- a/spec/frontend/invite_members/components/members_token_select_spec.js
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -130,6 +130,18 @@ describe('MembersTokenSelect', () => {
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
+ it('calls the API with search parameter with whitespaces and is trimmed', async () => {
+ tokenSelector.vm.$emit('text-input', ' foo@bar.com ');
+
+ await waitForPromises();
+
+ expect(UserApi.getUsers).toHaveBeenCalledWith('foo@bar.com', {
+ active: true,
+ without_project_bots: true,
+ });
+ expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
+ });
+
describe('when input text is an email', () => {
it('allows user defined tokens', async () => {
tokenSelector.vm.$emit('text-input', 'foo@bar.com');
diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
index 0e2f71fa3ee..4b4deafcabd 100644
--- a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
+++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
@@ -32,9 +32,9 @@ describe('CsvImportExportButtons', () => {
});
}
- const findExportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Export as CSV' });
- const findImportCsvButton = () => wrapper.findByRole('menuitem', { name: 'Import CSV' });
- const findImportFromJiraLink = () => wrapper.findByRole('menuitem', { name: 'Import from Jira' });
+ const findExportCsvButton = () => wrapper.findByTestId('export-as-csv-button');
+ const findImportCsvButton = () => wrapper.findByTestId('import-from-csv-button');
+ const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link');
const findExportCsvModal = () => wrapper.findComponent(CsvExportModal);
const findImportCsvModal = () => wrapper.findComponent(CsvImportModal);
@@ -111,7 +111,7 @@ describe('CsvImportExportButtons', () => {
});
it('passes the proper path to the link', () => {
- expect(findImportFromJiraLink().attributes('href')).toBe(projectImportJiraPath);
+ expect(findImportFromJiraLink().props('item').href).toBe(projectImportJiraPath);
});
});
diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
index ff772040d22..34f36bdf6cb 100644
--- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js
+++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
@@ -1,15 +1,13 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { createStore as createMrStore } from '~/mr_notes/stores';
+import mrStore from '~/mr_notes/stores';
import createIssueStore from '~/notes/stores';
import IssuableHeaderWarnings from '~/issuable/components/issuable_header_warnings.vue';
const ISSUABLE_TYPE_ISSUE = 'issue';
const ISSUABLE_TYPE_MR = 'merge_request';
-Vue.use(Vuex);
+jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
describe('IssuableHeaderWarnings', () => {
let wrapper;
@@ -22,7 +20,9 @@ describe('IssuableHeaderWarnings', () => {
const createComponent = ({ store, provide }) => {
wrapper = shallowMountExtended(IssuableHeaderWarnings, {
- store,
+ mocks: {
+ $store: store,
+ },
provide,
directives: {
GlTooltip: createMockDirective('gl-tooltip'),
@@ -47,9 +47,14 @@ describe('IssuableHeaderWarnings', () => {
`(
`when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`,
({ lockStatus, confidentialStatus, hiddenStatus }) => {
- const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore();
+ const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : mrStore;
beforeEach(() => {
+ // TODO: simplify to single assignment after issue store is mock
+ if (store === mrStore) {
+ store.getters.getNoteableData = {};
+ }
+
store.getters.getNoteableData.confidential = confidentialStatus;
store.getters.getNoteableData.discussion_locked = lockStatus;
store.getters.getNoteableData.targetType = issuableType;
@@ -58,7 +63,16 @@ describe('IssuableHeaderWarnings', () => {
});
it(`${renderTestMessage(lockStatus)} the locked icon`, () => {
- expect(findLockedIcon().exists()).toBe(lockStatus);
+ const lockedIcon = findLockedIcon();
+
+ expect(lockedIcon.exists()).toBe(lockStatus);
+
+ if (lockStatus) {
+ expect(lockedIcon.attributes('title')).toBe(
+ `This ${issuableType.replace('_', ' ')} is locked. Only project members can comment.`,
+ );
+ expect(getBinding(lockedIcon.element, 'gl-tooltip')).not.toBeUndefined();
+ }
});
it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => {
diff --git a/spec/frontend/issues/dashboard/mock_data.js b/spec/frontend/issues/dashboard/mock_data.js
index e789360d1d5..adcd4268449 100644
--- a/spec/frontend/issues/dashboard/mock_data.js
+++ b/spec/frontend/issues/dashboard/mock_data.js
@@ -3,6 +3,7 @@ export const issuesQueryResponse = {
issues: {
nodes: [
{
+ __persist: true,
__typename: 'Issue',
id: 'gid://gitlab/Issue/123456',
iid: '789',
@@ -27,6 +28,7 @@ export const issuesQueryResponse = {
assignees: {
nodes: [
{
+ __persist: true,
__typename: 'UserCore',
id: 'gid://gitlab/User/234',
avatarUrl: 'avatar/url',
@@ -37,6 +39,7 @@ export const issuesQueryResponse = {
],
},
author: {
+ __persist: true,
__typename: 'UserCore',
id: 'gid://gitlab/User/456',
avatarUrl: 'avatar/url',
@@ -47,6 +50,7 @@ export const issuesQueryResponse = {
labels: {
nodes: [
{
+ __persist: true,
id: 'gid://gitlab/ProjectLabel/456',
color: '#333',
title: 'Label title',
diff --git a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
index 4ea3a39f15b..a61e7ed1e86 100644
--- a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
+++ b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlEmptyState, GlLink } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue';
@@ -26,7 +26,7 @@ describe('EmptyStateWithoutAnyIssues component', () => {
};
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
- const findCsvImportExportDropdown = () => wrapper.findComponent(GlDropdown);
+ const findCsvImportExportDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuesHelpPageLink = () =>
@@ -136,7 +136,7 @@ describe('EmptyStateWithoutAnyIssues component', () => {
it('renders', () => {
mountComponent({ props: { showCsvButtons: true } });
- expect(findCsvImportExportDropdown().props('text')).toBe('Import issues');
+ expect(findCsvImportExportDropdown().props('toggleText')).toBe('Import issues');
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: defaultProps.exportCsvPathWithQuery,
issuableCount: 0,
diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js
index af24b547545..0e87e5e6595 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -1,4 +1,4 @@
-import { GlButton, GlDropdown } from '@gitlab/ui';
+import { GlButton, GlDisclosureDropdown } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
@@ -11,13 +11,14 @@ import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_coun
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import {
+ filteredTokens,
getIssuesCountsQueryResponse,
- getIssuesQueryResponse,
getIssuesQueryEmptyResponse,
- filteredTokens,
+ getIssuesQueryResponse,
locationSearch,
setSortPreferenceMutationResponse,
setSortPreferenceMutationResponseWithErrors,
@@ -34,6 +35,7 @@ import { issuableListTabs } from '~/vue_shared/issuable/list/constants';
import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue';
import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue';
import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import {
CREATED_DESC,
@@ -127,16 +129,18 @@ describe('CE IssuesListApp component', () => {
const mockIssuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse);
const mockIssuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse);
- const findCalendarButton = () =>
- wrapper.findByRole('menuitem', { name: IssuesListApp.i18n.calendarLabel });
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
const findGlButton = () => wrapper.findComponent(GlButton);
const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findIssuableList = () => wrapper.findComponent(IssuableList);
+ const findListViewTypeBtn = () => wrapper.findByTestId('list-view-type');
+ const findGridtViewTypeBtn = () => wrapper.findByTestId('grid-view-type');
+ const findViewTypeLocalStorageSync = () => wrapper.findAllComponents(LocalStorageSync).at(0);
const findNewResourceDropdown = () => wrapper.findComponent(NewResourceDropdown);
- const findRssButton = () => wrapper.findByRole('menuitem', { name: IssuesListApp.i18n.rssLabel });
+ const findCalendarButton = () => wrapper.findByTestId('subscribe-calendar');
+ const findRssButton = () => wrapper.findByTestId('subscribe-rss');
const findLabelsToken = () =>
findIssuableList()
@@ -233,6 +237,7 @@ describe('CE IssuesListApp component', () => {
hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage,
hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage,
});
+ expect(findIssuableList().props('isGridView')).toBe(false);
});
});
@@ -244,7 +249,7 @@ describe('CE IssuesListApp component', () => {
expect(findDropdown().props()).toMatchObject({
category: 'tertiary',
icon: 'ellipsis_v',
- text: 'Actions',
+ toggleText: 'Actions',
textSrOnly: true,
});
});
@@ -354,6 +359,37 @@ describe('CE IssuesListApp component', () => {
});
});
+ describe('header action buttons with the grid view enabled', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ mountFn: shallowMountExtended,
+ provide: {
+ glFeatures: {
+ issuesGridView: true,
+ },
+ },
+ stubs: {
+ IssuableList: stubComponent(IssuableList, {
+ template: `<div><slot name="nav-actions" /></div>`,
+ }),
+ },
+ });
+ });
+
+ it('switch between list and grid', async () => {
+ findGridtViewTypeBtn().vm.$emit('click');
+ await nextTick();
+
+ expect(findIssuableList().props('isGridView')).toBe(true);
+ expect(findViewTypeLocalStorageSync().props('value')).toBe('Grid');
+
+ findListViewTypeBtn().vm.$emit('click');
+ await nextTick();
+ expect(findIssuableList().props('isGridView')).toBe(false);
+ expect(findViewTypeLocalStorageSync().props('value')).toBe('List');
+ });
+ });
+
describe('initial url params', () => {
describe('page', () => {
it('page_after is set from the url params', () => {
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index bd006a6b3ce..b9a8bc171db 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -154,6 +154,22 @@ export const setSortPreferenceMutationResponseWithErrors = {
},
};
+export const setIdTypePreferenceMutationResponse = {
+ data: {
+ userPreferencesUpdate: {
+ errors: [],
+ },
+ },
+};
+
+export const setIdTypePreferenceMutationResponseWithErrors = {
+ data: {
+ userPreferencesUpdate: {
+ errors: ['oh no!'],
+ },
+ },
+};
+
export const locationSearch = [
'?search=find+issues',
'author_username=homer',
diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js
index 83707dfd254..ecca3e69ef6 100644
--- a/spec/frontend/issues/show/components/app_spec.js
+++ b/spec/frontend/issues/show/components/app_spec.js
@@ -326,12 +326,14 @@ describe('Issuable output', () => {
describe('when title is in view', () => {
it('is not shown', () => {
+ wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear');
expect(findStickyHeader().exists()).toBe(false);
});
});
describe('when title is not in view', () => {
beforeEach(() => {
+ global.pageYOffset = 100;
wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear');
});
@@ -395,7 +397,16 @@ describe('Issuable output', () => {
`('$title', async ({ isLocked }) => {
await wrapper.setProps({ isLocked });
- expect(findLockedBadge().exists()).toBe(isLocked);
+ const lockedBadge = findLockedBadge();
+
+ expect(lockedBadge.exists()).toBe(isLocked);
+
+ if (isLocked) {
+ expect(lockedBadge.attributes('title')).toBe(
+ 'This issue is locked. Only project members can comment.',
+ );
+ expect(getBinding(lockedBadge.element, 'gl-tooltip')).not.toBeUndefined();
+ }
});
it.each`
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 9a0cde15b24..93860aaa925 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -10,6 +10,7 @@ import Description from '~/issues/show/components/description.vue';
import eventHub from '~/issues/show/event_hub';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import workItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import TaskList from '~/task_list';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import {
@@ -17,6 +18,7 @@ import {
createWorkItemMutationResponse,
getIssueDetailsResponse,
projectWorkItemTypesQueryResponse,
+ workItemByIidResponseFactory,
} from 'jest/work_items/mock_data';
import {
descriptionProps as initialProps,
@@ -52,9 +54,23 @@ describe('Description component', () => {
issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse),
createWorkItemMutationHandler,
} = {}) {
+ const mockApollo = createMockApollo([
+ [workItemTypesQuery, workItemTypesQueryHandler],
+ [getIssueDetailsQuery, issueDetailsQueryHandler],
+ [createWorkItemMutation, createWorkItemMutationHandler],
+ ]);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: workItemByIidQuery,
+ variables: { fullPath: 'gitlab-org/gitlab-test', iid: '1' },
+ data: workItemByIidResponseFactory().data,
+ });
+
wrapper = shallowMountExtended(Description, {
+ apolloProvider: mockApollo,
propsData: {
issueId: 1,
+ issueIid: 1,
...initialProps,
...props,
},
@@ -63,11 +79,6 @@ describe('Description component', () => {
hasIterationsFeature: true,
...provide,
},
- apolloProvider: createMockApollo([
- [workItemTypesQuery, workItemTypesQueryHandler],
- [getIssueDetailsQuery, issueDetailsQueryHandler],
- [createWorkItemMutation, createWorkItemMutationHandler],
- ]),
mocks: {
$toast,
},
diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js
index a5ba512434c..9a503a2d882 100644
--- a/spec/frontend/issues/show/components/header_actions_spec.js
+++ b/spec/frontend/issues/show/components/header_actions_spec.js
@@ -103,7 +103,8 @@ describe('HeaderActions component', () => {
},
};
- const findToggleIssueStateButton = () => wrapper.find(`[data-testid="toggle-button"]`);
+ const findToggleIssueStateButton = () =>
+ wrapper.find(`[data-testid="toggle-issue-state-button"]`);
const findEditButton = () => wrapper.find(`[data-testid="edit-button"]`);
const findDropdownBy = (dataTestId) => wrapper.find(`[data-testid="${dataTestId}"]`);
@@ -134,6 +135,7 @@ describe('HeaderActions component', () => {
.mockResolvedValue(promoteToEpicMutationErrorResponse);
const mountComponent = ({
+ isLoggedIn = true,
props = {},
issueState = STATUS_OPEN,
blockedByIssues = [],
@@ -151,6 +153,10 @@ describe('HeaderActions component', () => {
[promoteToEpicMutation, promoteToEpicHandler],
];
+ if (isLoggedIn) {
+ window.gon.current_user_id = 1;
+ }
+
return shallowMount(HeaderActions, {
apolloProvider: createMockApollo(handlers),
store,
@@ -648,4 +654,40 @@ describe('HeaderActions component', () => {
});
});
});
+
+ describe('when logged out', () => {
+ describe.each`
+ movedMrSidebarEnabled | issueType | headerActionsVisible
+ ${true} | ${TYPE_ISSUE} | ${true}
+ ${true} | ${TYPE_INCIDENT} | ${true}
+ ${false} | ${TYPE_ISSUE} | ${false}
+ ${false} | ${TYPE_INCIDENT} | ${false}
+ `(
+ `with movedMrSidebarEnabled flag is "$movedMrSidebarEnabled" with issue type "$issueType"`,
+ ({ movedMrSidebarEnabled, issueType, headerActionsVisible }) => {
+ beforeEach(async () => {
+ wrapper = mountComponent({
+ props: {
+ issueType,
+ canCreateIssue: false,
+ canPromoteToEpic: false,
+ canReportSpam: false,
+ },
+ movedMrSidebarEnabled,
+ isLoggedIn: false,
+ });
+
+ await waitForPromises();
+ });
+
+ it(`${headerActionsVisible ? 'shows' : 'hides'} headers actions`, () => {
+ expect(findDesktopDropdown().exists()).toBe(headerActionsVisible);
+ expect(findCopyRefenceDropdownItem().exists()).toBe(headerActionsVisible);
+ expect(findNotificationWidget().exists()).toBe(false);
+ expect(findReportAbuseSelectorItem().exists()).toBe(false);
+ expect(findLockIssueWidget().exists()).toBe(false);
+ });
+ },
+ );
+ });
});
diff --git a/spec/frontend/issues/show/components/task_list_item_actions_spec.js b/spec/frontend/issues/show/components/task_list_item_actions_spec.js
index 7dacbefaeff..0b3ff0667b1 100644
--- a/spec/frontend/issues/show/components/task_list_item_actions_spec.js
+++ b/spec/frontend/issues/show/components/task_list_item_actions_spec.js
@@ -6,7 +6,7 @@ import eventHub from '~/issues/show/event_hub';
describe('TaskListItemActions component', () => {
let wrapper;
- const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findConvertToTaskItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(0);
const findDeleteItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(1);
@@ -20,7 +20,6 @@ describe('TaskListItemActions component', () => {
provide: { canUpdate: true },
attachTo: document.querySelector('div'),
});
- wrapper.vm.$refs.dropdown.close = jest.fn();
};
beforeEach(() => {
@@ -28,7 +27,7 @@ describe('TaskListItemActions component', () => {
});
it('renders dropdown', () => {
- expect(findGlDropdown().props()).toMatchObject({
+ expect(findGlDisclosureDropdown().props()).toMatchObject({
category: 'tertiary',
icon: 'ellipsis_v',
placement: 'right',
diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js
index 9d5bc8dff2a..845ada187ef 100644
--- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_spec.js
@@ -77,7 +77,7 @@ describe('GroupsList', () => {
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findGlAlert().exists()).toBe(true);
- expect(findGlAlert().text()).toBe('Failed to load namespaces. Please try again.');
+ expect(findGlAlert().text()).toBe('Failed to load groups. Please try again.');
});
});
@@ -89,7 +89,7 @@ describe('GroupsList', () => {
await waitForPromises();
expect(findGlLoadingIcon().exists()).toBe(false);
- expect(wrapper.text()).toContain('No available namespaces');
+ expect(wrapper.text()).toContain('No groups found');
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
index d262f4b2735..4819a870a27 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/subscriptions_page_spec.js
@@ -44,9 +44,7 @@ describe('SubscriptionsPage', () => {
});
});
- it(`${
- subscriptionsLoading ? 'does not render' : 'renders'
- } button to add namespace`, () => {
+ it(`${subscriptionsLoading ? 'does not render' : 'renders'} button to add group`, () => {
expect(findAddNamespaceButton().exists()).toBe(!subscriptionsLoading);
});
diff --git a/spec/frontend/jobs/components/job/manual_variables_form_spec.js b/spec/frontend/jobs/components/job/manual_variables_form_spec.js
index a48155d93ac..989fe5c11e9 100644
--- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js
+++ b/spec/frontend/jobs/components/job/manual_variables_form_spec.js
@@ -13,6 +13,8 @@ import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line imp
import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue';
import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql';
import playJobMutation from '~/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql';
+import retryJobMutation from '~/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql';
+
import {
mockFullPath,
mockId,
@@ -38,9 +40,32 @@ const defaultProvide = {
describe('Manual Variables Form', () => {
let wrapper;
let mockApollo;
- let getJobQueryResponse;
+ let requestHandlers;
+
+ const getJobQueryResponseHandlerWithVariables = jest.fn().mockResolvedValue(mockJobResponse);
+ const playJobMutationHandler = jest.fn().mockResolvedValue({});
+ const retryJobMutationHandler = jest.fn().mockResolvedValue({});
+
+ const defaultHandlers = {
+ getJobQueryResponseHandlerWithVariables,
+ playJobMutationHandler,
+ retryJobMutationHandler,
+ };
+
+ const createComponent = ({ props = {}, handlers = defaultHandlers } = {}) => {
+ requestHandlers = handlers;
+
+ mockApollo = createMockApollo([
+ [getJobQuery, handlers.getJobQueryResponseHandlerWithVariables],
+ [playJobMutation, handlers.playJobMutationHandler],
+ [retryJobMutation, handlers.retryJobMutationHandler],
+ ]);
+
+ const options = {
+ localVue,
+ apolloProvider: mockApollo,
+ };
- const createComponent = ({ options = {}, props = {} } = {}) => {
wrapper = mountExtended(ManualVariablesForm, {
propsData: {
jobId: mockId,
@@ -52,22 +77,6 @@ describe('Manual Variables Form', () => {
},
...options,
});
- };
-
- const createComponentWithApollo = ({ props = {} } = {}) => {
- const requestHandlers = [[getJobQuery, getJobQueryResponse]];
-
- mockApollo = createMockApollo(requestHandlers);
-
- const options = {
- localVue,
- apolloProvider: mockApollo,
- };
-
- createComponent({
- props,
- options,
- });
return waitForPromises();
};
@@ -96,18 +105,13 @@ describe('Manual Variables Form', () => {
nextTick();
};
- beforeEach(() => {
- getJobQueryResponse = jest.fn();
- });
-
afterEach(() => {
createAlert.mockClear();
});
describe('when page renders', () => {
beforeEach(async () => {
- getJobQueryResponse.mockResolvedValue(mockJobResponse);
- await createComponentWithApollo();
+ await createComponent();
});
it('renders help text with provided link', () => {
@@ -120,8 +124,11 @@ describe('Manual Variables Form', () => {
describe('when query is unsuccessful', () => {
beforeEach(async () => {
- getJobQueryResponse.mockRejectedValue({});
- await createComponentWithApollo();
+ await createComponent({
+ handlers: {
+ getJobQueryResponseHandlerWithVariables: jest.fn().mockRejectedValue({}),
+ },
+ });
});
it('shows an alert with error', () => {
@@ -133,8 +140,13 @@ describe('Manual Variables Form', () => {
describe('when job has not been retried', () => {
beforeEach(async () => {
- getJobQueryResponse.mockResolvedValue(mockJobWithVariablesResponse);
- await createComponentWithApollo();
+ await createComponent({
+ handlers: {
+ getJobQueryResponseHandlerWithVariables: jest
+ .fn()
+ .mockResolvedValue(mockJobWithVariablesResponse),
+ },
+ });
});
it('does not render the cancel button', () => {
@@ -145,8 +157,13 @@ describe('Manual Variables Form', () => {
describe('when job has variables', () => {
beforeEach(async () => {
- getJobQueryResponse.mockResolvedValue(mockJobWithVariablesResponse);
- await createComponentWithApollo();
+ await createComponent({
+ handlers: {
+ getJobQueryResponseHandlerWithVariables: jest
+ .fn()
+ .mockResolvedValue(mockJobWithVariablesResponse),
+ },
+ });
});
it('sets manual job variables', () => {
@@ -161,8 +178,11 @@ describe('Manual Variables Form', () => {
describe('when play mutation fires', () => {
beforeEach(async () => {
- await createComponentWithApollo();
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobPlayMutationData);
+ await createComponent({
+ handlers: {
+ playJobMutationHandler: jest.fn().mockResolvedValue(mockJobPlayMutationData),
+ },
+ });
});
it('passes variables in correct format', async () => {
@@ -172,18 +192,15 @@ describe('Manual Variables Form', () => {
await findRunBtn().vm.$emit('click');
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: playJobMutation,
- variables: {
- id: convertToGraphQLId(TYPENAME_CI_BUILD, mockId),
- variables: [
- {
- key: 'new key',
- value: 'new value',
- },
- ],
- },
+ expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1);
+ expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledWith({
+ id: convertToGraphQLId(TYPENAME_CI_BUILD, mockId),
+ variables: [
+ {
+ key: 'new key',
+ value: 'new value',
+ },
+ ],
});
});
@@ -191,15 +208,18 @@ describe('Manual Variables Form', () => {
findRunBtn().vm.$emit('click');
await waitForPromises();
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(mockJobPlayMutationData.data.jobPlay.job.webPath); // eslint-disable-line import/no-deprecated
});
});
describe('when play mutation is unsuccessful', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({});
- await createComponentWithApollo();
+ await createComponent({
+ handlers: {
+ playJobMutationHandler: jest.fn().mockRejectedValue({}),
+ },
+ });
});
it('shows an alert with error', async () => {
@@ -214,8 +234,12 @@ describe('Manual Variables Form', () => {
describe('when job is retryable', () => {
beforeEach(async () => {
- await createComponentWithApollo({ props: { isRetryable: true } });
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobRetryMutationData);
+ await createComponent({
+ props: { isRetryable: true },
+ handlers: {
+ retryJobMutationHandler: jest.fn().mockResolvedValue(mockJobRetryMutationData),
+ },
+ });
});
it('renders cancel button', () => {
@@ -226,15 +250,19 @@ describe('Manual Variables Form', () => {
findRunBtn().vm.$emit('click');
await waitForPromises();
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(requestHandlers.retryJobMutationHandler).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(mockJobRetryMutationData.data.jobRetry.job.webPath); // eslint-disable-line import/no-deprecated
});
});
describe('when retry mutation is unsuccessful', () => {
beforeEach(async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({});
- await createComponentWithApollo({ props: { isRetryable: true } });
+ await createComponent({
+ props: { isRetryable: true },
+ handlers: {
+ retryJobMutationHandler: jest.fn().mockRejectedValue({}),
+ },
+ });
});
it('shows an alert with error', async () => {
@@ -249,8 +277,11 @@ describe('Manual Variables Form', () => {
describe('updating variables in UI', () => {
beforeEach(async () => {
- getJobQueryResponse.mockResolvedValue(mockJobResponse);
- await createComponentWithApollo();
+ await createComponent({
+ handlers: {
+ getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse),
+ },
+ });
});
it('creates a new variable when user enters a new key value', async () => {
@@ -305,8 +336,11 @@ describe('Manual Variables Form', () => {
describe('variable delete button placeholder', () => {
beforeEach(async () => {
- getJobQueryResponse.mockResolvedValue(mockJobResponse);
- await createComponentWithApollo();
+ await createComponent({
+ handlers: {
+ getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse),
+ },
+ });
});
it('delete variable button placeholder should only exist when a user cannot remove', () => {
diff --git a/spec/frontend/jobs/components/job/stages_dropdown_spec.js b/spec/frontend/jobs/components/job/stages_dropdown_spec.js
index 9d01dc50e96..c42edc62183 100644
--- a/spec/frontend/jobs/components/job/stages_dropdown_spec.js
+++ b/spec/frontend/jobs/components/job/stages_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { Mousetrap } from '~/lib/mousetrap';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -16,8 +16,8 @@ describe('Stages Dropdown', () => {
let wrapper;
const findStatus = () => wrapper.findComponent(CiIcon);
- const findSelectedStageText = () => wrapper.findComponent(GlDropdown).props('text');
- const findStageItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findSelectedStageText = () => findDropdown().props('toggleText');
const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text();
@@ -50,10 +50,13 @@ describe('Stages Dropdown', () => {
});
it('renders dropdown with stages', () => {
- expect(findStageItem(0).text()).toBe('build');
+ expect(findDropdown().props('items')).toEqual([
+ expect.objectContaining({ text: 'build' }),
+ expect.objectContaining({ text: 'test' }),
+ ]);
});
- it('rendes selected stage', () => {
+ it('renders selected stage', () => {
expect(findSelectedStageText()).toBe('deploy');
});
});
diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js
index 0e59e9ab5b6..032b83ca22b 100644
--- a/spec/frontend/jobs/components/table/job_table_app_spec.js
+++ b/spec/frontend/jobs/components/table/job_table_app_spec.js
@@ -60,14 +60,8 @@ describe('Job table app', () => {
handler = successHandler,
countHandler = countSuccessHandler,
mountFn = shallowMount,
- data = {},
} = {}) => {
wrapper = mountFn(JobsTableApp, {
- data() {
- return {
- ...data,
- };
- },
provide: {
fullPath: projectPath,
},
@@ -108,34 +102,28 @@ describe('Job table app', () => {
});
it('should refetch jobs query on fetchJobsByStatus event', async () => {
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
await findTabs().vm.$emit('fetchJobsByStatus');
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
+ expect(successHandler).toHaveBeenCalledTimes(2);
});
it('avoids refetch jobs query when scope has not changed', async () => {
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
await findTabs().vm.$emit('fetchJobsByStatus', null);
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
});
it('should refetch jobs count query when the amount jobs and count do not match', async () => {
- jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
// after applying filter a new count is fetched
findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(2);
// tab is switched to `finished`, no count
await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']);
@@ -143,7 +131,7 @@ describe('Job table app', () => {
// tab is switched back to `all`, the old filter count has to be overwritten with new count
await findTabs().vm.$emit('fetchJobsByStatus', null);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(2);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(3);
});
describe('when infinite scrolling is triggered', () => {
@@ -261,25 +249,21 @@ describe('Job table app', () => {
it('refetches jobs query when filtering', async () => {
createComponent();
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
+ expect(successHandler).toHaveBeenCalledTimes(2);
});
it('refetches jobs count query when filtering', async () => {
createComponent();
- jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(2);
});
it('shows raw text warning when user inputs raw text', async () => {
@@ -292,14 +276,14 @@ describe('Job table app', () => {
createComponent();
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
- jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
+ expect(successHandler).toHaveBeenCalledTimes(1);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']);
expect(createAlert).toHaveBeenCalledWith(expectedWarning);
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
});
it('updates URL query string when filtering jobs by status', async () => {
diff --git a/spec/frontend/layout_nav_spec.js b/spec/frontend/layout_nav_spec.js
new file mode 100644
index 00000000000..30f4f7fcac1
--- /dev/null
+++ b/spec/frontend/layout_nav_spec.js
@@ -0,0 +1,39 @@
+import { initScrollingTabs } from '~/layout_nav';
+import { setHTMLFixture } from './__helpers__/fixtures';
+
+describe('initScrollingTabs', () => {
+ const htmlFixture = `
+ <button type='button' class='fade-left'></button>
+ <button type='button' class='fade-right'></button>
+ <div class='scrolling-tabs'></div>
+ `;
+ const findTabs = () => document.querySelector('.scrolling-tabs');
+ const findScrollLeftButton = () => document.querySelector('button.fade-left');
+ const findScrollRightButton = () => document.querySelector('button.fade-right');
+
+ beforeEach(() => {
+ setHTMLFixture(htmlFixture);
+ });
+
+ it('scrolls left when clicking on the left button', () => {
+ initScrollingTabs();
+ const tabs = findTabs();
+ tabs.scrollBy = jest.fn();
+ const fadeLeft = findScrollLeftButton();
+
+ fadeLeft.click();
+
+ expect(tabs.scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' });
+ });
+
+ it('scrolls right when clicking on the right button', () => {
+ initScrollingTabs();
+ const tabs = findTabs();
+ tabs.scrollBy = jest.fn();
+ const fadeRight = findScrollRightButton();
+
+ fadeRight.click();
+
+ expect(tabs.scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' });
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js
index 8d6ace165ab..f9e3c314d02 100644
--- a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js
@@ -1,5 +1,6 @@
import {
getDateWithUTC,
+ getCurrentUtcDate,
newDateAsLocaleTime,
nSecondsAfter,
nSecondsBefore,
@@ -84,3 +85,11 @@ describe('isToday', () => {
});
});
});
+
+describe('getCurrentUtcDate', () => {
+ useFakeDate(2022, 11, 5, 10, 10);
+
+ it('returns the date at midnight', () => {
+ expect(getCurrentUtcDate()).toEqual(new Date('2022-12-05T00:00:00.000Z'));
+ });
+});
diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js
index 172f8972653..a0504458037 100644
--- a/spec/frontend/lib/utils/dom_utils_spec.js
+++ b/spec/frontend/lib/utils/dom_utils_spec.js
@@ -256,8 +256,12 @@ describe('DOM Utils', () => {
resetHTMLFixture();
});
+ it('returns the height of default element that exists', () => {
+ expect(getContentWrapperHeight()).toBe('0px');
+ });
+
it('returns the height of an element that exists', () => {
- expect(getContentWrapperHeight('.content-wrapper')).toBe('0px');
+ expect(getContentWrapperHeight('.content')).toBe('0px');
});
it('returns an empty string for a class that does not exist', () => {
diff --git a/spec/frontend/lib/utils/listbox_helpers_spec.js b/spec/frontend/lib/utils/listbox_helpers_spec.js
new file mode 100644
index 00000000000..189aad41ceb
--- /dev/null
+++ b/spec/frontend/lib/utils/listbox_helpers_spec.js
@@ -0,0 +1,89 @@
+import { getSelectedOptionsText } from '~/lib/utils/listbox_helpers';
+
+describe('getSelectedOptionsText', () => {
+ it('returns an empty string per default when no options are selected', () => {
+ const options = [
+ { id: 1, text: 'first' },
+ { id: 2, text: 'second' },
+ ];
+ const selected = [];
+
+ expect(getSelectedOptionsText({ options, selected })).toBe('');
+ });
+
+ it('returns the provided placeholder when no options are selected', () => {
+ const options = [
+ { id: 1, text: 'first' },
+ { id: 2, text: 'second' },
+ ];
+ const selected = [];
+ const placeholder = 'placeholder';
+
+ expect(getSelectedOptionsText({ options, selected, placeholder })).toBe(placeholder);
+ });
+
+ describe('maxOptionsShown is not provided', () => {
+ it('returns the text of the first selected option when only one option is selected', () => {
+ const options = [{ id: 1, text: 'first' }];
+ const selected = [options[0].id];
+
+ expect(getSelectedOptionsText({ options, selected })).toBe('first');
+ });
+
+ it('should also work with the value property', () => {
+ const options = [{ value: 1, text: 'first' }];
+ const selected = [options[0].value];
+
+ expect(getSelectedOptionsText({ options, selected })).toBe('first');
+ });
+
+ it.each`
+ options | expectedText
+ ${[{ id: 1, text: 'first' }, { id: 2, text: 'second' }]} | ${'first +1 more'}
+ ${[{ id: 1, text: 'first' }, { id: 2, text: 'second' }, { id: 3, text: 'third' }]} | ${'first +2 more'}
+ `(
+ 'returns "$expectedText" when more than one option is selected',
+ ({ options, expectedText }) => {
+ const selected = options.map(({ id }) => id);
+
+ expect(getSelectedOptionsText({ options, selected })).toBe(expectedText);
+ },
+ );
+ });
+
+ describe('maxOptionsShown > 1', () => {
+ const options = [
+ { id: 1, text: 'first' },
+ { id: 2, text: 'second' },
+ { id: 3, text: 'third' },
+ { id: 4, text: 'fourth' },
+ { id: 5, text: 'fifth' },
+ ];
+
+ it.each`
+ selected | maxOptionsShown | expectedText
+ ${[1]} | ${2} | ${'first'}
+ ${[1, 2]} | ${2} | ${'first, second'}
+ ${[1, 2, 3]} | ${2} | ${'first, second +1 more'}
+ ${[1, 2, 3]} | ${3} | ${'first, second, third'}
+ ${[1, 2, 3, 4]} | ${3} | ${'first, second, third +1 more'}
+ ${[1, 2, 3, 4, 5]} | ${3} | ${'first, second, third +2 more'}
+ `(
+ 'returns "$expectedText" when "$selected.length" options are selected and maxOptionsShown is "$maxOptionsShown"',
+ ({ selected, maxOptionsShown, expectedText }) => {
+ expect(getSelectedOptionsText({ options, selected, maxOptionsShown })).toBe(expectedText);
+ },
+ );
+ });
+
+ it('ignores selected options that are not in the options array', () => {
+ const options = [
+ { id: 1, text: 'first' },
+ { id: 2, text: 'second' },
+ ];
+ const invalidOption = { id: 3, text: 'third' };
+ const selected = [options[0].id, options[1].id, invalidOption.id];
+
+ expect(getSelectedOptionsText({ options, selected })).toBe('first +1 more');
+ });
+});
diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js
index d2591cd2328..07e3e2f0422 100644
--- a/spec/frontend/lib/utils/number_utility_spec.js
+++ b/spec/frontend/lib/utils/number_utility_spec.js
@@ -109,8 +109,8 @@ describe('Number Utils', () => {
describe('numberToHumanSize', () => {
it('should return bytes', () => {
- expect(numberToHumanSize(654)).toEqual('654 bytes');
- expect(numberToHumanSize(-654)).toEqual('-654 bytes');
+ expect(numberToHumanSize(654)).toEqual('654 B');
+ expect(numberToHumanSize(-654)).toEqual('-654 B');
});
it('should return KiB', () => {
diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js
index 7bde6cc4a8e..3213ecf3fe1 100644
--- a/spec/frontend/lib/utils/secret_detection_spec.js
+++ b/spec/frontend/lib/utils/secret_detection_spec.js
@@ -26,6 +26,25 @@ describe('containsSensitiveToken', () => {
'token: glpat-cgyKc1k_AsnEpmP-5fRL',
'token: GlPat-abcdefghijklmnopqrstuvwxyz',
'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ 'token: feed_token=glft-ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ 'token: feed_token=glft-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-1234',
+ 'https://example.com/feed?feed_token=123456789_abcdefghij',
+ 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ ];
+
+ it.each(sensitiveMessages)('returns true for message: %s', (message) => {
+ expect(containsSensitiveToken(message)).toBe(true);
+ });
+ });
+
+ describe('when custom pat prefix is set', () => {
+ beforeEach(() => {
+ gon.pat_prefix = 'specpat-';
+ });
+
+ const sensitiveMessages = [
+ 'token: specpat-mGYFaXBmNLvLmrEb7xdf',
+ 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'https://example.com/feed?feed_token=123456789_abcdefghij',
'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
];
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index 71a84d56791..8f1f6899935 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -430,4 +430,21 @@ describe('text_utility', () => {
expect(textUtils.humanizeBranchValidationErrors([])).toEqual('');
});
});
+
+ describe('stripQuotes', () => {
+ it.each`
+ inputValue | outputValue
+ ${'"Foo Bar"'} | ${'Foo Bar'}
+ ${"'Foo Bar'"} | ${'Foo Bar'}
+ ${'FooBar'} | ${'FooBar'}
+ ${"Foo'Bar"} | ${"Foo'Bar"}
+ ${'Foo"Bar'} | ${'Foo"Bar'}
+ ${'Foo Bar'} | ${'Foo Bar'}
+ `(
+ 'returns string $outputValue when called with string $inputValue',
+ ({ inputValue, outputValue }) => {
+ expect(textUtils.stripQuotes(inputValue)).toBe(outputValue);
+ },
+ );
+ });
});
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 0799bc87c8c..0f32eaa4ca6 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -1,8 +1,11 @@
+import * as Sentry from '@sentry/browser';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as urlUtils from '~/lib/utils/url_utility';
import { safeUrls, unsafeUrls } from './mock_data';
+jest.mock('@sentry/browser');
+
const shas = {
valid: [
'ad9be38573f9ee4c4daec22673478c2dd1d81cd8',
@@ -397,6 +400,62 @@ describe('URL utility', () => {
});
});
+ describe('visitUrl', () => {
+ let originalLocation;
+ const mockUrl = 'http://example.com/page';
+
+ beforeAll(() => {
+ originalLocation = window.location;
+
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: {
+ assign: jest.fn(),
+ protocol: 'http:',
+ host: TEST_HOST,
+ },
+ });
+ });
+
+ afterAll(() => {
+ window.location = originalLocation;
+ });
+
+ it('does not navigate to unsafe urls', () => {
+ // eslint-disable-next-line no-script-url
+ const url = 'javascript:alert(document.domain)';
+ urlUtils.visitUrl(url);
+
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ new RangeError(`Only http and https protocols are allowed: ${url}`),
+ );
+ });
+
+ it('navigates to a page', () => {
+ urlUtils.visitUrl(mockUrl);
+
+ expect(window.location.assign).toHaveBeenCalledWith(mockUrl);
+ });
+
+ it('navigates to a new page', () => {
+ const otherWindow = {
+ location: {
+ assign: jest.fn(),
+ },
+ };
+
+ Object.defineProperty(window, 'open', {
+ writable: true,
+ value: jest.fn().mockReturnValue(otherWindow),
+ });
+
+ urlUtils.visitUrl(mockUrl, true);
+
+ expect(otherWindow.opener).toBe(null);
+ expect(otherWindow.location.assign).toHaveBeenCalledWith(mockUrl);
+ });
+ });
+
describe('updateHistory', () => {
const state = { key: 'prop' };
const title = 'TITLE';
diff --git a/spec/frontend/listbox/index_spec.js b/spec/frontend/listbox/index_spec.js
index 39e0332631b..ccbef1247ef 100644
--- a/spec/frontend/listbox/index_spec.js
+++ b/spec/frontend/listbox/index_spec.js
@@ -2,16 +2,15 @@ import { nextTick } from 'vue';
import { getAllByRole, getByTestId } from '@testing-library/dom';
import { GlCollapsibleListbox } from '@gitlab/ui';
import { createWrapper } from '@vue/test-utils';
+import htmlRedirectListbox from 'test_fixtures/listbox/redirect_listbox.html';
import { initListbox, parseAttributes } from '~/listbox';
-import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
jest.mock('~/lib/utils/url_utility');
-const fixture = getFixture('listbox/redirect_listbox.html');
-
const parsedAttributes = (() => {
const div = document.createElement('div');
- div.innerHTML = fixture;
+ div.innerHTML = htmlRedirectListbox;
return parseAttributes(div.firstChild);
})();
@@ -46,7 +45,7 @@ describe('initListbox', () => {
const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true });
beforeEach(async () => {
- setHTMLFixture(fixture);
+ setHTMLFixture(htmlRedirectListbox);
onChangeSpy = jest.fn();
setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy });
diff --git a/spec/frontend/listbox/redirect_behavior_spec.js b/spec/frontend/listbox/redirect_behavior_spec.js
index c2479e71e4a..eb3b6900a25 100644
--- a/spec/frontend/listbox/redirect_behavior_spec.js
+++ b/spec/frontend/listbox/redirect_behavior_spec.js
@@ -1,22 +1,21 @@
+import htmlRedirectListbox from 'test_fixtures/listbox/redirect_listbox.html';
import { initListbox } from '~/listbox';
import { initRedirectListboxBehavior } from '~/listbox/redirect_behavior';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
-import { getFixture, setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture } from 'helpers/fixtures';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/listbox', () => ({
initListbox: jest.fn().mockReturnValue({ foo: true }),
}));
-const fixture = getFixture('listbox/redirect_listbox.html');
-
describe('initRedirectListboxBehavior', () => {
let instances;
beforeEach(() => {
setHTMLFixture(`
- ${fixture}
- ${fixture}
+ ${htmlRedirectListbox}
+ ${htmlRedirectListbox}
`);
instances = initRedirectListboxBehavior();
diff --git a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js
index 679ad7897ed..4fb5a2fb99d 100644
--- a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js
+++ b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue';
@@ -26,7 +26,7 @@ describe('LeaveGroupDropdownItem', () => {
});
};
- const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
+ const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js
index 125f1f8fff3..2f0d4b8e655 100644
--- a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js
+++ b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
@@ -52,7 +52,7 @@ describe('RemoveMemberDropdownItem', () => {
});
};
- const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
+ const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
beforeEach(() => {
createComponent();
@@ -63,7 +63,7 @@ describe('RemoveMemberDropdownItem', () => {
});
it('calls Vuex action to show `remove member` modal when clicked', () => {
- findDropdownItem().vm.$emit('click');
+ findDropdownItem().vm.$emit('action');
expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), {
...modalData,
diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js
index 1045e3f9849..1285404fd9f 100644
--- a/spec/frontend/members/components/table/role_dropdown_spec.js
+++ b/spec/frontend/members/components/table/role_dropdown_spec.js
@@ -1,8 +1,7 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import * as Sentry from '@sentry/browser';
-import { within } from '@testing-library/dom';
-import { mount, createWrapper } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
@@ -55,59 +54,50 @@ describe('RoleDropdown', () => {
});
};
- const getDropdownMenu = () => within(wrapper.element).getByRole('menu');
- const getByTextInDropdownMenu = (text, options = {}) =>
- createWrapper(within(getDropdownMenu()).getByText(text, options));
- const getDropdownItemByText = (text) =>
- createWrapper(
- within(getDropdownMenu())
- .getByText(text, { selector: '[role="menuitem"] p' })
- .closest('[role="menuitem"]'),
- );
- const getCheckedDropdownItem = () =>
- wrapper
- .findAllComponents(GlDropdownItem)
- .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.props('isChecked'));
-
- const findDropdownToggle = () => wrapper.find('button[aria-haspopup="menu"]');
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findListboxItems = () => wrapper.findAllComponents(GlListboxItem);
+ const findListboxItemByText = (text) =>
+ findListboxItems().wrappers.find((item) => item.text() === text);
beforeEach(() => {
gon.features = { showOverageOnRolePromotion: true };
});
- describe('when dropdown is open', () => {
+ it('has correct header text props', () => {
+ createComponent();
+ expect(findListbox().props('headerText')).toBe('Change role');
+ });
+
+ it('has items prop with all valid roles', () => {
+ createComponent();
+ const roles = findListbox()
+ .props('items')
+ .map((item) => item.text);
+ expect(roles).toEqual(Object.keys(member.validRoles));
+ });
+
+ describe('when listbox is open', () => {
beforeEach(async () => {
guestOverageConfirmAction.mockReturnValue(true);
createComponent();
- await findDropdownToggle().trigger('click');
- });
-
- it('renders all valid roles', () => {
- Object.keys(member.validRoles).forEach((role) => {
- expect(getDropdownItemByText(role).exists()).toBe(true);
- });
- });
-
- it('renders dropdown header', () => {
- expect(getByTextInDropdownMenu('Change role').exists()).toBe(true);
+ await findListbox().vm.$emit('click');
});
it('sets dropdown toggle and checks selected role', () => {
- expect(findDropdownToggle().text()).toBe('Owner');
- expect(getCheckedDropdownItem().text()).toBe('Owner');
+ expect(findListbox().props('toggleText')).toBe('Owner');
+ expect(findListbox().find('[aria-selected=true]').text()).toBe('Owner');
});
describe('when dropdown item is selected', () => {
it('does nothing if the item selected was already selected', async () => {
- await getDropdownItemByText('Owner').trigger('click');
+ await findListboxItemByText('Owner').trigger('click');
expect(actions.updateMemberRole).not.toHaveBeenCalled();
});
it('calls `updateMemberRole` Vuex action', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), {
memberId: member.id,
@@ -117,7 +107,7 @@ describe('RoleDropdown', () => {
describe('when updateMemberRole is successful', () => {
it('displays toast', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
await nextTick();
@@ -125,21 +115,21 @@ describe('RoleDropdown', () => {
});
it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
- expect(findDropdown().props('loading')).toBe(true);
+ expect(findListbox().props('loading')).toBe(true);
});
it('enables dropdown after `updateMemberRole` resolves', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
await waitForPromises();
- expect(findDropdown().props('disabled')).toBe(false);
+ expect(findListbox().props('disabled')).toBe(false);
});
it('does not log error to Sentry', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
await waitForPromises();
@@ -155,7 +145,7 @@ describe('RoleDropdown', () => {
});
it('does not display toast', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
await nextTick();
@@ -163,21 +153,21 @@ describe('RoleDropdown', () => {
});
it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
- expect(findDropdown().props('loading')).toBe(true);
+ expect(findListbox().props('loading')).toBe(true);
});
it('enables dropdown after `updateMemberRole` resolves', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
await waitForPromises();
- expect(findDropdown().props('disabled')).toBe(false);
+ expect(findListbox().props('disabled')).toBe(false);
});
it('logs error to Sentry', async () => {
- await getDropdownItemByText('Developer').trigger('click');
+ await findListboxItemByText('Developer').trigger('click');
await waitForPromises();
@@ -190,7 +180,7 @@ describe('RoleDropdown', () => {
it("sets initial dropdown toggle value to member's role", () => {
createComponent();
- expect(findDropdownToggle().text()).toBe('Owner');
+ expect(findListbox().props('toggleText')).toBe('Owner');
});
it('sets the dropdown alignment to right on mobile', async () => {
@@ -199,7 +189,7 @@ describe('RoleDropdown', () => {
await nextTick();
- expect(findDropdown().props('right')).toBe(true);
+ expect(findListbox().props('placement')).toBe('right');
});
it('sets the dropdown alignment to left on desktop', async () => {
@@ -208,7 +198,7 @@ describe('RoleDropdown', () => {
await nextTick();
- expect(findDropdown().props('right')).toBe(false);
+ expect(findListbox().props('placement')).toBe('left');
});
describe('guestOverageConfirmAction', () => {
@@ -219,7 +209,7 @@ describe('RoleDropdown', () => {
beforeEach(() => {
createComponent();
- findDropdownToggle().trigger('click');
+ findListbox().vm.$emit('click');
});
afterEach(() => {
@@ -230,7 +220,7 @@ describe('RoleDropdown', () => {
beforeEach(() => {
mockConfirmAction({ confirmed: true });
- getDropdownItemByText('Reporter').trigger('click');
+ findListboxItemByText('Reporter').trigger('click');
});
it('calls updateMemberRole', () => {
@@ -242,7 +232,7 @@ describe('RoleDropdown', () => {
beforeEach(() => {
mockConfirmAction({ confirmed: false });
- getDropdownItemByText('Reporter').trigger('click');
+ findListboxItemByText('Reporter').trigger('click');
});
it('does not call updateMemberRole', () => {
diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js
index 6f80f8e6aab..a119ca8272e 100644
--- a/spec/frontend/merge_request_spec.js
+++ b/spec/frontend/merge_request_spec.js
@@ -1,7 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import htmlMergeRequestWithTaskList from 'test_fixtures/merge_requests/merge_request_with_task_list.html';
-import htmlMergeRequestOfCurrentUser from 'test_fixtures/merge_requests/merge_request_of_current_user.html';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'spec/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
@@ -110,20 +109,4 @@ describe('MergeRequest', () => {
});
});
});
-
- describe('hideCloseButton', () => {
- describe('merge request of current_user', () => {
- beforeEach(() => {
- setHTMLFixture(htmlMergeRequestOfCurrentUser);
- test.el = document.querySelector('.js-issuable-actions');
- MergeRequest.hideCloseButton();
- });
-
- it('hides the close button', () => {
- const smallCloseItem = test.el.querySelector('.js-close-item');
-
- expect(smallCloseItem).toHaveClass('hidden');
- });
- });
- });
});
diff --git a/spec/frontend/merge_requests/components/compare_dropdown_spec.js b/spec/frontend/merge_requests/components/compare_dropdown_spec.js
index ce03b80bdcb..bd8b16c8089 100644
--- a/spec/frontend/merge_requests/components/compare_dropdown_spec.js
+++ b/spec/frontend/merge_requests/components/compare_dropdown_spec.js
@@ -62,10 +62,10 @@ describe('Merge requests compare dropdown component', () => {
wrapper.find('[data-testid="base-dropdown-toggle"]').trigger('click');
await waitForPromises();
-
- expect(wrapper.findAll('li').length).toBe(2);
- expect(wrapper.findAll('li').at(0).text()).toBe('root/gitlab-test');
- expect(wrapper.findAll('li').at(1).text()).toBe('gitlab-org/gitlab-test');
+ const items = wrapper.findAll('[role="option"]');
+ expect(items.length).toBe(2);
+ expect(items.at(0).text()).toBe('root/gitlab-test');
+ expect(items.at(1).text()).toBe('gitlab-org/gitlab-test');
});
it('searches projects', async () => {
@@ -98,6 +98,6 @@ describe('Merge requests compare dropdown component', () => {
await waitForPromises();
- expect(wrapper.findAll('li').length).toBe(1);
+ expect(wrapper.findAll('[role="option"]').length).toBe(1);
});
});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js
index 8a39c5de2b3..53dbd796d85 100644
--- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLink } from '@gitlab/ui';
import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue';
describe('CandidateDetailRow', () => {
@@ -9,14 +8,14 @@ describe('CandidateDetailRow', () => {
let wrapper;
- const createWrapper = (href = '') => {
+ const createWrapper = ({ slots = {} } = {}) => {
wrapper = shallowMount(DetailRow, {
- propsData: { sectionLabel: 'Section', label: 'Item', text: 'Text', href },
+ propsData: { sectionLabel: 'Section', label: 'Item' },
+ slots,
});
};
const findCellAt = (index) => wrapper.findAll('td').at(index);
- const findLink = () => findCellAt(ROW_VALUE_CELL).findComponent(GlLink);
beforeEach(() => createWrapper());
@@ -28,22 +27,15 @@ describe('CandidateDetailRow', () => {
expect(findCellAt(ROW_LABEL_CELL).text()).toBe('Item');
});
- describe('No href', () => {
- it('Renders text', () => {
- expect(findCellAt(ROW_VALUE_CELL).text()).toBe('Text');
- });
-
- it('Does not render as link', () => {
- expect(findLink().exists()).toBe(false);
- });
+ it('renders nothing on item cell', () => {
+ expect(findCellAt(ROW_VALUE_CELL).text()).toBe('');
});
- describe('With href', () => {
- beforeEach(() => createWrapper('LINK'));
+ describe('With slot', () => {
+ beforeEach(() => createWrapper({ slots: { default: 'Some content' } }));
- it('Renders link', () => {
- expect(findLink().attributes().href).toBe('LINK');
- expect(findLink().text()).toBe('Text');
+ it('Renders slot', () => {
+ expect(findCellAt(ROW_VALUE_CELL).text()).toBe('Some content');
});
});
});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js
index 9d1c22faa8f..0b3b780cb3f 100644
--- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlAvatarLabeled, GlLink } from '@gitlab/ui';
import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show';
import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue';
import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/candidates/show/translations';
@@ -9,6 +10,7 @@ import { newCandidate } from './mock_data';
describe('MlCandidatesShow', () => {
let wrapper;
const CANDIDATE = newCandidate();
+ const USER_ROW = 6;
const createWrapper = (createCandidate = () => CANDIDATE) => {
wrapper = shallowMount(MlCandidatesShow, {
@@ -19,8 +21,12 @@ describe('MlCandidatesShow', () => {
const findDeleteButton = () => wrapper.findComponent(DeleteButton);
const findHeader = () => wrapper.findComponent(ModelExperimentsHeader);
const findNthDetailRow = (index) => wrapper.findAllComponents(DetailRow).at(index);
+ const findLinkInNthDetailRow = (index) => findNthDetailRow(index).findComponent(GlLink);
const findSectionLabel = (label) => wrapper.find(`[sectionLabel='${label}']`);
const findLabel = (label) => wrapper.find(`[label='${label}']`);
+ const findCiUserDetailRow = () => findNthDetailRow(USER_ROW);
+ const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled);
+ const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink);
describe('Header', () => {
beforeEach(() => createWrapper());
@@ -42,28 +48,64 @@ describe('MlCandidatesShow', () => {
describe('All info available', () => {
beforeEach(() => createWrapper());
+ const mrText = `!${CANDIDATE.info.ci_job.merge_request.iid} ${CANDIDATE.info.ci_job.merge_request.title}`;
const expectedTable = [
- ['Info', 'ID', CANDIDATE.info.iid, ''],
- ['', 'MLflow run ID', CANDIDATE.info.eid, ''],
- ['', 'Status', CANDIDATE.info.status, ''],
- ['', 'Experiment', CANDIDATE.info.experiment_name, CANDIDATE.info.path_to_experiment],
- ['', 'Artifacts', 'Artifacts', CANDIDATE.info.path_to_artifact],
- ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value, ''],
- ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value, ''],
- ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value, ''],
- ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value, ''],
- ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value, ''],
- ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value, ''],
+ ['Info', 'ID', CANDIDATE.info.iid],
+ ['', 'MLflow run ID', CANDIDATE.info.eid],
+ ['', 'Status', CANDIDATE.info.status],
+ ['', 'Experiment', CANDIDATE.info.experiment_name],
+ ['', 'Artifacts', 'Artifacts'],
+ ['CI', 'Job', CANDIDATE.info.ci_job.name],
+ ['', 'Triggered by', 'CI User'],
+ ['', 'Merge request', mrText],
+ ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value],
+ ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value],
+ ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value],
+ ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value],
+ ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value],
+ ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value],
].map((row, index) => [index, ...row]);
it.each(expectedTable)(
'row %s is created correctly',
- (index, sectionLabel, label, text, href) => {
- const row = findNthDetailRow(index);
+ (rowIndex, sectionLabel, label, text) => {
+ const row = findNthDetailRow(rowIndex);
- expect(row.props()).toMatchObject({ sectionLabel, label, text, href });
+ expect(row.props()).toMatchObject({ sectionLabel, label });
+ expect(row.text()).toBe(text);
},
);
+
+ describe('Table links', () => {
+ const linkRows = [
+ [3, CANDIDATE.info.path_to_experiment],
+ [4, CANDIDATE.info.path_to_artifact],
+ [5, CANDIDATE.info.ci_job.path],
+ [7, CANDIDATE.info.ci_job.merge_request.path],
+ ];
+
+ it.each(linkRows)('row %s is created correctly', (rowIndex, href) => {
+ expect(findLinkInNthDetailRow(rowIndex).attributes().href).toBe(href);
+ });
+ });
+
+ describe('CI triggerer', () => {
+ it('renders user row', () => {
+ const avatar = findCiUserAvatar();
+ expect(avatar.props()).toMatchObject({
+ label: '',
+ });
+ expect(avatar.attributes().src).toEqual('/img.png');
+ });
+
+ it('renders user name', () => {
+ const nameLink = findCiUserAvatarNameLink();
+
+ expect(nameLink.attributes().href).toEqual('path/to/ci/user');
+ expect(nameLink.text()).toEqual('CI User');
+ });
+ });
+
it('does not render params', () => {
expect(findSectionLabel('Parameters').exists()).toBe(true);
});
@@ -75,6 +117,9 @@ describe('MlCandidatesShow', () => {
expect(findSectionLabel('Parameters').exists()).toBe(true);
expect(findSectionLabel('Metadata').exists()).toBe(true);
expect(findSectionLabel('Metrics').exists()).toBe(true);
+ expect(findSectionLabel('CI').exists()).toBe(true);
+ expect(findLabel('Merge request').exists()).toBe(true);
+ expect(findLabel('Triggered by').exists()).toBe(true);
});
});
@@ -99,6 +144,7 @@ describe('MlCandidatesShow', () => {
delete candidate.params;
delete candidate.metrics;
delete candidate.metadata;
+ delete candidate.info.ci_job;
return candidate;
}),
);
@@ -114,6 +160,29 @@ describe('MlCandidatesShow', () => {
it('does not render metrics', () => {
expect(findSectionLabel('Metrics').exists()).toBe(false);
});
+
+ it('does not render CI info', () => {
+ expect(findSectionLabel('CI').exists()).toBe(false);
+ });
+ });
+
+ describe('Has CI, but no user or mr', () => {
+ beforeEach(() =>
+ createWrapper(() => {
+ const candidate = newCandidate();
+ delete candidate.info.ci_job.user;
+ delete candidate.info.ci_job.merge_request;
+ return candidate;
+ }),
+ );
+
+ it('does not render MR info', () => {
+ expect(findLabel('Merge request').exists()).toBe(false);
+ });
+
+ it('does not render CI user info', () => {
+ expect(findLabel('Triggered by').exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js
index cad2c03fc93..3fbcf122997 100644
--- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js
+++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js
@@ -19,5 +19,20 @@ export const newCandidate = () => ({
path_to_experiment: 'path/to/experiment',
status: 'SUCCESS',
path: 'path_to_candidate',
+ ci_job: {
+ name: 'test',
+ path: 'path/to/job',
+ merge_request: {
+ path: 'path/to/mr',
+ iid: 1,
+ title: 'Some MR',
+ },
+ user: {
+ path: 'path/to/ci/user',
+ name: 'CI User',
+ username: 'ciuser',
+ avatar: '/img.png',
+ },
+ },
},
});
diff --git a/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js b/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js
index 0c83be1822e..c1158fd2ca4 100644
--- a/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js
+++ b/spec/frontend/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index_spec.js
@@ -46,8 +46,8 @@ describe('MlExperimentsIndex', () => {
expect(findPagination().exists()).toBe(false);
});
- it('does not render header', () => {
- expect(findTitleHeader().exists()).toBe(false);
+ it('renders header', () => {
+ expect(findTitleHeader().exists()).toBe(true);
});
});
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 1f995965003..d7f1d4873bb 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -6,7 +6,6 @@ import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
-import { ESC_KEY } from '~/lib/utils/keys';
import { objectToQuery } from '~/lib/utils/url_utility';
import Dashboard from '~/monitoring/components/dashboard.vue';
import DashboardHeader from '~/monitoring/components/dashboard_header.vue';
@@ -479,8 +478,6 @@ describe('Dashboard', () => {
let group;
let panel;
- const mockKeyup = (key) => window.dispatchEvent(new KeyboardEvent('keyup', { key }));
-
const MockPanel = {
template: `<div><slot name="top-left"/></div>`,
};
@@ -531,14 +528,6 @@ describe('Dashboard', () => {
undefined,
);
});
-
- it('restores dashboard from full screen by typing the Escape key', () => {
- mockKeyup(ESC_KEY);
- expect(store.dispatch).toHaveBeenCalledWith(
- `monitoringDashboard/clearExpandedPanel`,
- undefined,
- );
- });
});
});
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index 70f25afc5ba..6c774a1ecd0 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -19,6 +19,7 @@ import * as constants from '~/notes/constants';
import eventHub from '~/notes/event_hub';
import { COMMENT_FORM } from '~/notes/i18n';
import notesModule from '~/notes/stores/modules';
+import { sprintf } from '~/locale';
import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
jest.mock('autosize');
@@ -195,6 +196,35 @@ describe('issue_comment_form component', () => {
},
);
+ describe('if response contains validation errors', () => {
+ beforeEach(() => {
+ store = createStore({
+ actions: {
+ saveNote: jest.fn().mockRejectedValue({
+ response: {
+ status: HTTP_STATUS_UNPROCESSABLE_ENTITY,
+ data: { errors: 'error 1 and error 2' },
+ },
+ }),
+ },
+ });
+
+ mountComponent({ mountFunction: mount, initialData: { note: 'invalid note' } });
+
+ clickCommentButton();
+ });
+
+ it('renders an error message', () => {
+ const errorAlerts = findErrorAlerts();
+
+ expect(errorAlerts.length).toBe(1);
+
+ expect(errorAlerts[0].text()).toBe(
+ sprintf(COMMENT_FORM.error, { reason: 'error 1 and error 2' }),
+ );
+ });
+ });
+
it('should remove the correct error from the list when it is dismissed', async () => {
const commandErrors = ['1', '2', '3'];
store = createStore({
diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js
index c352265654b..508f2ced4c4 100644
--- a/spec/frontend/notes/components/diff_with_note_spec.js
+++ b/spec/frontend/notes/components/diff_with_note_spec.js
@@ -3,6 +3,7 @@ import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json
import imageDiscussionFixture from 'test_fixtures/merge_requests/image_diff_discussion.json';
import { createStore } from '~/mr_notes/stores';
import DiffWithNote from '~/notes/components/diff_with_note.vue';
+import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
describe('diff_with_note', () => {
let store;
@@ -20,6 +21,8 @@ describe('diff_with_note', () => {
},
};
+ const findDiffViewer = () => wrapper.findComponent(DiffViewer);
+
beforeEach(() => {
store = createStore();
store.replaceState({
@@ -85,4 +88,43 @@ describe('diff_with_note', () => {
expect(selectors.diffTable.exists()).toBe(false);
});
});
+
+ describe('legacy diff note', () => {
+ const mockCommitId = 'abc123';
+
+ beforeEach(() => {
+ const diffDiscussion = {
+ ...discussionFixture[0],
+ commit_id: mockCommitId,
+ diff_file: {
+ ...discussionFixture[0].diff_file,
+ diff_refs: null,
+ viewer: {
+ ...discussionFixture[0].diff_file.viewer,
+ name: 'no_preview',
+ },
+ },
+ };
+
+ wrapper = shallowMount(DiffWithNote, {
+ propsData: {
+ discussion: diffDiscussion,
+ },
+ store,
+ });
+ });
+
+ it('shows file diff', () => {
+ expect(selectors.diffTable.exists()).toBe(false);
+ });
+
+ it('uses "no_preview" diff mode', () => {
+ expect(findDiffViewer().props('diffMode')).toBe('no_preview');
+ });
+
+ it('falls back to discussion.commit_id for baseSha and headSha', () => {
+ expect(findDiffViewer().props('oldSha')).toBe(mockCommitId);
+ expect(findDiffViewer().props('newSha')).toBe(mockCommitId);
+ });
+ });
});
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index 879bada4aee..fc50afcb01d 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -175,11 +175,6 @@ describe('noteActions', () => {
const { resolveButton } = wrapper.vm.$refs;
expect(resolveButton.$el.getAttribute('title')).toBe(`Resolved by ${complexUnescapedName}`);
});
-
- it('closes the dropdown', () => {
- findReportAbuseButton().vm.$emit('action');
- expect(mockCloseDropdown).toHaveBeenCalled();
- });
});
});
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index ac0c037fe36..36f89e479e6 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -1,14 +1,24 @@
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import MockAdapter from 'axios-mock-adapter';
import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
+import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file';
import DiscussionNotes from '~/notes/components/discussion_notes.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue';
import NoteForm from '~/notes/components/note_form.vue';
import NoteableDiscussion from '~/notes/components/noteable_discussion.vue';
-import createStore from '~/notes/stores';
+import { COMMENT_FORM } from '~/notes/i18n';
+import notesModule from '~/notes/stores/modules';
+import { sprintf } from '~/locale';
+import { createAlert } from '~/alert';
+
import {
noteableDataMock,
discussionMock,
@@ -17,22 +27,46 @@ import {
userDataMock,
} from '../mock_data';
+Vue.use(Vuex);
+
jest.mock('~/behaviors/markdown/render_gfm');
+jest.mock('~/alert');
describe('noteable_discussion component', () => {
let store;
let wrapper;
+ let axiosMock;
- beforeEach(() => {
- window.mrTabs = {};
- store = createStore();
+ const createStore = ({ saveNoteMock = jest.fn() } = {}) => {
+ const baseModule = notesModule();
+
+ return new Vuex.Store({
+ ...baseModule,
+ actions: {
+ ...baseModule.actions,
+ saveNote: saveNoteMock,
+ },
+ });
+ };
+
+ const createComponent = ({ storeMock = createStore(), discussion = discussionMock } = {}) => {
+ store = storeMock;
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
- wrapper = mount(NoteableDiscussion, {
+ wrapper = mountExtended(NoteableDiscussion, {
store,
- propsData: { discussion: discussionMock },
+ propsData: { discussion },
});
+ };
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(axios);
+ createComponent();
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
});
it('should not render thread header for non diff threads', () => {
@@ -126,6 +160,40 @@ describe('noteable_discussion component', () => {
false,
);
});
+
+ it('should add `internal-note` class when the discussion is internal', async () => {
+ const softCopyInternalNotes = [...discussionMock.notes];
+ const mockInternalNotes = softCopyInternalNotes.splice(0, 2);
+ mockInternalNotes[0].internal = true;
+
+ const mockDiscussion = {
+ ...discussionMock,
+ notes: [...mockInternalNotes],
+ };
+ wrapper.setProps({ discussion: mockDiscussion });
+ await nextTick();
+
+ const replyWrapper = wrapper.find('[data-testid="reply-wrapper"]');
+ expect(replyWrapper.exists()).toBe(true);
+ expect(replyWrapper.classes('internal-note')).toBe(true);
+ });
+
+ it('should add `public-note` class when the discussion is not internal', async () => {
+ const softCopyInternalNotes = [...discussionMock.notes];
+ const mockPublicNotes = softCopyInternalNotes.splice(0, 2);
+ mockPublicNotes[0].internal = false;
+
+ const mockDiscussion = {
+ ...discussionMock,
+ notes: [...mockPublicNotes],
+ };
+ wrapper.setProps({ discussion: mockDiscussion });
+ await nextTick();
+
+ const replyWrapper = wrapper.find('[data-testid="reply-wrapper"]');
+ expect(replyWrapper.exists()).toBe(true);
+ expect(replyWrapper.classes('public-note')).toBe(true);
+ });
});
describe('for resolved thread', () => {
@@ -161,6 +229,39 @@ describe('noteable_discussion component', () => {
});
});
+ describe('save reply', () => {
+ describe('if response contains validation errors', () => {
+ beforeEach(async () => {
+ const storeMock = createStore({
+ saveNoteMock: jest.fn().mockRejectedValue({
+ response: {
+ status: HTTP_STATUS_UNPROCESSABLE_ENTITY,
+ data: { errors: 'error 1 and error 2' },
+ },
+ }),
+ });
+
+ createComponent({ storeMock });
+
+ wrapper.findComponent(ReplyPlaceholder).vm.$emit('focus');
+ await nextTick();
+
+ wrapper
+ .findComponent(NoteForm)
+ .vm.$emit('handleFormUpdate', 'invalid note', null, () => {});
+
+ await waitForPromises();
+ });
+
+ it('renders an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: sprintf(COMMENT_FORM.error, { reason: 'error 1 and error 2' }),
+ parent: wrapper.vm.$el,
+ });
+ });
+ });
+ });
+
describe('signout widget', () => {
describe('user is logged in', () => {
beforeEach(() => {
diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js
index 5d81a7a9a0f..d50fb130a69 100644
--- a/spec/frontend/notes/components/noteable_note_spec.js
+++ b/spec/frontend/notes/components/noteable_note_spec.js
@@ -1,6 +1,7 @@
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { GlAvatar } from '@gitlab/ui';
+import { clone } from 'lodash';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DiffsModule from '~/diffs/store/modules';
@@ -10,9 +11,13 @@ import NoteHeader from '~/notes/components/note_header.vue';
import issueNote from '~/notes/components/noteable_note.vue';
import NotesModule from '~/notes/stores/modules';
import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants';
+import { createAlert } from '~/alert';
+import { UPDATE_COMMENT_FORM } from '~/notes/i18n';
+import { sprintf } from '~/locale';
import { noteableDataMock, notesDataMock, note } from '../mock_data';
Vue.use(Vuex);
+jest.mock('~/alert');
const singleLineNotePosition = {
line_range: {
@@ -54,10 +59,13 @@ describe('issue_note', () => {
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
+ // the component overwrites the `note` prop with every action, hence create a copy
+ const noteCopy = clone(props.note || note);
+
wrapper = mountExtended(issueNote, {
store,
propsData: {
- note,
+ note: noteCopy,
...props,
},
stubs: [
@@ -252,7 +260,7 @@ describe('issue_note', () => {
});
it('should render issue body', () => {
- expect(findNoteBody().props().note).toBe(note);
+ expect(findNoteBody().props().note).toMatchObject(note);
expect(findNoteBody().props().line).toBe(null);
expect(findNoteBody().props().canEdit).toBe(note.current_user.can_edit);
expect(findNoteBody().props().isEditing).toBe(false);
@@ -297,7 +305,7 @@ describe('issue_note', () => {
});
it('does not have internal note class for external notes', () => {
- createWrapper({ note });
+ createWrapper();
expect(wrapper.classes()).not.toContain('internal-note');
});
@@ -327,7 +335,6 @@ describe('issue_note', () => {
});
await nextTick();
-
expect(findNoteBody().props().note.note_html).toBe(`<p dir="auto">${updatedText}</p>\n`);
findNoteBody().vm.$emit('cancelForm', {});
@@ -340,7 +347,7 @@ describe('issue_note', () => {
describe('formUpdateHandler', () => {
const updateNote = jest.fn();
const params = {
- noteText: '',
+ noteText: 'updated note text',
parentElement: null,
callback: jest.fn(),
resolveDiscussion: false,
@@ -359,28 +366,38 @@ describe('issue_note', () => {
});
};
+ beforeEach(() => {
+ createWrapper();
+ updateActions();
+ });
+
afterEach(() => updateNote.mockReset());
it('responds to handleFormUpdate', () => {
- createWrapper();
- updateActions();
findNoteBody().vm.$emit('handleFormUpdate', params);
+
expect(wrapper.emitted('handleUpdateNote')).toHaveLength(1);
});
+ it('updates note content', async () => {
+ findNoteBody().vm.$emit('handleFormUpdate', params);
+
+ await nextTick();
+
+ expect(findNoteBody().props().note.note_html).toBe(`<p dir="auto">${params.noteText}</p>\n`);
+ expect(findNoteBody().props('isEditing')).toBe(false);
+ });
+
it('should not update note with sensitive token', () => {
const sensitiveMessage = 'token: glpat-1234567890abcdefghij';
-
- createWrapper();
- updateActions();
findNoteBody().vm.$emit('handleFormUpdate', { ...params, noteText: sensitiveMessage });
+
expect(updateNote).not.toHaveBeenCalled();
});
it('does not stringify empty position', () => {
- createWrapper();
- updateActions();
findNoteBody().vm.$emit('handleFormUpdate', params);
+
expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined();
});
@@ -388,10 +405,35 @@ describe('issue_note', () => {
const position = { test: true };
const expectation = JSON.stringify(position);
createWrapper({ note: { ...note, position } });
+
updateActions();
findNoteBody().vm.$emit('handleFormUpdate', params);
+
expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation);
});
+
+ describe('when updateNote returns errors', () => {
+ beforeEach(() => {
+ updateNote.mockRejectedValue({
+ response: { status: 422, data: { errors: 'error 1 and error 2' } },
+ });
+ });
+
+ beforeEach(() => {
+ findNoteBody().vm.$emit('handleFormUpdate', { ...params, noteText: 'invalid note' });
+ });
+
+ it('renders error message and restores content of updated note', async () => {
+ await waitForPromises();
+ expect(createAlert).toHaveBeenCalledWith({
+ message: sprintf(UPDATE_COMMENT_FORM.error, { reason: 'error 1 and error 2' }, false),
+ parent: wrapper.vm.$el,
+ });
+
+ expect(findNoteBody().props('isEditing')).toBe(true);
+ expect(findNoteBody().props().note.note_html).toBe(note.note_html);
+ });
+ });
});
describe('diffFile', () => {
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index cdfe8b02b48..0f70b264326 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -334,14 +334,12 @@ describe('note_app', () => {
});
it('should listen hashchange event', () => {
- const notesApp = wrapper.findComponent(NotesApp);
const hash = 'some dummy hash';
jest.spyOn(urlUtility, 'getLocationHash').mockReturnValue(hash);
- const setTargetNoteHash = jest.spyOn(notesApp.vm, 'setTargetNoteHash');
-
+ const dispatchMock = jest.spyOn(store, 'dispatch');
window.dispatchEvent(new Event('hashchange'), hash);
- expect(setTargetNoteHash).toHaveBeenCalled();
+ expect(dispatchMock).toHaveBeenCalledWith('setTargetNoteHash', 'some dummy hash');
});
});
diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js
index 81e4ed3ebe7..b6a2b318ec3 100644
--- a/spec/frontend/notes/mixins/discussion_navigation_spec.js
+++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import { setHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import createEventHub from '~/helpers/event_hub_factory';
import * as utils from '~/lib/utils/common_utils';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
@@ -10,14 +10,15 @@ import notesModule from '~/notes/stores/modules';
let scrollToFile;
const discussion = (id, index) => ({
id,
- resolvable: index % 2 === 0,
+ resolvable: index % 2 === 0, // discussions 'b' and 'd' are not resolvable
active: true,
notes: [{}],
diff_discussion: true,
position: { new_line: 1, old_line: 1 },
diff_file: { file_path: 'test.js' },
});
-const createDiscussions = () => [...'abcde'].map(discussion);
+const mockDiscussionIds = [...'abcde'];
+const createDiscussions = () => mockDiscussionIds.map(discussion);
const createComponent = () => ({
mixins: [discussionNavigation],
render() {
@@ -32,22 +33,25 @@ describe('Discussion navigation mixin', () => {
let store;
let expandDiscussion;
+ const findDiscussionEl = (id) => document.querySelector(`div[data-discussion-id="${id}"]`);
+
beforeEach(() => {
setHTMLFixture(
`<div class="tab-pane notes">
- ${[...'abcde']
+ ${mockDiscussionIds
.map(
- (id) =>
+ (id, index) =>
`<ul class="notes" data-discussion-id="${id}"></ul>
- <div class="discussion" data-discussion-id="${id}"></div>`,
+ <div class="discussion" data-discussion-id="${id}" ${
+ discussion(id, index).resolvable
+ ? 'data-discussion-resolvable="true"'
+ : 'data-discussion-resolved="true"'
+ }></div>`,
)
.join('')}
</div>`,
);
- jest.spyOn(utils, 'scrollToElementWithContext');
- jest.spyOn(utils, 'scrollToElement');
-
expandDiscussion = jest.fn();
scrollToFile = jest.fn();
const { actions, ...notesRest } = notesModule();
@@ -70,8 +74,8 @@ describe('Discussion navigation mixin', () => {
});
afterEach(() => {
- wrapper.vm.$destroy();
jest.clearAllMocks();
+ resetHTMLFixture();
});
describe('jumpToFirstUnresolvedDiscussion method', () => {
@@ -105,41 +109,61 @@ describe('Discussion navigation mixin', () => {
describe('cycle through discussions', () => {
beforeEach(() => {
window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() };
- });
- describe.each`
- fn | args | currentId
- ${'jumpToNextDiscussion'} | ${[]} | ${null}
- ${'jumpToNextDiscussion'} | ${[]} | ${'a'}
- ${'jumpToNextDiscussion'} | ${[]} | ${'e'}
- ${'jumpToPreviousDiscussion'} | ${[]} | ${null}
- ${'jumpToPreviousDiscussion'} | ${[]} | ${'e'}
- ${'jumpToPreviousDiscussion'} | ${[]} | ${'c'}
- `('$fn (args = $args, currentId = $currentId)', ({ fn, args, currentId }) => {
- beforeEach(() => {
- store.state.notes.currentDiscussionId = currentId;
+ // Since we cannot actually scroll on the window, we have to mock each
+ // discussion's `getBoundingClientRect` to replicate the scroll position:
+ // a is at 100, b is at 200, c is at 300, d is at 400, e is at 500.
+ mockDiscussionIds.forEach((id, index) => {
+ jest
+ .spyOn(findDiscussionEl(id), 'getBoundingClientRect')
+ .mockReturnValue({ y: (index + 1) * 100 });
});
- describe('on `show` active tab', () => {
- beforeEach(async () => {
- window.mrTabs.currentAction = 'show';
- wrapper.vm[fn](...args);
-
- await nextTick();
- });
-
- it('expands discussion', async () => {
- await nextTick();
-
- expect(expandDiscussion).toHaveBeenCalled();
- });
-
- it('scrolls to element', async () => {
- await nextTick();
+ jest.spyOn(utils, 'scrollToElement');
+ });
- expect(utils.scrollToElement).toHaveBeenCalled();
+ describe.each`
+ fn | currentScrollPosition | expectedId
+ ${'jumpToNextDiscussion'} | ${null} | ${'a'}
+ ${'jumpToNextDiscussion'} | ${100} | ${'c'}
+ ${'jumpToNextDiscussion'} | ${200} | ${'c'}
+ ${'jumpToNextDiscussion'} | ${500} | ${'a'}
+ ${'jumpToPreviousDiscussion'} | ${null} | ${'e'}
+ ${'jumpToPreviousDiscussion'} | ${100} | ${'e'}
+ ${'jumpToPreviousDiscussion'} | ${200} | ${'a'}
+ ${'jumpToPreviousDiscussion'} | ${500} | ${'c'}
+ `(
+ '$fn (currentScrollPosition = $currentScrollPosition)',
+ ({ fn, currentScrollPosition, expectedId }) => {
+ describe('on `show` active tab', () => {
+ beforeEach(async () => {
+ window.mrTabs.currentAction = 'show';
+
+ // Set `document.body.scrollHeight` higher than `window.innerHeight` (which is 768)
+ // to prevent `hasReachedPageEnd` from always returning true
+ jest.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(1000);
+ // Mock current scroll position
+ jest.spyOn(utils, 'contentTop').mockReturnValue(currentScrollPosition);
+
+ wrapper.vm[fn]();
+
+ await nextTick();
+ });
+
+ it('expands discussion', () => {
+ expect(expandDiscussion).toHaveBeenCalledWith(expect.any(Object), {
+ discussionId: expectedId,
+ });
+ });
+
+ it(`scrolls to discussion element with id "${expectedId}"`, () => {
+ expect(utils.scrollToElement).toHaveBeenLastCalledWith(
+ findDiscussionEl(expectedId),
+ undefined,
+ );
+ });
});
- });
- });
+ },
+ );
});
});
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 97249d232dc..50df63d06af 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -68,6 +68,8 @@ describe('Actions Notes Store', () => {
resetStore(store);
axiosMock.restore();
resetHTMLFixture();
+
+ window.gon = {};
});
describe('setNotesData', () => {
@@ -872,26 +874,6 @@ describe('Actions Notes Store', () => {
});
});
- describe('if response contains errors.base', () => {
- const res = { errors: { base: ['something went wrong'] } };
- const error = { message: 'Unprocessable entity', response: { data: res } };
-
- it('sets an alert using errors.base message', async () => {
- const resp = await actions.saveNote(
- {
- commit() {},
- dispatch: () => Promise.reject(error),
- },
- { ...payload, flashContainer },
- );
- expect(resp.hasAlert).toBe(true);
- expect(createAlert).toHaveBeenCalledWith({
- message: 'Your comment could not be submitted because something went wrong',
- parent: flashContainer,
- });
- });
- });
-
describe('if response contains no errors', () => {
const res = { valid: true };
@@ -1467,6 +1449,29 @@ describe('Actions Notes Store', () => {
);
});
+ it('dispatches `fetchDiscussionsBatch` action with notes_filter 0 for merge request', () => {
+ window.gon = { features: { mrActivityFilters: true } };
+
+ return testAction(
+ actions.fetchDiscussions,
+ { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' },
+ { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE },
+ [],
+ [
+ {
+ type: 'fetchDiscussionsBatch',
+ payload: {
+ config: {
+ params: { notes_filter: 0, persist_filter: false },
+ },
+ path: 'test-path',
+ perPage: 20,
+ },
+ },
+ ],
+ );
+ });
+
it('dispatches `fetchDiscussionsBatch` action if noteable is an Issue', () => {
return testAction(
actions.fetchDiscussions,
diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js
index 8809a496c52..385aee2c1aa 100644
--- a/spec/frontend/notes/stores/mutation_spec.js
+++ b/spec/frontend/notes/stores/mutation_spec.js
@@ -114,13 +114,33 @@ describe('Notes Store mutations', () => {
});
describe('REMOVE_PLACEHOLDER_NOTES', () => {
- it('should remove all placeholder notes in indivudal notes and discussion', () => {
+ it('should remove all placeholder individual notes', () => {
const placeholderNote = { ...individualNote, isPlaceholderNote: true };
const state = { discussions: [placeholderNote] };
+
mutations.REMOVE_PLACEHOLDER_NOTES(state);
expect(state.discussions).toEqual([]);
});
+
+ it.each`
+ discussionType | discussion
+ ${'initial'} | ${individualNote}
+ ${'continued'} | ${discussionMock}
+ `('should remove all placeholder notes from $discussionType discussions', ({ discussion }) => {
+ const lengthBefore = discussion.notes.length;
+
+ const placeholderNote = { ...individualNote, isPlaceholderNote: true };
+ discussion.notes.push(placeholderNote);
+
+ const state = {
+ discussions: [discussion],
+ };
+
+ mutations.REMOVE_PLACEHOLDER_NOTES(state);
+
+ expect(state.discussions[0].notes.length).toEqual(lengthBefore);
+ });
});
describe('SET_NOTES_DATA', () => {
diff --git a/spec/frontend/notes/utils_spec.js b/spec/frontend/notes/utils_spec.js
new file mode 100644
index 00000000000..0882e0a5759
--- /dev/null
+++ b/spec/frontend/notes/utils_spec.js
@@ -0,0 +1,46 @@
+import { sprintf } from '~/locale';
+import { getErrorMessages } from '~/notes/utils';
+import { HTTP_STATUS_UNPROCESSABLE_ENTITY, HTTP_STATUS_BAD_REQUEST } from '~/lib/utils/http_status';
+import { COMMENT_FORM } from '~/notes/i18n';
+
+describe('getErrorMessages', () => {
+ describe('when http status is not HTTP_STATUS_UNPROCESSABLE_ENTITY', () => {
+ it('returns generic error', () => {
+ const errorMessages = getErrorMessages(
+ { errors: ['unknown error'] },
+ HTTP_STATUS_BAD_REQUEST,
+ );
+
+ expect(errorMessages).toStrictEqual([COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]);
+ });
+ });
+
+ describe('when http status is HTTP_STATUS_UNPROCESSABLE_ENTITY', () => {
+ it('returns all errors', () => {
+ const errorMessages = getErrorMessages(
+ { errors: 'error 1 and error 2' },
+ HTTP_STATUS_UNPROCESSABLE_ENTITY,
+ );
+
+ expect(errorMessages).toStrictEqual([
+ sprintf(COMMENT_FORM.error, { reason: 'error 1 and error 2' }),
+ ]);
+ });
+
+ describe('when response contains commands_only errors', () => {
+ it('only returns commands_only errors', () => {
+ const errorMessages = getErrorMessages(
+ {
+ errors: {
+ commands_only: ['commands_only error 1', 'commands_only error 2'],
+ base: ['base error 1'],
+ },
+ },
+ HTTP_STATUS_UNPROCESSABLE_ENTITY,
+ );
+
+ expect(errorMessages).toStrictEqual(['commands_only error 1', 'commands_only error 2']);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js
deleted file mode 100644
index 5bccf4943ae..00000000000
--- a/spec/frontend/operation_settings/components/metrics_settings_spec.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import { GlButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { TEST_HOST } from 'helpers/test_constants';
-import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { refreshCurrentPage } from '~/lib/utils/url_utility';
-import { timezones } from '~/monitoring/format_date';
-import DashboardTimezone from '~/operation_settings/components/form_group/dashboard_timezone.vue';
-import ExternalDashboard from '~/operation_settings/components/form_group/external_dashboard.vue';
-import MetricsSettings from '~/operation_settings/components/metrics_settings.vue';
-
-import store from '~/operation_settings/store';
-
-jest.mock('~/lib/utils/url_utility');
-jest.mock('~/alert');
-
-describe('operation settings external dashboard component', () => {
- let wrapper;
-
- const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`;
- const helpPage = `${TEST_HOST}/help/metrics/page/path`;
- const externalDashboardUrl = `http://mock-external-domain.com/external/dashboard/url`;
- const dashboardTimezoneSetting = timezones.LOCAL;
-
- const mountComponent = (shallow = true) => {
- const config = [
- MetricsSettings,
- {
- store: store({
- operationsSettingsEndpoint,
- helpPage,
- externalDashboardUrl,
- dashboardTimezoneSetting,
- }),
- stubs: {
- ExternalDashboard,
- DashboardTimezone,
- },
- },
- ];
- wrapper = shallow ? shallowMount(...config) : mount(...config);
- };
-
- beforeEach(() => {
- jest.spyOn(axios, 'patch').mockImplementation();
- });
-
- afterEach(() => {
- axios.patch.mockReset();
- refreshCurrentPage.mockReset();
- createAlert.mockReset();
- });
-
- it('renders header text', () => {
- mountComponent();
- expect(wrapper.find('.js-section-header').text()).toBe('Metrics');
- });
-
- describe('expand/collapse button', () => {
- it('renders as an expand button by default', () => {
- mountComponent();
- const button = wrapper.findComponent(GlButton);
-
- expect(button.text()).toBe('Expand');
- });
- });
-
- describe('sub-header', () => {
- let subHeader;
-
- beforeEach(() => {
- mountComponent();
- subHeader = wrapper.find('.js-section-sub-header');
- });
-
- it('renders descriptive text', () => {
- expect(subHeader.text()).toContain('Manage metrics dashboard settings.');
- });
-
- it('renders help page link', () => {
- const link = subHeader.findComponent(GlLink);
-
- expect(link.text()).toBe('Learn more.');
- expect(link.attributes().href).toBe(helpPage);
- });
- });
-
- describe('form', () => {
- describe('dashboard timezone', () => {
- describe('field label', () => {
- let formGroup;
-
- beforeEach(() => {
- mountComponent(false);
- formGroup = wrapper.findComponent(DashboardTimezone).findComponent(GlFormGroup);
- });
-
- it('uses label text', () => {
- expect(formGroup.find('label').text()).toBe('Dashboard timezone');
- });
-
- it('uses description text', () => {
- const description = formGroup.find('small');
- const expectedDescription =
- "Choose whether to display dashboard metrics in UTC or the user's local timezone.";
-
- expect(description.text()).toBe(expectedDescription);
- });
- });
-
- describe('select field', () => {
- let select;
-
- beforeEach(() => {
- mountComponent();
- select = wrapper.findComponent(DashboardTimezone).findComponent(GlFormSelect);
- });
-
- it('defaults to externalDashboardUrl', () => {
- expect(select.attributes('value')).toBe(dashboardTimezoneSetting);
- });
- });
- });
-
- describe('external dashboard', () => {
- describe('input label', () => {
- let formGroup;
-
- beforeEach(() => {
- mountComponent(false);
- formGroup = wrapper.findComponent(ExternalDashboard).findComponent(GlFormGroup);
- });
-
- it('uses label text', () => {
- expect(formGroup.find('label').text()).toBe('External dashboard URL');
- });
-
- it('uses description text', () => {
- const description = formGroup.find('small');
- const expectedDescription =
- 'Add a button to the metrics dashboard linking directly to your existing external dashboard.';
-
- expect(description.text()).toBe(expectedDescription);
- });
- });
-
- describe('input field', () => {
- let input;
-
- beforeEach(() => {
- mountComponent();
- input = wrapper.findComponent(ExternalDashboard).findComponent(GlFormInput);
- });
-
- it('defaults to externalDashboardUrl', () => {
- expect(input.attributes().value).toBe(externalDashboardUrl);
- });
-
- it('uses a placeholder', () => {
- expect(input.attributes().placeholder).toBe('https://my-org.gitlab.io/my-dashboards');
- });
- });
- });
-
- describe('submit button', () => {
- const findSubmitButton = () => wrapper.find('.settings-content form').findComponent(GlButton);
-
- const endpointRequest = [
- operationsSettingsEndpoint,
- {
- project: {
- metrics_setting_attributes: {
- dashboard_timezone: dashboardTimezoneSetting,
- external_dashboard_url: externalDashboardUrl,
- },
- },
- },
- ];
-
- it('renders button label', () => {
- mountComponent();
- const submit = findSubmitButton();
- expect(submit.text()).toBe('Save Changes');
- });
-
- it('submits form on click', async () => {
- mountComponent(false);
- axios.patch.mockResolvedValue();
- findSubmitButton().trigger('click');
-
- expect(axios.patch).toHaveBeenCalledWith(...endpointRequest);
-
- await nextTick();
- expect(refreshCurrentPage).toHaveBeenCalled();
- });
-
- it('creates an alert on error', async () => {
- mountComponent(false);
- const message = 'mockErrorMessage';
- axios.patch.mockRejectedValue({ response: { data: { message } } });
- findSubmitButton().trigger('click');
-
- expect(axios.patch).toHaveBeenCalledWith(...endpointRequest);
-
- await nextTick();
- await jest.runAllTicks();
- expect(createAlert).toHaveBeenCalledWith({
- message: `There was an error saving your changes. ${message}`,
- });
- });
- });
- });
-});
diff --git a/spec/frontend/operation_settings/store/mutations_spec.js b/spec/frontend/operation_settings/store/mutations_spec.js
deleted file mode 100644
index db6b54b503d..00000000000
--- a/spec/frontend/operation_settings/store/mutations_spec.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { timezones } from '~/monitoring/format_date';
-import mutations from '~/operation_settings/store/mutations';
-import createState from '~/operation_settings/store/state';
-
-describe('operation settings mutations', () => {
- let localState;
-
- beforeEach(() => {
- localState = createState();
- });
-
- describe('SET_EXTERNAL_DASHBOARD_URL', () => {
- it('sets externalDashboardUrl', () => {
- const mockUrl = 'mockUrl';
- mutations.SET_EXTERNAL_DASHBOARD_URL(localState, mockUrl);
-
- expect(localState.externalDashboard.url).toBe(mockUrl);
- });
- });
-
- describe('SET_DASHBOARD_TIMEZONE', () => {
- it('sets dashboardTimezoneSetting', () => {
- mutations.SET_DASHBOARD_TIMEZONE(localState, timezones.LOCAL);
-
- expect(localState.dashboardTimezone.selected).not.toBeUndefined();
- expect(localState.dashboardTimezone.selected).toBe(timezones.LOCAL);
- });
- });
-});
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js
index 1e9b9b1ce47..d5a87945c16 100644
--- a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js
@@ -132,7 +132,7 @@ describe('Harbor artifact list row', () => {
},
});
- expect(findByTestId('size').text()).toBe('0 bytes');
+ expect(findByTestId('size').text()).toBe('0 B');
});
});
});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
index 148e87699f1..7f56d3e216c 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
@@ -51,7 +51,7 @@ describe('PackageTitle', () => {
it('correctly calculates the size', async () => {
await createComponent();
- expect(packageSize().props('text')).toBe('300 bytes');
+ expect(packageSize().props('text')).toBe('300 B');
});
});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
index c3e0818fc11..ca65d87f86c 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlButton } from '@gitlab/ui';
+import { GlDisclosureDropdown, GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue/';
import stubChildren from 'helpers/stub_children';
@@ -19,7 +19,7 @@ describe('Package Files', () => {
const findSecondRowCommitLink = () => findSecondRow().find('[data-testid="commit-link"]');
const findFirstRowFileIcon = () => findFirstRow().findComponent(FileIcon);
const findFirstRowCreatedAt = () => findFirstRow().findComponent(TimeAgoTooltip);
- const findFirstActionMenu = () => findFirstRow().findComponent(GlDropdown);
+ const findFirstActionMenu = () => findFirstRow().findComponent(GlDisclosureDropdown);
const findActionMenuDelete = () => findFirstActionMenu().find('[data-testid="delete-file"]');
const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton);
const findFirstRowShaComponent = (id) => wrapper.find(`[data-testid="${id}"]`);
@@ -159,7 +159,7 @@ describe('Package Files', () => {
it('emits a delete event when clicked', () => {
createComponent();
- findActionMenuDelete().vm.$emit('click');
+ findActionMenuDelete().vm.$emit('action');
const [[{ id }]] = wrapper.emitted('delete-file');
expect(id).toBe(npmFiles[0].id);
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
index 1dcac017ccf..2b60684e60a 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
@@ -1,22 +1,50 @@
-import { GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui';
-import { nextTick } from 'vue';
+import { GlAlert, GlDropdown, GlButton, GlFormCheckbox, GlLoadingIcon, GlModal } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { stubComponent } from 'helpers/stub_component';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { packageFiles as packageFilesMock } from 'jest/packages_and_registries/package_registry/mock_data';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import Tracking from '~/tracking';
+import { s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import {
+ packageFiles as packageFilesMock,
+ packageFilesQuery,
+ packageDestroyFilesMutation,
+ packageDestroyFilesMutationError,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import {
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
+ DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
+ DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
+ DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+} from '~/packages_and_registries/package_registry/constants';
import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import getPackageFiles from '~/packages_and_registries/package_registry/graphql/queries/get_package_files.query.graphql';
+import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
+
+Vue.use(VueApollo);
+jest.mock('~/alert');
+
describe('Package Files', () => {
let wrapper;
+ let apolloProvider;
const findAllRows = () => wrapper.findAllByTestId('file-row');
const findDeleteSelectedButton = () => wrapper.findByTestId('delete-selected');
+ const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal');
const findFirstRow = () => extendedWrapper(findAllRows().at(0));
const findSecondRow = () => extendedWrapper(findAllRows().at(1));
+ const findPackageFilesAlert = () => wrapper.findComponent(GlAlert);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findFirstRowDownloadLink = () => findFirstRow().findByTestId('download-link');
- const findFirstRowCommitLink = () => findFirstRow().findByTestId('commit-link');
- const findSecondRowCommitLink = () => findSecondRow().findByTestId('commit-link');
const findFirstRowFileIcon = () => findFirstRow().findComponent(FileIcon);
const findFirstRowCreatedAt = () => findFirstRow().findComponent(TimeAgoTooltip);
const findFirstActionMenu = () => extendedWrapper(findFirstRow().findComponent(GlDropdown));
@@ -29,146 +57,150 @@ describe('Package Files', () => {
const files = packageFilesMock();
const [file] = files;
+ const showMock = jest.fn();
+ const eventCategory = 'UI::NpmPackages';
+
const createComponent = ({
- packageFiles = [file],
- isLoading = false,
+ packageId = '1',
+ packageType = 'NPM',
+ projectPath = 'gitlab-test',
canDelete = true,
stubs,
+ resolver = jest.fn().mockResolvedValue(packageFilesQuery({ files: [file] })),
+ filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()),
} = {}) => {
+ const requestHandlers = [
+ [getPackageFiles, resolver],
+ [destroyPackageFilesMutation, filesDeleteMutationResolver],
+ ];
+ apolloProvider = createMockApollo(requestHandlers);
+
wrapper = mountExtended(PackageFiles, {
+ apolloProvider,
propsData: {
canDelete,
- isLoading,
- packageFiles,
+ packageId,
+ packageType,
+ projectPath,
},
stubs: {
GlTable: false,
+ GlModal: stubComponent(GlModal, {
+ methods: {
+ show: showMock,
+ },
+ }),
...stubs,
},
});
};
describe('rows', () => {
- it('renders a single file for an npm package', () => {
+ it('do not get rendered when query is loading', () => {
createComponent();
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findDeleteSelectedButton().props('disabled')).toBe(true);
+ });
+
+ it('renders a single file for an npm package', async () => {
+ createComponent();
+ await waitForPromises();
+
expect(findAllRows()).toHaveLength(1);
+ expect(findLoadingIcon().exists()).toBe(false);
});
- it('renders multiple files for a package that contains more than one file', () => {
- createComponent({ packageFiles: files });
+ it('renders multiple files for a package that contains more than one file', async () => {
+ createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) });
+ await waitForPromises();
expect(findAllRows()).toHaveLength(2);
});
+
+ it('does not render gl-alert', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findPackageFilesAlert().exists()).toBe(false);
+ });
+
+ it('renders gl-alert if load fails', async () => {
+ createComponent({ resolver: jest.fn().mockRejectedValue() });
+ await waitForPromises();
+
+ expect(findPackageFilesAlert().exists()).toBe(true);
+ expect(findPackageFilesAlert().text()).toBe(
+ s__('PackageRegistry|Something went wrong while fetching package assets.'),
+ );
+ });
});
describe('link', () => {
- it('exists', () => {
+ beforeEach(async () => {
createComponent();
+ await waitForPromises();
+ });
+ it('exists', () => {
expect(findFirstRowDownloadLink().exists()).toBe(true);
});
it('has the correct attrs bound', () => {
- createComponent();
-
expect(findFirstRowDownloadLink().attributes('href')).toBe(file.downloadPath);
});
- it('emits "download-file" event on click', () => {
- createComponent();
+ it('tracks "download-file" event on click', () => {
+ const eventSpy = jest.spyOn(Tracking, 'event');
findFirstRowDownloadLink().vm.$emit('click');
- expect(wrapper.emitted('download-file')).toEqual([[]]);
+ expect(eventSpy).toHaveBeenCalledWith(
+ eventCategory,
+ DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
+ expect.any(Object),
+ );
});
});
describe('file-icon', () => {
- it('exists', () => {
+ beforeEach(async () => {
createComponent();
+ await waitForPromises();
+ });
+ it('exists', () => {
expect(findFirstRowFileIcon().exists()).toBe(true);
});
it('has the correct props bound', () => {
- createComponent();
-
expect(findFirstRowFileIcon().props('fileName')).toBe(file.fileName);
});
});
describe('time-ago tooltip', () => {
- it('exists', () => {
+ beforeEach(async () => {
createComponent();
+ await waitForPromises();
+ });
+ it('exists', () => {
expect(findFirstRowCreatedAt().exists()).toBe(true);
});
it('has the correct props bound', () => {
- createComponent();
-
expect(findFirstRowCreatedAt().props('time')).toBe(file.createdAt);
});
});
- describe('commit', () => {
- const withPipeline = {
- ...file,
- pipelines: [
- {
- sha: 'sha',
- id: 1,
- commitPath: 'commitPath',
- },
- ],
- };
-
- describe('when package file has a pipeline associated', () => {
- it('exists', () => {
- createComponent({ packageFiles: [withPipeline] });
-
- expect(findFirstRowCommitLink().exists()).toBe(true);
- });
-
- it('the link points to the commit path', () => {
- createComponent({ packageFiles: [withPipeline] });
-
- expect(findFirstRowCommitLink().attributes('href')).toBe(
- withPipeline.pipelines[0].commitPath,
- );
- });
-
- it('the text is the pipeline sha', () => {
- createComponent({ packageFiles: [withPipeline] });
-
- expect(findFirstRowCommitLink().text()).toBe(withPipeline.pipelines[0].sha);
- });
- });
-
- describe('when package file has no pipeline associated', () => {
- it('does not exist', () => {
- createComponent();
-
- expect(findFirstRowCommitLink().exists()).toBe(false);
- });
- });
-
- describe('when only one file lacks an associated pipeline', () => {
- it('renders the commit when it exists and not otherwise', () => {
- createComponent({ packageFiles: [withPipeline, file] });
-
- expect(findFirstRowCommitLink().exists()).toBe(true);
- expect(findSecondRowCommitLink().exists()).toBe(false);
- });
- });
- });
-
describe('action menu', () => {
describe('when the user can delete', () => {
- it('exists', () => {
+ beforeEach(async () => {
createComponent();
+ await waitForPromises();
+ });
+ it('exists', () => {
expect(findFirstActionMenu().exists()).toBe(true);
expect(findFirstActionMenu().props('icon')).toBe('ellipsis_v');
expect(findFirstActionMenu().props('textSrOnly')).toBe(true);
@@ -178,19 +210,17 @@ describe('Package Files', () => {
describe('menu items', () => {
describe('delete file', () => {
it('exists', () => {
- createComponent();
-
expect(findActionMenuDelete().exists()).toBe(true);
});
- it('emits a delete event when clicked', async () => {
- createComponent();
-
+ it('shows delete file confirmation modal', async () => {
await findActionMenuDelete().trigger('click');
- const [[items]] = wrapper.emitted('delete-files');
- const [{ id }] = items;
- expect(id).toBe(file.id);
+ expect(showMock).toHaveBeenCalledTimes(1);
+
+ expect(findDeleteFilesModal().text()).toBe(
+ 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?',
+ );
});
});
});
@@ -199,8 +229,9 @@ describe('Package Files', () => {
describe('when the user can not delete', () => {
const canDelete = false;
- it('does not exist', () => {
+ it('does not exist', async () => {
createComponent({ canDelete });
+ await waitForPromises();
expect(findFirstActionMenu().exists()).toBe(false);
});
@@ -209,22 +240,18 @@ describe('Package Files', () => {
describe('multi select', () => {
describe('when user can delete', () => {
- it('delete selected button exists & is disabled', () => {
+ it('delete selected button exists & is disabled', async () => {
createComponent();
+ await waitForPromises();
expect(findDeleteSelectedButton().exists()).toBe(true);
expect(findDeleteSelectedButton().text()).toMatchInterpolatedText('Delete selected');
expect(findDeleteSelectedButton().props('disabled')).toBe(true);
});
- it('delete selected button exists & is disabled when isLoading prop is true', () => {
- createComponent({ isLoading: true });
-
- expect(findDeleteSelectedButton().props('disabled')).toBe(true);
- });
-
- it('checkboxes to select file are visible', () => {
- createComponent({ packageFiles: files });
+ it('checkboxes to select file are visible', async () => {
+ createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) });
+ await waitForPromises();
expect(findCheckAllCheckbox().exists()).toBe(true);
expect(findAllRowCheckboxes()).toHaveLength(2);
@@ -232,6 +259,7 @@ describe('Package Files', () => {
it('selecting a checkbox enables delete selected button', async () => {
createComponent();
+ await waitForPromises();
const first = findAllRowCheckboxes().at(0);
@@ -244,7 +272,8 @@ describe('Package Files', () => {
it('will toggle between selecting all and deselecting all files', async () => {
const getChecked = () => findAllRowCheckboxes().filter((x) => x.element.checked === true);
- createComponent({ packageFiles: files });
+ createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) });
+ await waitForPromises();
expect(getChecked()).toHaveLength(0);
@@ -262,9 +291,10 @@ describe('Package Files', () => {
expect(findCheckAllCheckbox().props('indeterminate')).toBe(state);
createComponent({
- packageFiles: files,
+ resolver: jest.fn().mockResolvedValue(packageFilesQuery()),
stubs: { GlFormCheckbox: stubComponent(GlFormCheckbox, { props: ['indeterminate'] }) },
});
+ await waitForPromises();
expectIndeterminateState(false);
@@ -286,8 +316,9 @@ describe('Package Files', () => {
});
});
- it('emits a delete event when selected', async () => {
+ it('shows delete modal with single file confirmation text when delete selected is clicked', async () => {
createComponent();
+ await waitForPromises();
const first = findAllRowCheckboxes().at(0);
@@ -295,34 +326,94 @@ describe('Package Files', () => {
await findDeleteSelectedButton().trigger('click');
- const [[items]] = wrapper.emitted('delete-files');
- const [{ id }] = items;
- expect(id).toBe(file.id);
+ expect(showMock).toHaveBeenCalledTimes(1);
+
+ expect(findDeleteFilesModal().text()).toBe(
+ 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?',
+ );
});
- it('emits delete event with both items when all are selected', async () => {
- createComponent({ packageFiles: files });
+ it('shows delete modal with multiple files confirmation text when delete selected is clicked', async () => {
+ createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()) });
+ await waitForPromises();
await findCheckAllCheckbox().setChecked(true);
await findDeleteSelectedButton().trigger('click');
- const [[items]] = wrapper.emitted('delete-files');
- expect(items).toHaveLength(2);
+ expect(showMock).toHaveBeenCalledTimes(1);
+
+ expect(findDeleteFilesModal().text()).toMatchInterpolatedText(
+ 'You are about to delete 2 assets. This operation is irreversible.',
+ );
+ });
+
+ describe('emits delete-all-files event', () => {
+ it('with right content for last file in package', async () => {
+ createComponent({
+ resolver: jest.fn().mockResolvedValue(
+ packageFilesQuery({
+ files: [file],
+ pageInfo: {
+ hasNextPage: false,
+ },
+ }),
+ ),
+ });
+ await waitForPromises();
+ const first = findAllRowCheckboxes().at(0);
+
+ await first.setChecked(true);
+
+ await findDeleteSelectedButton().trigger('click');
+
+ expect(showMock).toHaveBeenCalledTimes(0);
+
+ expect(wrapper.emitted('delete-all-files')).toHaveLength(1);
+ expect(wrapper.emitted('delete-all-files')[0]).toEqual([
+ DELETE_LAST_PACKAGE_FILE_MODAL_CONTENT,
+ ]);
+ });
+
+ it('with right content for all files in package', async () => {
+ createComponent({
+ resolver: jest.fn().mockResolvedValue(
+ packageFilesQuery({
+ pageInfo: {
+ hasNextPage: false,
+ },
+ }),
+ ),
+ });
+ await waitForPromises();
+
+ await findCheckAllCheckbox().setChecked(true);
+
+ await findDeleteSelectedButton().trigger('click');
+
+ expect(showMock).toHaveBeenCalledTimes(0);
+
+ expect(wrapper.emitted('delete-all-files')).toHaveLength(1);
+ expect(wrapper.emitted('delete-all-files')[0]).toEqual([
+ DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
+ ]);
+ });
});
});
describe('when user cannot delete', () => {
const canDelete = false;
- it('delete selected button does not exist', () => {
+ it('delete selected button does not exist', async () => {
createComponent({ canDelete });
+ await waitForPromises();
expect(findDeleteSelectedButton().exists()).toBe(false);
});
- it('checkboxes to select file are not visible', () => {
- createComponent({ packageFiles: files, canDelete });
+ it('checkboxes to select file are not visible', async () => {
+ createComponent({ resolver: jest.fn().mockResolvedValue(packageFilesQuery()), canDelete });
+ await waitForPromises();
expect(findCheckAllCheckbox().exists()).toBe(false);
expect(findAllRowCheckboxes()).toHaveLength(0);
@@ -330,26 +421,220 @@ describe('Package Files', () => {
});
});
+ describe('deleting a file', () => {
+ const doDeleteFile = async () => {
+ const first = findAllRowCheckboxes().at(0);
+
+ await first.setChecked(true);
+
+ await findDeleteSelectedButton().trigger('click');
+
+ findDeleteFilesModal().vm.$emit('primary');
+ };
+
+ it('confirming on the modal sets the loading state', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ await doDeleteFile();
+
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('confirming on the modal deletes the file and shows a success message', async () => {
+ const resolver = jest.fn().mockResolvedValue(packageFilesQuery({ files: [file] }));
+ const filesDeleteMutationResolver = jest
+ .fn()
+ .mockResolvedValue(packageDestroyFilesMutation());
+ createComponent({ resolver, filesDeleteMutationResolver });
+
+ await waitForPromises();
+
+ await doDeleteFile();
+
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ }),
+ );
+
+ expect(filesDeleteMutationResolver).toHaveBeenCalledWith({
+ ids: [file.id],
+ projectPath: 'gitlab-test',
+ });
+
+ // we are re-fetching the package files, so we expect the resolver to have been called twice
+ expect(resolver).toHaveBeenCalledTimes(2);
+ expect(resolver).toHaveBeenCalledWith({
+ id: '1',
+ first: 100,
+ });
+ });
+
+ describe('errors', () => {
+ it('shows an error when the mutation request fails', async () => {
+ createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
+ await waitForPromises();
+
+ await doDeleteFile();
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ }),
+ );
+ });
+
+ it('shows an error when the mutation request returns an error payload', async () => {
+ createComponent({
+ filesDeleteMutationResolver: jest
+ .fn()
+ .mockResolvedValue(packageDestroyFilesMutationError()),
+ });
+ await waitForPromises();
+
+ await doDeleteFile();
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ }),
+ );
+ });
+ });
+ });
+
+ describe('deleting multiple files', () => {
+ const doDeleteFiles = async () => {
+ await findCheckAllCheckbox().setChecked(true);
+
+ await findDeleteSelectedButton().trigger('click');
+
+ findDeleteFilesModal().vm.$emit('primary');
+ };
+
+ it('confirming on the modal sets the loading state', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ await doDeleteFiles();
+
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('confirming on the modal deletes the file and shows a success message', async () => {
+ const resolver = jest.fn().mockResolvedValue(packageFilesQuery());
+ const filesDeleteMutationResolver = jest
+ .fn()
+ .mockResolvedValue(packageDestroyFilesMutation());
+ createComponent({ resolver, filesDeleteMutationResolver });
+
+ await waitForPromises();
+
+ await doDeleteFiles();
+
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
+ }),
+ );
+
+ expect(filesDeleteMutationResolver).toHaveBeenCalledWith({
+ ids: files.map(({ id }) => id),
+ projectPath: 'gitlab-test',
+ });
+
+ // we are re-fetching the package files, so we expect the resolver to have been called twice
+ expect(resolver).toHaveBeenCalledTimes(2);
+ expect(resolver).toHaveBeenCalledWith({
+ id: '1',
+ first: 100,
+ });
+ });
+
+ describe('errors', () => {
+ it('shows an error when the mutation request fails', async () => {
+ const resolver = jest.fn().mockResolvedValue(packageFilesQuery());
+ createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue(), resolver });
+ await waitForPromises();
+
+ await doDeleteFiles();
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+ }),
+ );
+ });
+
+ it('shows an error when the mutation request returns an error payload', async () => {
+ const resolver = jest.fn().mockResolvedValue(packageFilesQuery());
+ createComponent({
+ filesDeleteMutationResolver: jest
+ .fn()
+ .mockResolvedValue(packageDestroyFilesMutationError()),
+ resolver,
+ });
+ await waitForPromises();
+
+ await doDeleteFiles();
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+ }),
+ );
+ });
+ });
+ });
+
describe('additional details', () => {
describe('details toggle button', () => {
- it('exists', () => {
+ it('exists', async () => {
createComponent();
+ await waitForPromises();
expect(findFirstToggleDetailsButton().exists()).toBe(true);
});
- it('is hidden when no details is present', () => {
+ it('is hidden when no details is present', async () => {
const { ...noShaFile } = file;
noShaFile.fileSha256 = null;
noShaFile.fileMd5 = null;
noShaFile.fileSha1 = null;
- createComponent({ packageFiles: [noShaFile] });
+ createComponent({
+ resolver: jest.fn().mockResolvedValue(packageFilesQuery({ files: [noShaFile] })),
+ });
+ await waitForPromises();
expect(findFirstToggleDetailsButton().exists()).toBe(false);
});
it('toggles the details row', async () => {
createComponent();
+ await waitForPromises();
expect(findFirstToggleDetailsButton().props('icon')).toBe('chevron-down');
@@ -380,6 +665,7 @@ describe('Package Files', () => {
${'sha-1'} | ${'SHA-1'} | ${'be93151dc23ac34a82752444556fe79b32c7a1ad'}
`('has a $title row', async ({ selector, title, sha }) => {
createComponent();
+ await waitForPromises();
await showShaFiles();
@@ -393,7 +679,10 @@ describe('Package Files', () => {
const { ...missingMd5 } = file;
missingMd5.fileMd5 = null;
- createComponent({ packageFiles: [missingMd5] });
+ createComponent({
+ resolver: jest.fn().mockResolvedValue(packageFilesQuery({ files: [missingMd5] })),
+ });
+ await waitForPromises();
await showShaFiles();
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
index fc0ca0e898f..7fe8db1c2f7 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
@@ -46,7 +46,6 @@ describe('PackageTitle', () => {
const findTitleArea = () => wrapper.findComponent(TitleArea);
const findPackageType = () => wrapper.findByTestId('package-type');
- const findPackageSize = () => wrapper.findByTestId('package-size');
const findPipelineProject = () => wrapper.findByTestId('pipeline-project');
const findPackageRef = () => wrapper.findByTestId('package-ref');
const findPackageLastDownloadedAt = () => wrapper.findByTestId('package-last-downloaded-at');
@@ -147,20 +146,6 @@ describe('PackageTitle', () => {
});
});
- describe('calculates the package size', () => {
- it('correctly calculates when there is only 1 file', async () => {
- await createComponent({ ...packageData(), packageFiles: { nodes: [packageFiles()[0]] } });
-
- expect(findPackageSize().props()).toMatchObject({ text: '400.00 KiB', icon: 'disk' });
- });
-
- it('correctly calculates when there are multiple files', async () => {
- await createComponent();
-
- expect(findPackageSize().props('text')).toBe('800.00 KiB');
- });
- });
-
describe('package tags', () => {
it('displays the package-tags component when the package has tags', async () => {
await createComponent();
diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js
index 5fb53566d4e..6995a4cc635 100644
--- a/spec/frontend/packages_and_registries/package_registry/mock_data.js
+++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js
@@ -253,13 +253,6 @@ export const packageDetailsQuery = ({
nodes: packagePipelines(),
__typename: 'PipelineConnection',
},
- packageFiles: {
- pageInfo: {
- hasNextPage: true,
- },
- nodes: packageFiles(),
- __typename: 'PackageFileConnection',
- },
versions: {
count: packageVersions().length,
},
@@ -285,6 +278,23 @@ export const packagePipelinesQuery = (pipelines = packagePipelines()) => ({
},
});
+export const packageFilesQuery = ({ files = packageFiles(), pageInfo = {} } = {}) => ({
+ data: {
+ package: {
+ id: 'gid://gitlab/Packages::Package/111',
+ packageFiles: {
+ pageInfo: {
+ hasNextPage: true,
+ ...pageInfo,
+ },
+ nodes: files,
+ __typename: 'PackageFileConnection',
+ },
+ __typename: 'PackageDetailsType',
+ },
+ },
+});
+
export const emptyPackageDetailsQuery = () => ({
data: {
package: {
diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js
index 0962b4fa757..0f91a7aeb50 100644
--- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js
@@ -21,10 +21,7 @@ import {
REQUEST_FORWARDING_HELP_PAGE_PATH,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
PACKAGE_TYPE_COMPOSER,
- DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
- DELETE_PACKAGE_FILE_ERROR_MESSAGE,
- DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
- DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+ DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT,
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_MAVEN,
PACKAGE_TYPE_CONAN,
@@ -32,7 +29,6 @@ import {
PACKAGE_TYPE_NPM,
} from '~/packages_and_registries/package_registry/constants';
-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 {
@@ -41,9 +37,6 @@ import {
packageVersions,
dependencyLinks,
emptyPackageDetailsQuery,
- packageFiles,
- packageDestroyFilesMutation,
- packageDestroyFilesMutationError,
defaultPackageGroupSettings,
} from '../mock_data';
@@ -74,13 +67,9 @@ describe('PackagesApp', () => {
function createComponent({
resolver = jest.fn().mockResolvedValue(packageDetailsQuery()),
- filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()),
routeId = '1',
} = {}) {
- const requestHandlers = [
- [getPackageDetails, resolver],
- [destroyPackageFilesMutation, filesDeleteMutationResolver],
- ];
+ const requestHandlers = [[getPackageDetails, resolver]];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMountExtended(PackagesApp, {
@@ -117,8 +106,6 @@ describe('PackagesApp', () => {
const findDeleteModal = () => wrapper.findByTestId('delete-modal');
const findDeleteButton = () => wrapper.findByTestId('delete-package');
const findPackageFiles = () => wrapper.findComponent(PackageFiles);
- const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal');
- const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal');
const findVersionsList = () => wrapper.findComponent(PackageVersionsList);
const findVersionsCountBadge = () => wrapper.findByTestId('other-versions-badge');
const findNoVersionsMessage = () => wrapper.findByTestId('no-versions-message');
@@ -328,18 +315,18 @@ describe('PackagesApp', () => {
describe('package files', () => {
it('renders the package files component and has the right props', async () => {
- const expectedFile = { ...packageFiles()[0] };
- // eslint-disable-next-line no-underscore-dangle
- delete expectedFile.__typename;
createComponent();
await waitForPromises();
expect(findPackageFiles().exists()).toBe(true);
- expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile);
- expect(findPackageFiles().props('canDelete')).toBe(packageData().canDestroy);
- expect(findPackageFiles().props('isLoading')).toEqual(false);
+ expect(findPackageFiles().props()).toMatchObject({
+ canDelete: packageData().canDestroy,
+ packageId: packageData().id,
+ packageType: packageData().packageType,
+ projectPath: 'gitlab-test',
+ });
});
it('does not render the package files table when the package is composer', async () => {
@@ -356,250 +343,26 @@ describe('PackagesApp', () => {
expect(findPackageFiles().exists()).toBe(false);
});
- describe('deleting a file', () => {
- const [fileToDelete] = packageFiles();
-
- const doDeleteFile = () => {
- findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
-
- findDeleteFileModal().vm.$emit('primary');
-
- return waitForPromises();
- };
-
- it('opens delete file confirmation modal', async () => {
- createComponent();
-
- await waitForPromises();
-
- findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
-
- expect(showMock).toHaveBeenCalledTimes(1);
-
- await waitForPromises();
-
- expect(findDeleteFileModal().text()).toBe(
- 'You are about to delete foo-1.0.1.tgz. This is a destructive action that may render your package unusable. Are you sure?',
- );
- });
-
- it('when its the only file opens delete package confirmation modal', async () => {
- const [packageFile] = packageFiles();
+ describe('emits delete-all-files event', () => {
+ it('opens the delete package confirmation modal and shows confirmation text', async () => {
const resolver = jest.fn().mockResolvedValue(
packageDetailsQuery({
- extendPackage: {
- packageFiles: {
- pageInfo: {
- hasNextPage: false,
- },
- nodes: [packageFile],
- __typename: 'PackageFileConnection',
- },
- },
+ extendPackage: {},
packageSettings: {
...defaultPackageGroupSettings,
npmPackageRequestsForwarding: false,
},
}),
);
-
- createComponent({
- resolver,
- });
-
- await waitForPromises();
-
- findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
-
- expect(showMock).toHaveBeenCalledTimes(1);
-
- await waitForPromises();
-
- expect(findDeleteModal().text()).toBe(
- 'Deleting the last package asset will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?',
- );
- });
-
- it('confirming on the modal sets the loading state', async () => {
- createComponent();
-
- await waitForPromises();
-
- findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
-
- findDeleteFileModal().vm.$emit('primary');
-
- await nextTick();
-
- expect(findPackageFiles().props('isLoading')).toEqual(true);
- });
-
- it('confirming on the modal deletes the file and shows a success message', async () => {
- const resolver = jest.fn().mockResolvedValue(packageDetailsQuery());
createComponent({ resolver });
await waitForPromises();
- await doDeleteFile();
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
- }),
- );
- // we are re-fetching the package details, so we expect the resolver to have been called twice
- expect(resolver).toHaveBeenCalledTimes(2);
- });
-
- describe('errors', () => {
- it('shows an error when the mutation request fails', async () => {
- createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
- await waitForPromises();
-
- await doDeleteFile();
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
- }),
- );
- });
-
- it('shows an error when the mutation request returns an error payload', async () => {
- createComponent({
- filesDeleteMutationResolver: jest
- .fn()
- .mockResolvedValue(packageDestroyFilesMutationError()),
- });
- await waitForPromises();
-
- await doDeleteFile();
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
- }),
- );
- });
- });
- });
-
- describe('deleting multiple files', () => {
- const doDeleteFiles = () => {
- findPackageFiles().vm.$emit('delete-files', packageFiles());
-
- findDeleteFilesModal().vm.$emit('primary');
-
- return waitForPromises();
- };
-
- it('opens delete files confirmation modal', async () => {
- createComponent();
-
- await waitForPromises();
-
- const showDeleteFilesSpy = jest.spyOn(wrapper.vm.$refs.deleteFilesModal, 'show');
-
- findPackageFiles().vm.$emit('delete-files', packageFiles());
-
- expect(showDeleteFilesSpy).toHaveBeenCalled();
- });
-
- it('confirming on the modal sets the loading state', async () => {
- createComponent();
-
- await waitForPromises();
-
- findPackageFiles().vm.$emit('delete-files', packageFiles());
-
- findDeleteFilesModal().vm.$emit('primary');
-
- await nextTick();
-
- expect(findPackageFiles().props('isLoading')).toEqual(true);
- });
-
- it('confirming on the modal deletes the file and shows a success message', async () => {
- const resolver = jest.fn().mockResolvedValue(packageDetailsQuery());
- createComponent({ resolver });
-
- await waitForPromises();
-
- await doDeleteFiles();
-
- expect(resolver).toHaveBeenCalledTimes(2);
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
- }),
- );
- // we are re-fetching the package details, so we expect the resolver to have been called twice
- expect(resolver).toHaveBeenCalledTimes(2);
- });
-
- describe('errors', () => {
- it('shows an error when the mutation request fails', async () => {
- createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
- await waitForPromises();
-
- await doDeleteFiles();
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
- }),
- );
- });
-
- it('shows an error when the mutation request returns an error payload', async () => {
- createComponent({
- filesDeleteMutationResolver: jest
- .fn()
- .mockResolvedValue(packageDestroyFilesMutationError()),
- });
- await waitForPromises();
-
- await doDeleteFiles();
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
- }),
- );
- });
- });
- });
-
- describe('deleting all files', () => {
- it('opens the delete package confirmation modal', async () => {
- const resolver = jest.fn().mockResolvedValue(
- packageDetailsQuery({
- extendPackage: {
- packageFiles: {
- pageInfo: {
- hasNextPage: false,
- },
- nodes: packageFiles(),
- },
- },
- packageSettings: {
- ...defaultPackageGroupSettings,
- npmPackageRequestsForwarding: false,
- },
- }),
- );
- createComponent({
- resolver,
- });
-
- await waitForPromises();
-
- findPackageFiles().vm.$emit('delete-files', packageFiles());
+ findPackageFiles().vm.$emit('delete-all-files', DELETE_ALL_PACKAGE_FILES_MODAL_CONTENT);
expect(showMock).toHaveBeenCalledTimes(1);
- await waitForPromises();
+ await nextTick();
expect(findDeleteModal().text()).toBe(
'Deleting all package assets will remove version 1.0.0 of @gitlab-org/package-15. Are you sure?',
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
index a68087f7f57..5c64d4cb697 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
@@ -18,7 +18,7 @@ describe('Container Expiration Policy Settings Form', () => {
const defaultProvidedValues = {
projectPath: 'path',
- projectSettingsPath: 'settings-path',
+ projectSettingsPath: '/settings-path',
};
const {
@@ -286,8 +286,8 @@ describe('Container Expiration Policy Settings Form', () => {
await submitForm();
- expect(window.location.href.endsWith('settings-path?showSetupSuccessAlert=true')).toBe(
- true,
+ expect(window.location.assign).toHaveBeenCalledWith(
+ '/settings-path?showSetupSuccessAlert=true',
);
});
diff --git a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js
index dad7308ac0a..71ebf64f43c 100644
--- a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js
+++ b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js
@@ -120,34 +120,28 @@ describe('Job table app', () => {
});
it('should refetch jobs query on fetchJobsByStatus event', async () => {
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
await findTabs().vm.$emit('fetchJobsByStatus');
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
+ expect(successHandler).toHaveBeenCalledTimes(2);
});
it('avoids refetch jobs query when scope has not changed', async () => {
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
await findTabs().vm.$emit('fetchJobsByStatus', null);
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
});
it('should refetch jobs count query when the amount jobs and count do not match', async () => {
- jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
// after applying filter a new count is fetched
findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+ expect(successHandler).toHaveBeenCalledTimes(2);
// tab is switched to `finished`, no count
await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']);
@@ -155,7 +149,7 @@ describe('Job table app', () => {
// tab is switched back to `all`, the old filter count has to be overwritten with new count
await findTabs().vm.$emit('fetchJobsByStatus', null);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(2);
+ expect(successHandler).toHaveBeenCalledTimes(4);
});
describe('when infinite scrolling is triggered', () => {
@@ -313,25 +307,21 @@ describe('Job table app', () => {
it('refetches jobs query when filtering', async () => {
createComponent();
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
+ expect(successHandler).toHaveBeenCalledTimes(2);
});
it('refetches jobs count query when filtering', async () => {
createComponent();
- jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
-
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(1);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(2);
});
it('shows raw text warning when user inputs raw text', async () => {
@@ -342,14 +332,14 @@ describe('Job table app', () => {
createComponent();
- jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
- jest.spyOn(wrapper.vm.$apollo.queries.jobsCount, 'refetch').mockImplementation(jest.fn());
+ expect(successHandler).toHaveBeenCalledTimes(1);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']);
expect(createAlert).toHaveBeenCalledWith(expectedWarning);
- expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
- expect(wrapper.vm.$apollo.queries.jobsCount.refetch).toHaveBeenCalledTimes(0);
+ expect(successHandler).toHaveBeenCalledTimes(1);
+ expect(countSuccessHandler).toHaveBeenCalledTimes(1);
});
it('updates URL query string when filtering jobs by status', async () => {
diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
index b308d6305da..23fa4739645 100644
--- a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
@@ -113,7 +113,7 @@ describe('ProjectNamespace component', () => {
});
it('displays fetched namespaces', () => {
- const listItems = wrapper.findAll('li');
+ const listItems = wrapper.findAll('[role="option"]');
expect(listItems).toHaveLength(2);
expect(listItems.at(0).text()).toBe(data.project.forkTargets.nodes[0].fullPath);
expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[1].fullPath);
diff --git a/spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js
new file mode 100644
index 00000000000..4ac3a511fa2
--- /dev/null
+++ b/spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js
@@ -0,0 +1,147 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlBadge, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui';
+
+import { createAlert } from '~/alert';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+
+import catalogResourcesCreate from '~/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql';
+import getCiCatalogSettingsQuery from '~/pages/projects/shared/permissions/graphql/queries/get_ci_catalog_settings.query.graphql';
+import CiCatalogSettings, {
+ i18n,
+} from '~/pages/projects/shared/permissions/components/ci_catalog_settings.vue';
+
+import { mockCiCatalogSettingsResponse } from './mock_data';
+
+Vue.use(VueApollo);
+jest.mock('~/alert');
+
+describe('CiCatalogSettings', () => {
+ let wrapper;
+ let ciCatalogSettingsResponse;
+ let catalogResourcesCreateResponse;
+
+ const fullPath = 'gitlab-org/gitlab';
+
+ const createComponent = ({ ciCatalogSettingsHandler = ciCatalogSettingsResponse } = {}) => {
+ const handlers = [
+ [getCiCatalogSettingsQuery, ciCatalogSettingsHandler],
+ [catalogResourcesCreate, catalogResourcesCreateResponse],
+ ];
+ const mockApollo = createMockApollo(handlers);
+
+ wrapper = shallowMountExtended(CiCatalogSettings, {
+ propsData: {
+ fullPath,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ apolloProvider: mockApollo,
+ });
+
+ return waitForPromises();
+ };
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findToggle = () => wrapper.findComponent(GlToggle);
+
+ const findCiCatalogSettings = () => wrapper.findByTestId('ci-catalog-settings');
+
+ beforeEach(() => {
+ ciCatalogSettingsResponse = jest.fn().mockResolvedValue(mockCiCatalogSettingsResponse);
+ catalogResourcesCreateResponse = jest.fn();
+ });
+
+ describe('when initial queries are loading', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows a loading icon and no CI catalog settings', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findCiCatalogSettings().exists()).toBe(false);
+ });
+ });
+
+ describe('when queries have loaded', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ it('does not show a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders the CI Catalog settings', () => {
+ expect(findCiCatalogSettings().exists()).toBe(true);
+ });
+
+ it('renders the experiment badge', () => {
+ expect(findBadge().exists()).toBe(true);
+ });
+
+ it('renders the toggle', () => {
+ expect(findToggle().exists()).toBe(true);
+ });
+
+ it('renders the modal', () => {
+ expect(findModal().exists()).toBe(true);
+ expect(findModal().attributes('title')).toBe(i18n.modal.title);
+ });
+
+ describe('when queries have loaded', () => {
+ beforeEach(() => {
+ catalogResourcesCreateResponse.mockResolvedValue(mockCiCatalogSettingsResponse);
+ });
+
+ it('shows the modal when the toggle is clicked', async () => {
+ expect(findModal().props('visible')).toBe(false);
+
+ await findToggle().vm.$emit('change', true);
+
+ expect(findModal().props('visible')).toBe(true);
+ expect(findModal().props('actionPrimary').text).toBe(i18n.modal.actionPrimary.text);
+ });
+
+ it('hides the modal when cancel is clicked', () => {
+ findToggle().vm.$emit('change', true);
+ findModal().vm.$emit('canceled');
+
+ expect(findModal().props('visible')).toBe(false);
+ expect(catalogResourcesCreateResponse).not.toHaveBeenCalled();
+ });
+
+ it('calls the mutation with the correct input from the modal click', async () => {
+ expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(0);
+
+ findToggle().vm.$emit('change', true);
+ findModal().vm.$emit('primary');
+ await waitForPromises();
+
+ expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(1);
+ expect(catalogResourcesCreateResponse).toHaveBeenCalledWith({
+ input: {
+ projectPath: fullPath,
+ },
+ });
+ });
+ });
+ });
+
+ describe('when the query is unsuccessful', () => {
+ const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+
+ it('throws an error', async () => {
+ await createComponent({ ciCatalogSettingsHandler: failedHandler });
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: i18n.catalogResourceQueryError });
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/mock_data.js b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js
new file mode 100644
index 00000000000..44bbf2a5eb2
--- /dev/null
+++ b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js
@@ -0,0 +1,7 @@
+export const mockCiCatalogSettingsResponse = {
+ data: {
+ catalogResourcesCreate: {
+ errors: [],
+ },
+ },
+};
diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
index a7a1e649cd0..02e510c9541 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
@@ -1,6 +1,7 @@
import { GlSprintf, GlToggle } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
+import CiCatalogSettings from '~/pages/projects/shared/permissions/components/ci_catalog_settings.vue';
import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue';
import {
featureAccessLevel,
@@ -24,7 +25,6 @@ const defaultProps = {
buildsAccessLevel: 20,
wikiAccessLevel: 20,
snippetsAccessLevel: 20,
- metricsDashboardAccessLevel: 20,
pagesAccessLevel: 10,
analyticsAccessLevel: 20,
containerRegistryAccessLevel: 20,
@@ -35,6 +35,7 @@ const defaultProps = {
warnAboutPotentiallyUnwantedCharacters: true,
},
isGitlabCom: true,
+ canAddCatalogResource: false,
canDisableEmails: true,
canChangeVisibilityLevel: true,
allowedVisibilityOptions: [0, 10, 20],
@@ -119,6 +120,7 @@ describe('Settings Panel', () => {
const findPagesSettings = () => wrapper.findComponent({ ref: 'pages-settings' });
const findPagesAccessLevels = () =>
wrapper.find('[name="project[project_feature_attributes][pages_access_level]"]');
+ const findCiCatalogSettings = () => wrapper.findComponent(CiCatalogSettings);
const findEmailSettings = () => wrapper.findComponent({ ref: 'email-settings' });
const findShowDefaultAwardEmojis = () =>
wrapper.find('input[name="project[project_setting_attributes][show_default_award_emojis]"]');
@@ -126,10 +128,6 @@ describe('Settings Panel', () => {
wrapper.find(
'input[name="project[project_setting_attributes][warn_about_potentially_unwanted_characters]"]',
);
- const findMetricsVisibilitySettings = () =>
- wrapper.findComponent({ ref: 'metrics-visibility-settings' });
- const findMetricsVisibilityInput = () =>
- findMetricsVisibilitySettings().findComponent(ProjectFeatureSetting);
const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger);
const findEnvironmentsSettings = () => wrapper.findComponent({ ref: 'environments-settings' });
const findFeatureFlagsSettings = () => wrapper.findComponent({ ref: 'feature-flags-settings' });
@@ -137,8 +135,8 @@ describe('Settings Panel', () => {
wrapper.findComponent({ ref: 'infrastructure-settings' });
const findReleasesSettings = () => wrapper.findComponent({ ref: 'environments-settings' });
const findMonitorSettings = () => wrapper.findComponent({ ref: 'monitor-settings' });
- const findMonitorVisibilityInput = () =>
- findMonitorSettings().findComponent(ProjectFeatureSetting);
+ const findModelExperimentsSettings = () =>
+ wrapper.findComponent({ ref: 'model-experiments-settings' });
describe('Project Visibility', () => {
it('should set the project visibility help path', () => {
@@ -652,6 +650,19 @@ describe('Settings Panel', () => {
});
});
+ describe('CI Catalog Settings', () => {
+ it('should show the CI Catalog settings if user has permission', () => {
+ wrapper = mountComponent({ canAddCatalogResource: true });
+
+ expect(findCiCatalogSettings().exists()).toBe(true);
+ });
+ it('should not show the CI Catalog settings if user does not have permission', () => {
+ wrapper = mountComponent();
+
+ expect(findCiCatalogSettings().exists()).toBe(false);
+ });
+ });
+
describe('Email notifications', () => {
it('should show the disable email notifications input if emails an be disabled', () => {
wrapper = mountComponent({ canDisableEmails: true });
@@ -682,69 +693,6 @@ describe('Settings Panel', () => {
});
});
- describe('Metrics dashboard', () => {
- it('should show the metrics dashboard access select', () => {
- wrapper = mountComponent();
-
- expect(findMetricsVisibilitySettings().exists()).toBe(true);
- });
-
- it('should contain help text', () => {
- wrapper = mountComponent();
-
- expect(findMetricsVisibilitySettings().props('helpText')).toBe(
- "Visualize the project's performance metrics.",
- );
- });
-
- it.each`
- before | after
- ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.EVERYONE}
- ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.PROJECT_MEMBERS}
- ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.PROJECT_MEMBERS}
- ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.NOT_ENABLED}
- ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.NOT_ENABLED}
- `(
- 'when updating Monitor access level from `$before` to `$after`, Metric Dashboard access is updated to `$after` as well',
- async ({ before, after }) => {
- wrapper = mountComponent({
- currentSettings: { monitorAccessLevel: before, metricsDashboardAccessLevel: before },
- });
-
- await findMonitorVisibilityInput().vm.$emit('change', after);
-
- expect(findMetricsVisibilityInput().props('value')).toBe(after);
- },
- );
-
- it('when updating Monitor access level from `10` to `20`, Metric Dashboard access is not increased', async () => {
- wrapper = mountComponent({
- currentSettings: {
- monitorAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
- metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
- },
- });
-
- await findMonitorVisibilityInput().vm.$emit('change', featureAccessLevel.EVERYONE);
-
- expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS);
- });
-
- it('should reduce Metrics visibility level when visibility is set to private', async () => {
- wrapper = mountComponent({
- currentSettings: {
- visibilityLevel: VISIBILITY_LEVEL_PUBLIC_INTEGER,
- monitorAccessLevel: featureAccessLevel.EVERYONE,
- metricsDashboardAccessLevel: featureAccessLevel.EVERYONE,
- },
- });
-
- await findProjectVisibilityLevelInput().setValue(VISIBILITY_LEVEL_PRIVATE_INTEGER);
-
- expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS);
- });
- });
-
describe('Analytics', () => {
it('should show the analytics toggle', () => {
wrapper = mountComponent();
@@ -794,12 +742,12 @@ describe('Settings Panel', () => {
expectedAccessLevel,
);
});
- it('when monitorAccessLevel is for project members, it is also for everyone', () => {
- wrapper = mountComponent({
- currentSettings: { monitorAccessLevel: featureAccessLevel.PROJECT_MEMBERS },
- });
+ });
+ describe('Model experiments', () => {
+ it('shows model experiments toggle', () => {
+ wrapper = mountComponent({});
- expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.EVERYONE);
+ expect(findModelExperimentsSettings().exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
index ddaa3df71e8..1a3eb86a00e 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -14,6 +14,7 @@ import {
WIKI_FORMAT_LABEL,
WIKI_FORMAT_UPDATED_ACTION,
} from '~/pages/shared/wikis/constants';
+import { DRAWIO_ORIGIN } from 'spec/test_constants';
jest.mock('~/emoji');
@@ -69,12 +70,12 @@ describe('WikiForm', () => {
AsciiDoc: 'asciidoc',
Org: 'org',
};
-
function createWrapper({
mountFn = shallowMount,
persisted = false,
pageInfo,
glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false },
+ provide = { drawioUrl: null },
} = {}) {
wrapper = extendedWrapper(
mountFn(WikiForm, {
@@ -85,6 +86,7 @@ describe('WikiForm', () => {
...(persisted ? pageInfoPersisted : pageInfoNew),
...pageInfo,
},
+ ...provide,
},
stubs: {
GlAlert,
@@ -334,4 +336,20 @@ describe('WikiForm', () => {
});
});
});
+
+ describe('when drawioURL is provided', () => {
+ it('enables drawio editor in the Markdown Editor', () => {
+ createWrapper({ provide: { drawioUrl: DRAWIO_ORIGIN } });
+
+ expect(findMarkdownEditor().props().drawioEnabled).toBe(true);
+ });
+ });
+
+ describe('when drawioURL is empty', () => {
+ it('disables drawio editor in the Markdown Editor', () => {
+ createWrapper();
+
+ expect(findMarkdownEditor().props().drawioEnabled).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
deleted file mode 100644
index 724ec7366d3..00000000000
--- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
+++ /dev/null
@@ -1,471 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`DAG visualization parsing utilities generateColumnsFromLayersList matches the snapshot 1`] = `
-Array [
- Object {
- "groups": Array [
- Object {
- "__typename": "CiGroup",
- "id": "4",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "6",
- "kind": "BUILD",
- "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- "needs": Array [],
- "previousStageJobsOrNeeds": Array [],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "8",
- "path": "/root/abcd-dag/-/jobs/1482/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1482",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "7",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- ],
- "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- "size": 1,
- "stageName": "build",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "5",
- "label": "passed",
- },
- },
- Object {
- "__typename": "CiGroup",
- "id": "9",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "11",
- "kind": "BUILD",
- "name": "build_b",
- "needs": Array [],
- "previousStageJobsOrNeeds": Array [],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "13",
- "path": "/root/abcd-dag/-/jobs/1515/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1515",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "12",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- ],
- "name": "build_b",
- "size": 1,
- "stageName": "build",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "10",
- "label": "passed",
- },
- },
- Object {
- "__typename": "CiGroup",
- "id": "14",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "16",
- "kind": "BUILD",
- "name": "build_c",
- "needs": Array [],
- "previousStageJobsOrNeeds": Array [],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "18",
- "path": "/root/abcd-dag/-/jobs/1484/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1484",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "17",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- ],
- "name": "build_c",
- "size": 1,
- "stageName": "build",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "15",
- "label": "passed",
- },
- },
- Object {
- "__typename": "CiGroup",
- "id": "19",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "21",
- "kind": "BUILD",
- "name": "build_d 1/3",
- "needs": Array [],
- "previousStageJobsOrNeeds": Array [],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "23",
- "path": "/root/abcd-dag/-/jobs/1485/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1485",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "22",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- Object {
- "__typename": "CiJob",
- "id": "24",
- "kind": "BUILD",
- "name": "build_d 2/3",
- "needs": Array [],
- "previousStageJobsOrNeeds": Array [],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "26",
- "path": "/root/abcd-dag/-/jobs/1486/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1486",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "25",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- Object {
- "__typename": "CiJob",
- "id": "27",
- "kind": "BUILD",
- "name": "build_d 3/3",
- "needs": Array [],
- "previousStageJobsOrNeeds": Array [],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "29",
- "path": "/root/abcd-dag/-/jobs/1487/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1487",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "28",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- ],
- "name": "build_d",
- "size": 3,
- "stageName": "build",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "20",
- "label": "passed",
- },
- },
- Object {
- "__typename": "CiGroup",
- "id": "57",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "59",
- "kind": "BUILD",
- "name": "test_c",
- "needs": Array [],
- "previousStageJobsOrNeeds": Array [],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": null,
- "detailsPath": "/root/kinder-pipe/-/pipelines/154",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "60",
- "label": null,
- "tooltip": null,
- },
- },
- ],
- "name": "test_c",
- "size": 1,
- "stageName": "test",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "58",
- "label": null,
- },
- },
- ],
- "id": "layer-0",
- "name": "",
- "status": Object {
- "action": null,
- },
- },
- Object {
- "groups": Array [
- Object {
- "__typename": "CiGroup",
- "id": "32",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "34",
- "kind": "BUILD",
- "name": "test_a",
- "needs": Array [
- "build_c",
- "build_b",
- "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- ],
- "previousStageJobsOrNeeds": Array [
- "build_c",
- "build_b",
- "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- ],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "36",
- "path": "/root/abcd-dag/-/jobs/1514/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1514",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "35",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- ],
- "name": "test_a",
- "size": 1,
- "stageName": "test",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "33",
- "label": "passed",
- },
- },
- Object {
- "__typename": "CiGroup",
- "id": "40",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "42",
- "kind": "BUILD",
- "name": "test_b 1/2",
- "needs": Array [
- "build_d 3/3",
- "build_d 2/3",
- "build_d 1/3",
- "build_b",
- "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- ],
- "previousStageJobsOrNeeds": Array [
- "build_d 3/3",
- "build_d 2/3",
- "build_d 1/3",
- "build_b",
- "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- ],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "44",
- "path": "/root/abcd-dag/-/jobs/1489/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1489",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "43",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- Object {
- "__typename": "CiJob",
- "id": "67",
- "kind": "BUILD",
- "name": "test_b 2/2",
- "needs": Array [
- "build_d 3/3",
- "build_d 2/3",
- "build_d 1/3",
- "build_b",
- "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- ],
- "previousStageJobsOrNeeds": Array [
- "build_d 3/3",
- "build_d 2/3",
- "build_d 1/3",
- "build_b",
- "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
- ],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": Object {
- "__typename": "StatusAction",
- "buttonTitle": "Retry this job",
- "icon": "retry",
- "id": "51",
- "path": "/root/abcd-dag/-/jobs/1490/retry",
- "title": "Retry",
- },
- "detailsPath": "/root/abcd-dag/-/jobs/1490",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "50",
- "label": "passed",
- "tooltip": "passed",
- },
- },
- ],
- "name": "test_b",
- "size": 2,
- "stageName": "test",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "41",
- "label": "passed",
- },
- },
- Object {
- "__typename": "CiGroup",
- "id": "61",
- "jobs": Array [
- Object {
- "__typename": "CiJob",
- "id": "53",
- "kind": "BUILD",
- "name": "test_d",
- "needs": Array [
- "build_b",
- ],
- "previousStageJobsOrNeeds": Array [
- "build_b",
- ],
- "scheduledAt": null,
- "status": Object {
- "__typename": "DetailedStatus",
- "action": null,
- "detailsPath": "/root/abcd-dag/-/pipelines/153",
- "group": "success",
- "hasDetails": true,
- "icon": "status_success",
- "id": "64",
- "label": null,
- "tooltip": null,
- },
- },
- ],
- "name": "test_d",
- "size": 1,
- "stageName": "test",
- "status": Object {
- "__typename": "DetailedStatus",
- "group": "success",
- "icon": "status_success",
- "id": "62",
- "label": null,
- },
- },
- ],
- "id": "layer-1",
- "name": "",
- "status": Object {
- "action": null,
- },
- },
-]
-`;
diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph_spec.js
new file mode 100644
index 00000000000..69b223461bd
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph_spec.js
@@ -0,0 +1,123 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlLoadingIcon } from '@gitlab/ui';
+
+import { createAlert } from '~/alert';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+
+import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql';
+import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import GraphqlPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/graphql_pipeline_mini_graph.vue';
+import * as sharedGraphQlUtils from '~/graphql_shared/utils';
+
+import {
+ linkedPipelinesFetchError,
+ stagesFetchError,
+ mockPipelineStagesQueryResponse,
+ mockUpstreamDownstreamQueryResponse,
+} from './mock_data';
+
+Vue.use(VueApollo);
+jest.mock('~/alert');
+
+describe('GraphqlPipelineMiniGraph', () => {
+ let wrapper;
+ let linkedPipelinesResponse;
+ let pipelineStagesResponse;
+
+ const fullPath = 'gitlab-org/gitlab';
+ const iid = '315';
+ const pipelineEtag = '/api/graphql:pipelines/id/315';
+
+ const createComponent = ({
+ pipelineStagesHandler = pipelineStagesResponse,
+ linkedPipelinesHandler = linkedPipelinesResponse,
+ } = {}) => {
+ const handlers = [
+ [getLinkedPipelinesQuery, linkedPipelinesHandler],
+ [getPipelineStagesQuery, pipelineStagesHandler],
+ ];
+ const mockApollo = createMockApollo(handlers);
+
+ wrapper = shallowMountExtended(GraphqlPipelineMiniGraph, {
+ propsData: {
+ fullPath,
+ iid,
+ pipelineEtag,
+ },
+ apolloProvider: mockApollo,
+ });
+
+ return waitForPromises();
+ };
+
+ const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ beforeEach(() => {
+ linkedPipelinesResponse = jest.fn().mockResolvedValue(mockUpstreamDownstreamQueryResponse);
+ pipelineStagesResponse = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse);
+ });
+
+ describe('when initial queries are loading', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows a loading icon and no mini graph', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findPipelineMiniGraph().exists()).toBe(false);
+ });
+ });
+
+ describe('when queries have loaded', () => {
+ it('does not show a loading icon', async () => {
+ await createComponent();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders the Pipeline Mini Graph', async () => {
+ await createComponent();
+
+ expect(findPipelineMiniGraph().exists()).toBe(true);
+ });
+
+ it('fires the queries', async () => {
+ await createComponent();
+
+ expect(linkedPipelinesResponse).toHaveBeenCalledWith({ iid, fullPath });
+ expect(pipelineStagesResponse).toHaveBeenCalledWith({ iid, fullPath });
+ });
+ });
+
+ describe('polling', () => {
+ it('toggles query polling with visibility check', async () => {
+ jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility');
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('when pipeline queries are unsuccessful', () => {
+ const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+ it.each`
+ query | handlerName | errorMessage
+ ${'pipeline stages'} | ${'pipelineStagesHandler'} | ${stagesFetchError}
+ ${'linked pipelines'} | ${'linkedPipelinesHandler'} | ${linkedPipelinesFetchError}
+ `('throws an error for the $query query', async ({ errorMessage, handlerName }) => {
+ await createComponent({ [handlerName]: failedHandler });
+
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: errorMessage });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js b/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js
new file mode 100644
index 00000000000..1c13e9eb62b
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js
@@ -0,0 +1,150 @@
+export const mockDownstreamPipelinesGraphql = ({ includeSourceJobRetried = true } = {}) => ({
+ nodes: [
+ {
+ id: 'gid://gitlab/Ci::Pipeline/612',
+ path: '/root/job-log-sections/-/pipelines/612',
+ project: {
+ id: 'gid://gitlab/Project/21',
+ name: 'job-log-sections',
+ __typename: 'Project',
+ },
+ detailedStatus: {
+ id: 'success-612-612',
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ __typename: 'DetailedStatus',
+ },
+ sourceJob: {
+ id: 'gid://gitlab/Ci::Bridge/532',
+ retried: includeSourceJobRetried ? false : null,
+ },
+ __typename: 'Pipeline',
+ },
+ {
+ id: 'gid://gitlab/Ci::Pipeline/611',
+ path: '/root/job-log-sections/-/pipelines/611',
+ project: {
+ id: 'gid://gitlab/Project/21',
+ name: 'job-log-sections',
+ __typename: 'Project',
+ },
+ detailedStatus: {
+ id: 'success-611-611',
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ __typename: 'DetailedStatus',
+ },
+ sourceJob: {
+ id: 'gid://gitlab/Ci::Bridge/531',
+ retried: includeSourceJobRetried ? true : null,
+ },
+ __typename: 'Pipeline',
+ },
+ {
+ id: 'gid://gitlab/Ci::Pipeline/609',
+ path: '/root/job-log-sections/-/pipelines/609',
+ project: {
+ id: 'gid://gitlab/Project/21',
+ name: 'job-log-sections',
+ __typename: 'Project',
+ },
+ detailedStatus: {
+ id: 'success-609-609',
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ __typename: 'DetailedStatus',
+ },
+ sourceJob: {
+ id: 'gid://gitlab/Ci::Bridge/530',
+ retried: includeSourceJobRetried ? true : null,
+ },
+ __typename: 'Pipeline',
+ },
+ ],
+ __typename: 'PipelineConnection',
+});
+
+const upstream = {
+ id: 'gid://gitlab/Ci::Pipeline/610',
+ path: '/root/trigger-downstream/-/pipelines/610',
+ project: {
+ id: 'gid://gitlab/Project/21',
+ name: 'trigger-downstream',
+ __typename: 'Project',
+ },
+ detailedStatus: {
+ id: 'success-610-610',
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ __typename: 'DetailedStatus',
+ },
+ __typename: 'Pipeline',
+};
+
+export const mockPipelineStagesQueryResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/320',
+ stages: {
+ nodes: [
+ {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/409',
+ name: 'build',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-409-409',
+ icon: 'status_success',
+ group: 'success',
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+export const mockPipelineStatusResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/320',
+ detailedStatus: {
+ id: 'pending-320-320',
+ detailsPath: '/root/ci-project/-/pipelines/320',
+ icon: 'status_pending',
+ group: 'pending',
+ __typename: 'DetailedStatus',
+ },
+ __typename: 'Pipeline',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const mockUpstreamDownstreamQueryResponse = {
+ data: {
+ project: {
+ id: '1',
+ pipeline: {
+ id: 'pipeline-1',
+ path: '/root/ci-project/-/pipelines/790',
+ downstream: mockDownstreamPipelinesGraphql(),
+ upstream,
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const linkedPipelinesFetchError = 'There was a problem fetching linked pipelines.';
+export const stagesFetchError = 'There was a problem fetching the pipeline stages.';
diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js
new file mode 100644
index 00000000000..a4c90fa3876
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js
@@ -0,0 +1,45 @@
+export const job = {
+ id: 'gid://gitlab/Ci::Build/5241',
+ allowFailure: false,
+ detailedStatus: {
+ id: 'status',
+ action: {
+ id: 'action',
+ path: '/retry',
+ icon: 'retry',
+ },
+ group: 'running',
+ icon: 'running-icon',
+ },
+ name: 'job-name',
+ retried: false,
+ stage: {
+ id: '1',
+ name: 'build',
+ },
+ trace: {
+ htmlSummary:
+ '<span>To install the missing version, run `gem install bundler:2.4.13`<br/>\tfrom /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems.rb:302:in `activate_bin_path\'<br/>\tfrom /usr/bin/bundle:23:in `&lt;main>\'<br/></span><div class="section-start" data-timestamp="1685044123" data-section="upload-artifacts-on-failure" role="button"></div><span class="term-fg-l-cyan term-bold section section-header js-s-upload-artifacts-on-failure">Uploading artifacts for failed job</span><span class="section section-header js-s-upload-artifacts-on-failure"><br/></span><span class="term-fg-l-green term-bold section line js-s-upload-artifacts-on-failure">Uploading artifacts...</span><span class="section line js-s-upload-artifacts-on-failure"><br/>Runtime platform </span><span class="section line js-s-upload-artifacts-on-failure"> arch</span><span class="section line js-s-upload-artifacts-on-failure">=arm64 os</span><span class="section line js-s-upload-artifacts-on-failure">=darwin pid</span><span class="section line js-s-upload-artifacts-on-failure">=16706 revision</span><span class="section line js-s-upload-artifacts-on-failure">=43b2dc3d version</span><span class="section line js-s-upload-artifacts-on-failure">=15.4.0<br/></span><span class="term-fg-yellow section line js-s-upload-artifacts-on-failure">WARNING: rspec.xml: no matching files. Ensure that the artifact path is relative to the working directory</span><span class="section line js-s-upload-artifacts-on-failure"> <br/></span><span class="term-fg-l-red term-bold section line js-s-upload-artifacts-on-failure">ERROR: No files to upload </span><span class="section line js-s-upload-artifacts-on-failure"> <br/></span><div class="section-end" data-section="upload-artifacts-on-failure"></div><span class="term-fg-l-red term-bold">ERROR: Job failed: exit status 1<br/></span><span><br/></span>',
+ },
+ webPath: '/',
+};
+
+export const allowedToFailJob = {
+ ...job,
+ id: 'gid://gitlab/Ci::Build/5242',
+ allowFailure: true,
+};
+
+export const failedJobsMock = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ id: 'gid://gitlab/Pipeline/20',
+ jobs: {
+ nodes: [allowedToFailJob, job],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js
new file mode 100644
index 00000000000..df6d114f683
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js
@@ -0,0 +1,144 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+
+import { GlButton, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue';
+import { createAlert } from '~/alert';
+import WidgetFailedJobRow from '~/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue';
+import * as utils from '~/pipelines/components/pipelines_list/failure_widget/utils';
+import getPipelineFailedJobs from '~/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql';
+import { failedJobsMock } from './mock';
+
+Vue.use(VueApollo);
+jest.mock('~/alert');
+
+describe('PipelineFailedJobsWidget component', () => {
+ let wrapper;
+ let mockFailedJobsResponse;
+
+ const defaultProps = {
+ pipelineIid: 1,
+ pipelinePath: '/pipelines/1',
+ };
+
+ const defaultProvide = {
+ fullPath: 'namespace/project/',
+ };
+
+ const createComponent = ({ props = {}, provide } = {}) => {
+ const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]];
+ const mockApollo = createMockApollo(handlers);
+
+ wrapper = shallowMountExtended(PipelineFailedJobsWidget, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ apolloProvider: mockApollo,
+ });
+ };
+
+ const findAllHeaders = () => wrapper.findAllByTestId('header');
+ const findFailedJobsButton = () => wrapper.findComponent(GlButton);
+ const findFailedJobRows = () => wrapper.findAllComponents(WidgetFailedJobRow);
+ const findInfoIcon = () => wrapper.findComponent(GlIcon);
+ const findInfoPopover = () => wrapper.findComponent(GlPopover);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ beforeEach(() => {
+ mockFailedJobsResponse = jest.fn();
+ });
+
+ describe('ui', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the show failed jobs button', () => {
+ expect(findFailedJobsButton().exists()).toBe(true);
+ expect(findFailedJobsButton().text()).toBe('Show failed jobs');
+ });
+
+ it('renders the info icon', () => {
+ expect(findInfoIcon().exists()).toBe(true);
+ });
+
+ it('renders the info popover', () => {
+ expect(findInfoPopover().exists()).toBe(true);
+ });
+
+ it('does not show the list of failed jobs', () => {
+ expect(findFailedJobRows()).toHaveLength(0);
+ });
+ });
+
+ describe('when loading failed jobs', () => {
+ beforeEach(async () => {
+ mockFailedJobsResponse.mockResolvedValue(failedJobsMock);
+ createComponent();
+ await findFailedJobsButton().vm.$emit('click');
+ });
+
+ it('shows a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('when failed jobs have loaded', () => {
+ beforeEach(async () => {
+ mockFailedJobsResponse.mockResolvedValue(failedJobsMock);
+ jest.spyOn(utils, 'sortJobsByStatus');
+
+ createComponent();
+
+ await findFailedJobsButton().vm.$emit('click');
+ await waitForPromises();
+ });
+ it('does not renders a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders table column', () => {
+ expect(findAllHeaders()).toHaveLength(3);
+ });
+
+ it('shows the list of failed jobs', () => {
+ expect(findFailedJobRows()).toHaveLength(
+ failedJobsMock.data.project.pipeline.jobs.nodes.length,
+ );
+ });
+
+ it('calls sortJobsByStatus', () => {
+ expect(utils.sortJobsByStatus).toHaveBeenCalledWith(
+ failedJobsMock.data.project.pipeline.jobs.nodes,
+ );
+ });
+ });
+
+ describe('when an error occurs loading jobs', () => {
+ const errorMessage = "We couldn't fetch jobs for you because you are not qualified";
+
+ beforeEach(async () => {
+ mockFailedJobsResponse.mockRejectedValue({ message: errorMessage });
+
+ createComponent();
+
+ await findFailedJobsButton().vm.$emit('click');
+ await waitForPromises();
+ });
+ it('does not renders a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('calls create Alert with the error message and danger variant', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js
new file mode 100644
index 00000000000..44f16478151
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js
@@ -0,0 +1,58 @@
+import {
+ isFailedJob,
+ sortJobsByStatus,
+} from '~/pipelines/components/pipelines_list/failure_widget/utils';
+
+describe('isFailedJob', () => {
+ describe('when the job argument is undefined', () => {
+ it('returns false', () => {
+ expect(isFailedJob()).toBe(false);
+ });
+ });
+
+ describe('when the job is of status `failed`', () => {
+ it('returns false', () => {
+ expect(isFailedJob({ detailedStatus: { group: 'success' } })).toBe(false);
+ });
+ });
+
+ describe('when the job status is `failed`', () => {
+ it('returns true', () => {
+ expect(isFailedJob({ detailedStatus: { group: 'failed' } })).toBe(true);
+ });
+ });
+});
+
+describe('sortJobsByStatus', () => {
+ describe('when the arg is undefined', () => {
+ it('returns an empty array', () => {
+ expect(sortJobsByStatus()).toEqual([]);
+ });
+ });
+
+ describe('when receiving an empty array', () => {
+ it('returns an empty array', () => {
+ expect(sortJobsByStatus([])).toEqual([]);
+ });
+ });
+
+ describe('when reciving a list of jobs', () => {
+ const jobArr = [
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'allowed_to_fail' } },
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'success' } },
+ ];
+
+ const expectedResult = [
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'allowed_to_fail' } },
+ { detailedStatus: { group: 'success' } },
+ ];
+
+ it('sorts failed jobs first', () => {
+ expect(sortJobsByStatus(jobArr)).toEqual(expectedResult);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js
new file mode 100644
index 00000000000..dfc2806840f
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row_spec.js
@@ -0,0 +1,140 @@
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import WidgetFailedJobRow from '~/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue';
+
+describe('WidgetFailedJobRow component', () => {
+ let wrapper;
+
+ const defaultProps = {
+ job: {
+ id: 'gid://gitlab/Ci::Build/5240',
+ detailedStatus: {
+ group: 'running',
+ icon: 'icon_status_running',
+ },
+ name: 'my-job',
+ stage: {
+ name: 'build',
+ },
+ trace: {
+ htmlSummary: '<h1>job log</h1>',
+ },
+ webpath: '/',
+ },
+ };
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(WidgetFailedJobRow, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ const findArrowIcon = () => wrapper.findComponent(GlIcon);
+ const findJobCiStatus = () => wrapper.findComponent(CiIcon);
+ const findJobId = () => wrapper.findComponent(GlLink);
+ const findHiddenJobLog = () => wrapper.findByTestId('log-is-hidden');
+ const findVisibleJobLog = () => wrapper.findByTestId('log-is-visible');
+ const findJobName = () => wrapper.findByText(defaultProps.job.name);
+ const findRow = () => wrapper.findByTestId('widget-row');
+ const findStageName = () => wrapper.findByText(defaultProps.job.stage.name);
+
+ describe('ui', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the job name', () => {
+ expect(findJobName().exists()).toBe(true);
+ });
+
+ it('renders the stage name', () => {
+ expect(findStageName().exists()).toBe(true);
+ });
+
+ it('renders the job id as a link', () => {
+ const jobId = getIdFromGraphQLId(defaultProps.job.id);
+
+ expect(findJobId().exists()).toBe(true);
+ expect(findJobId().text()).toContain(String(jobId));
+ });
+
+ it('renders the ci status badge', () => {
+ expect(findJobCiStatus().exists()).toBe(true);
+ });
+
+ it('renders the right arrow', () => {
+ expect(findArrowIcon().props().name).toBe('chevron-right');
+ });
+
+ it('does not renders the job lob', () => {
+ expect(findHiddenJobLog().exists()).toBe(true);
+ expect(findVisibleJobLog().exists()).toBe(false);
+ });
+ });
+
+ describe('Job log', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when clicking on the row', () => {
+ beforeEach(async () => {
+ await findRow().trigger('click');
+ });
+
+ describe('while collapsed', () => {
+ it('expands the job log', () => {
+ expect(findHiddenJobLog().exists()).toBe(false);
+ expect(findVisibleJobLog().exists()).toBe(true);
+ });
+
+ it('renders the down arrow', () => {
+ expect(findArrowIcon().props().name).toBe('chevron-down');
+ });
+
+ it('renders the received html', () => {
+ expect(findVisibleJobLog().html()).toContain(defaultProps.job.trace.htmlSummary);
+ });
+ });
+
+ describe('while expanded', () => {
+ it('collapes the job log', async () => {
+ expect(findHiddenJobLog().exists()).toBe(false);
+ expect(findVisibleJobLog().exists()).toBe(true);
+
+ await findRow().trigger('click');
+
+ expect(findHiddenJobLog().exists()).toBe(true);
+ expect(findVisibleJobLog().exists()).toBe(false);
+ });
+
+ it('renders the right arrow', async () => {
+ expect(findArrowIcon().props().name).toBe('chevron-down');
+
+ await findRow().trigger('click');
+
+ expect(findArrowIcon().props().name).toBe('chevron-right');
+ });
+ });
+ });
+
+ describe('when clicking on a link element within the row', () => {
+ it('does not expands/collapse the job log', async () => {
+ expect(findHiddenJobLog().exists()).toBe(true);
+ expect(findVisibleJobLog().exists()).toBe(false);
+ expect(findArrowIcon().props().name).toBe('chevron-right');
+
+ await findJobId().vm.$emit('click');
+
+ expect(findHiddenJobLog().exists()).toBe(true);
+ expect(findVisibleJobLog().exists()).toBe(false);
+ expect(findArrowIcon().props().name).toBe('chevron-right');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index 95207fd59ff..e9bce037800 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
@@ -7,11 +8,8 @@ import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import { calculatePipelineLayersInfo } from '~/pipelines/components/graph/utils';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
-import {
- generateResponse,
- mockPipelineResponse,
- pipelineWithUpstreamDownstream,
-} from './mock_data';
+
+import { generateResponse, pipelineWithUpstreamDownstream } from './mock_data';
describe('graph component', () => {
let wrapper;
diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
index cc952eac1d7..9599b5e6b7b 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -2,6 +2,7 @@ import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitl
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -26,7 +27,6 @@ import {
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue';
import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue';
-import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import * as Api from '~/pipelines/components/graph_shared/api';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
@@ -34,7 +34,7 @@ import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_head
import * as sentryUtils from '~/pipelines/utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { mockRunningPipelineHeaderData } from '../mock_data';
-import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
+import { mapCallouts, mockCalloutsResponse } from './mock_data';
const defaultProvide = {
graphqlResourceEtag: 'frog/amphibirama/etag/',
@@ -55,8 +55,6 @@ describe('Pipeline graph wrapper', () => {
const findLinksLayer = () => wrapper.findComponent(LinksLayer);
const findGraph = () => wrapper.findComponent(PipelineGraph);
const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title');
- const findAllStageColumnGroupsInColumn = () =>
- wrapper.findComponent(StageColumnComponent).findAll('[data-testid="stage-column-group"]');
const findViewSelector = () => wrapper.findComponent(GraphViewSelector);
const findViewSelectorToggle = () => findViewSelector().findComponent(GlToggle);
const findViewSelectorTrip = () => findViewSelector().findComponent(GlAlert);
@@ -316,12 +314,10 @@ describe('Pipeline graph wrapper', () => {
});
it('switches between views', async () => {
- const groupsInFirstColumn =
- mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes.length;
- expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn);
- expect(findStageColumnTitle().text()).toBe('build');
+ expect(findStageColumnTitle().text()).toBe('deploy');
+
await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW);
- expect(findAllStageColumnGroupsInColumn()).toHaveLength(groupsInFirstColumn + 1);
+
expect(findStageColumnTitle().text()).toBe('');
});
@@ -507,9 +503,9 @@ describe('Pipeline graph wrapper', () => {
});
describe('with metrics path', () => {
- const duration = 875;
- const numLinks = 7;
- const totalGroups = 8;
+ const duration = 500;
+ const numLinks = 3;
+ const totalGroups = 7;
const metricsData = {
histograms: [
{ name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
@@ -559,9 +555,6 @@ describe('Pipeline graph wrapper', () => {
createComponentWithApollo({
provide: {
metricsPath,
- glFeatures: {
- pipelineGraphLayersView: true,
- },
},
data: {
currentViewType: LAYER_VIEW,
diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js
index 2a5dfd7e0ee..8a8b0e9aa63 100644
--- a/spec/frontend/pipelines/graph/job_item_spec.js
+++ b/spec/frontend/pipelines/graph/job_item_spec.js
@@ -1,5 +1,4 @@
import MockAdapter from 'axios-mock-adapter';
-import { shallowMount, mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import { GlBadge, GlModal, GlToast } from '@gitlab/ui';
import JobItem from '~/pipelines/components/graph/job_item.vue';
@@ -7,7 +6,7 @@ import axios from '~/lib/utils/axios_utils';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
delayedJob,
mockJob,
@@ -44,23 +43,21 @@ describe('pipeline graph job item', () => {
job: mockJob,
};
- const createWrapper = ({ props, data, mountFn = mount, mocks = {} } = {}) => {
- wrapper = extendedWrapper(
- mountFn(JobItem, {
- data() {
- return {
- ...data,
- };
- },
- propsData: {
- ...defaultProps,
- ...props,
- },
- mocks: {
- ...mocks,
- },
- }),
- );
+ const createWrapper = ({ props, data, mountFn = mountExtended, mocks = {} } = {}) => {
+ wrapper = mountFn(JobItem, {
+ data() {
+ return {
+ ...data,
+ };
+ },
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ mocks: {
+ ...mocks,
+ },
+ });
};
const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500';
@@ -219,7 +216,7 @@ describe('pipeline graph job item', () => {
});
expect(findJobWithLink().attributes('title')).toBe(
- `delayed job - delayed manual action (${wrapper.vm.remainingTime})`,
+ `delayed job - delayed manual action (00:00:00)`,
);
});
});
@@ -249,10 +246,7 @@ describe('pipeline graph job item', () => {
beforeEach(async () => {
createWrapper({
- mountFn: shallowMount,
- data: {
- currentSkipModalValue: true,
- },
+ mountFn: shallowMountExtended,
props: {
skipRetryModal: true,
job: triggerJobWithRetryAction,
@@ -264,8 +258,6 @@ describe('pipeline graph job item', () => {
},
});
- jest.spyOn(wrapper.vm.$toast, 'show');
-
await findActionVueComponent().vm.$emit('pipelineActionRequestComplete');
await nextTick();
});
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
index 6e4b9498918..bcea140f2dd 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
@@ -1,6 +1,7 @@
import { mount, shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
@@ -15,11 +16,8 @@ import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
import { LOAD_FAILURE } from '~/pipelines/constants';
-import {
- mockPipelineResponse,
- pipelineWithUpstreamDownstream,
- wrappedPipelineReturn,
-} from './mock_data';
+
+import { pipelineWithUpstreamDownstream, wrappedPipelineReturn } from './mock_data';
const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse);
diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js
index 08624cc511d..b012e7f66e1 100644
--- a/spec/frontend/pipelines/graph/mock_data.js
+++ b/spec/frontend/pipelines/graph/mock_data.js
@@ -5,710 +5,6 @@ import {
RETRY_ACTION_TITLE,
} from '~/pipelines/components/graph/constants';
-export const mockPipelineResponse = {
- data: {
- project: {
- __typename: 'Project',
- id: '1',
- pipeline: {
- __typename: 'Pipeline',
- id: 163,
- iid: '22',
- complete: true,
- usesNeeds: true,
- downstream: null,
- upstream: null,
- userPermissions: {
- __typename: 'PipelinePermissions',
- updatePipeline: true,
- },
- stages: {
- __typename: 'CiStageConnection',
- nodes: [
- {
- __typename: 'CiStage',
- id: '2',
- name: 'build',
- status: {
- __typename: 'DetailedStatus',
- id: '3',
- action: null,
- },
- groups: {
- __typename: 'CiGroupConnection',
- nodes: [
- {
- __typename: 'CiGroup',
- id: '4',
- name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- size: 1,
- status: {
- __typename: 'DetailedStatus',
- id: '5',
- label: 'passed',
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '6',
- kind: BUILD_KIND,
- name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '7',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1482',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '8',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1482/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [],
- },
- },
- ],
- },
- },
- {
- __typename: 'CiGroup',
- name: 'build_b',
- id: '9',
- size: 1,
- status: {
- __typename: 'DetailedStatus',
- id: '10',
- label: 'passed',
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '11',
- name: 'build_b',
- kind: BUILD_KIND,
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '12',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1515',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '13',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1515/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [],
- },
- },
- ],
- },
- },
- {
- __typename: 'CiGroup',
- id: '14',
- name: 'build_c',
- size: 1,
- status: {
- __typename: 'DetailedStatus',
- id: '15',
- label: 'passed',
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '16',
- name: 'build_c',
- kind: BUILD_KIND,
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '17',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1484',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '18',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1484/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [],
- },
- },
- ],
- },
- },
- {
- __typename: 'CiGroup',
- id: '19',
- name: 'build_d',
- size: 3,
- status: {
- __typename: 'DetailedStatus',
- id: '20',
- label: 'passed',
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '21',
- kind: BUILD_KIND,
- name: 'build_d 1/3',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '22',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1485',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '23',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1485/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [],
- },
- },
- {
- __typename: 'CiJob',
- id: '24',
- kind: BUILD_KIND,
- name: 'build_d 2/3',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '25',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1486',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '26',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1486/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [],
- },
- },
- {
- __typename: 'CiJob',
- id: '27',
- kind: BUILD_KIND,
- name: 'build_d 3/3',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '28',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1487',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '29',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1487/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [],
- },
- },
- ],
- },
- },
- ],
- },
- },
- {
- __typename: 'CiStage',
- id: '30',
- name: 'test',
- status: {
- __typename: 'DetailedStatus',
- id: '31',
- action: null,
- },
- groups: {
- __typename: 'CiGroupConnection',
- nodes: [
- {
- __typename: 'CiGroup',
- id: '32',
- name: 'test_a',
- size: 1,
- status: {
- __typename: 'DetailedStatus',
- id: '33',
- label: 'passed',
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '34',
- kind: BUILD_KIND,
- name: 'test_a',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '35',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1514',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '36',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1514/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '37',
- name: 'build_c',
- },
- {
- __typename: 'CiBuildNeed',
- id: '38',
- name: 'build_b',
- },
- {
- __typename: 'CiBuildNeed',
- id: '39',
- name:
- 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- },
- ],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '37',
- name: 'build_c',
- },
- {
- __typename: 'CiBuildNeed',
- id: '38',
- name: 'build_b',
- },
- {
- __typename: 'CiBuildNeed',
- id: '39',
- name:
- 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- },
- ],
- },
- },
- ],
- },
- },
- {
- __typename: 'CiGroup',
- id: '40',
- name: 'test_b',
- size: 2,
- status: {
- __typename: 'DetailedStatus',
- id: '41',
- label: 'passed',
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '42',
- kind: BUILD_KIND,
- name: 'test_b 1/2',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '43',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1489',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '44',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1489/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '45',
- name: 'build_d 3/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '46',
- name: 'build_d 2/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '47',
- name: 'build_d 1/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '48',
- name: 'build_b',
- },
- {
- __typename: 'CiBuildNeed',
- id: '49',
- name:
- 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- },
- ],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '45',
- name: 'build_d 3/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '46',
- name: 'build_d 2/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '47',
- name: 'build_d 1/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '48',
- name: 'build_b',
- },
- {
- __typename: 'CiBuildNeed',
- id: '49',
- name:
- 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- },
- ],
- },
- },
- {
- __typename: 'CiJob',
- id: '67',
- kind: BUILD_KIND,
- name: 'test_b 2/2',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '50',
- icon: 'status_success',
- tooltip: 'passed',
- label: 'passed',
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/jobs/1490',
- group: 'success',
- action: {
- __typename: 'StatusAction',
- id: '51',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- path: '/root/abcd-dag/-/jobs/1490/retry',
- title: 'Retry',
- },
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '52',
- name: 'build_d 3/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '53',
- name: 'build_d 2/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '54',
- name: 'build_d 1/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '55',
- name: 'build_b',
- },
- {
- __typename: 'CiBuildNeed',
- id: '56',
- name:
- 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- },
- ],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '52',
- name: 'build_d 3/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '53',
- name: 'build_d 2/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '54',
- name: 'build_d 1/3',
- },
- {
- __typename: 'CiBuildNeed',
- id: '55',
- name: 'build_b',
- },
- {
- __typename: 'CiBuildNeed',
- id: '56',
- name:
- 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
- },
- ],
- },
- },
- ],
- },
- },
- {
- __typename: 'CiGroup',
- name: 'test_c',
- id: '57',
- size: 1,
- status: {
- __typename: 'DetailedStatus',
- id: '58',
- label: null,
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '59',
- kind: BUILD_KIND,
- name: 'test_c',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '60',
- icon: 'status_success',
- tooltip: null,
- label: null,
- hasDetails: true,
- detailsPath: '/root/kinder-pipe/-/pipelines/154',
- group: 'success',
- action: null,
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [],
- },
- },
- ],
- },
- },
- {
- __typename: 'CiGroup',
- id: '61',
- name: 'test_d',
- size: 1,
- status: {
- id: '62',
- __typename: 'DetailedStatus',
- label: null,
- group: 'success',
- icon: 'status_success',
- },
- jobs: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiJob',
- id: '53',
- kind: BUILD_KIND,
- name: 'test_d',
- scheduledAt: null,
- status: {
- __typename: 'DetailedStatus',
- id: '64',
- icon: 'status_success',
- tooltip: null,
- label: null,
- hasDetails: true,
- detailsPath: '/root/abcd-dag/-/pipelines/153',
- group: 'success',
- action: null,
- },
- needs: {
- __typename: 'CiBuildNeedConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '65',
- name: 'build_b',
- },
- ],
- },
- previousStageJobsOrNeeds: {
- __typename: 'CiJobConnection',
- nodes: [
- {
- __typename: 'CiBuildNeed',
- id: '65',
- name: 'build_b',
- },
- ],
- },
- },
- ],
- },
- },
- ],
- },
- },
- ],
- },
- },
- },
- },
-};
-
export const downstream = {
nodes: [
{
diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
index 9d39c86ed5e..88ba84c395a 100644
--- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
@@ -1,7 +1,9 @@
import { shallowMount } from '@vue/test-utils';
+import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
-import { generateResponse, mockPipelineResponse } from '../graph/mock_data';
+
+import { generateResponse } from '../graph/mock_data';
describe('links layer component', () => {
let wrapper;
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
index a4b8d223a0c..62c0d6e2d91 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -1,3 +1,8 @@
+import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json';
+import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json';
+import pipelineHeaderRunningWithDuration from 'test_fixtures/graphql/pipelines/pipeline_header_running_with_duration.json';
+import pipelineHeaderFailed from 'test_fixtures/graphql/pipelines/pipeline_header_failed.json';
+
const PIPELINE_RUNNING = 'RUNNING';
const PIPELINE_CANCELED = 'CANCELED';
const PIPELINE_FAILED = 'FAILED';
@@ -5,6 +10,37 @@ const PIPELINE_FAILED = 'FAILED';
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+export {
+ pipelineHeaderSuccess,
+ pipelineHeaderRunning,
+ pipelineHeaderRunningWithDuration,
+ pipelineHeaderFailed,
+};
+
+export const pipelineRetryMutationResponseSuccess = {
+ data: { pipelineRetry: { errors: [] } },
+};
+
+export const pipelineRetryMutationResponseFailed = {
+ data: { pipelineRetry: { errors: ['error'] } },
+};
+
+export const pipelineCancelMutationResponseSuccess = {
+ data: { pipelineRetry: { errors: [] } },
+};
+
+export const pipelineCancelMutationResponseFailed = {
+ data: { pipelineRetry: { errors: ['error'] } },
+};
+
+export const pipelineDeleteMutationResponseSuccess = {
+ data: { pipelineRetry: { errors: [] } },
+};
+
+export const pipelineDeleteMutationResponseFailed = {
+ data: { pipelineRetry: { errors: ['error'] } },
+};
+
export const mockPipelineHeader = {
detailedStatus: {},
id: 123,
diff --git a/spec/frontend/pipelines/pipeline_details_header_spec.js b/spec/frontend/pipelines/pipeline_details_header_spec.js
new file mode 100644
index 00000000000..deaf5c6f72f
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_details_header_spec.js
@@ -0,0 +1,440 @@
+import { GlAlert, GlBadge, GlLoadingIcon, GlModal, GlSprintf } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import PipelineDetailsHeader from '~/pipelines/components/pipeline_details_header.vue';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/pipelines/constants';
+import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue';
+import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
+import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
+import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql';
+import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
+import getPipelineDetailsQuery from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql';
+import {
+ pipelineHeaderSuccess,
+ pipelineHeaderRunning,
+ pipelineHeaderRunningWithDuration,
+ pipelineHeaderFailed,
+ pipelineRetryMutationResponseSuccess,
+ pipelineCancelMutationResponseSuccess,
+ pipelineDeleteMutationResponseSuccess,
+ pipelineRetryMutationResponseFailed,
+ pipelineCancelMutationResponseFailed,
+ pipelineDeleteMutationResponseFailed,
+} from './mock_data';
+
+Vue.use(VueApollo);
+
+describe('Pipeline details header', () => {
+ let wrapper;
+ let glModalDirective;
+
+ const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess);
+ const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning);
+ const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration);
+ const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed);
+
+ const retryMutationHandlerSuccess = jest
+ .fn()
+ .mockResolvedValue(pipelineRetryMutationResponseSuccess);
+ const cancelMutationHandlerSuccess = jest
+ .fn()
+ .mockResolvedValue(pipelineCancelMutationResponseSuccess);
+ const deleteMutationHandlerSuccess = jest
+ .fn()
+ .mockResolvedValue(pipelineDeleteMutationResponseSuccess);
+ const retryMutationHandlerFailed = jest
+ .fn()
+ .mockResolvedValue(pipelineRetryMutationResponseFailed);
+ const cancelMutationHandlerFailed = jest
+ .fn()
+ .mockResolvedValue(pipelineCancelMutationResponseFailed);
+ const deleteMutationHandlerFailed = jest
+ .fn()
+ .mockResolvedValue(pipelineDeleteMutationResponseFailed);
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findStatus = () => wrapper.findComponent(CiBadgeLink);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTimeAgo = () => wrapper.findComponent(TimeAgo);
+ const findAllBadges = () => wrapper.findAllComponents(GlBadge);
+ const findPipelineName = () => wrapper.findByTestId('pipeline-name');
+ const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title');
+ const findTotalJobs = () => wrapper.findByTestId('total-jobs');
+ const findComputeCredits = () => wrapper.findByTestId('compute-credits');
+ const findCommitLink = () => wrapper.findByTestId('commit-link');
+ const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text();
+ const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text();
+ const findRetryButton = () => wrapper.findByTestId('retry-pipeline');
+ const findCancelButton = () => wrapper.findByTestId('cancel-pipeline');
+ const findDeleteButton = () => wrapper.findByTestId('delete-pipeline');
+ const findDeleteModal = () => wrapper.findComponent(GlModal);
+ const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link');
+ const findPipelineDuration = () => wrapper.findByTestId('pipeline-duration-text');
+
+ const defaultHandlers = [[getPipelineDetailsQuery, successHandler]];
+
+ const defaultProvideOptions = {
+ pipelineIid: 1,
+ paths: {
+ pipelinesPath: '/namespace/my-project/-/pipelines',
+ fullProject: '/namespace/my-project',
+ triggeredByPath: '',
+ },
+ };
+
+ const defaultProps = {
+ name: 'Ruby 3.0 master branch pipeline',
+ totalJobs: '50',
+ computeCredits: '0.65',
+ yamlErrors: 'errors',
+ failureReason: 'pipeline failed',
+ badges: {
+ schedule: true,
+ child: false,
+ latest: true,
+ mergeTrainPipeline: false,
+ invalid: false,
+ failed: false,
+ autoDevops: false,
+ detached: false,
+ stuck: false,
+ },
+ refText:
+ 'Related merge request <a class="mr-iid" href="/root/ci-project/-/merge_requests/1">!1</a> to merge <a class="ref-name" href="/root/ci-project/-/commits/test">test</a>',
+ };
+
+ const createMockApolloProvider = (handlers) => {
+ return createMockApollo(handlers);
+ };
+
+ const createComponent = (handlers = defaultHandlers, props = defaultProps) => {
+ glModalDirective = jest.fn();
+
+ wrapper = shallowMountExtended(PipelineDetailsHeader, {
+ provide: {
+ ...defaultProvideOptions,
+ },
+ propsData: {
+ ...props,
+ },
+ directives: {
+ glModal: {
+ bind(_, { value }) {
+ glModalDirective(value);
+ },
+ },
+ },
+ stubs: { GlSprintf },
+ apolloProvider: createMockApolloProvider(handlers),
+ });
+ };
+
+ describe('loading state', () => {
+ it('shows a loading state while graphQL is fetching initial data', () => {
+ createComponent();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('defaults', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('does not display loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('displays pipeline status', () => {
+ expect(findStatus().exists()).toBe(true);
+ });
+
+ it('displays pipeline name', () => {
+ expect(findPipelineName().text()).toBe(defaultProps.name);
+ });
+
+ it('displays total jobs', () => {
+ expect(findTotalJobs().text()).toBe('50 Jobs');
+ });
+
+ it('has link to commit', () => {
+ const {
+ data: {
+ project: { pipeline },
+ },
+ } = pipelineHeaderSuccess;
+
+ expect(findCommitLink().attributes('href')).toBe(pipeline.commit.webPath);
+ });
+
+ it('displays correct badges', () => {
+ expect(findAllBadges()).toHaveLength(2);
+ expect(wrapper.findByText('latest').exists()).toBe(true);
+ expect(wrapper.findByText('Scheduled').exists()).toBe(true);
+ });
+
+ it('displays ref text', () => {
+ expect(findPipelineRefText()).toBe('Related merge request !1 to merge test');
+ });
+
+ it('displays pipeline user link with required user popover attributes', () => {
+ const {
+ data: {
+ project: {
+ pipeline: { user },
+ },
+ },
+ } = pipelineHeaderSuccess;
+
+ const userId = getIdFromGraphQLId(user.id).toString();
+
+ expect(findPipelineUserLink().classes()).toContain('js-user-link');
+ expect(findPipelineUserLink().attributes('data-user-id')).toBe(userId);
+ expect(findPipelineUserLink().attributes('data-username')).toBe(user.username);
+ expect(findPipelineUserLink().attributes('href')).toBe(user.webUrl);
+ });
+ });
+
+ describe('without pipeline name', () => {
+ it('displays commit title', async () => {
+ createComponent(defaultHandlers, { ...defaultProps, name: '' });
+
+ await waitForPromises();
+
+ const expectedTitle = pipelineHeaderSuccess.data.project.pipeline.commit.title;
+
+ expect(findPipelineName().exists()).toBe(false);
+ expect(findCommitTitle().text()).toBe(expectedTitle);
+ });
+ });
+
+ describe('finished pipeline', () => {
+ it('displays compute credits when not zero', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findComputeCredits().text()).toBe('0.65');
+ });
+
+ it('does not display compute credits when zero', async () => {
+ createComponent(defaultHandlers, { ...defaultProps, computeCredits: '0.0' });
+
+ await waitForPromises();
+
+ expect(findComputeCredits().exists()).toBe(false);
+ });
+
+ it('displays time ago', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findTimeAgo().exists()).toBe(true);
+ });
+
+ it('displays pipeline duartion text', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findPipelineDuration().text()).toBe(
+ '120 minutes 10 seconds, queued for 3,600 seconds',
+ );
+ });
+ });
+
+ describe('running pipeline', () => {
+ beforeEach(async () => {
+ createComponent([[getPipelineDetailsQuery, runningHandler]]);
+
+ await waitForPromises();
+ });
+
+ it('does not display compute credits', () => {
+ expect(findComputeCredits().exists()).toBe(false);
+ });
+
+ it('does not display time ago', () => {
+ expect(findTimeAgo().exists()).toBe(false);
+ });
+
+ it('does not display pipeline duration text', () => {
+ expect(findPipelineDuration().exists()).toBe(false);
+ });
+
+ it('displays pipeline running text', () => {
+ expect(findPipelineRunningText()).toBe('In progress, queued for 3,600 seconds');
+ });
+ });
+
+ describe('running pipeline with duration', () => {
+ beforeEach(async () => {
+ createComponent([[getPipelineDetailsQuery, runningHandlerWithDuration]]);
+
+ await waitForPromises();
+ });
+
+ it('does not display pipeline duration text', () => {
+ expect(findPipelineDuration().exists()).toBe(false);
+ });
+ });
+
+ describe('actions', () => {
+ describe('retry action', () => {
+ beforeEach(async () => {
+ createComponent([
+ [getPipelineDetailsQuery, failedHandler],
+ [retryPipelineMutation, retryMutationHandlerSuccess],
+ ]);
+
+ await waitForPromises();
+ });
+
+ it('should call retryPipeline Mutation with pipeline id', () => {
+ findRetryButton().vm.$emit('click');
+
+ expect(retryMutationHandlerSuccess).toHaveBeenCalledWith({
+ id: pipelineHeaderFailed.data.project.pipeline.id,
+ });
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should render retry action tooltip', () => {
+ expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY);
+ });
+ });
+
+ describe('retry action failed', () => {
+ beforeEach(async () => {
+ createComponent([
+ [getPipelineDetailsQuery, failedHandler],
+ [retryPipelineMutation, retryMutationHandlerFailed],
+ ]);
+
+ await waitForPromises();
+ });
+
+ it('should display error message on failure', async () => {
+ findRetryButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('retry button loading state should reset on error', async () => {
+ findRetryButton().vm.$emit('click');
+
+ await nextTick();
+
+ expect(findRetryButton().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findRetryButton().props('loading')).toBe(false);
+ });
+ });
+
+ describe('cancel action', () => {
+ it('should call cancelPipeline Mutation with pipeline id', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, runningHandler],
+ [cancelPipelineMutation, cancelMutationHandlerSuccess],
+ ]);
+
+ await waitForPromises();
+
+ findCancelButton().vm.$emit('click');
+
+ expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({
+ id: pipelineHeaderRunning.data.project.pipeline.id,
+ });
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should render cancel action tooltip', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, runningHandler],
+ [cancelPipelineMutation, cancelMutationHandlerSuccess],
+ ]);
+
+ await waitForPromises();
+
+ expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL);
+ });
+
+ it('should display error message on failure', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, runningHandler],
+ [cancelPipelineMutation, cancelMutationHandlerFailed],
+ ]);
+
+ await waitForPromises();
+
+ findCancelButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('delete action', () => {
+ it('displays delete modal when clicking on delete and does not call the delete action', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, successHandler],
+ [deletePipelineMutation, deleteMutationHandlerSuccess],
+ ]);
+
+ await waitForPromises();
+
+ findDeleteButton().vm.$emit('click');
+
+ const modalId = 'pipeline-delete-modal';
+
+ expect(findDeleteModal().props('modalId')).toBe(modalId);
+ expect(glModalDirective).toHaveBeenCalledWith(modalId);
+ expect(deleteMutationHandlerSuccess).not.toHaveBeenCalled();
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should call deletePipeline Mutation with pipeline id when modal is submitted', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, successHandler],
+ [deletePipelineMutation, deleteMutationHandlerSuccess],
+ ]);
+
+ await waitForPromises();
+
+ findDeleteModal().vm.$emit('primary');
+
+ expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({
+ id: pipelineHeaderSuccess.data.project.pipeline.id,
+ });
+ });
+
+ it('should display error message on failure', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, successHandler],
+ [deletePipelineMutation, deleteMutationHandlerFailed],
+ ]);
+
+ await waitForPromises();
+
+ findDeleteModal().vm.$emit('primary');
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
index e3c9983aa52..43336bbc748 100644
--- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js
+++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
@@ -1,9 +1,11 @@
+import { nextTick } from 'vue';
import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import PipelineMultiActions, {
@@ -14,6 +16,7 @@ import { TRACKING_CATEGORIES } from '~/pipelines/constants';
describe('Pipeline Multi Actions Dropdown', () => {
let wrapper;
let mockAxios;
+ const focusInputMock = jest.fn();
const artifacts = [
{
@@ -30,7 +33,7 @@ describe('Pipeline Multi Actions Dropdown', () => {
const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`;
const pipelineId = 108;
- const createComponent = ({ mockData = {} } = {}) => {
+ const createComponent = () => {
wrapper = extendedWrapper(
shallowMount(PipelineMultiActions, {
provide: {
@@ -40,14 +43,12 @@ describe('Pipeline Multi Actions Dropdown', () => {
propsData: {
pipelineId,
},
- data() {
- return {
- ...mockData,
- };
- },
stubs: {
GlSprintf,
GlDropdown,
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ methods: { focusInput: focusInputMock },
+ }),
},
}),
);
@@ -76,70 +77,91 @@ describe('Pipeline Multi Actions Dropdown', () => {
});
describe('Artifacts', () => {
- it('should fetch artifacts and show search box on dropdown click', async () => {
- const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
- mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
- createComponent();
- findDropdown().vm.$emit('show');
- await waitForPromises();
+ const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
- expect(mockAxios.history.get).toHaveLength(1);
- expect(wrapper.vm.artifacts).toEqual(artifacts);
- expect(findSearchBox().exists()).toBe(true);
- });
+ describe('while loading artifacts', () => {
+ beforeEach(() => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
+ });
- it('should focus the search box when opened with artifacts', () => {
- createComponent({ mockData: { artifacts } });
- wrapper.vm.$refs.searchInput.focusInput = jest.fn();
+ it('should render a loading spinner and no empty message', async () => {
+ createComponent();
- findDropdown().vm.$emit('shown');
+ findDropdown().vm.$emit('show');
+ await nextTick();
- expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findEmptyMessage().exists()).toBe(false);
+ });
});
- it('should render all the provided artifacts when search query is empty', () => {
- const searchQuery = '';
- createComponent({ mockData: { searchQuery, artifacts } });
+ describe('artifacts loaded successfully', () => {
+ describe('artifacts exist', () => {
+ beforeEach(async () => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
- expect(findAllArtifactItems()).toHaveLength(artifacts.length);
- expect(findEmptyMessage().exists()).toBe(false);
- });
+ createComponent();
- it('should render filtered artifacts when search query is not empty', () => {
- const searchQuery = 'job-2';
- createComponent({ mockData: { searchQuery, artifacts } });
+ findDropdown().vm.$emit('show');
+ await waitForPromises();
+ });
- expect(findAllArtifactItems()).toHaveLength(1);
- expect(findEmptyMessage().exists()).toBe(false);
- });
+ it('should fetch artifacts and show search box on dropdown click', () => {
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(findSearchBox().exists()).toBe(true);
+ });
- it('should render the correct artifact name and path', () => {
- createComponent({ mockData: { artifacts } });
+ it('should focus the search box when opened with artifacts', () => {
+ findDropdown().vm.$emit('shown');
- expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path);
- expect(findFirstArtifactItem().text()).toBe(artifacts[0].name);
- });
+ expect(focusInputMock).toHaveBeenCalled();
+ });
- it('should render empty message and no search box when no artifacts are found', () => {
- createComponent({ mockData: { artifacts: [] } });
+ it('should render all the provided artifacts when search query is empty', () => {
+ findSearchBox().vm.$emit('input', '');
- expect(findEmptyMessage().exists()).toBe(true);
- expect(findSearchBox().exists()).toBe(false);
- });
+ expect(findAllArtifactItems()).toHaveLength(artifacts.length);
+ expect(findEmptyMessage().exists()).toBe(false);
+ });
- describe('while loading artifacts', () => {
- it('should render a loading spinner and no empty message', () => {
- createComponent({ mockData: { isLoading: true, artifacts: [] } });
+ it('should render filtered artifacts when search query is not empty', async () => {
+ findSearchBox().vm.$emit('input', 'job-2');
+ await waitForPromises();
- expect(findLoadingIcon().exists()).toBe(true);
- expect(findEmptyMessage().exists()).toBe(false);
+ expect(findAllArtifactItems()).toHaveLength(1);
+ expect(findEmptyMessage().exists()).toBe(false);
+ });
+
+ it('should render the correct artifact name and path', () => {
+ expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path);
+ expect(findFirstArtifactItem().text()).toBe(artifacts[0].name);
+ });
+ });
+
+ describe('artifacts list is empty', () => {
+ beforeEach(() => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] });
+ });
+
+ it('should render empty message and no search box when no artifacts are found', async () => {
+ createComponent();
+
+ findDropdown().vm.$emit('show');
+ await waitForPromises();
+
+ expect(findEmptyMessage().exists()).toBe(true);
+ expect(findSearchBox().exists()).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
});
});
describe('with a failing request', () => {
- it('should render an error message', async () => {
- const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
+ beforeEach(() => {
mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ });
+
+ it('should render an error message', async () => {
createComponent();
findDropdown().vm.$emit('show');
await waitForPromises();
diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js
index f00ee4a6367..797ec676ccc 100644
--- a/spec/frontend/pipelines/pipeline_url_spec.js
+++ b/spec/frontend/pipelines/pipeline_url_spec.js
@@ -24,7 +24,7 @@ describe('Pipeline Url Component', () => {
const findPipelineNameContainer = () => wrapper.findByTestId('pipeline-name-container');
const findCommitTitle = (commitWrapper) => commitWrapper.find('[data-testid="commit-title"]');
- const defaultProps = mockPipeline(projectPath);
+ const defaultProps = { ...mockPipeline(projectPath), refClass: 'gl-text-black' };
const createComponent = (props) => {
wrapper = shallowMountExtended(PipelineUrlComponent, {
@@ -69,6 +69,18 @@ describe('Pipeline Url Component', () => {
expect(findPipelineNameContainer().exists()).toBe(false);
});
+ it('should pass the refClass prop to merge request link', () => {
+ createComponent();
+
+ expect(findRefName().classes()).toContain(defaultProps.refClass);
+ });
+
+ it('should pass the refClass prop to the commit ref name link', () => {
+ createComponent(mockPipelineBranch());
+
+ expect(findCommitRefName().classes()).toContain(defaultProps.refClass);
+ });
+
describe('commit user avatar', () => {
it('renders when commit author exists', () => {
const pipelineBranch = mockPipelineBranch();
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index f0772bce167..5b77d44c5bd 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -1,5 +1,13 @@
import '~/commons';
-import { GlButton, GlEmptyState, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui';
+import {
+ GlButton,
+ GlEmptyState,
+ GlFilteredSearch,
+ GlLoadingIcon,
+ GlPagination,
+ GlCollapsibleListbox,
+} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { chunk } from 'lodash';
@@ -10,8 +18,10 @@ import { TEST_HOST } from 'helpers/test_constants';
import { mockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
import Api from '~/api';
import { createAlert, VARIANT_WARNING } from '~/alert';
+import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue';
@@ -22,9 +32,14 @@ import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/pipelines/constants';
import Store from '~/pipelines/stores/pipelines_store';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import {
+ setIdTypePreferenceMutationResponse,
+ setIdTypePreferenceMutationResponseWithErrors,
+} from 'jest/issues/list/mock_data';
import { stageReply, users, mockSearch, branches } from './mock_data';
+jest.mock('@sentry/browser');
jest.mock('~/alert');
const mockProjectPath = 'twitter/flight';
@@ -38,13 +53,14 @@ const mockPipelineWithStages = mockPipelinesResponse.pipelines.find(
describe('Pipelines', () => {
let wrapper;
+ let mockApollo;
let mock;
let trackingSpy;
const paths = {
emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
- noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
ciLintPath: '/ci/lint',
resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`,
newPipelinePath: `${mockProjectPath}/pipelines/new`,
@@ -55,7 +71,7 @@ describe('Pipelines', () => {
const noPermissions = {
emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
- noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
+ noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
};
const defaultProps = {
@@ -70,6 +86,7 @@ describe('Pipelines', () => {
const findNavigationControls = () => wrapper.findComponent(NavigationControls);
const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent);
const findTablePagination = () => wrapper.findComponent(TablePagination);
+ const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox);
const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`);
const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box');
@@ -81,6 +98,9 @@ describe('Pipelines', () => {
const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]');
const createComponent = (props = defaultProps) => {
+ const { mutationMock, ...restProps } = props;
+ mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]);
+
wrapper = extendedWrapper(
mount(PipelinesComponent, {
provide: {
@@ -95,8 +115,9 @@ describe('Pipelines', () => {
defaultBranchName: mockDefaultBranchName,
endpoint: mockPipelinesEndpoint,
params: {},
- ...props,
+ ...restProps,
},
+ apolloProvider: mockApollo,
}),
);
};
@@ -115,6 +136,7 @@ describe('Pipelines', () => {
afterEach(() => {
mock.reset();
+ mockApollo = null;
window.history.pushState.mockReset();
});
@@ -349,6 +371,45 @@ describe('Pipelines', () => {
});
});
+ describe('when user changes Show Pipeline ID to Show Pipeline IID', () => {
+ const mockFilteredPipeline = mockPipelinesResponse.pipelines[0];
+
+ beforeEach(() => {
+ gon.current_user_id = 1;
+ });
+
+ it('should change the text to Show Pipeline IID', async () => {
+ expect(findPipelineKeyCollapsibleBox().exists()).toBe(true);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`);
+ findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid');
+
+ await waitForPromises();
+
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.iid}`);
+ });
+
+ it('calls mutation to save idType preference', () => {
+ const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse);
+ createComponent({ ...defaultProps, mutationMock });
+
+ findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid');
+
+ expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } });
+ });
+
+ it('captures error when mutation response has errors', async () => {
+ const mutationMock = jest
+ .fn()
+ .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors);
+ createComponent({ ...defaultProps, mutationMock });
+
+ findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid');
+ await waitForPromises();
+
+ expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!'));
+ });
+ });
+
describe('when user triggers a filtered search with raw text', () => {
beforeEach(async () => {
findFilteredSearch().vm.$emit('submit', ['rawText']);
diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js
index 8d2a52eb6d0..10752cee841 100644
--- a/spec/frontend/pipelines/pipelines_table_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_spec.js
@@ -10,6 +10,7 @@ import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_tr
import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue';
import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue';
+import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue';
import {
PipelineKeyOptions,
BUTTON_TOOLTIP_RETRY,
@@ -26,6 +27,18 @@ describe('Pipelines Table', () => {
let wrapper;
let trackingSpy;
+ const defaultProvide = {
+ glFeatures: {},
+ withFailedJobsDetails: false,
+ };
+
+ const provideWithDetails = {
+ glFeatures: {
+ ciJobFailuresInMr: true,
+ },
+ withFailedJobsDetails: true,
+ };
+
const defaultProps = {
pipelines: [],
viewType: 'root',
@@ -38,13 +51,18 @@ describe('Pipelines Table', () => {
return pipelines.find((p) => p.user !== null && p.commit !== null);
};
- const createComponent = (props = {}) => {
+ const createComponent = (props = {}, provide = {}) => {
wrapper = extendedWrapper(
mount(PipelinesTable, {
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ stubs: ['PipelineFailedJobsWidget'],
}),
);
};
@@ -56,6 +74,7 @@ describe('Pipelines Table', () => {
const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph);
const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago);
const findActions = () => wrapper.findComponent(PipelineOperations);
+ const findPipelineFailedJobsWidget = () => wrapper.findComponent(PipelineFailedJobsWidget);
const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row');
const findStatusTh = () => wrapper.findByTestId('status-th');
@@ -163,6 +182,68 @@ describe('Pipelines Table', () => {
});
});
+ describe('failed jobs details', () => {
+ describe('row', () => {
+ describe('when the FF is disabled', () => {
+ beforeEach(() => {
+ createComponent({ pipelines: [pipeline] });
+ });
+
+ it('does not render', () => {
+ expect(findTableRows()).toHaveLength(1);
+ });
+ });
+
+ describe('when the FF is enabled', () => {
+ describe('and `withFailedJobsDetails` value is provided', () => {
+ beforeEach(() => {
+ createComponent({ pipelines: [pipeline] }, provideWithDetails);
+ });
+ it('renders', () => {
+ expect(findTableRows()).toHaveLength(2);
+ });
+ });
+
+ describe('and `withFailedJobsDetails` value is not provided', () => {
+ beforeEach(() => {
+ createComponent(
+ { pipelines: [pipeline] },
+ { glFeatures: { ciJobFailuresInMr: true } },
+ );
+ });
+
+ it('does not render', () => {
+ expect(findTableRows()).toHaveLength(1);
+ });
+ });
+ });
+ });
+
+ describe('widget', () => {
+ describe('when there are no failed jobs', () => {
+ beforeEach(() => {
+ createComponent(
+ { pipelines: [{ ...pipeline, failed_builds: [] }] },
+ provideWithDetails,
+ );
+ });
+
+ it('does not renders', () => {
+ expect(findPipelineFailedJobsWidget().exists()).toBe(false);
+ });
+ });
+
+ describe('when there are failed jobs', () => {
+ beforeEach(() => {
+ createComponent({ pipelines: [pipeline] }, provideWithDetails);
+ });
+ it('renders', () => {
+ expect(findPipelineFailedJobsWidget().exists()).toBe(true);
+ });
+ });
+ });
+ });
+
describe('tracking', () => {
beforeEach(() => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js
index efb1bf09d20..5afe91c4784 100644
--- a/spec/frontend/pipelines/time_ago_spec.js
+++ b/spec/frontend/pipelines/time_ago_spec.js
@@ -8,7 +8,7 @@ describe('Timeago component', () => {
const defaultProps = { duration: 0, finished_at: '' };
- const createComponent = (props = defaultProps, stuck = false) => {
+ const createComponent = (props = defaultProps, extraProps) => {
wrapper = extendedWrapper(
shallowMount(TimeAgo, {
propsData: {
@@ -16,10 +16,8 @@ describe('Timeago component', () => {
details: {
...props,
},
- flags: {
- stuck,
- },
},
+ ...extraProps,
},
data() {
return {
@@ -32,10 +30,7 @@ describe('Timeago component', () => {
const duration = () => wrapper.find('.duration');
const finishedAt = () => wrapper.find('.finished-at');
- const findInProgress = () => wrapper.findByTestId('pipeline-in-progress');
- const findSkipped = () => wrapper.findByTestId('pipeline-skipped');
- const findHourGlassIcon = () => wrapper.findByTestId('hourglass-icon');
- const findWarningIcon = () => wrapper.findByTestId('warning-icon');
+ const findCalendarIcon = () => wrapper.findByTestId('calendar-icon');
describe('with duration', () => {
beforeEach(() => {
@@ -61,68 +56,41 @@ describe('Timeago component', () => {
});
describe('with finishedTime', () => {
- beforeEach(() => {
+ it('should render time', () => {
createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' });
- });
- it('should render time and calendar icon', () => {
- const icon = finishedAt().findComponent(GlIcon);
const time = finishedAt().find('time');
expect(finishedAt().exists()).toBe(true);
- expect(icon.props('name')).toBe('calendar');
expect(time.exists()).toBe(true);
});
- });
- describe('without finishedTime', () => {
- beforeEach(() => {
- createComponent();
- });
+ it('should display calendar icon by default', () => {
+ createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' });
- it('should not render time and calendar icon', () => {
- expect(finishedAt().exists()).toBe(false);
+ expect(findCalendarIcon().exists()).toBe(true);
});
- });
-
- describe('in progress', () => {
- it.each`
- durationTime | finishedAtTime | shouldShow
- ${10} | ${'2017-04-26T12:40:23.277Z'} | ${false}
- ${10} | ${''} | ${false}
- ${0} | ${'2017-04-26T12:40:23.277Z'} | ${false}
- ${0} | ${''} | ${true}
- `(
- 'progress state shown: $shouldShow when pipeline duration is $durationTime and finished_at is $finishedAtTime',
- ({ durationTime, finishedAtTime, shouldShow }) => {
- createComponent({
- duration: durationTime,
- finished_at: finishedAtTime,
- });
-
- expect(findInProgress().exists()).toBe(shouldShow);
- expect(findSkipped().exists()).toBe(false);
- },
- );
- it('should show warning icon beside in progress if pipeline is stuck', () => {
- const stuck = true;
-
- createComponent(defaultProps, stuck);
+ it('should hide calendar icon if correct prop is passed', () => {
+ createComponent(
+ { duration: 0, finished_at: '2017-04-26T12:40:23.277Z' },
+ {
+ displayCalendarIcon: false,
+ },
+ );
- expect(findWarningIcon().exists()).toBe(true);
- expect(findHourGlassIcon().exists()).toBe(false);
+ expect(findCalendarIcon().exists()).toBe(false);
});
});
- describe('skipped', () => {
- it('should show skipped if pipeline was skipped', () => {
- createComponent({
- status: { label: 'skipped' },
- });
+ describe('without finishedTime', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- expect(findSkipped().exists()).toBe(true);
- expect(findInProgress().exists()).toBe(false);
+ it('should not render time and calendar icon', () => {
+ expect(finishedAt().exists()).toBe(false);
+ expect(findCalendarIcon().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/pipelines/utils_spec.js b/spec/frontend/pipelines/utils_spec.js
index 51e0e0705ff..286d79edc6c 100644
--- a/spec/frontend/pipelines/utils_spec.js
+++ b/spec/frontend/pipelines/utils_spec.js
@@ -1,3 +1,4 @@
+import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json';
import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import {
makeLinksFromNodes,
@@ -14,7 +15,7 @@ import { createNodeDict } from '~/pipelines/utils';
import { mockDownstreamPipelinesRest } from '../vue_merge_request_widget/mock_data';
import { mockDownstreamPipelinesGraphql } from '../commit/mock_data';
import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data';
-import { generateResponse, mockPipelineResponse } from './graph/mock_data';
+import { generateResponse } from './graph/mock_data';
describe('DAG visualization parsing utilities', () => {
const nodeDict = createNodeDict(mockParsedGraphQLNodes);
@@ -152,14 +153,6 @@ describe('DAG visualization parsing utilities', () => {
});
});
});
-
- /*
- Just as a fallback in case multiple functions change, so tests pass
- but the implementation moves away from case.
- */
- it('matches the snapshot', () => {
- expect(columns).toMatchSnapshot();
- });
});
});
diff --git a/spec/frontend/profile/components/follow_spec.js b/spec/frontend/profile/components/follow_spec.js
new file mode 100644
index 00000000000..2555e41257f
--- /dev/null
+++ b/spec/frontend/profile/components/follow_spec.js
@@ -0,0 +1,99 @@
+import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import users from 'test_fixtures/api/users/followers/get.json';
+import Follow from '~/profile/components/follow.vue';
+import { DEFAULT_PER_PAGE } from '~/api';
+
+jest.mock('~/rest_api');
+
+describe('FollowersTab', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ users,
+ loading: false,
+ page: 1,
+ totalItems: 50,
+ };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMount(Follow, {
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
+ });
+ };
+
+ const findPagination = () => wrapper.findComponent(GlPagination);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ describe('when `loading` prop is `true`', () => {
+ it('renders loading icon', () => {
+ createComponent({ propsData: { loading: true } });
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('when `loading` prop is `false`', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('does not render loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders users', () => {
+ const avatarLinksHref = wrapper
+ .findAllComponents(GlAvatarLink)
+ .wrappers.map((avatarLinkWrapper) => avatarLinkWrapper.attributes('href'));
+ const expectedAvatarLinksHref = users.map((user) => user.web_url);
+
+ const avatarLabeledProps = wrapper
+ .findAllComponents(GlAvatarLabeled)
+ .wrappers.map((avatarLabeledWrapper) => ({
+ label: avatarLabeledWrapper.props('label'),
+ subLabel: avatarLabeledWrapper.props('subLabel'),
+ size: avatarLabeledWrapper.attributes('size'),
+ entityName: avatarLabeledWrapper.attributes('entity-name'),
+ entityId: avatarLabeledWrapper.attributes('entity-id'),
+ src: avatarLabeledWrapper.attributes('src'),
+ }));
+ const expectedAvatarLabeledProps = users.map((user) => ({
+ src: user.avatar_url,
+ size: '48',
+ entityId: user.id.toString(),
+ entityName: user.name,
+ label: user.name,
+ subLabel: user.username,
+ }));
+
+ expect(avatarLinksHref).toEqual(expectedAvatarLinksHref);
+ expect(avatarLabeledProps).toEqual(expectedAvatarLabeledProps);
+ });
+
+ it('renders `GlPagination` and passes correct props', () => {
+ expect(wrapper.findComponent(GlPagination).props()).toMatchObject({
+ align: 'center',
+ value: defaultPropsData.page,
+ totalItems: defaultPropsData.totalItems,
+ perPage: DEFAULT_PER_PAGE,
+ prevText: Follow.i18n.prev,
+ nextText: Follow.i18n.next,
+ });
+ });
+
+ describe('when `GlPagination` emits `input` event', () => {
+ it('emits `pagination-input` event', () => {
+ const nextPage = defaultPropsData.page + 1;
+
+ findPagination().vm.$emit('input', nextPage);
+
+ expect(wrapper.emitted('pagination-input')).toEqual([[nextPage]]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/profile/components/followers_tab_spec.js b/spec/frontend/profile/components/followers_tab_spec.js
index 9cc5bdea9be..0370005d0a4 100644
--- a/spec/frontend/profile/components/followers_tab_spec.js
+++ b/spec/frontend/profile/components/followers_tab_spec.js
@@ -1,32 +1,127 @@
import { GlBadge, GlTab } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import followers from 'test_fixtures/api/users/followers/get.json';
import { s__ } from '~/locale';
import FollowersTab from '~/profile/components/followers_tab.vue';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Follow from '~/profile/components/follow.vue';
+import { getUserFollowers } from '~/rest_api';
+import { createAlert } from '~/alert';
+import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
+
+jest.mock('~/rest_api');
+jest.mock('~/alert');
describe('FollowersTab', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMountExtended(FollowersTab, {
+ wrapper = shallowMount(FollowersTab, {
provide: {
- followers: 2,
+ followersCount: 2,
+ userId: 1,
+ },
+ stubs: {
+ GlTab: stubComponent(GlTab, {
+ template: `
+ <li>
+ <slot name="title"></slot>
+ <slot></slot>
+ </li>
+ `,
+ }),
},
});
};
- it('renders `GlTab` and sets title', () => {
- createComponent();
+ const findGlBadge = () => wrapper.findComponent(GlBadge);
+ const findFollow = () => wrapper.findComponent(Follow);
+
+ describe('when API request is loading', () => {
+ beforeEach(() => {
+ getUserFollowers.mockReturnValueOnce(new Promise(() => {}));
+ createComponent();
+ });
+
+ it('renders `Follow` component and sets `loading` prop to `true`', () => {
+ expect(findFollow().props('loading')).toBe(true);
+ });
+ });
+
+ describe('when API request is successful', () => {
+ beforeEach(async () => {
+ getUserFollowers.mockResolvedValueOnce({
+ data: followers,
+ headers: { 'X-TOTAL': '6' },
+ });
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('renders `GlTab` and sets title', () => {
+ expect(wrapper.findComponent(GlTab).text()).toContain(s__('UserProfile|Followers'));
+ });
+
+ it('renders `GlBadge`, sets size and content', () => {
+ expect(findGlBadge().props('size')).toBe('sm');
+ expect(findGlBadge().text()).toBe('2');
+ });
+
+ it('renders `Follow` component and passes correct props', () => {
+ expect(findFollow().props()).toMatchObject({
+ users: followers,
+ loading: false,
+ page: 1,
+ totalItems: 6,
+ });
+ });
+
+ describe('when `Follow` component emits `pagination-input` event', () => {
+ it('calls API and updates `users` and `page` props', async () => {
+ const lastFollower = followers.at(-1);
+ const paginationFollowers = [
+ {
+ ...lastFollower,
+ id: lastFollower.id + 1,
+ name: 'page 2 follower',
+ },
+ ];
+
+ getUserFollowers.mockResolvedValueOnce({
+ data: paginationFollowers,
+ headers: { 'X-TOTAL': '6' },
+ });
- expect(wrapper.findComponent(GlTab).element.textContent).toContain(
- s__('UserProfile|Followers'),
- );
+ findFollow().vm.$emit('pagination-input', 2);
+
+ await waitForPromises();
+
+ expect(findFollow().props()).toMatchObject({
+ users: paginationFollowers,
+ loading: false,
+ page: 2,
+ totalItems: 6,
+ });
+ });
+ });
});
- it('renders `GlBadge`, sets size and content', () => {
- createComponent();
+ describe('when API request is not successful', () => {
+ beforeEach(async () => {
+ getUserFollowers.mockRejectedValueOnce(new Error());
+ createComponent();
- expect(wrapper.findComponent(GlBadge).attributes('size')).toBe('sm');
- expect(wrapper.findComponent(GlBadge).element.textContent).toBe('2');
+ await waitForPromises();
+ });
+
+ it('shows error alert', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: FollowersTab.i18n.errorMessage,
+ error: new Error(),
+ captureError: true,
+ });
+ });
});
});
diff --git a/spec/frontend/profile/components/following_tab_spec.js b/spec/frontend/profile/components/following_tab_spec.js
index c9d56360c3e..c0583cf4877 100644
--- a/spec/frontend/profile/components/following_tab_spec.js
+++ b/spec/frontend/profile/components/following_tab_spec.js
@@ -10,7 +10,7 @@ describe('FollowingTab', () => {
const createComponent = () => {
wrapper = shallowMountExtended(FollowingTab, {
provide: {
- followees: 3,
+ followeesCount: 3,
},
});
};
diff --git a/spec/frontend/profile/components/overview_tab_spec.js b/spec/frontend/profile/components/overview_tab_spec.js
index aeab24cb730..0122735e8a3 100644
--- a/spec/frontend/profile/components/overview_tab_spec.js
+++ b/spec/frontend/profile/components/overview_tab_spec.js
@@ -1,27 +1,47 @@
import { GlLoadingIcon, GlTab, GlLink } from '@gitlab/ui';
+import AxiosMockAdapter from 'axios-mock-adapter';
import projects from 'test_fixtures/api/users/projects/get.json';
+import events from 'test_fixtures/controller/users/activity.json';
import { s__ } from '~/locale';
import OverviewTab from '~/profile/components/overview_tab.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ActivityCalendar from '~/profile/components/activity_calendar.vue';
import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
+import ContributionEvents from '~/contribution_events/components/contribution_events.vue';
+import { createAlert } from '~/alert';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/alert');
describe('OverviewTab', () => {
let wrapper;
+ let axiosMock;
const defaultPropsData = {
personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }),
personalProjectsLoading: false,
};
+ const defaultProvide = { userActivityPath: '/users/root/activity.json' };
+
const createComponent = ({ propsData = {} } = {}) => {
wrapper = shallowMountExtended(OverviewTab, {
propsData: { ...defaultPropsData, ...propsData },
+ provide: defaultProvide,
});
};
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
it('renders `GlTab` and sets `title` prop', () => {
createComponent();
@@ -70,4 +90,50 @@ describe('OverviewTab', () => {
).toMatchObject(defaultPropsData.personalProjects);
});
});
+
+ describe('when activity API request is loading', () => {
+ beforeEach(() => {
+ axiosMock.onGet(defaultProvide.userActivityPath).reply(200, events);
+
+ createComponent();
+ });
+
+ it('shows loading icon', () => {
+ expect(wrapper.findByTestId('activity-section').findComponent(GlLoadingIcon).exists()).toBe(
+ true,
+ );
+ });
+ });
+
+ describe('when activity API request is successful', () => {
+ beforeEach(() => {
+ axiosMock.onGet(defaultProvide.userActivityPath).reply(200, events);
+
+ createComponent();
+ });
+
+ it('renders `ContributionEvents` component', async () => {
+ await waitForPromises();
+
+ expect(wrapper.findComponent(ContributionEvents).props('events')).toEqual(events);
+ });
+ });
+
+ describe('when activity API request is not successful', () => {
+ beforeEach(() => {
+ axiosMock.onGet(defaultProvide.userActivityPath).networkError();
+
+ createComponent();
+ });
+
+ it('calls `createAlert`', async () => {
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: OverviewTab.i18n.eventsErrorMessage,
+ error: new Error('Network Error'),
+ captureError: true,
+ });
+ });
+ });
});
diff --git a/spec/frontend/profile/components/profile_tabs_spec.js b/spec/frontend/profile/components/profile_tabs_spec.js
index 80a1ff422ab..f3dda2e205f 100644
--- a/spec/frontend/profile/components/profile_tabs_spec.js
+++ b/spec/frontend/profile/components/profile_tabs_spec.js
@@ -10,7 +10,7 @@ import GroupsTab from '~/profile/components/groups_tab.vue';
import ContributedProjectsTab from '~/profile/components/contributed_projects_tab.vue';
import PersonalProjectsTab from '~/profile/components/personal_projects_tab.vue';
import StarredProjectsTab from '~/profile/components/starred_projects_tab.vue';
-import SnippetsTab from '~/profile/components/snippets_tab.vue';
+import SnippetsTab from '~/profile/components/snippets/snippets_tab.vue';
import FollowersTab from '~/profile/components/followers_tab.vue';
import FollowingTab from '~/profile/components/following_tab.vue';
import waitForPromises from 'helpers/wait_for_promises';
diff --git a/spec/frontend/profile/components/snippets/snippet_row_spec.js b/spec/frontend/profile/components/snippets/snippet_row_spec.js
new file mode 100644
index 00000000000..68f06ace226
--- /dev/null
+++ b/spec/frontend/profile/components/snippets/snippet_row_spec.js
@@ -0,0 +1,146 @@
+import { GlAvatar, GlSprintf, GlIcon } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import {
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVEL_INTERNAL_STRING,
+ VISIBILITY_LEVEL_PUBLIC_STRING,
+} from '~/visibility_level/constants';
+import { SNIPPET_VISIBILITY } from '~/snippets/constants';
+import SnippetRow from '~/profile/components/snippets/snippet_row.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { MOCK_USER, MOCK_SNIPPET } from 'jest/profile/mock_data';
+
+describe('UserProfileSnippetRow', () => {
+ let wrapper;
+
+ const defaultProps = {
+ userInfo: MOCK_USER,
+ snippet: MOCK_SNIPPET,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(SnippetRow, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ const findGlAvatar = () => wrapper.findComponent(GlAvatar);
+ const findSnippetUrl = () => wrapper.findByTestId('snippet-url');
+ const findSnippetId = () => wrapper.findByTestId('snippet-id');
+ const findSnippetCreatedAt = () => wrapper.findByTestId('snippet-created-at');
+ const findSnippetAuthor = () => wrapper.findByTestId('snippet-author');
+ const findSnippetBlob = () => wrapper.findByTestId('snippet-blob');
+ const findSnippetComments = () => wrapper.findByTestId('snippet-comments');
+ const findSnippetVisibility = () => wrapper.findByTestId('snippet-visibility');
+ const findSnippetUpdatedAt = () => wrapper.findByTestId('snippet-updated-at');
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlAvatar with user avatar', () => {
+ expect(findGlAvatar().exists()).toBe(true);
+ expect(findGlAvatar().attributes('src')).toBe(MOCK_USER.avatarUrl);
+ });
+
+ it('renders Snippet Url with snippet webUrl', () => {
+ expect(findSnippetUrl().exists()).toBe(true);
+ expect(findSnippetUrl().attributes('href')).toBe(MOCK_SNIPPET.webUrl);
+ });
+
+ it('renders Snippet ID correctly formatted', () => {
+ expect(findSnippetId().exists()).toBe(true);
+ expect(findSnippetId().text()).toBe(`$${getIdFromGraphQLId(MOCK_SNIPPET.id)}`);
+ });
+
+ it('renders Snippet Created At with correct date string', () => {
+ expect(findSnippetCreatedAt().exists()).toBe(true);
+ expect(findSnippetCreatedAt().attributes('time')).toBe(MOCK_SNIPPET.createdAt.toString());
+ });
+
+ it('renders Snippet Author with profileLink', () => {
+ expect(findSnippetAuthor().exists()).toBe(true);
+ expect(findSnippetAuthor().attributes('href')).toBe(`/${MOCK_USER.username}`);
+ });
+
+ it('renders Snippet Updated At with correct date string', () => {
+ expect(findSnippetUpdatedAt().exists()).toBe(true);
+ expect(findSnippetUpdatedAt().attributes('time')).toBe(MOCK_SNIPPET.updatedAt.toString());
+ });
+ });
+
+ describe.each`
+ nodes | hasOpacity | tooltip
+ ${[]} | ${true} | ${'0 files'}
+ ${[{ name: 'file.txt' }]} | ${false} | ${'1 file'}
+ ${[{ name: 'file.txt' }, { name: 'file2.txt' }]} | ${false} | ${'2 files'}
+ `('Blob Icon', ({ nodes, hasOpacity, tooltip }) => {
+ describe(`when blobs length ${nodes.length}`, () => {
+ beforeEach(() => {
+ createComponent({ snippet: { ...MOCK_SNIPPET, blobs: { nodes } } });
+ });
+
+ it(`does${hasOpacity ? '' : ' not'} render icon with opacity`, () => {
+ expect(findSnippetBlob().findComponent(GlIcon).props('name')).toBe('documents');
+ expect(findSnippetBlob().classes('gl-opacity-5')).toBe(hasOpacity);
+ });
+
+ it('renders text and tooltip correctly', () => {
+ expect(findSnippetBlob().text()).toBe(nodes.length.toString());
+ expect(findSnippetBlob().attributes('title')).toBe(tooltip);
+ });
+ });
+ });
+
+ describe.each`
+ nodes | hasOpacity
+ ${[]} | ${true}
+ ${[{ id: 'note/1' }]} | ${false}
+ ${[{ id: 'note/1' }, { id: 'note/2' }]} | ${false}
+ `('Comments Icon', ({ nodes, hasOpacity }) => {
+ describe(`when comments length ${nodes.length}`, () => {
+ beforeEach(() => {
+ createComponent({ snippet: { ...MOCK_SNIPPET, notes: { nodes } } });
+ });
+
+ it(`does${hasOpacity ? '' : ' not'} render icon with opacity`, () => {
+ expect(findSnippetComments().findComponent(GlIcon).props('name')).toBe('comments');
+ expect(findSnippetComments().classes('gl-opacity-5')).toBe(hasOpacity);
+ });
+
+ it('renders text correctly', () => {
+ expect(findSnippetComments().text()).toBe(nodes.length.toString());
+ });
+
+ it('renders link to comments correctly', () => {
+ expect(findSnippetComments().attributes('href')).toBe(`${MOCK_SNIPPET.webUrl}#notes`);
+ });
+ });
+ });
+
+ describe.each`
+ visibilityLevel
+ ${VISIBILITY_LEVEL_PUBLIC_STRING}
+ ${VISIBILITY_LEVEL_PRIVATE_STRING}
+ ${VISIBILITY_LEVEL_INTERNAL_STRING}
+ `('Visibility Icon', ({ visibilityLevel }) => {
+ describe(`when visibilityLevel is ${visibilityLevel}`, () => {
+ beforeEach(() => {
+ createComponent({ snippet: { ...MOCK_SNIPPET, visibilityLevel } });
+ });
+
+ it(`renders the ${SNIPPET_VISIBILITY[visibilityLevel].icon} icon`, () => {
+ expect(findSnippetVisibility().findComponent(GlIcon).props('name')).toBe(
+ SNIPPET_VISIBILITY[visibilityLevel].icon,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/profile/components/snippets/snippets_tab_spec.js b/spec/frontend/profile/components/snippets/snippets_tab_spec.js
new file mode 100644
index 00000000000..47e2fbcf2c0
--- /dev/null
+++ b/spec/frontend/profile/components/snippets/snippets_tab_spec.js
@@ -0,0 +1,162 @@
+import { GlEmptyState, GlKeysetPagination } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
+import { SNIPPET_MAX_LIST_COUNT } from '~/profile/constants';
+import SnippetsTab from '~/profile/components/snippets/snippets_tab.vue';
+import SnippetRow from '~/profile/components/snippets/snippet_row.vue';
+import getUserSnippets from '~/profile/components/graphql/get_user_snippets.query.graphql';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import {
+ MOCK_USER,
+ MOCK_SNIPPETS_EMPTY_STATE,
+ MOCK_USER_SNIPPETS_RES,
+ MOCK_USER_SNIPPETS_PAGINATION_RES,
+ MOCK_USER_SNIPPETS_EMPTY_RES,
+} from 'jest/profile/mock_data';
+
+Vue.use(VueApollo);
+
+describe('UserProfileSnippetsTab', () => {
+ let wrapper;
+
+ let queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_RES);
+
+ const createComponent = () => {
+ const apolloProvider = createMockApollo([[getUserSnippets, queryHandlerMock]]);
+
+ wrapper = shallowMountExtended(SnippetsTab, {
+ apolloProvider,
+ provide: {
+ userId: MOCK_USER.id,
+ snippetsEmptyState: MOCK_SNIPPETS_EMPTY_STATE,
+ },
+ });
+ };
+
+ const findSnippetRows = () => wrapper.findAllComponents(SnippetRow);
+ const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
+
+ describe('when user has no snippets', () => {
+ beforeEach(async () => {
+ queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_EMPTY_RES);
+ createComponent();
+
+ await nextTick();
+ });
+
+ it('does not render snippet row', () => {
+ expect(findSnippetRows().exists()).toBe(false);
+ });
+
+ it('does render empty state with correct svg', () => {
+ expect(findGlEmptyState().exists()).toBe(true);
+ expect(findGlEmptyState().attributes('svgpath')).toBe(MOCK_SNIPPETS_EMPTY_STATE);
+ });
+ });
+
+ describe('when snippets returns an error', () => {
+ beforeEach(async () => {
+ queryHandlerMock = jest.fn().mockRejectedValue({ errors: [] });
+ createComponent();
+
+ await nextTick();
+ });
+
+ it('does not render snippet row', () => {
+ expect(findSnippetRows().exists()).toBe(false);
+ });
+
+ it('does render empty state with correct svg', () => {
+ expect(findGlEmptyState().exists()).toBe(true);
+ expect(findGlEmptyState().attributes('svgpath')).toBe(MOCK_SNIPPETS_EMPTY_STATE);
+ });
+ });
+
+ describe('when snippets are returned', () => {
+ beforeEach(async () => {
+ queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_RES);
+ createComponent();
+
+ await nextTick();
+ });
+
+ it('renders a snippet row for each snippet', () => {
+ expect(findSnippetRows().exists()).toBe(true);
+ expect(findSnippetRows().length).toBe(MOCK_USER_SNIPPETS_RES.data.user.snippets.nodes.length);
+ });
+
+ it('does not render empty state', () => {
+ expect(findGlEmptyState().exists()).toBe(false);
+ });
+
+ it('adds bottom border when snippet is not last in list', () => {
+ expect(findSnippetRows().at(0).classes('gl-border-b')).toBe(true);
+ });
+
+ it('does not add bottom border when snippet is last in list', () => {
+ expect(
+ findSnippetRows()
+ .at(MOCK_USER_SNIPPETS_RES.data.user.snippets.nodes.length - 1)
+ .classes('gl-border-b'),
+ ).toBe(false);
+ });
+ });
+
+ describe('Snippet Pagination', () => {
+ describe('when user has one page of snippets', () => {
+ beforeEach(async () => {
+ queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_RES);
+ createComponent();
+
+ await nextTick();
+ });
+
+ it('does not render pagination', () => {
+ expect(findGlKeysetPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('when user has multiple pages of snippets', () => {
+ beforeEach(async () => {
+ queryHandlerMock = jest.fn().mockResolvedValue(MOCK_USER_SNIPPETS_PAGINATION_RES);
+ createComponent();
+
+ await nextTick();
+ });
+
+ it('does render pagination', () => {
+ expect(findGlKeysetPagination().exists()).toBe(true);
+ });
+
+ it('when nextPage is clicked', async () => {
+ findGlKeysetPagination().vm.$emit('next');
+
+ await nextTick();
+
+ expect(queryHandlerMock).toHaveBeenCalledWith({
+ id: convertToGraphQLId(TYPENAME_USER, MOCK_USER.id),
+ first: SNIPPET_MAX_LIST_COUNT,
+ last: null,
+ afterToken: MOCK_USER_SNIPPETS_RES.data.user.snippets.pageInfo.endCursor,
+ });
+ });
+
+ it('when previousPage is clicked', async () => {
+ findGlKeysetPagination().vm.$emit('prev');
+
+ await nextTick();
+
+ expect(queryHandlerMock).toHaveBeenCalledWith({
+ id: convertToGraphQLId(TYPENAME_USER, MOCK_USER.id),
+ first: null,
+ last: SNIPPET_MAX_LIST_COUNT,
+ beforeToken: MOCK_USER_SNIPPETS_RES.data.user.snippets.pageInfo.startCursor,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/profile/components/snippets_tab_spec.js b/spec/frontend/profile/components/snippets_tab_spec.js
deleted file mode 100644
index 1306757314c..00000000000
--- a/spec/frontend/profile/components/snippets_tab_spec.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { GlTab } from '@gitlab/ui';
-
-import { s__ } from '~/locale';
-import SnippetsTab from '~/profile/components/snippets_tab.vue';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-
-describe('SnippetsTab', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = shallowMountExtended(SnippetsTab);
- };
-
- it('renders `GlTab` and sets `title` prop', () => {
- createComponent();
-
- expect(wrapper.findComponent(GlTab).attributes('title')).toBe(s__('UserProfile|Snippets'));
- });
-});
diff --git a/spec/frontend/profile/components/user_achievements_spec.js b/spec/frontend/profile/components/user_achievements_spec.js
index ff6f323621a..5743c8575d5 100644
--- a/spec/frontend/profile/components/user_achievements_spec.js
+++ b/spec/frontend/profile/components/user_achievements_spec.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { GlBadge } from '@gitlab/ui';
import getUserAchievementsEmptyResponse from 'test_fixtures/graphql/get_user_achievements_empty_response.json';
import getUserAchievementsLongResponse from 'test_fixtures/graphql/get_user_achievements_long_response.json';
import getUserAchievementsResponse from 'test_fixtures/graphql/get_user_achievements_with_avatar_and_description_response.json';
@@ -63,6 +64,14 @@ describe('UserAchievements', () => {
expect(wrapper.findAllByTestId('user-achievement').length).toBe(3);
});
+ it('renders count for achievements awarded more than once', async () => {
+ createComponent({ queryHandler: jest.fn().mockResolvedValue(getUserAchievementsLongResponse) });
+
+ await waitForPromises();
+
+ expect(achievement().findComponent(GlBadge).text()).toBe('2x');
+ });
+
it('renders correctly if the achievement is from a private namespace', async () => {
createComponent({
queryHandler: jest.fn().mockResolvedValue(getUserAchievementsPrivateGroupResponse),
diff --git a/spec/frontend/profile/mock_data.js b/spec/frontend/profile/mock_data.js
index 7106ea84619..856534aebd3 100644
--- a/spec/frontend/profile/mock_data.js
+++ b/spec/frontend/profile/mock_data.js
@@ -20,3 +20,79 @@ export const userCalendarResponse = {
'2023-02-06': 2,
'2023-02-07': 2,
};
+
+export const MOCK_SNIPPETS_EMPTY_STATE = 'illustrations/empty-state/empty-snippets-md.svg';
+
+export const MOCK_USER = {
+ id: '1',
+ avatarUrl: 'https://www.gravatar.com/avatar/test',
+ name: 'Test User',
+ username: 'test',
+};
+
+const getMockSnippet = (id) => {
+ return {
+ id: `gid://gitlab/PersonalSnippet/${id}`,
+ title: `Test snippet ${id}`,
+ visibilityLevel: 'public',
+ webUrl: `http://gitlab.com/-/snippets/${id}`,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ blobs: {
+ nodes: [
+ {
+ name: 'test.txt',
+ },
+ ],
+ },
+ notes: {
+ nodes: [
+ {
+ id: 'git://gitlab/Note/1',
+ },
+ ],
+ },
+ };
+};
+
+const MOCK_PAGE_INFO = {
+ startCursor: 'asdfqwer',
+ endCursor: 'reqwfdsa',
+ __typename: 'PageInfo',
+};
+
+const getMockSnippetRes = (hasPagination) => {
+ return {
+ data: {
+ user: {
+ ...MOCK_USER,
+ snippets: {
+ pageInfo: {
+ ...MOCK_PAGE_INFO,
+ hasNextPage: hasPagination,
+ hasPreviousPage: hasPagination,
+ },
+ nodes: [getMockSnippet(1), getMockSnippet(2)],
+ },
+ },
+ },
+ };
+};
+
+export const MOCK_SNIPPET = getMockSnippet(1);
+export const MOCK_USER_SNIPPETS_RES = getMockSnippetRes(false);
+export const MOCK_USER_SNIPPETS_PAGINATION_RES = getMockSnippetRes(true);
+export const MOCK_USER_SNIPPETS_EMPTY_RES = {
+ data: {
+ user: {
+ ...MOCK_USER,
+ snippets: {
+ pageInfo: {
+ endCursor: null,
+ startCursor: null,
+ },
+ nodes: [],
+ },
+ },
+ },
+};
diff --git a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js
index 7df498f597b..8a9c3bfff44 100644
--- a/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js
+++ b/spec/frontend/projects/commit/components/commit_options_dropdown_spec.js
@@ -1,6 +1,4 @@
-import { GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import CommitOptionsDropdown from '~/projects/commit/components/commit_options_dropdown.vue';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
import eventHub from '~/projects/commit/event_hub';
@@ -14,18 +12,16 @@ describe('BranchesDropdown', () => {
};
const createComponent = (props = {}) => {
- wrapper = extendedWrapper(
- shallowMount(CommitOptionsDropdown, {
- provide,
- propsData: {
- canRevert: true,
- canCherryPick: true,
- canTag: true,
- canEmailPatches: true,
- ...props,
- },
- }),
- );
+ wrapper = mountExtended(CommitOptionsDropdown, {
+ provide,
+ propsData: {
+ canRevert: true,
+ canCherryPick: true,
+ canTag: true,
+ canEmailPatches: true,
+ ...props,
+ },
+ });
};
const findRevertLink = () => wrapper.findByTestId('revert-link');
@@ -33,8 +29,6 @@ describe('BranchesDropdown', () => {
const findTagItem = () => wrapper.findByTestId('tag-link');
const findEmailPatchesItem = () => wrapper.findByTestId('email-patches-link');
const findPlainDiffItem = () => wrapper.findByTestId('plain-diff-link');
- const findDivider = () => wrapper.findComponent(GlDropdownDivider);
- const findSectionHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
describe('Everything enabled', () => {
beforeEach(() => {
@@ -42,7 +36,7 @@ describe('BranchesDropdown', () => {
});
it('has expected dropdown button text', () => {
- expect(wrapper.attributes('text')).toBe('Options');
+ expect(wrapper.findByTestId('base-dropdown-toggle').text()).toBe('Options');
});
it('has expected items', () => {
@@ -51,8 +45,6 @@ describe('BranchesDropdown', () => {
findRevertLink().exists(),
findCherryPickLink().exists(),
findTagItem().exists(),
- findDivider().exists(),
- findSectionHeader().exists(),
findEmailPatchesItem().exists(),
findPlainDiffItem().exists(),
].every((exists) => exists),
@@ -94,7 +86,6 @@ describe('BranchesDropdown', () => {
it('only has the download items', () => {
createComponent({ canRevert: false, canCherryPick: false, canTag: false });
- expect(findDivider().exists()).toBe(false);
expect(findEmailPatchesItem().exists()).toBe(true);
expect(findPlainDiffItem().exists()).toBe(true);
});
@@ -109,13 +100,13 @@ describe('BranchesDropdown', () => {
});
it('emits openModal for revert', () => {
- findRevertLink().vm.$emit('click');
+ findRevertLink().trigger('click');
expect(spy).toHaveBeenCalledWith(OPEN_REVERT_MODAL);
});
it('emits openModal for cherry-pick', () => {
- findCherryPickLink().vm.$emit('click');
+ findCherryPickLink().trigger('click');
expect(spy).toHaveBeenCalledWith(OPEN_CHERRY_PICK_MODAL);
});
diff --git a/spec/frontend/projects/commit_box/info/load_branches_spec.js b/spec/frontend/projects/commit_box/info/load_branches_spec.js
deleted file mode 100644
index b00a6378e07..00000000000
--- a/spec/frontend/projects/commit_box/info/load_branches_spec.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import { setHTMLFixture } from 'helpers/fixtures';
-import waitForPromises from 'helpers/wait_for_promises';
-import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { loadBranches } from '~/projects/commit_box/info/load_branches';
-import { initDetailsButton } from '~/projects/commit_box/info/init_details_button';
-
-jest.mock('~/projects/commit_box/info/init_details_button');
-
-const mockCommitPath = '/commit/abcd/branches';
-const mockBranchesRes =
- '<a href="/-/commits/main">main</a><span><a href="/-/commits/my-branch">my-branch</a></span>';
-
-describe('~/projects/commit_box/info/load_branches', () => {
- let mock;
-
- const getElInnerHtml = () => document.querySelector('.js-commit-box-info').innerHTML;
-
- beforeEach(() => {
- setHTMLFixture(`
- <div class="js-commit-box-info" data-commit-path="${mockCommitPath}">
- <div class="commit-info branches">
- <span class="spinner"/>
- </div>
- </div>`);
-
- mock = new MockAdapter(axios);
- mock.onGet(mockCommitPath).reply(HTTP_STATUS_OK, mockBranchesRes);
- });
-
- it('initializes the details button', async () => {
- loadBranches();
- await waitForPromises();
-
- expect(initDetailsButton).toHaveBeenCalled();
- });
-
- it('loads and renders branches info', async () => {
- loadBranches();
- await waitForPromises();
-
- expect(getElInnerHtml()).toMatchInterpolatedText(
- `<div class="commit-info branches">${mockBranchesRes}</div>`,
- );
- });
-
- it('does not load when no container is provided', async () => {
- loadBranches('.js-another-class');
- await waitForPromises();
-
- expect(mock.history.get).toHaveLength(0);
- });
-
- describe('when branches request returns unsafe content', () => {
- beforeEach(() => {
- mock
- .onGet(mockCommitPath)
- .reply(HTTP_STATUS_OK, '<a onload="alert(\'xss!\');" href="/-/commits/main">main</a>');
- });
-
- it('displays sanitized html', async () => {
- loadBranches();
- await waitForPromises();
-
- expect(getElInnerHtml()).toMatchInterpolatedText(
- '<div class="commit-info branches"><a href="/-/commits/main">main</a></div>',
- );
- });
- });
-
- describe('when branches request fails', () => {
- beforeEach(() => {
- mock.onGet(mockCommitPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, 'Error!');
- });
-
- it('attempts to load and renders an error', async () => {
- loadBranches();
- await waitForPromises();
-
- expect(getElInnerHtml()).toMatchInterpolatedText(
- '<div class="commit-info branches">Failed to load branches. Please try again.</div>',
- );
- });
- });
-});
diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js
index 0b1085470b8..44aaac21733 100644
--- a/spec/frontend/projects/compare/components/repo_dropdown_spec.js
+++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue';
@@ -13,10 +13,14 @@ describe('RepoDropdown component', () => {
...defaultProps,
...props,
},
+ stubs: {
+ GlCollapsibleListbox,
+ GlListboxItem,
+ },
});
};
- const findGlDropdown = () => wrapper.findComponent(GlDropdown);
+ const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findHiddenInput = () => wrapper.find('input[type="hidden"]');
describe('Source Revision', () => {
@@ -29,8 +33,10 @@ describe('RepoDropdown component', () => {
});
it('displays the project name in the disabled dropdown', () => {
- expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name);
- expect(findGlDropdown().props('disabled')).toBe(true);
+ expect(findGlCollapsibleListbox().props('toggleText')).toBe(
+ defaultProps.selectedProject.name,
+ );
+ expect(findGlCollapsibleListbox().props('disabled')).toBe(true);
});
it('does not emit `changeTargetProject` event', async () => {
@@ -57,18 +63,21 @@ describe('RepoDropdown component', () => {
});
it('displays matching project name of the source revision initially in the dropdown', () => {
- expect(findGlDropdown().props('text')).toBe(defaultProps.selectedProject.name);
+ expect(findGlCollapsibleListbox().props('toggleText')).toBe(
+ defaultProps.selectedProject.name,
+ );
});
- it('updates the hidden input value when onClick method is triggered', async () => {
+ it('updates the hidden input value when dropdown item is selected', () => {
const repoId = '1';
- wrapper.vm.onClick({ id: repoId });
- await nextTick();
+ findGlCollapsibleListbox().vm.$emit('select', repoId);
expect(findHiddenInput().attributes('value')).toBe(repoId);
});
it('emits `selectProject` event when another target project is selected', async () => {
- findGlDropdown().findAllComponents(GlDropdownItem).at(0).vm.$emit('click');
+ const repoId = '1';
+ findGlCollapsibleListbox().vm.$emit('select', repoId);
+
await nextTick();
expect(wrapper.emitted('selectProject')[0][0]).toEqual({
diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js
index 8a1e9904a3f..54d0cfaa8c6 100644
--- a/spec/frontend/projects/project_new_spec.js
+++ b/spec/frontend/projects/project_new_spec.js
@@ -13,6 +13,8 @@ describe('New Project', () => {
const mockKeyup = (el) => el.dispatchEvent(new KeyboardEvent('keyup'));
const mockChange = (el) => el.dispatchEvent(new Event('change'));
+ const mockSubmit = () =>
+ document.getElementById('new_project').dispatchEvent(new Event('submit'));
beforeEach(() => {
setHTMLFixture(`
@@ -311,4 +313,35 @@ describe('New Project', () => {
expect($projectName.value).toEqual(dummyProjectName);
});
});
+
+ describe('project path trimming', () => {
+ beforeEach(() => {
+ projectNew.bindEvents();
+ });
+
+ describe('when the project path field is filled in', () => {
+ const dirtyProjectPath = ' my-awesome-project ';
+ const cleanProjectPath = dirtyProjectPath.trim();
+
+ beforeEach(() => {
+ $projectPath.value = dirtyProjectPath;
+ mockSubmit();
+ });
+
+ it('trims the project path on submit', () => {
+ expect($projectPath.value).not.toBe(dirtyProjectPath);
+ expect($projectPath.value).toBe(cleanProjectPath);
+ });
+ });
+
+ describe('when the project path field is left empty', () => {
+ beforeEach(() => {
+ mockSubmit();
+ });
+
+ it('leaves the field empty', () => {
+ expect($projectPath.value).toBe('');
+ });
+ });
+ });
});
diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
index f3e536de703..ce696ee321b 100644
--- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
@@ -99,6 +99,9 @@ describe('Access Level Dropdown', () => {
const findDropdownItemWithText = (items, text) =>
items.filter((item) => item.text().includes(text)).at(0);
+ const findSelected = (type) =>
+ wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked'));
+
describe('data request', () => {
it('should make an api call for users, groups && deployKeys when user has a license', () => {
createComponent();
@@ -305,9 +308,6 @@ describe('Access Level Dropdown', () => {
{ id: 122, type: 'deploy_key', deploy_key_id: 12 },
];
- const findSelected = (type) =>
- wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked'));
-
beforeEach(async () => {
createComponent({ preselectedItems });
await waitForPromises();
@@ -339,6 +339,34 @@ describe('Access Level Dropdown', () => {
});
});
+ describe('handling two-way data binding', () => {
+ it('emits a formatted update on selection', async () => {
+ createComponent();
+ await waitForPromises();
+ const dropdownItems = findAllDropdownItems();
+ // select new item from each group
+ findDropdownItemWithText(dropdownItems, 'role1').trigger('click');
+ findDropdownItemWithText(dropdownItems, 'group4').trigger('click');
+ findDropdownItemWithText(dropdownItems, 'user7').trigger('click');
+ findDropdownItemWithText(dropdownItems, 'key10').trigger('click');
+
+ await wrapper.setProps({ items: [{ user_id: 7 }] });
+
+ const selectedUsers = findSelected(LEVEL_TYPES.USER);
+ expect(selectedUsers).toHaveLength(1);
+ expect(selectedUsers.at(0).text()).toBe('user7');
+
+ const selectedRoles = findSelected(LEVEL_TYPES.ROLE);
+ expect(selectedRoles).toHaveLength(0);
+
+ const selectedGroups = findSelected(LEVEL_TYPES.GROUP);
+ expect(selectedGroups).toHaveLength(0);
+
+ const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY);
+ expect(selectedDeployKeys).toHaveLength(0);
+ });
+ });
+
describe('on dropdown open', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
index 86e4e88e3cf..7f6ecbac748 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
@@ -18,6 +18,7 @@ describe('ServiceDeskRoot', () => {
endpoint: '/gitlab-org/gitlab-test/service_desk',
initialIncomingEmail: 'servicedeskaddress@example.com',
initialIsEnabled: true,
+ isIssueTrackerEnabled: true,
outgoingName: 'GitLab Support Bot',
projectKey: 'key',
selectedTemplate: 'Bug',
@@ -59,6 +60,7 @@ describe('ServiceDeskRoot', () => {
initialSelectedTemplate: provideData.selectedTemplate,
initialSelectedFileTemplateProjectId: provideData.selectedFileTemplateProjectId,
isEnabled: provideData.initialIsEnabled,
+ isIssueTrackerEnabled: provideData.isIssueTrackerEnabled,
isTemplateSaving: false,
templates: provideData.templates,
});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
index 84eafc3d0f3..5631927cc2f 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
@@ -1,7 +1,8 @@
-import { GlButton, GlDropdown, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlLoadingIcon, GlToggle, GlAlert } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { helpPagePath } from '~/helpers/help_page_helper';
import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -16,17 +17,44 @@ describe('ServiceDeskSetting', () => {
const findTemplateDropdown = () => wrapper.findComponent(GlDropdown);
const findToggle = () => wrapper.findComponent(GlToggle);
const findSuffixFormGroup = () => wrapper.findByTestId('suffix-form-group');
+ const findIssueTrackerInfo = () => wrapper.findComponent(GlAlert);
+ const findIssueHelpLink = () => wrapper.findByTestId('issue-help-page');
const createComponent = ({ props = {} } = {}) =>
extendedWrapper(
mount(ServiceDeskSetting, {
propsData: {
isEnabled: true,
+ isIssueTrackerEnabled: true,
...props,
},
}),
);
+ describe('with issue tracker', () => {
+ it('does not show the info notice when enabled', () => {
+ wrapper = createComponent();
+
+ expect(findIssueTrackerInfo().exists()).toBe(false);
+ });
+
+ it('shows info notice when disabled with help page link', () => {
+ wrapper = createComponent({
+ props: {
+ isIssueTrackerEnabled: false,
+ },
+ });
+
+ expect(findIssueTrackerInfo().exists()).toBe(true);
+ expect(findIssueHelpLink().text()).toEqual('activate the issue tracker');
+ expect(findIssueHelpLink().attributes('href')).toBe(
+ helpPagePath('user/project/settings/index.md', {
+ anchor: 'configure-project-visibility-features-and-permissions',
+ }),
+ );
+ });
+ });
+
describe('when isEnabled=true', () => {
describe('only isEnabled', () => {
describe('as project admin', () => {
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js
index 7090db5cad7..1a76e7d1ec6 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js
@@ -14,6 +14,7 @@ describe('ServiceDeskTemplateDropdown', () => {
mount(ServiceDeskTemplateDropdown, {
propsData: {
isEnabled: true,
+ isIssueTrackerEnabled: true,
...props,
},
}),
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index 7e14d292946..ecd617ca44b 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -16,7 +16,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue';
import { loadViewer } from '~/repository/components/blob_viewers';
import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue';
import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue';
-import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_deprecated.vue';
+import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql';
import projectInfoQuery from '~/repository/queries/project_info.query.graphql';
import userInfoQuery from '~/repository/queries/user_info.query.graphql';
@@ -38,6 +38,7 @@ import {
userPermissionsMock,
propsMock,
refMock,
+ axiosMockResponse,
} from '../mock_data';
jest.mock('~/repository/components/blob_viewers');
@@ -61,6 +62,8 @@ const mockRouter = {
push: mockRouterPush,
};
+const legacyViewerUrl = 'some_file.js?format=json&viewer=simple';
+
const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute = {}) => {
Vue.use(VueApollo);
@@ -79,8 +82,12 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute
const blobInfo = {
...projectMock,
repository: {
+ __typename: 'Repository',
empty,
- blobs: { nodes: [blob] },
+ blobs: {
+ __typename: 'RepositoryBlobConnection',
+ nodes: [blob],
+ },
},
};
@@ -148,10 +155,6 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute
}),
);
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ project: blobInfo, isBinary });
-
await waitForPromises();
};
@@ -216,7 +219,6 @@ describe('Blob content viewer component', () => {
});
describe('legacy viewers', () => {
- const legacyViewerUrl = 'some_file.js?format=json&viewer=simple';
const fileType = 'text';
const highlightJs = false;
@@ -437,8 +439,8 @@ describe('Blob content viewer component', () => {
});
it('renders WebIdeLink button for binary files', async () => {
- await createComponent({ blob: richViewerMock, isBinary: true }, mount);
-
+ mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, axiosMockResponse);
+ await createComponent({}, mount);
expect(findWebIdeLink().props()).toMatchObject({
editUrl: editBlobPath,
webIdeUrl: ideEditPath,
@@ -448,7 +450,8 @@ describe('Blob content viewer component', () => {
describe('blob header binary file', () => {
it('passes the correct isBinary value when viewing a binary file', async () => {
- await createComponent({ blob: richViewerMock, isBinary: true });
+ mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, axiosMockResponse);
+ await createComponent();
expect(findBlobHeader().props('isBinary')).toBe(true);
});
diff --git a/spec/frontend/repository/components/blob_viewers/geo_json/geo_json_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/geo_json/geo_json_viewer_spec.js
new file mode 100644
index 00000000000..15918b4d8d5
--- /dev/null
+++ b/spec/frontend/repository/components/blob_viewers/geo_json/geo_json_viewer_spec.js
@@ -0,0 +1,40 @@
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import GeoJsonViewer from '~/repository/components/blob_viewers/geo_json/geo_json_viewer.vue';
+import { initLeafletMap } from '~/repository/components/blob_viewers/geo_json/utils';
+import { RENDER_ERROR_MSG } from '~/repository/components/blob_viewers/geo_json/constants';
+import { createAlert } from '~/alert';
+
+jest.mock('~/repository/components/blob_viewers/geo_json/utils');
+jest.mock('~/alert');
+
+describe('GeoJson Viewer', () => {
+ let wrapper;
+
+ const GEO_JSON_MOCK_DATA = '{ "type": "FeatureCollection" }';
+
+ const createComponent = (rawTextBlob = GEO_JSON_MOCK_DATA) => {
+ wrapper = shallowMountExtended(GeoJsonViewer, {
+ propsData: { blob: { rawTextBlob } },
+ });
+ };
+
+ beforeEach(() => createComponent());
+
+ const findMapWrapper = () => wrapper.findByTestId('map');
+
+ it('calls a the initLeafletMap util', () => {
+ const mapWrapper = findMapWrapper();
+
+ expect(initLeafletMap).toHaveBeenCalledWith(mapWrapper.element, JSON.parse(GEO_JSON_MOCK_DATA));
+ expect(mapWrapper.exists()).toBe(true);
+ });
+
+ it('displays an error if invalid json is provided', async () => {
+ createComponent('invalid JSON');
+ await nextTick();
+
+ expect(createAlert).toHaveBeenCalledWith({ message: RENDER_ERROR_MSG });
+ expect(findMapWrapper().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/repository/components/blob_viewers/geo_json/utils_spec.js b/spec/frontend/repository/components/blob_viewers/geo_json/utils_spec.js
new file mode 100644
index 00000000000..c80a83c0ca0
--- /dev/null
+++ b/spec/frontend/repository/components/blob_viewers/geo_json/utils_spec.js
@@ -0,0 +1,68 @@
+import { map, tileLayer, geoJson, featureGroup, Icon } from 'leaflet';
+import * as utils from '~/repository/components/blob_viewers/geo_json/utils';
+import {
+ OPEN_STREET_TILE_URL,
+ MAP_ATTRIBUTION,
+ OPEN_STREET_COPYRIGHT_LINK,
+ ICON_CONFIG,
+} from '~/repository/components/blob_viewers/geo_json/constants';
+
+jest.mock('leaflet', () => ({
+ featureGroup: () => ({ getBounds: jest.fn() }),
+ Icon: { Default: { mergeOptions: jest.fn() } },
+ tileLayer: jest.fn(),
+ map: jest.fn().mockReturnValue({ fitBounds: jest.fn() }),
+ geoJson: jest.fn().mockReturnValue({ addTo: jest.fn() }),
+}));
+
+describe('GeoJson utilities', () => {
+ const mockWrapper = document.createElement('div');
+ const mockData = { test: 'data' };
+
+ describe('initLeafletMap', () => {
+ describe('valid params', () => {
+ beforeEach(() => utils.initLeafletMap(mockWrapper, mockData));
+
+ it('sets the correct icon', () => {
+ expect(Icon.Default.mergeOptions).toHaveBeenCalledWith(ICON_CONFIG);
+ });
+
+ it('inits the leaflet map', () => {
+ const attribution = `${MAP_ATTRIBUTION} ${OPEN_STREET_COPYRIGHT_LINK}`;
+
+ expect(tileLayer).toHaveBeenCalledWith(OPEN_STREET_TILE_URL, { attribution });
+ expect(map).toHaveBeenCalledWith(mockWrapper, { layers: [] });
+ });
+
+ it('adds geojson data to the leaflet map', () => {
+ expect(geoJson().addTo).toHaveBeenCalledWith(map());
+ });
+
+ it('fits the map to the correct bounds', () => {
+ expect(map().fitBounds).toHaveBeenCalledWith(featureGroup().getBounds());
+ });
+
+ it('generates popup content containing the metaData', () => {
+ const popupContent = utils.popupContent(mockData);
+
+ expect(popupContent).toContain(Object.keys(mockData)[0]);
+ expect(popupContent).toContain(mockData.test);
+ });
+ });
+
+ describe('invalid params', () => {
+ it.each([
+ [null, null],
+ [null, mockData],
+ [mockWrapper, null],
+ ])('does nothing (returns early) if any of the params are not provided', (wrapper, data) => {
+ utils.initLeafletMap(wrapper, data);
+ expect(Icon.Default.mergeOptions).not.toHaveBeenCalled();
+ expect(tileLayer).not.toHaveBeenCalled();
+ expect(map).not.toHaveBeenCalled();
+ expect(geoJson().addTo).not.toHaveBeenCalled();
+ expect(map().fitBounds).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/repository/components/fork_info_spec.js b/spec/frontend/repository/components/fork_info_spec.js
index 62a66e59d24..23609c95ca0 100644
--- a/spec/frontend/repository/components/fork_info_spec.js
+++ b/spec/frontend/repository/components/fork_info_spec.js
@@ -27,7 +27,6 @@ describe('ForkInfo component', () => {
const forkInfoError = new Error('Something went wrong');
const projectId = 'gid://gitlab/Project/1';
const showMock = jest.fn();
- const synchronizeFork = true;
Vue.use(VueApollo);
@@ -72,11 +71,6 @@ describe('ForkInfo component', () => {
methods: { show: showMock },
}),
},
- provide: {
- glFeatures: {
- synchronizeFork,
- },
- },
});
return waitForPromises();
};
diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js
index f7be367887c..a89a107b68f 100644
--- a/spec/frontend/repository/components/table/index_spec.js
+++ b/spec/frontend/repository/components/table/index_spec.js
@@ -1,11 +1,24 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import Table from '~/repository/components/table/index.vue';
import TableRow from '~/repository/components/table/row.vue';
+import refQuery from '~/repository/queries/ref.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
-let vm;
-let $apollo;
+let wrapper;
+
+const createMockApolloProvider = (ref) => {
+ Vue.use(VueApollo);
+ const apolloProver = createMockApollo([]);
+ apolloProver.clients.defaultClient.cache.writeQuery({
+ query: refQuery,
+ data: { ref, escapedRef: ref },
+ });
+
+ return apolloProver;
+};
const MOCK_BLOBS = [
{
@@ -70,8 +83,15 @@ const MOCK_COMMITS = [
},
];
-function factory({ path, isLoading = false, hasMore = true, entries = {}, commits = [] }) {
- vm = shallowMount(Table, {
+function factory({
+ path,
+ isLoading = false,
+ hasMore = true,
+ entries = {},
+ commits = [],
+ ref = 'main',
+}) {
+ wrapper = shallowMount(Table, {
propsData: {
path,
isLoading,
@@ -79,13 +99,11 @@ function factory({ path, isLoading = false, hasMore = true, entries = {}, commit
hasMore,
commits,
},
- mocks: {
- $apollo,
- },
+ apolloProvider: createMockApolloProvider(ref),
});
}
-const findTableRows = () => vm.findAllComponents(TableRow);
+const findTableRows = () => wrapper.findAllComponents(TableRow);
describe('Repository table component', () => {
it.each`
@@ -94,14 +112,10 @@ describe('Repository table component', () => {
${'app/assets'} | ${'main'}
${'/'} | ${'test'}
`('renders table caption for $ref in $path', async ({ path, ref }) => {
- factory({ path });
-
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ ref });
+ factory({ path, ref });
await nextTick();
- expect(vm.find('.table').attributes('aria-label')).toEqual(
+ expect(wrapper.find('.table').attributes('aria-label')).toEqual(
`Files, directories, and submodules in the path ${path} for commit reference ${ref}`,
);
});
@@ -109,7 +123,7 @@ describe('Repository table component', () => {
it('shows loading icon', () => {
factory({ path: '/', isLoading: true });
- expect(vm.findComponent(GlSkeletonLoader).exists()).toBe(true);
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
});
it('renders table rows', () => {
@@ -152,7 +166,7 @@ describe('Repository table component', () => {
});
describe('Show more button', () => {
- const showMoreButton = () => vm.findComponent(GlButton);
+ const showMoreButton = () => wrapper.findComponent(GlButton);
it.each`
hasMore | expectButtonToExist
@@ -170,7 +184,7 @@ describe('Repository table component', () => {
await nextTick();
- expect(vm.emitted('showMore')).toHaveLength(1);
+ expect(wrapper.emitted('showMore')).toHaveLength(1);
});
});
});
diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js
index 399341d23a0..e20849d1085 100644
--- a/spec/frontend/repository/mock_data.js
+++ b/spec/frontend/repository/mock_data.js
@@ -198,3 +198,5 @@ export const paginatedTreeResponseFactory = ({
},
},
});
+
+export const axiosMockResponse = { html: 'text', binary: true };
diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js
index f8dd6f6df27..7cf8633d749 100644
--- a/spec/frontend/search/mock_data.js
+++ b/spec/frontend/search/mock_data.js
@@ -7,6 +7,8 @@ export const MOCK_QUERY = {
confidential: null,
group_id: 1,
language: ['C', 'JavaScript'],
+ labels: ['60', '37'],
+ search: '*',
};
export const MOCK_GROUP = {
@@ -542,3 +544,346 @@ export const MOCK_NAVIGATION_ITEMS = [
items: [],
},
];
+
+export const PROCESS_LABELS_DATA = [
+ {
+ key: '60',
+ count: 14,
+ title: 'Brist',
+ color: 'rgb(170, 174, 187)',
+ type: 'GroupLabel',
+ parent_full_name: 'Twitter',
+ },
+ {
+ key: '69',
+ count: 13,
+ title: 'Brouneforge',
+ color: 'rgb(170, 174, 187)',
+ type: 'GroupLabel',
+ parent_full_name: 'Twitter',
+ },
+ {
+ key: '33',
+ count: 12,
+ title: 'Brifunc',
+ color: 'rgb(170, 174, 187)',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+ },
+ {
+ key: '37',
+ count: 12,
+ title: 'Aftersync',
+ color: 'rgb(170, 174, 187)',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+ },
+];
+
+export const APPLIED_SELECTED_LABELS = [
+ {
+ key: '60',
+ count: 14,
+ title: 'Brist',
+ color: '#aaaebb',
+ type: 'GroupLabel',
+ parent_full_name: 'Twitter',
+ },
+ {
+ key: '37',
+ count: 12,
+ title: 'Aftersync',
+ color: '#79fdbf',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+ },
+];
+
+export const MOCK_LABEL_AGGREGATIONS = {
+ fetching: false,
+ error: false,
+ data: [
+ {
+ name: 'labels',
+ buckets: [
+ {
+ key: '60',
+ count: 14,
+ title: 'Brist',
+ color: '#aaaebb',
+ type: 'GroupLabel',
+ parent_full_name: 'Twitter',
+ },
+ {
+ key: '37',
+ count: 12,
+ title: 'Aftersync',
+ color: '#79fdbf',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+ },
+ {
+ key: '6',
+ count: 12,
+ title: 'Cosche',
+ color: '#cea786',
+ type: 'GroupLabel',
+ parent_full_name: 'Toolbox',
+ },
+ {
+ key: '73',
+ count: 12,
+ title: 'Accent',
+ color: '#a5c6fb',
+ type: 'ProjectLabel',
+ parent_full_name: 'Toolbox / Gitlab Smoke Tests',
+ },
+ ],
+ },
+ ],
+};
+
+export const MOCK_LABEL_SEARCH_RESULT = {
+ key: '37',
+ count: 12,
+ title: 'Aftersync',
+ color: '#79fdbf',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+};
+
+export const MOCK_FILTERED_UNSELECTED_LABELS = [
+ {
+ key: '6',
+ count: 12,
+ title: 'Cosche',
+ color: '#cea786',
+ type: 'GroupLabel',
+ parent_full_name: 'Toolbox',
+ },
+ {
+ key: '73',
+ count: 12,
+ title: 'Accent',
+ color: '#a5c6fb',
+ type: 'ProjectLabel',
+ parent_full_name: 'Toolbox / Gitlab Smoke Tests',
+ },
+];
+
+export const MOCK_FILTERED_APPLIED_SELECTED_LABELS = [
+ {
+ key: '60',
+ count: 14,
+ title: 'Brist',
+ color: '#aaaebb',
+ type: 'GroupLabel',
+ parent_full_name: 'Twitter',
+ },
+ {
+ key: '37',
+ count: 12,
+ title: 'Aftersync',
+ color: '#79fdbf',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+ },
+];
+
+export const MOCK_FILTERED_LABELS = [
+ {
+ key: '60',
+ count: 14,
+ title: 'Brist',
+ color: '#aaaebb',
+ type: 'GroupLabel',
+ parent_full_name: 'Twitter',
+ },
+ {
+ key: '69',
+ count: 13,
+ title: 'Brouneforge',
+ color: '#8a13d3',
+ type: 'GroupLabel',
+ parent_full_name: 'Twitter',
+ },
+ {
+ key: '33',
+ count: 12,
+ title: 'Brifunc',
+ color: '#b76463',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+ },
+ {
+ key: '37',
+ count: 12,
+ title: 'Aftersync',
+ color: '#79fdbf',
+ type: 'GroupLabel',
+ parent_full_name: 'Commit451',
+ },
+ {
+ key: '6',
+ count: 12,
+ title: 'Cosche',
+ color: '#cea786',
+ type: 'GroupLabel',
+ parent_full_name: 'Toolbox',
+ },
+ {
+ key: '73',
+ count: 12,
+ title: 'Accent',
+ color: '#a5c6fb',
+ type: 'ProjectLabel',
+ parent_full_name: 'Toolbox / Gitlab Smoke Tests',
+ },
+ {
+ key: '9',
+ count: 12,
+ title: 'Briph',
+ color: '#e69182',
+ type: 'GroupLabel',
+ parent_full_name: 'Toolbox',
+ },
+ {
+ key: '91',
+ count: 12,
+ title: 'Cobalt',
+ color: '#9eae75',
+ type: 'ProjectLabel',
+ parent_full_name: 'Commit451 / Lab Coat',
+ },
+ {
+ key: '94',
+ count: 12,
+ title: 'Protege',
+ color: '#777b83',
+ type: 'ProjectLabel',
+ parent_full_name: 'Commit451 / Lab Coat',
+ },
+ {
+ key: '84',
+ count: 11,
+ title: 'Avenger',
+ color: '#5c5161',
+ type: 'ProjectLabel',
+ parent_full_name: 'Gitlab Org / Gitlab Shell',
+ },
+ {
+ key: '99',
+ count: 11,
+ title: 'Cobalt',
+ color: '#9eae75',
+ type: 'ProjectLabel',
+ parent_full_name: 'Jashkenas / Underscore',
+ },
+ {
+ key: '77',
+ count: 10,
+ title: 'Avenger',
+ color: '#5c5161',
+ type: 'ProjectLabel',
+ parent_full_name: 'Gitlab Org / Gitlab Test',
+ },
+ {
+ key: '79',
+ count: 10,
+ title: 'Fiero',
+ color: '#681cd0',
+ type: 'ProjectLabel',
+ parent_full_name: 'Gitlab Org / Gitlab Test',
+ },
+ {
+ key: '98',
+ count: 9,
+ title: 'Golf',
+ color: '#007aaf',
+ type: 'ProjectLabel',
+ parent_full_name: 'Jashkenas / Underscore',
+ },
+ {
+ key: '101',
+ count: 7,
+ title: 'Accord',
+ color: '#a72b3b',
+ type: 'ProjectLabel',
+ parent_full_name: 'Flightjs / Flight',
+ },
+ {
+ key: '53',
+ count: 7,
+ title: 'Amsche',
+ color: '#9964cf',
+ type: 'GroupLabel',
+ parent_full_name: 'Flightjs',
+ },
+ {
+ key: '11',
+ count: 3,
+ title: 'Aquasync',
+ color: '#347e7f',
+ type: 'GroupLabel',
+ parent_full_name: 'Gitlab Org',
+ },
+ {
+ key: '15',
+ count: 3,
+ title: 'Lunix',
+ color: '#aad577',
+ type: 'GroupLabel',
+ parent_full_name: 'Gitlab Org',
+ },
+ {
+ key: '88',
+ count: 3,
+ title: 'Aztek',
+ color: '#59160a',
+ type: 'ProjectLabel',
+ parent_full_name: 'Gnuwget / Wget2',
+ },
+ {
+ key: '89',
+ count: 3,
+ title: 'Intrigue',
+ color: '#5039bd',
+ type: 'ProjectLabel',
+ parent_full_name: 'Gnuwget / Wget2',
+ },
+ {
+ key: '96',
+ count: 2,
+ title: 'Trailblazer',
+ color: '#5a3e93',
+ type: 'ProjectLabel',
+ parent_full_name: 'Jashkenas / Underscore',
+ },
+ {
+ key: '54',
+ count: 1,
+ title: 'NB',
+ color: '#a4a53a',
+ type: 'GroupLabel',
+ parent_full_name: 'Flightjs',
+ },
+];
+
+export const MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS = [
+ {
+ key: '6',
+ count: 12,
+ title: 'Cosche',
+ color: '#cea786',
+ type: 'GroupLabel',
+ parent_full_name: 'Toolbox',
+ },
+ {
+ key: '73',
+ count: 12,
+ title: 'Accent',
+ color: '#a5c6fb',
+ type: 'ProjectLabel',
+ parent_full_name: 'Toolbox / Gitlab Smoke Tests',
+ },
+];
diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js
index 963b73aeae5..ba492833ec4 100644
--- a/spec/frontend/search/sidebar/components/app_spec.js
+++ b/spec/frontend/search/sidebar/components/app_spec.js
@@ -3,8 +3,9 @@ import Vue from 'vue';
import Vuex from 'vuex';
import { MOCK_QUERY } from 'jest/search/mock_data';
import GlobalSearchSidebar from '~/search/sidebar/components/app.vue';
-import ResultsFilters from '~/search/sidebar/components/results_filters.vue';
-import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
+import IssuesFilters from '~/search/sidebar/components/issues_filters.vue';
+import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue';
+import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue';
import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue';
Vue.use(Vuex);
@@ -12,22 +13,16 @@ Vue.use(Vuex);
describe('GlobalSearchSidebar', () => {
let wrapper;
- const actionSpies = {
- applyQuery: jest.fn(),
- resetQuery: jest.fn(),
- };
-
const getterSpies = {
currentScope: jest.fn(() => 'issues'),
};
- const createComponent = (initialState, featureFlags) => {
+ const createComponent = (initialState = {}, featureFlags = {}) => {
const store = new Vuex.Store({
state: {
urlQuery: MOCK_QUERY,
...initialState,
},
- actions: actionSpies,
getters: getterSpies,
});
@@ -42,14 +37,15 @@ describe('GlobalSearchSidebar', () => {
};
const findSidebarSection = () => wrapper.find('section');
- const findFilters = () => wrapper.findComponent(ResultsFilters);
- const findSidebarNavigation = () => wrapper.findComponent(ScopeNavigation);
+ const findFilters = () => wrapper.findComponent(IssuesFilters);
+ const findScopeLegacyNavigation = () => wrapper.findComponent(ScopeLegacyNavigation);
+ const findScopeSidebarNavigation = () => wrapper.findComponent(ScopeSidebarNavigation);
const findLanguageAggregation = () => wrapper.findComponent(LanguageFilter);
describe('renders properly', () => {
describe('always', () => {
beforeEach(() => {
- createComponent({});
+ createComponent();
});
it(`shows section`, () => {
expect(findSidebarSection().exists()).toBe(true);
@@ -77,12 +73,24 @@ describe('GlobalSearchSidebar', () => {
});
});
- describe('renders navigation', () => {
+ describe.each`
+ currentScope | sidebarNavShown | legacyNavShown
+ ${'issues'} | ${false} | ${true}
+ ${''} | ${false} | ${false}
+ ${'issues'} | ${true} | ${false}
+ ${''} | ${true} | ${false}
+ `('renders navigation', ({ currentScope, sidebarNavShown, legacyNavShown }) => {
beforeEach(() => {
- createComponent({});
+ getterSpies.currentScope = jest.fn(() => currentScope);
+ createComponent({ useSidebarNavigation: sidebarNavShown });
});
- it('shows the vertical navigation', () => {
- expect(findSidebarNavigation().exists()).toBe(true);
+
+ it(`${!legacyNavShown ? 'hides' : 'shows'} the legacy navigation`, () => {
+ expect(findScopeLegacyNavigation().exists()).toBe(legacyNavShown);
+ });
+
+ it(`${!sidebarNavShown ? 'hides' : 'shows'} the sidebar navigation`, () => {
+ expect(findScopeSidebarNavigation().exists()).toBe(sidebarNavShown);
});
});
});
diff --git a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js
index 3907e199cae..54fdf6e869e 100644
--- a/spec/frontend/search/sidebar/components/checkbox_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/checkbox_filter_spec.js
@@ -7,7 +7,7 @@ import { MOCK_QUERY, MOCK_LANGUAGE_AGGREGATIONS_BUCKETS } from 'jest/search/mock
import CheckboxFilter, {
TRACKING_LABEL_CHECKBOX,
TRACKING_LABEL_SET,
-} from '~/search/sidebar/components/checkbox_filter.vue';
+} from '~/search/sidebar/components/language_filter/checkbox_filter.vue';
import { languageFilterData } from '~/search/sidebar/components/language_filter/data';
import { convertFiltersData } from '~/search/sidebar/utils';
diff --git a/spec/frontend/search/sidebar/components/filters_spec.js b/spec/frontend/search/sidebar/components/filters_spec.js
index d189c695467..a92fafd3508 100644
--- a/spec/frontend/search/sidebar/components/filters_spec.js
+++ b/spec/frontend/search/sidebar/components/filters_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { MOCK_QUERY } from 'jest/search/mock_data';
-import ResultsFilters from '~/search/sidebar/components/results_filters.vue';
+import IssuesFilters from '~/search/sidebar/components/issues_filters.vue';
import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
import StatusFilter from '~/search/sidebar/components/status_filter.vue';
@@ -31,7 +31,7 @@ describe('GlobalSearchSidebarFilters', () => {
getters: defaultGetters,
});
- wrapper = shallowMount(ResultsFilters, {
+ wrapper = shallowMount(IssuesFilters, {
store,
});
};
diff --git a/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js b/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js
new file mode 100644
index 00000000000..135b12956b2
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/label_dropdown_items_spec.js
@@ -0,0 +1,57 @@
+import { GlFormCheckbox } from '@gitlab/ui';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { shallowMount } from '@vue/test-utils';
+import { PROCESS_LABELS_DATA } from 'jest/search/mock_data';
+import LabelDropdownItems from '~/search/sidebar/components/label_filter/label_dropdown_items.vue';
+
+Vue.use(Vuex);
+
+describe('LabelDropdownItems', () => {
+ let wrapper;
+
+ const defaultProps = {
+ labels: PROCESS_LABELS_DATA,
+ };
+
+ const createComponent = (Props = defaultProps) => {
+ wrapper = shallowMount(LabelDropdownItems, {
+ propsData: {
+ ...Props,
+ },
+ });
+ };
+
+ const findAllLabelItems = () => wrapper.findAll('.label-filter-menu-item');
+ const findFirstLabelCheckbox = () => findAllLabelItems().at(0).findComponent(GlFormCheckbox);
+ const findFirstLabelTitle = () => findAllLabelItems().at(0).findComponent('.label-title');
+ const findFirstLabelColor = () =>
+ findAllLabelItems().at(0).findComponent('[data-testid="label-color-indicator"]');
+
+ describe('Renders correctly', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders items', () => {
+ expect(findAllLabelItems().exists()).toBe(true);
+ expect(findAllLabelItems()).toHaveLength(defaultProps.labels.length);
+ });
+
+ it('renders items checkbox', () => {
+ expect(findFirstLabelCheckbox().exists()).toBe(true);
+ });
+
+ it('renders label title', () => {
+ expect(findFirstLabelTitle().exists()).toBe(true);
+ expect(findFirstLabelTitle().text()).toBe(defaultProps.labels[0].title);
+ });
+
+ it('renders label color', () => {
+ expect(findFirstLabelColor().exists()).toBe(true);
+ expect(findFirstLabelColor().attributes('style')).toBe(
+ `background-color: ${defaultProps.labels[0].color};`,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/label_filter_spec.js b/spec/frontend/search/sidebar/components/label_filter_spec.js
new file mode 100644
index 00000000000..c5df374d4ef
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/label_filter_spec.js
@@ -0,0 +1,322 @@
+import {
+ GlAlert,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlLabel,
+ GlDropdownForm,
+ GlFormCheckboxGroup,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { MOCK_QUERY, MOCK_LABEL_AGGREGATIONS } from 'jest/search/mock_data';
+import LabelFilter from '~/search/sidebar/components/label_filter/index.vue';
+import LabelDropdownItems from '~/search/sidebar/components/label_filter/label_dropdown_items.vue';
+
+import * as actions from '~/search/store/actions';
+import * as getters from '~/search/store/getters';
+import mutations from '~/search/store/mutations';
+import createState from '~/search/store/state';
+
+import {
+ TRACKING_LABEL_FILTER,
+ TRACKING_LABEL_DROPDOWN,
+ TRACKING_LABEL_CHECKBOX,
+ TRACKING_ACTION_SELECT,
+ TRACKING_ACTION_SHOW,
+} from '~/search/sidebar/components/label_filter/tracking';
+
+import { labelFilterData } from '~/search/sidebar/components/label_filter/data';
+
+import {
+ RECEIVE_AGGREGATIONS_SUCCESS,
+ REQUEST_AGGREGATIONS,
+ RECEIVE_AGGREGATIONS_ERROR,
+} from '~/search/store/mutation_types';
+
+Vue.use(Vuex);
+
+const actionSpies = {
+ fetchAllAggregation: jest.fn(),
+ setQuery: jest.fn(),
+ closeLabel: jest.fn(),
+ setLabelFilterSearch: jest.fn(),
+};
+
+describe('GlobalSearchSidebarLabelFilter', () => {
+ let wrapper;
+ let trackingSpy;
+ let config;
+ let store;
+
+ const createComponent = (initialState) => {
+ config = {
+ actions: {
+ ...actions,
+ fetchAllAggregation: actionSpies.fetchAllAggregation,
+ closeLabel: actionSpies.closeLabel,
+ setLabelFilterSearch: actionSpies.setLabelFilterSearch,
+ setQuery: actionSpies.setQuery,
+ },
+ getters,
+ mutations,
+ state: createState({
+ query: MOCK_QUERY,
+ aggregations: MOCK_LABEL_AGGREGATIONS,
+ ...initialState,
+ }),
+ };
+
+ store = new Vuex.Store(config);
+
+ wrapper = mountExtended(LabelFilter, {
+ store,
+ provide: {
+ glFeatures: {
+ searchIssueLabelAggregation: true,
+ },
+ },
+ });
+ };
+
+ const findComponentTitle = () => wrapper.findComponentByTestId('label-filter-title');
+ const findAllSelectedLabelsAbove = () => wrapper.findAllComponents(GlLabel);
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findDropdownForm = () => wrapper.findComponent(GlDropdownForm);
+ const findCheckboxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
+ const findDropdownSectionHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
+ const findDivider = () => wrapper.findComponent(GlDropdownDivider);
+ const findCheckboxFilter = () => wrapper.findAllComponents(LabelDropdownItems);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ describe('Renders correctly closed', () => {
+ beforeEach(async () => {
+ createComponent();
+ store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data);
+
+ await Vue.nextTick();
+ });
+
+ it('renders component title', () => {
+ expect(findComponentTitle().exists()).toBe(true);
+ });
+
+ it('renders selected labels above search box', () => {
+ expect(findAllSelectedLabelsAbove().exists()).toBe(true);
+ expect(findAllSelectedLabelsAbove()).toHaveLength(2);
+ });
+
+ it('renders search box', () => {
+ expect(findSearchBox().exists()).toBe(true);
+ });
+
+ it("doesn't render dropdown form", () => {
+ expect(findDropdownForm().exists()).toBe(false);
+ });
+
+ it("doesn't render checkbox group", () => {
+ expect(findCheckboxGroup().exists()).toBe(false);
+ });
+
+ it("doesn't render dropdown section header", () => {
+ expect(findDropdownSectionHeader().exists()).toBe(false);
+ });
+
+ it("doesn't render divider", () => {
+ expect(findDivider().exists()).toBe(false);
+ });
+
+ it("doesn't render checkbox filter", () => {
+ expect(findCheckboxFilter().exists()).toBe(false);
+ });
+
+ it("doesn't render alert", () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it("doesn't render loading icon", () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('Renders correctly opened', () => {
+ beforeEach(async () => {
+ createComponent();
+ store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data);
+
+ await Vue.nextTick();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ findSearchBox().vm.$emit('focusin');
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('renders component title', () => {
+ expect(findComponentTitle().exists()).toBe(true);
+ });
+
+ it('renders selected labels above search box', () => {
+ // default data need to provide at least two selected labels
+ expect(findAllSelectedLabelsAbove().exists()).toBe(true);
+ expect(findAllSelectedLabelsAbove()).toHaveLength(2);
+ });
+
+ it('renders search box', () => {
+ expect(findSearchBox().exists()).toBe(true);
+ });
+
+ it('renders dropdown form', () => {
+ expect(findDropdownForm().exists()).toBe(true);
+ });
+
+ it('renders checkbox group', () => {
+ expect(findCheckboxGroup().exists()).toBe(true);
+ });
+
+ it('renders dropdown section header', () => {
+ expect(findDropdownSectionHeader().exists()).toBe(true);
+ });
+
+ it('renders divider', () => {
+ expect(findDivider().exists()).toBe(true);
+ });
+
+ it('renders checkbox filter', () => {
+ expect(findCheckboxFilter().exists()).toBe(true);
+ });
+
+ it("doesn't render alert", () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it("doesn't render loading icon", () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('sends tracking information when dropdown is opened', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SHOW, TRACKING_LABEL_DROPDOWN, {
+ label: TRACKING_LABEL_DROPDOWN,
+ });
+ });
+ });
+
+ describe('Renders loading state correctly', () => {
+ beforeEach(async () => {
+ createComponent();
+ store.commit(REQUEST_AGGREGATIONS);
+ await Vue.nextTick();
+
+ findSearchBox().vm.$emit('focusin');
+ });
+
+ it('renders checkbox filter', () => {
+ expect(findCheckboxFilter().exists()).toBe(false);
+ });
+
+ it("doesn't render alert", () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('renders loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('Renders error state correctly', () => {
+ beforeEach(async () => {
+ createComponent();
+ store.commit(RECEIVE_AGGREGATIONS_ERROR);
+ await Vue.nextTick();
+
+ findSearchBox().vm.$emit('focusin');
+ });
+
+ it("doesn't render checkbox filter", () => {
+ expect(findCheckboxFilter().exists()).toBe(false);
+ });
+
+ it('renders alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it("doesn't render loading icon", () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('Actions', () => {
+ describe('dispatch action when component is created', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders checkbox filter', async () => {
+ await Vue.nextTick();
+ expect(actionSpies.fetchAllAggregation).toHaveBeenCalled();
+ });
+ });
+
+ describe('Closing label works correctly', () => {
+ beforeEach(async () => {
+ createComponent();
+ store.commit(RECEIVE_AGGREGATIONS_SUCCESS, MOCK_LABEL_AGGREGATIONS.data);
+ await Vue.nextTick();
+ });
+
+ it('renders checkbox filter', async () => {
+ await findAllSelectedLabelsAbove().at(0).find('.btn-reset').trigger('click');
+ expect(actionSpies.closeLabel).toHaveBeenCalled();
+ });
+ });
+
+ describe('label search input box works properly', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders checkbox filter', () => {
+ findSearchBox().find('input').setValue('test');
+ expect(actionSpies.setLabelFilterSearch).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ value: 'test',
+ }),
+ );
+ });
+ });
+
+ describe('dropdown checkboxes work', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await findSearchBox().vm.$emit('focusin');
+ await Vue.nextTick();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ await findCheckboxGroup().vm.$emit('input', 6);
+ await Vue.nextTick();
+ });
+
+ it('trigger event', () => {
+ expect(actionSpies.setQuery).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ key: labelFilterData?.filterParam, value: 6 }),
+ );
+ });
+
+ it('sends tracking information when checkbox is selected', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_ACTION_SELECT, TRACKING_LABEL_CHECKBOX, {
+ label: TRACKING_LABEL_FILTER,
+ property: 6,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/language_filter_spec.js b/spec/frontend/search/sidebar/components/language_filter_spec.js
index 9ad9d095aca..817199d7cfe 100644
--- a/spec/frontend/search/sidebar/components/language_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/language_filter_spec.js
@@ -9,7 +9,7 @@ import {
MOCK_LANGUAGE_AGGREGATIONS_BUCKETS,
} from 'jest/search/mock_data';
import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue';
-import CheckboxFilter from '~/search/sidebar/components/checkbox_filter.vue';
+import CheckboxFilter from '~/search/sidebar/components/language_filter/checkbox_filter.vue';
import {
TRACKING_LABEL_SHOW_MORE,
@@ -32,7 +32,7 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
let trackingSpy;
const actionSpies = {
- fetchLanguageAggregation: jest.fn(),
+ fetchAllAggregation: jest.fn(),
applyQuery: jest.fn(),
};
@@ -61,10 +61,6 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
});
};
- afterEach(() => {
- unmockTracking();
- });
-
const findForm = () => wrapper.findComponent(GlForm);
const findCheckboxFilter = () => wrapper.findComponent(CheckboxFilter);
const findApplyButton = () => wrapper.findByTestId('apply-button');
@@ -80,6 +76,10 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('renders form', () => {
expect(findForm().exists()).toBe(true);
});
@@ -108,19 +108,19 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
describe('resetButton', () => {
describe.each`
- description | sidebarDirty | queryFilters | isDisabled
- ${'sidebar dirty only'} | ${true} | ${[]} | ${undefined}
- ${'query filters only'} | ${false} | ${['JSON', 'C']} | ${undefined}
- ${'sidebar dirty and query filters'} | ${true} | ${['JSON', 'C']} | ${undefined}
- ${'no sidebar and no query filters'} | ${false} | ${[]} | ${'true'}
- `('$description', ({ sidebarDirty, queryFilters, isDisabled }) => {
+ description | sidebarDirty | queryFilters | exists
+ ${'sidebar dirty only'} | ${true} | ${[]} | ${false}
+ ${'query filters only'} | ${false} | ${['JSON', 'C']} | ${false}
+ ${'sidebar dirty and query filters'} | ${true} | ${['JSON', 'C']} | ${true}
+ ${'no sidebar and no query filters'} | ${false} | ${[]} | ${false}
+ `('$description', ({ sidebarDirty, queryFilters, exists }) => {
beforeEach(() => {
getterSpies.queryLanguageFilters = jest.fn(() => queryFilters);
createComponent({ sidebarDirty, query: { ...MOCK_QUERY, language: queryFilters } });
});
- it(`button is ${isDisabled ? 'enabled' : 'disabled'}`, () => {
- expect(findResetButton().attributes('disabled')).toBe(isDisabled);
+ it(`button is ${exists ? 'shown' : 'hidden'}`, () => {
+ expect(findResetButton().exists()).toBe(exists);
});
});
});
@@ -153,6 +153,10 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
it(`renders ${MAX_ITEM_LENGTH} amount of items`, async () => {
findShowMoreButton().vm.$emit('click');
@@ -196,13 +200,16 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
createComponent({});
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
+ afterEach(() => {
+ unmockTracking();
+ });
it('uses getter languageAggregationBuckets', () => {
expect(getterSpies.languageAggregationBuckets).toHaveBeenCalled();
});
- it('uses action fetchLanguageAggregation', () => {
- expect(actionSpies.fetchLanguageAggregation).toHaveBeenCalled();
+ it('uses action fetchAllAggregation', () => {
+ expect(actionSpies.fetchAllAggregation).toHaveBeenCalled();
});
it('clicking ApplyButton calls applyQuery', () => {
diff --git a/spec/frontend/search/sidebar/components/scope_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js
index e8737384f27..6a94da31a1b 100644
--- a/spec/frontend/search/sidebar/components/scope_navigation_spec.js
+++ b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js
@@ -3,11 +3,11 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { MOCK_QUERY, MOCK_NAVIGATION } from 'jest/search/mock_data';
-import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
+import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue';
Vue.use(Vuex);
-describe('ScopeNavigation', () => {
+describe('ScopeLegacyNavigation', () => {
let wrapper;
const actionSpies = {
@@ -29,7 +29,7 @@ describe('ScopeNavigation', () => {
getters: getterSpies,
});
- wrapper = shallowMount(ScopeNavigation, {
+ wrapper = shallowMount(ScopeLegacyNavigation, {
store,
});
};
diff --git a/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js
index 5207665f883..4b71ff0bedc 100644
--- a/spec/frontend/search/sidebar/components/scope_new_navigation_spec.js
+++ b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js
@@ -1,13 +1,13 @@
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import ScopeNewNavigation from '~/search/sidebar/components/scope_new_navigation.vue';
+import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue';
import NavItem from '~/super_sidebar/components/nav_item.vue';
import { MOCK_QUERY, MOCK_NAVIGATION, MOCK_NAVIGATION_ITEMS } from '../../mock_data';
Vue.use(Vuex);
-describe('ScopeNewNavigation', () => {
+describe('ScopeSidebarNavigation', () => {
let wrapper;
const actionSpies = {
@@ -30,7 +30,7 @@ describe('ScopeNewNavigation', () => {
getters: getterSpies,
});
- wrapper = mount(ScopeNewNavigation, {
+ wrapper = mount(ScopeSidebarNavigation, {
store,
stubs: {
NavItem,
@@ -42,7 +42,7 @@ describe('ScopeNewNavigation', () => {
const findNavItems = () => wrapper.findAllComponents(NavItem);
const findNavItemActive = () => wrapper.find('[aria-current=page]');
const findNavItemActiveLabel = () =>
- findNavItemActive().find('[class="gl-pr-8 gl-text-gray-900 gl-truncate-end"]');
+ findNavItemActive().find('[class="gl-flex-grow-1 gl-text-gray-900 gl-truncate-end"]');
describe('scope navigation', () => {
beforeEach(() => {
diff --git a/spec/frontend/search/sort/components/app_spec.js b/spec/frontend/search/sort/components/app_spec.js
index 322ce1b16ef..09c295e3ea9 100644
--- a/spec/frontend/search/sort/components/app_spec.js
+++ b/spec/frontend/search/sort/components/app_spec.js
@@ -1,4 +1,4 @@
-import { GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
@@ -35,13 +35,16 @@ describe('GlobalSearchSort', () => {
...defaultProps,
...props,
},
+ stubs: {
+ GlCollapsibleListbox,
+ },
});
};
const findSortButtonGroup = () => wrapper.findComponent(GlButtonGroup);
- const findSortDropdown = () => wrapper.findComponent(GlDropdown);
+ const findSortDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
const findSortDirectionButton = () => wrapper.findComponent(GlButton);
- const findDropdownItems = () => findSortDropdown().findAllComponents(GlDropdownItem);
+ const findDropdownItems = () => findSortDropdown().findAllComponents(GlListboxItem);
const findDropdownItemsText = () => findDropdownItems().wrappers.map((w) => w.text());
describe('template', () => {
@@ -89,7 +92,7 @@ describe('GlobalSearchSort', () => {
});
it('is set correctly', () => {
- expect(findSortDropdown().attributes('text')).toBe(value);
+ expect(findSortDropdown().props('toggleText')).toBe(value);
});
});
});
@@ -116,14 +119,14 @@ describe('GlobalSearchSort', () => {
describe('actions', () => {
describe.each`
- description | index | value
- ${'non-sortable'} | ${0} | ${MOCK_SORT_OPTIONS[0].sortParam}
- ${'sortable'} | ${1} | ${MOCK_SORT_OPTIONS[1].sortParam.desc}
- `('handleSortChange', ({ description, index, value }) => {
- describe(`when clicking a ${description} option`, () => {
+ description | text | value
+ ${'non-sortable'} | ${MOCK_SORT_OPTIONS[0].title} | ${MOCK_SORT_OPTIONS[0].sortParam}
+ ${'sortable'} | ${MOCK_SORT_OPTIONS[1].title} | ${MOCK_SORT_OPTIONS[1].sortParam.desc}
+ `('handleSortChange', ({ description, text, value }) => {
+ describe(`when selecting a ${description} option`, () => {
beforeEach(() => {
createComponent();
- findDropdownItems().at(index).vm.$emit('click');
+ findSortDropdown().vm.$emit('select', text);
});
it('calls setQuery and applyQuery correctly', () => {
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index 0884411df0c..2051e731647 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -31,6 +31,7 @@ import {
MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION,
MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION,
MOCK_AGGREGATIONS,
+ MOCK_LABEL_AGGREGATIONS,
} from '../mock_data';
jest.mock('~/alert');
@@ -132,7 +133,7 @@ describe('Global Search Store Actions', () => {
describe('when groupId is set', () => {
it('calls Api.groupProjects with expected parameters', () => {
- actions.fetchProjects({ commit: mockCommit, state }, undefined);
+ actions.fetchProjects({ commit: mockCommit, state }, MOCK_QUERY.search);
expect(Api.groupProjects).toHaveBeenCalledWith(state.query.group_id, state.query.search, {
order_by: 'similarity',
include_subgroups: true,
@@ -301,11 +302,11 @@ describe('Global Search Store Actions', () => {
});
describe.each`
- action | axiosMock | type | expectedMutations | errorLogs
- ${actions.fetchLanguageAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION} | ${0}
- ${actions.fetchLanguageAggregation} | ${{ method: 'onPut', code: 0 }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1}
- ${actions.fetchLanguageAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1}
- `('fetchLanguageAggregation', ({ action, axiosMock, type, expectedMutations, errorLogs }) => {
+ action | axiosMock | type | expectedMutations | errorLogs
+ ${actions.fetchAllAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION} | ${0}
+ ${actions.fetchAllAggregation} | ${{ method: 'onPut', code: 0 }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1}
+ ${actions.fetchAllAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR }} | ${'error'} | ${MOCK_RECEIVE_AGGREGATIONS_ERROR_MUTATION} | ${1}
+ `('fetchAllAggregation', ({ action, axiosMock, type, expectedMutations, errorLogs }) => {
describe(`on ${type}`, () => {
beforeEach(() => {
if (axiosMock.method) {
@@ -347,4 +348,49 @@ describe('Global Search Store Actions', () => {
);
});
});
+
+ describe('closeLabel', () => {
+ beforeEach(() => {
+ state = createState({
+ query: MOCK_QUERY,
+ aggregations: MOCK_LABEL_AGGREGATIONS,
+ });
+ });
+
+ it('removes correct labels from query and sets sidebar dirty', () => {
+ const expectedResult = [
+ {
+ payload: {
+ key: 'labels',
+ value: ['37'],
+ },
+ type: 'SET_QUERY',
+ },
+ {
+ payload: true,
+ type: 'SET_SIDEBAR_DIRTY',
+ },
+ ];
+ return testAction(actions.closeLabel, { key: '60' }, state, expectedResult, []);
+ });
+ });
+
+ describe('setLabelFilterSearch', () => {
+ beforeEach(() => {
+ state = createState({
+ query: MOCK_QUERY,
+ aggregations: MOCK_LABEL_AGGREGATIONS,
+ });
+ });
+
+ it('sets search string', () => {
+ const expectedResult = [
+ {
+ payload: 'test',
+ type: 'SET_LABEL_SEARCH_STRING',
+ },
+ ];
+ return testAction(actions.setLabelFilterSearch, { value: 'test' }, state, expectedResult, []);
+ });
+ });
});
diff --git a/spec/frontend/search/store/getters_spec.js b/spec/frontend/search/store/getters_spec.js
index e3b8e7575a4..772acb39a57 100644
--- a/spec/frontend/search/store/getters_spec.js
+++ b/spec/frontend/search/store/getters_spec.js
@@ -1,3 +1,4 @@
+import { cloneDeep } from 'lodash';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
import * as getters from '~/search/store/getters';
import createState from '~/search/store/state';
@@ -11,13 +12,24 @@ import {
TEST_FILTER_DATA,
MOCK_NAVIGATION,
MOCK_NAVIGATION_ITEMS,
+ MOCK_LABEL_AGGREGATIONS,
+ SMALL_MOCK_AGGREGATIONS,
+ MOCK_LABEL_SEARCH_RESULT,
+ MOCK_FILTERED_APPLIED_SELECTED_LABELS,
+ MOCK_FILTERED_UNSELECTED_LABELS,
+ MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS,
} from '../mock_data';
describe('Global Search Store Getters', () => {
let state;
+ const defaultState = createState({ query: MOCK_QUERY });
+
+ defaultState.aggregations = MOCK_LABEL_AGGREGATIONS;
+ defaultState.aggregations.data.push(SMALL_MOCK_AGGREGATIONS[0]);
beforeEach(() => {
- state = createState({ query: MOCK_QUERY });
+ state = cloneDeep(defaultState);
+
useMockLocationHelper();
});
@@ -76,4 +88,82 @@ describe('Global Search Store Getters', () => {
expect(getters.navigationItems(state)).toStrictEqual(MOCK_NAVIGATION_ITEMS);
});
});
+
+ describe('labelAggregationBuckets', () => {
+ it('strips labels buckets from all aggregations', () => {
+ expect(getters.labelAggregationBuckets(state)).toStrictEqual(
+ MOCK_LABEL_AGGREGATIONS.data[0].buckets,
+ );
+ });
+ });
+
+ describe('filteredLabels', () => {
+ it('gets all labels if no string is set', () => {
+ state.searchLabelString = '';
+ expect(getters.filteredLabels(state)).toStrictEqual(MOCK_LABEL_AGGREGATIONS.data[0].buckets);
+ });
+
+ it('get correct labels if string is set', () => {
+ state.searchLabelString = 'SYNC';
+ expect(getters.filteredLabels(state)).toStrictEqual([MOCK_LABEL_SEARCH_RESULT]);
+ });
+ });
+
+ describe('filteredAppliedSelectedLabels', () => {
+ it('returns all labels that are selected (part of URL)', () => {
+ expect(getters.filteredAppliedSelectedLabels(state)).toStrictEqual(
+ MOCK_FILTERED_APPLIED_SELECTED_LABELS,
+ );
+ });
+
+ it('returns labels that are selected (part of URL) and result of search', () => {
+ state.searchLabelString = 'SYNC';
+ expect(getters.filteredAppliedSelectedLabels(state)).toStrictEqual([
+ MOCK_FILTERED_APPLIED_SELECTED_LABELS[1],
+ ]);
+ });
+ });
+
+ describe('appliedSelectedLabels', () => {
+ it('returns all labels that are selected (part of URL) no search', () => {
+ state.searchLabelString = 'SYNC';
+ expect(getters.appliedSelectedLabels(state)).toStrictEqual(
+ MOCK_FILTERED_APPLIED_SELECTED_LABELS,
+ );
+ });
+ });
+
+ describe('filteredUnappliedSelectedLabels', () => {
+ beforeEach(() => {
+ state.query.labels = ['6', '73'];
+ });
+
+ it('returns all labels that are selected (part of URL) no search', () => {
+ expect(getters.filteredUnappliedSelectedLabels(state)).toStrictEqual(
+ MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS,
+ );
+ });
+
+ it('returns labels that are selected (part of URL) and result of search', () => {
+ state.searchLabelString = 'ACC';
+ expect(getters.filteredUnappliedSelectedLabels(state)).toStrictEqual([
+ MOCK_FILTERED_UNAPPLIED_SELECTED_LABELS[1],
+ ]);
+ });
+ });
+
+ describe('filteredUnselectedLabels', () => {
+ it('returns all labels that are selected (part of URL) no search', () => {
+ expect(getters.filteredUnselectedLabels(state)).toStrictEqual(
+ MOCK_FILTERED_UNSELECTED_LABELS,
+ );
+ });
+
+ it('returns labels that are selected (part of URL) and result of search', () => {
+ state.searchLabelString = 'ACC';
+ expect(getters.filteredUnselectedLabels(state)).toStrictEqual([
+ MOCK_FILTERED_UNSELECTED_LABELS[1],
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js
index d604cf38f8f..a517932b0eb 100644
--- a/spec/frontend/search/store/mutations_spec.js
+++ b/spec/frontend/search/store/mutations_spec.js
@@ -122,4 +122,12 @@ describe('Global Search Store Mutations', () => {
expect(state.aggregations).toStrictEqual(result);
});
});
+
+ describe('SET_LABEL_SEARCH_STRING', () => {
+ it('sets the search string to the given data', () => {
+ mutations[types.SET_LABEL_SEARCH_STRING](state, 'test');
+
+ expect(state.searchLabelString).toBe('test');
+ });
+ });
});
diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js
index aa19bb03cda..3130e01cc9e 100644
--- a/spec/frontend/sentry/index_spec.js
+++ b/spec/frontend/sentry/index_spec.js
@@ -4,6 +4,7 @@ import LegacySentryConfig from '~/sentry/legacy_sentry_config';
import SentryConfig from '~/sentry/sentry_config';
describe('Sentry init', () => {
+ const version = '1.0.0';
const dsn = 'https://123@sentry.gitlab.test/123';
const environment = 'test';
const currentUserId = '1';
@@ -13,6 +14,7 @@ describe('Sentry init', () => {
beforeEach(() => {
window.gon = {
+ version,
sentry_dsn: dsn,
sentry_environment: environment,
current_user_id: currentUserId,
@@ -42,7 +44,7 @@ describe('Sentry init', () => {
currentUserId,
allowUrls: [gitlabUrl, 'webpack-internal://'],
environment,
- release: revision,
+ release: version,
tags: {
revision,
feature_category: featureCategory,
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
index 25a19b5808b..00fa0a8ae56 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_participant_spec.js
@@ -16,7 +16,12 @@ describe('Sidebar participant component', () => {
const findAvatar = () => wrapper.findComponent(GlAvatarLabeled);
const findIcon = () => wrapper.findComponent(GlIcon);
- const createComponent = ({ status = null, issuableType = TYPE_ISSUE, canMerge = false } = {}) => {
+ const createComponent = ({
+ status = null,
+ issuableType = TYPE_ISSUE,
+ canMerge = false,
+ selected = false,
+ } = {}) => {
wrapper = shallowMount(SidebarParticipant, {
propsData: {
user: {
@@ -25,6 +30,7 @@ describe('Sidebar participant component', () => {
status,
},
issuableType,
+ selected,
},
stubs: {
GlAvatarLabeled,
@@ -52,13 +58,27 @@ describe('Sidebar participant component', () => {
});
describe('when on merge request sidebar', () => {
- it('when project member cannot merge', () => {
- createComponent({ issuableType: TYPE_MERGE_REQUEST });
+ describe('when project member cannot merge', () => {
+ it('renders a `cannot-merge` icon', () => {
+ createComponent({ issuableType: TYPE_MERGE_REQUEST });
- expect(findIcon().exists()).toBe(true);
+ expect(findIcon().exists()).toBe(true);
+ });
+
+ it('does not apply `gl-left-6!` class to an icon if participant is not selected', () => {
+ createComponent({ issuableType: TYPE_MERGE_REQUEST, canMerge: false });
+
+ expect(findIcon().classes('gl-left-6!')).toBe(false);
+ });
+
+ it('applies `gl-left-6!` class to an icon if participant is selected', () => {
+ createComponent({ issuableType: TYPE_MERGE_REQUEST, canMerge: false, selected: true });
+
+ expect(findIcon().classes('gl-left-6!')).toBe(true);
+ });
});
- it('when project member can merge', () => {
+ it('does not render an icon when project member can merge', () => {
createComponent({ issuableType: TYPE_MERGE_REQUEST, canMerge: true });
expect(findIcon().exists()).toBe(false);
diff --git a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
index 5e766e9a41c..47f68e1fe83 100644
--- a/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
+++ b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js
@@ -7,6 +7,7 @@ import createStore from '~/notes/stores';
import EditForm from '~/sidebar/components/lock/edit_form.vue';
import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue';
import toast from '~/vue_shared/plugins/global_toast';
+import waitForPromises from 'helpers/wait_for_promises';
import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants';
jest.mock('~/vue_shared/plugins/global_toast');
@@ -27,6 +28,7 @@ describe('IssuableLockForm', () => {
const findLockStatus = () => wrapper.find('[data-testid="lock-status"]');
const findEditLink = () => wrapper.find('[data-testid="edit-link"]');
const findEditForm = () => wrapper.findComponent(EditForm);
+ const findLockButton = () => wrapper.find('[data-testid="issuable-lock"]');
const findSidebarLockStatusTooltip = () =>
getBinding(findSidebarCollapseIcon().element, 'gl-tooltip');
const findIssuableLockClickable = () => wrapper.find('[data-testid="issuable-lock"]');
@@ -172,7 +174,9 @@ describe('IssuableLockForm', () => {
createComponent({ movedMrSidebar: true });
- await wrapper.find('.dropdown-item').trigger('click');
+ await findLockButton().trigger('click');
+
+ await waitForPromises();
expect(toast).toHaveBeenCalledWith(message);
});
@@ -187,7 +191,7 @@ describe('IssuableLockForm', () => {
});
describe('when the flag is on', () => {
- it('does not show the non editable lock status', () => {
+ it('shows the non editable lock status', () => {
createComponent({ movedMrSidebar: true });
expect(findIssuableLockClickable().exists()).toBe(true);
});
diff --git a/spec/frontend/sidebar/components/status/status_dropdown_spec.js b/spec/frontend/sidebar/components/status/status_dropdown_spec.js
index 229b51ea568..923b171e763 100644
--- a/spec/frontend/sidebar/components/status/status_dropdown_spec.js
+++ b/spec/frontend/sidebar/components/status/status_dropdown_spec.js
@@ -1,17 +1,23 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import StatusDropdown from '~/sidebar/components/status/status_dropdown.vue';
import { statusDropdownOptions } from '~/sidebar/constants';
describe('SubscriptionsDropdown component', () => {
let wrapper;
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlListboxItem);
const findHiddenInput = () => wrapper.find('input');
function createComponent() {
- wrapper = shallowMount(StatusDropdown);
+ wrapper = shallowMount(StatusDropdown, {
+ stubs: {
+ GlCollapsibleListbox,
+ GlListboxItem,
+ },
+ });
}
describe('with no value selected', () => {
@@ -20,52 +26,55 @@ describe('SubscriptionsDropdown component', () => {
});
it('renders default text', () => {
- expect(findDropdown().props('text')).toBe('Select status');
+ expect(findDropdown().props('toggleText')).toBe('Select status');
});
- it('renders dropdown items with `is-checked` prop set to `false`', () => {
+ it('renders dropdown items with `isSelected` prop set to `false`', () => {
const dropdownItems = findAllDropdownItems();
- expect(dropdownItems.at(0).props('isChecked')).toBe(false);
- expect(dropdownItems.at(1).props('isChecked')).toBe(false);
+ expect(dropdownItems.at(0).props('isSelected')).toBe(false);
+ expect(dropdownItems.at(1).props('isSelected')).toBe(false);
});
});
describe('when selecting a value', () => {
- const selectItemAtIndex = 0;
+ const optionToSelect = statusDropdownOptions[0];
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
- await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click');
+ findDropdown().vm.$emit('select', optionToSelect.value);
});
it('updates value of the hidden input', () => {
- expect(findHiddenInput().attributes('value')).toBe(
- statusDropdownOptions[selectItemAtIndex].value,
- );
+ expect(findHiddenInput().attributes('value')).toBe(optionToSelect.value);
});
it('updates the dropdown text prop', () => {
- expect(findDropdown().props('text')).toBe(statusDropdownOptions[selectItemAtIndex].text);
+ expect(findDropdown().props('toggleText')).toBe(optionToSelect.text);
});
- it('sets dropdown item `is-checked` prop to `true`', () => {
+ it('sets dropdown item `isSelected` prop to `true`', () => {
const dropdownItems = findAllDropdownItems();
- expect(dropdownItems.at(0).props('isChecked')).toBe(true);
- expect(dropdownItems.at(1).props('isChecked')).toBe(false);
+ expect(dropdownItems.at(0).props('isSelected')).toBe(true);
+ expect(dropdownItems.at(1).props('isSelected')).toBe(false);
});
+ });
- describe('when selecting the value that is already selected', () => {
- it('clears dropdown selection', async () => {
- await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click');
+ describe('when reset is triggered', () => {
+ beforeEach(() => {
+ createComponent();
+ findDropdown().vm.$emit('select', statusDropdownOptions[0].value);
+ });
- const dropdownItems = findAllDropdownItems();
+ it('clears dropdown selection', async () => {
+ findDropdown().vm.$emit('reset');
+ await nextTick();
+ const dropdownItems = findAllDropdownItems();
- expect(dropdownItems.at(0).props('isChecked')).toBe(false);
- expect(dropdownItems.at(1).props('isChecked')).toBe(false);
- expect(findDropdown().props('text')).toBe('Select status');
- });
+ expect(dropdownItems.at(0).props('isSelected')).toBe(false);
+ expect(dropdownItems.at(1).props('isSelected')).toBe(false);
+ expect(findDropdown().props('toggleText')).toBe('Select status');
});
});
});
diff --git a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
index 7275557e7f2..39b80c1d886 100644
--- a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
+++ b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon, GlToggle } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlIcon, GlToggle } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -28,6 +28,7 @@ describe('Sidebar Subscriptions Widget', () => {
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findToggle = () => wrapper.findComponent(GlToggle);
const findIcon = () => wrapper.findComponent(GlIcon);
+ const findDropdownToggleItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
const createComponent = ({
subscriptionsQueryHandler = jest.fn().mockResolvedValue(issueSubscriptionsResponse()),
@@ -155,7 +156,7 @@ describe('Sidebar Subscriptions Widget', () => {
});
await waitForPromises();
- await wrapper.find('[data-testid="notifications-toggle"]').vm.$emit('change');
+ await findDropdownToggleItem().vm.$emit('action');
await waitForPromises();
diff --git a/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js
index eaf7bc13d20..052e6ec9553 100644
--- a/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js
+++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import SubscriptionsDropdown from '~/sidebar/components/subscriptions/subscriptions_dropdown.vue';
@@ -7,12 +7,17 @@ import { subscriptionsDropdownOptions } from '~/sidebar/constants';
describe('SubscriptionsDropdown component', () => {
let wrapper;
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlListboxItem);
const findHiddenInput = () => wrapper.find('input');
function createComponent() {
- wrapper = shallowMount(SubscriptionsDropdown);
+ wrapper = shallowMount(SubscriptionsDropdown, {
+ stubs: {
+ GlCollapsibleListbox,
+ GlListboxItem,
+ },
+ });
}
describe('with no value selected', () => {
@@ -25,48 +30,59 @@ describe('SubscriptionsDropdown component', () => {
});
it('renders default text', () => {
- expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText);
+ expect(findDropdown().props('toggleText')).toBe(
+ SubscriptionsDropdown.i18n.defaultDropdownText,
+ );
});
- it('renders dropdown items with `is-checked` prop set to `false`', () => {
+ it('renders dropdown items with `isSelected` prop set to `false`', () => {
const dropdownItems = findAllDropdownItems();
- expect(dropdownItems.at(0).props('isChecked')).toBe(false);
- expect(dropdownItems.at(1).props('isChecked')).toBe(false);
+ expect(dropdownItems.at(0).props('isSelected')).toBe(false);
+ expect(dropdownItems.at(1).props('isSelected')).toBe(false);
});
});
describe('when selecting a value', () => {
+ const optionToSelect = subscriptionsDropdownOptions[0];
+
beforeEach(() => {
createComponent();
- findAllDropdownItems().at(0).vm.$emit('click');
+ findDropdown().vm.$emit('select', optionToSelect.value);
});
it('updates value of the hidden input', () => {
- expect(findHiddenInput().attributes('value')).toBe(subscriptionsDropdownOptions[0].value);
+ expect(findHiddenInput().attributes('value')).toBe(optionToSelect.value);
});
it('updates the dropdown text prop', () => {
- expect(findDropdown().props('text')).toBe(subscriptionsDropdownOptions[0].text);
+ expect(findDropdown().props('toggleText')).toBe(optionToSelect.text);
});
- it('sets dropdown item `is-checked` prop to `true`', () => {
+ it('sets dropdown item `isSelected` prop to `true`', () => {
const dropdownItems = findAllDropdownItems();
- expect(dropdownItems.at(0).props('isChecked')).toBe(true);
- expect(dropdownItems.at(1).props('isChecked')).toBe(false);
+ expect(dropdownItems.at(0).props('isSelected')).toBe(true);
+ expect(dropdownItems.at(1).props('isSelected')).toBe(false);
+ });
+ });
+
+ describe('when reset is triggered', () => {
+ beforeEach(() => {
+ createComponent();
+ findDropdown().vm.$emit('select', subscriptionsDropdownOptions[0].value);
});
- describe('when selecting the value that is already selected', () => {
- it('clears dropdown selection', async () => {
- findAllDropdownItems().at(0).vm.$emit('click');
- await nextTick();
- const dropdownItems = findAllDropdownItems();
+ it('clears dropdown selection', async () => {
+ findDropdown().vm.$emit('reset');
+ await nextTick();
+ const dropdownItems = findAllDropdownItems();
- expect(dropdownItems.at(0).props('isChecked')).toBe(false);
- expect(dropdownItems.at(1).props('isChecked')).toBe(false);
- expect(findDropdown().props('text')).toBe(SubscriptionsDropdown.i18n.defaultDropdownText);
- });
+ expect(dropdownItems.at(0).props('isSelected')).toBe(false);
+ expect(dropdownItems.at(1).props('isSelected')).toBe(false);
+ expect(findDropdown().props('toggleText')).toBe(
+ SubscriptionsDropdown.i18n.defaultDropdownText,
+ );
});
});
});
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index d17e20ac227..17862953920 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -113,7 +113,7 @@ describe('Snippet Edit app', () => {
const triggerBlobActions = (actions) => findBlobActions().vm.$emit('actions', actions);
const setUploadFilesHtml = (paths) => {
- wrapper.vm.$el.innerHTML = paths
+ wrapper.element.innerHTML = paths
.map((path) => `<input name="files[]" value="${path}">`)
.join('');
};
diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js
index 45a7c7b0b4a..5973768c337 100644
--- a/spec/frontend/snippets/components/show_spec.js
+++ b/spec/frontend/snippets/components/show_spec.js
@@ -11,7 +11,7 @@ import {
VISIBILITY_LEVEL_PRIVATE_STRING,
VISIBILITY_LEVEL_PUBLIC_STRING,
} from '~/visibility_level/constants';
-import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
+import CloneDropdownButton from '~/vue_shared/components/clone_dropdown/clone_dropdown.vue';
import { stubPerformanceWebAPI } from 'helpers/performance';
describe('Snippet view app', () => {
@@ -89,22 +89,32 @@ describe('Snippet view app', () => {
describe('Embed dropdown rendering', () => {
it.each`
- visibilityLevel | condition | isRendered
- ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${'not render'} | ${false}
- ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${'not render'} | ${false}
- ${'foo'} | ${'not render'} | ${false}
- ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'render'} | ${true}
- `('does $condition embed-dropdown by default', ({ visibilityLevel, isRendered }) => {
- createComponent({
- data: {
- snippet: {
- visibilityLevel,
- webUrl,
+ snippetVisibility | projectVisibility | condition | isRendered
+ ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'not render'} | ${false}
+ ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'not render'} | ${false}
+ ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${undefined} | ${'render'} | ${true}
+ ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'render'} | ${true}
+ ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${'not render'} | ${false}
+ ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${undefined} | ${'not render'} | ${false}
+ ${'foo'} | ${undefined} | ${'not render'} | ${false}
+ ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${'not render'} | ${false}
+ `(
+ 'does $condition embed-dropdown by default',
+ ({ snippetVisibility, projectVisibility, isRendered }) => {
+ createComponent({
+ data: {
+ snippet: {
+ visibilityLevel: snippetVisibility,
+ webUrl,
+ project: {
+ visibility: projectVisibility,
+ },
+ },
},
- },
- });
- expect(findEmbedDropdown().exists()).toBe(isRendered);
- });
+ });
+ expect(findEmbedDropdown().exists()).toBe(isRendered);
+ },
+ );
});
describe('hasUnretrievableBlobs alert rendering', () => {
diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
index 58f47e8b0dc..cb11e98cd35 100644
--- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js
@@ -8,8 +8,9 @@ import {
SNIPPET_MAX_BLOBS,
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_MOVE,
+ SNIPPET_LIMITATIONS,
} from '~/snippets/constants';
-import { s__ } from '~/locale';
+import { s__, sprintf } from '~/locale';
import { testEntries, createBlobFromTestEntry } from '../test_utils';
const TEST_BLOBS = [
@@ -40,6 +41,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
}));
const findFirstBlobEdit = () => findBlobEdits().at(0);
const findAddButton = () => wrapper.find('[data-testid="add_button"]');
+ const findLimitationsText = () => wrapper.find('[data-testid="limitations_text"]');
const getLastActions = () => {
const events = wrapper.emitted().actions;
@@ -97,6 +99,10 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
expect(button.props('disabled')).toBe(false);
});
+ it('do not show limitations text', () => {
+ expect(findLimitationsText().exists()).toBe(false);
+ });
+
describe('when add is clicked', () => {
beforeEach(() => {
findAddButton().vm.$emit('click');
@@ -276,6 +282,12 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
it('should disable add button', () => {
expect(findAddButton().props('disabled')).toBe(true);
});
+
+ it('shows limitations text', () => {
+ expect(findLimitationsText().text()).toBe(
+ sprintf(SNIPPET_LIMITATIONS, { total: SNIPPET_MAX_BLOBS }),
+ );
+ });
});
describe('isValid prop', () => {
diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js
index dcef8fc9a8b..76b03c0aa0d 100644
--- a/spec/frontend/snippets/test_utils.js
+++ b/spec/frontend/snippets/test_utils.js
@@ -30,6 +30,7 @@ export const createGQLSnippet = () => ({
id: 'project-1',
fullPath: 'group/project',
webUrl: `${TEST_HOST}/group/project`,
+ visibility: 'public',
},
author: {
__typename: 'User',
diff --git a/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js b/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js
new file mode 100644
index 00000000000..12bd27488b1
--- /dev/null
+++ b/spec/frontend/streaming/handle_streamed_relative_timestamps_spec.js
@@ -0,0 +1,94 @@
+import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
+import waitForPromises from 'helpers/wait_for_promises';
+import { handleStreamedRelativeTimestamps } from '~/streaming/handle_streamed_relative_timestamps';
+import { localTimeAgo } from '~/lib/utils/datetime_utility';
+import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
+
+jest.mock('~/lib/utils/datetime_utility');
+
+const TIMESTAMP_MOCK = `<div class="js-timeago">Oct 2, 2019</div>`;
+
+describe('handleStreamedRelativeTimestamps', () => {
+ const findRoot = () => document.querySelector('#root');
+ const findStreamingElement = () => document.querySelector('streaming-element');
+ const findTimestamp = () => document.querySelector('.js-timeago');
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ describe('when element is present', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div id="root">${TIMESTAMP_MOCK}</div>`);
+ handleStreamedRelativeTimestamps(findRoot());
+ });
+
+ it('does nothing', async () => {
+ await waitForPromises();
+ expect(localTimeAgo).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when element is streamed', () => {
+ let relativeTimestampsHandler;
+ const { trigger: triggerIntersection } = useMockIntersectionObserver();
+
+ const insertStreamingElement = () =>
+ findRoot().insertAdjacentHTML('afterbegin', `<streaming-element></streaming-element>`);
+
+ beforeEach(() => {
+ setHTMLFixture('<div id="root"></div>');
+ relativeTimestampsHandler = handleStreamedRelativeTimestamps(findRoot());
+ });
+
+ it('formats and unobserved the timestamp when inserted and intersecting', async () => {
+ insertStreamingElement();
+ await waitForPromises();
+ findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK);
+ await waitForPromises();
+
+ const timestamp = findTimestamp();
+ const unobserveMock = jest.fn();
+
+ triggerIntersection(findTimestamp(), {
+ entry: { isIntersecting: true },
+ observer: { unobserve: unobserveMock },
+ });
+
+ expect(unobserveMock).toHaveBeenCalled();
+ expect(localTimeAgo).toHaveBeenCalledWith([timestamp]);
+ });
+
+ it('does not format the timestamp when inserted but not intersecting', async () => {
+ insertStreamingElement();
+ await waitForPromises();
+ findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK);
+ await waitForPromises();
+
+ const unobserveMock = jest.fn();
+
+ triggerIntersection(findTimestamp(), {
+ entry: { isIntersecting: false },
+ observer: { unobserve: unobserveMock },
+ });
+
+ expect(unobserveMock).not.toHaveBeenCalled();
+ expect(localTimeAgo).not.toHaveBeenCalled();
+ });
+
+ it('does not format the time when destroyed', async () => {
+ insertStreamingElement();
+
+ const stop = await relativeTimestampsHandler;
+ stop();
+
+ await waitForPromises();
+ findStreamingElement().insertAdjacentHTML('afterbegin', TIMESTAMP_MOCK);
+ await waitForPromises();
+
+ triggerIntersection(findTimestamp(), { entry: { isIntersecting: true } });
+
+ expect(localTimeAgo).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/brand_logo_spec.js b/spec/frontend/super_sidebar/components/brand_logo_spec.js
new file mode 100644
index 00000000000..63c4bb9668b
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/brand_logo_spec.js
@@ -0,0 +1,42 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createMockDirective } from 'helpers/vue_mock_directive';
+import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue';
+
+describe('Brand Logo component', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ logoUrl: 'path/to/logo',
+ };
+
+ const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo');
+ const findDefaultLogo = () => wrapper.findByTestId('brand-header-default-logo');
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMountExtended(BrandLogo, {
+ provide: {
+ rootPath: '/',
+ },
+ propsData: {
+ ...defaultPropsData,
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ });
+ };
+
+ it('renders it', () => {
+ createWrapper();
+ expect(findBrandLogo().exists()).toBe(true);
+ expect(findBrandLogo().attributes('src')).toBe(defaultPropsData.logoUrl);
+ });
+
+ it('when logoUrl given empty', () => {
+ createWrapper({ logoUrl: '' });
+
+ expect(findBrandLogo().exists()).toBe(false);
+ expect(findDefaultLogo().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js
index 7928ee6400c..4317f451377 100644
--- a/spec/frontend/super_sidebar/components/context_switcher_spec.js
+++ b/spec/frontend/super_sidebar/components/context_switcher_spec.js
@@ -158,12 +158,6 @@ describe('ContextSwitcher component', () => {
expect(findContextSwitcherToggle().props('expanded')).toEqual(false);
});
- it("passes Popper.js' options to the disclosure dropdown", () => {
- expect(findDisclosureDropdown().props('popperOptions')).toMatchObject({
- modifiers: expect.any(Array),
- });
- });
-
it('does not emit the `toggle` event initially', () => {
expect(wrapper.emitted('toggle')).toBe(undefined);
});
diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js
index 456085e23da..fe2fd17ae4d 100644
--- a/spec/frontend/super_sidebar/components/create_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/create_menu_spec.js
@@ -6,7 +6,6 @@ import {
GlDisclosureDropdownItem,
} from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { stubComponent } from 'helpers/stub_component';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import { __ } from '~/locale';
import CreateMenu from '~/super_sidebar/components/create_menu.vue';
@@ -21,8 +20,6 @@ describe('CreateMenu component', () => {
const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger);
const findGlTooltip = () => wrapper.findComponent(GlTooltip);
- const closeAndFocusMock = jest.fn();
-
const createWrapper = () => {
wrapper = shallowMountExtended(CreateMenu, {
propsData: {
@@ -30,9 +27,7 @@ describe('CreateMenu component', () => {
},
stubs: {
InviteMembersTrigger,
- GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
- methods: { closeAndFocus: closeAndFocusMock },
- }),
+ GlDisclosureDropdown,
},
});
};
@@ -42,11 +37,12 @@ describe('CreateMenu component', () => {
createWrapper();
});
- it('passes popper options to the dropdown', () => {
+ it('passes custom offset to the dropdown', () => {
createWrapper();
- expect(findGlDisclosureDropdown().props('popperOptions')).toEqual({
- modifiers: [{ name: 'offset', options: { offset: [-147, 4] } }],
+ expect(findGlDisclosureDropdown().props('dropdownOffset')).toEqual({
+ crossAxis: -147,
+ mainAxis: 4,
});
});
@@ -93,10 +89,5 @@ describe('CreateMenu component', () => {
expect(findGlTooltip().exists()).toBe(true);
});
-
- it('closes the dropdown when invite members modal is opened', () => {
- findInviteMembersTrigger().vm.$emit('modal-opened');
- expect(closeAndFocusMock).toHaveBeenCalled();
- });
});
});
diff --git a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
index 5329a8f5da3..63dd941974a 100644
--- a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
+++ b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
@@ -1,4 +1,4 @@
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import { s__ } from '~/locale';
import FrequentItemsList from '~/super_sidebar/components//frequent_items_list.vue';
import ItemsList from '~/super_sidebar/components/items_list.vue';
@@ -18,18 +18,20 @@ describe('FrequentItemsList component', () => {
const findListTitle = () => wrapper.findByTestId('list-title');
const findItemsList = () => wrapper.findComponent(ItemsList);
const findEmptyText = () => wrapper.findByTestId('empty-text');
+ const findRemoveItemButton = () => wrapper.findByTestId('item-remove');
- const createWrapper = ({ props = {} } = {}) => {
- wrapper = shallowMountExtended(FrequentItemsList, {
+ const createWrapperFactory = (mountFn = shallowMountExtended) => () => {
+ wrapper = mountFn(FrequentItemsList, {
propsData: {
title,
pristineText,
storageKey,
maxItems,
- ...props,
},
});
};
+ const createWrapper = createWrapperFactory();
+ const createFullWrapper = createWrapperFactory(mountExtended);
describe('default', () => {
beforeEach(() => {
@@ -64,16 +66,20 @@ describe('FrequentItemsList component', () => {
it('does not render the empty text slot', () => {
expect(findEmptyText().exists()).toBe(false);
});
+ });
- describe('items editing', () => {
- it('remove-item event emission from items-list causes list item to be removed', async () => {
- const localStorageProjects = findItemsList().props('items');
+ describe('items editing', () => {
+ beforeEach(() => {
+ window.localStorage.setItem(storageKey, cachedFrequentProjects);
+ createFullWrapper();
+ });
- await findItemsList().vm.$emit('remove-item', localStorageProjects[0]);
+ it('remove-item event emission from items-list causes list item to be removed', async () => {
+ const localStorageProjects = findItemsList().props('items');
+ await findRemoveItemButton().trigger('click');
- expect(findItemsList().props('items')).toHaveLength(maxItems - 1);
- expect(findItemsList().props('items')).not.toContain(localStorageProjects[0]);
- });
+ expect(findItemsList().props('items')).toHaveLength(maxItems - 1);
+ expect(findItemsList().props('items')).not.toContain(localStorageProjects[0]);
});
});
});
diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap b/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap
new file mode 100644
index 00000000000..d16d137db2f
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap
@@ -0,0 +1,122 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SearchItem should render the item 1`] = `
+<div
+ class="gl-display-flex gl-align-items-center"
+>
+ <gl-avatar-stub
+ alt="avatar"
+ aria-hidden="true"
+ class="gl-mr-3"
+ entityid="37"
+ entityname=""
+ shape="rect"
+ size="16"
+ src="https://www.gravatar.com/avatar/a9638f4ec70148d51e56bf05ad41e993?s=80&d=identicon"
+ />
+
+ <!---->
+
+ <span
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <span
+ class="gl-text-gray-900"
+ />
+
+ <!---->
+ </span>
+</div>
+`;
+
+exports[`SearchItem should render the item 2`] = `
+<div
+ class="gl-display-flex gl-align-items-center"
+>
+ <!---->
+
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="users"
+ size="16"
+ />
+
+ <span
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <span
+ class="gl-text-gray-900"
+ >
+ Manage &gt; Activity
+ </span>
+
+ <!---->
+ </span>
+</div>
+`;
+
+exports[`SearchItem should render the item 3`] = `
+<div
+ class="gl-display-flex gl-align-items-center"
+>
+ <gl-avatar-stub
+ alt="avatar"
+ aria-hidden="true"
+ class="gl-mr-3"
+ entityid="1"
+ entityname="MockProject1"
+ shape="rect"
+ size="32"
+ src="/project/avatar/1/avatar.png"
+ />
+
+ <!---->
+
+ <span
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <span
+ class="gl-text-gray-900"
+ >
+ MockProject1
+ </span>
+
+ <span
+ class="gl-font-sm gl-text-gray-500"
+ >
+ Gitlab Org / MockProject1
+ </span>
+ </span>
+</div>
+`;
+
+exports[`SearchItem should render the item 4`] = `
+<div
+ class="gl-display-flex gl-align-items-center"
+>
+ <gl-avatar-stub
+ alt="avatar"
+ aria-hidden="true"
+ class="gl-mr-3"
+ entityid="7"
+ entityname="Flight"
+ shape="rect"
+ size="16"
+ src=""
+ />
+
+ <!---->
+
+ <span
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <span
+ class="gl-text-gray-900"
+ >
+ Dismiss Cipher with no integrity
+ </span>
+
+ <!---->
+ </span>
+</div>
+`;
diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js
new file mode 100644
index 00000000000..21d085dc0fb
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js
@@ -0,0 +1,143 @@
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import CommandPaletteItems from '~/super_sidebar/components/global_search/command_palette/command_palette_items.vue';
+import {
+ COMMAND_HANDLE,
+ USERS_GROUP_TITLE,
+ USER_HANDLE,
+ SEARCH_SCOPE,
+} from '~/super_sidebar/components/global_search/command_palette/constants';
+import {
+ commandMapper,
+ linksReducer,
+} from '~/super_sidebar/components/global_search/command_palette/utils';
+import { getFormattedItem } from '~/super_sidebar/components/global_search/utils';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import waitForPromises from 'helpers/wait_for_promises';
+import { COMMANDS, LINKS, USERS } from './mock_data';
+
+const links = LINKS.reduce(linksReducer, []);
+
+describe('CommandPaletteItems', () => {
+ let wrapper;
+ const autocompletePath = '/autocomplete';
+ const searchContext = { project: { id: 1 }, group: { id: 2 } };
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(CommandPaletteItems, {
+ propsData: {
+ handle: COMMAND_HANDLE,
+ searchQuery: '',
+ ...props,
+ },
+ stubs: {
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+ },
+ provide: {
+ commandPaletteCommands: COMMANDS,
+ commandPaletteLinks: LINKS,
+ autocompletePath,
+ searchContext,
+ },
+ });
+ };
+
+ const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findGroups = () => wrapper.findAllComponents(GlDisclosureDropdownGroup);
+ const findLoader = () => wrapper.findComponent(GlLoadingIcon);
+
+ describe('COMMANDS & LINKS', () => {
+ it('renders all commands initially', () => {
+ createComponent();
+ const commandGroup = COMMANDS.map(commandMapper)[0];
+ expect(findItems()).toHaveLength(commandGroup.items.length);
+ expect(findGroups().at(0).props('group')).toEqual({
+ name: commandGroup.name,
+ items: commandGroup.items,
+ });
+ });
+
+ describe('with search query', () => {
+ it('should filter commands and links by the search query', async () => {
+ jest.spyOn(fuzzaldrinPlus, 'filter');
+ createComponent({ searchQuery: 'mr' });
+ const searchQuery = 'todo';
+ await wrapper.setProps({ searchQuery });
+ const commandGroup = COMMANDS.map(commandMapper)[0];
+ expect(fuzzaldrinPlus.filter).toHaveBeenCalledWith(
+ commandGroup.items,
+ searchQuery,
+ expect.objectContaining({ key: 'text' }),
+ );
+ expect(fuzzaldrinPlus.filter).toHaveBeenCalledWith(
+ links,
+ searchQuery,
+ expect.objectContaining({ key: 'keywords' }),
+ );
+ });
+
+ it('should display no results message when no command matched the search query', async () => {
+ jest.spyOn(fuzzaldrinPlus, 'filter').mockReturnValue([]);
+ createComponent({ searchQuery: 'mr' });
+ const searchQuery = 'todo';
+ await wrapper.setProps({ searchQuery });
+ expect(wrapper.text()).toBe('No results found');
+ });
+ });
+ });
+
+ describe('USERS, ISSUES, PROJECTS', () => {
+ let mockAxios;
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ });
+
+ it('should NOT start search by the search query which is less than 3 chars', () => {
+ jest.spyOn(axios, 'get');
+ const searchQuery = 'us';
+ createComponent({ handle: USER_HANDLE, searchQuery });
+
+ expect(axios.get).not.toHaveBeenCalled();
+
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('should start scoped search with 3+ chars and display a loader', () => {
+ jest.spyOn(axios, 'get');
+ const searchQuery = 'user';
+ createComponent({ handle: USER_HANDLE, searchQuery });
+
+ expect(axios.get).toHaveBeenCalledWith(
+ `${autocompletePath}?term=${searchQuery}&project_id=${searchContext.project.id}&filter=search&scope=${SEARCH_SCOPE[USER_HANDLE]}`,
+ );
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('should render returned items', async () => {
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, USERS);
+
+ const searchQuery = 'user';
+ createComponent({ handle: USER_HANDLE, searchQuery });
+
+ await waitForPromises();
+ expect(findItems()).toHaveLength(USERS.length);
+ expect(findGroups().at(0).props('group')).toMatchObject({
+ name: USERS_GROUP_TITLE,
+ items: USERS.map(getFormattedItem),
+ });
+ });
+
+ it('should display no results message when no users matched the search query', async () => {
+ mockAxios.onGet().replyOnce(HTTP_STATUS_OK, []);
+ const searchQuery = 'user';
+ createComponent({ handle: USER_HANDLE, searchQuery });
+ await waitForPromises();
+ expect(wrapper.text()).toBe('No results found');
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/fake_search_input_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/fake_search_input_spec.js
new file mode 100644
index 00000000000..a8e91395303
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/command_palette/fake_search_input_spec.js
@@ -0,0 +1,44 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import FakeSearchInput from '~/super_sidebar/components/global_search/command_palette/fake_search_input.vue';
+import {
+ SEARCH_SCOPE_PLACEHOLDER,
+ COMMON_HANDLES,
+ COMMAND_HANDLE,
+} from '~/super_sidebar/components/global_search/command_palette/constants';
+
+describe('FakeSearchInput', () => {
+ let wrapper;
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(FakeSearchInput, {
+ propsData: {
+ scope: COMMAND_HANDLE,
+ userInput: '',
+ ...props,
+ },
+ });
+ };
+
+ const findSearchScope = () => wrapper.findByTestId('search-scope');
+ const findSearchScopePlaceholder = () => wrapper.findByTestId('search-scope-placeholder');
+
+ it('should render the search scope', () => {
+ createComponent();
+ expect(findSearchScope().text()).toBe(COMMAND_HANDLE);
+ });
+
+ describe('placeholder', () => {
+ it.each(COMMON_HANDLES)(
+ 'should render the placeholder for the %s scope when there is no user input',
+ (scope) => {
+ createComponent({ scope });
+ expect(findSearchScopePlaceholder().text()).toBe(SEARCH_SCOPE_PLACEHOLDER[scope]);
+ },
+ );
+
+ it('should NOT render the placeholder when there is user input', () => {
+ createComponent({ userInput: 'todo' });
+ expect(findSearchScopePlaceholder().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js
new file mode 100644
index 00000000000..ec65a43d549
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js
@@ -0,0 +1,133 @@
+export const COMMANDS = [
+ {
+ name: 'Global',
+ items: [
+ {
+ text: 'New project/repository',
+ href: '/projects/new',
+ },
+ {
+ text: 'New group',
+ href: '/groups/new',
+ },
+ {
+ text: 'New snippet',
+ href: '/-/snippets/new',
+ },
+ {
+ text: 'Invite members',
+ href: '/-/snippets/new',
+ component: 'invite_members',
+ },
+ ],
+ },
+];
+
+export const LINKS = [
+ {
+ title: 'Manage',
+ icon: 'users',
+ link: '/flightjs/Flight/activity',
+ is_active: false,
+ pill_count: null,
+ items: [
+ {
+ id: 'activity',
+ title: 'Activity',
+ icon: null,
+ link: '/flightjs/Flight/activity',
+ pill_count: null,
+ link_classes: 'shortcuts-project-activity',
+ is_active: false,
+ },
+ {
+ id: 'members',
+ title: 'Members',
+ icon: null,
+ link: '/flightjs/Flight/-/project_members',
+ pill_count: null,
+ link_classes: null,
+ is_active: false,
+ },
+ {
+ id: 'labels',
+ title: 'Labels',
+ icon: null,
+ link: '/flightjs/Flight/-/labels',
+ pill_count: null,
+ link_classes: null,
+ is_active: false,
+ },
+ ],
+ separated: false,
+ },
+];
+
+export const TRANSFORMED_LINKS = [
+ {
+ href: '/flightjs/Flight/activity',
+ icon: 'users',
+ keywords: 'Manage',
+ text: 'Manage',
+ },
+ {
+ href: '/flightjs/Flight/activity',
+ icon: 'users',
+ keywords: 'Activity',
+ text: 'Manage > Activity',
+ },
+ {
+ href: '/flightjs/Flight/-/project_members',
+ icon: 'users',
+ keywords: 'Members',
+ text: 'Manage > Members',
+ },
+ {
+ href: '/flightjs/Flight/-/labels',
+ icon: 'users',
+ keywords: 'Labels',
+ text: 'Manage > Labels',
+ },
+];
+
+export const USERS = [
+ {
+ id: 37,
+ username: 'reported_user_14',
+ name: 'Cole Dickinson',
+ web_url: 'http://127.0.0.1:3000/reported_user_14',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/a9638f4ec70148d51e56bf05ad41e993?s=80\u0026d=identicon',
+ },
+ {
+ id: 47,
+ username: 'sharlatenok',
+ name: 'Olena Horal-Koretska',
+ web_url: 'http://127.0.0.1:3000/sharlatenok',
+ },
+ {
+ id: 30,
+ username: 'reported_user_7',
+ name: 'Violeta Feeney',
+ web_url: 'http://127.0.0.1:3000/reported_user_7',
+ },
+];
+
+export const PROJECT = {
+ category: 'Projects',
+ id: 1,
+ label: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
+ url: 'project/1',
+ avatar_url: '/project/avatar/1/avatar.png',
+};
+
+export const ISSUE = {
+ avatar_url: '',
+ category: 'Recent issues',
+ id: 516,
+ label: 'Dismiss Cipher with no integrity',
+ project_id: 7,
+ project_name: 'Flight',
+ url: '/flightjs/Flight/-/issues/37',
+};
diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/search_item_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/search_item_spec.js
new file mode 100644
index 00000000000..c7e49310588
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/command_palette/search_item_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import SearchItem from '~/super_sidebar/components/global_search/command_palette/search_item.vue';
+import { getFormattedItem } from '~/super_sidebar/components/global_search/utils';
+import { linksReducer } from '~/super_sidebar/components/global_search/command_palette/utils';
+import { USERS, LINKS, PROJECT, ISSUE } from './mock_data';
+
+jest.mock('~/lib/utils/highlight', () => ({
+ __esModule: true,
+ default: (text) => text,
+}));
+const mockUser = getFormattedItem(USERS[0]);
+const mockCommand = LINKS.reduce(linksReducer, [])[1];
+const mockProject = getFormattedItem(PROJECT);
+const mockIssue = getFormattedItem(ISSUE);
+
+describe('SearchItem', () => {
+ let wrapper;
+
+ const createComponent = (item) => {
+ wrapper = shallowMount(SearchItem, {
+ propsData: {
+ item,
+ searchQuery: 'root',
+ },
+ });
+ };
+
+ it.each([mockUser, mockCommand, mockProject, mockIssue])('should render the item', (item) => {
+ createComponent(item);
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js
new file mode 100644
index 00000000000..0b75787723e
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js
@@ -0,0 +1,18 @@
+import {
+ commandMapper,
+ linksReducer,
+} from '~/super_sidebar/components/global_search/command_palette/utils';
+import { COMMANDS, LINKS, TRANSFORMED_LINKS } from './mock_data';
+
+describe('linksReducer', () => {
+ it('should transform links', () => {
+ expect(LINKS.reduce(linksReducer, [])).toEqual(TRANSFORMED_LINKS);
+ });
+});
+
+describe('commandMapper', () => {
+ it('should temporarily remove the `invite_members` item', () => {
+ const initialCommandsLength = COMMANDS[0].items.length;
+ expect(COMMANDS.map(commandMapper)[0].items).toHaveLength(initialCommandsLength - 1);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js
index f78e141afad..9b7b9e288df 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js
@@ -7,6 +7,12 @@ import GlobalSearchModal from '~/super_sidebar/components/global_search/componen
import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue';
import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue';
import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue';
+import FakeSearchInput from '~/super_sidebar/components/global_search/command_palette/fake_search_input.vue';
+import CommandPaletteItems from '~/super_sidebar/components/global_search/command_palette/command_palette_items.vue';
+import {
+ SEARCH_OR_COMMAND_MODE_PLACEHOLDER,
+ COMMON_HANDLES,
+} from '~/super_sidebar/components/global_search/command_palette/constants';
import {
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
@@ -17,6 +23,7 @@ import {
IS_SEARCHING,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '~/super_sidebar/components/global_search/constants';
+import { SEARCH_GITLAB } from '~/vue_shared/global_search/constants';
import { truncate } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import { ENTER_KEY } from '~/lib/utils/keys';
@@ -53,7 +60,18 @@ describe('GlobalSearchModal', () => {
},
};
- const createComponent = (initialState, mockGetters, stubs) => {
+ const defaultMockGetters = {
+ searchQuery: () => MOCK_SEARCH_QUERY,
+ searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
+ scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS,
+ };
+
+ const createComponent = (
+ initialState = deafaultMockState,
+ mockGetters = defaultMockGetters,
+ stubs,
+ glFeatures = { commandPalette: false },
+ ) => {
const store = new Vuex.Store({
state: {
...deafaultMockState,
@@ -71,6 +89,7 @@ describe('GlobalSearchModal', () => {
wrapper = shallowMountExtended(GlobalSearchModal, {
store,
stubs,
+ provide: { glFeatures },
});
};
@@ -98,6 +117,8 @@ describe('GlobalSearchModal', () => {
wrapper.findComponent(GlobalSearchAutocompleteItems);
const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`);
const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION);
+ const findCommandPaletteItems = () => wrapper.findComponent(CommandPaletteItems);
+ const findFakeSearchInput = () => wrapper.findComponent(FakeSearchInput);
describe('template', () => {
describe('always renders', () => {
@@ -281,6 +302,45 @@ describe('GlobalSearchModal', () => {
).toBe(iconName);
});
});
+
+ describe('Command palette', () => {
+ describe('when FF `command_palette` is disabled', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should not render command mode components', () => {
+ expect(findCommandPaletteItems().exists()).toBe(false);
+ expect(findFakeSearchInput().exists()).toBe(false);
+ });
+
+ it('should provide default placeholder to the search input', () => {
+ expect(findGlobalSearchInput().attributes('placeholder')).toBe(SEARCH_GITLAB);
+ });
+ });
+
+ describe.each(COMMON_HANDLES)(
+ 'when FF `command_palette` is enabled and search handle is %s',
+ (handle) => {
+ beforeEach(() => {
+ createComponent({ search: handle }, undefined, undefined, {
+ commandPalette: true,
+ });
+ });
+
+ it('should render command mode components', () => {
+ expect(findCommandPaletteItems().exists()).toBe(true);
+ expect(findFakeSearchInput().exists()).toBe(true);
+ });
+
+ it('should provide an alternative placeholder to the search input', () => {
+ expect(findGlobalSearchInput().attributes('placeholder')).toBe(
+ SEARCH_OR_COMMAND_MODE_PLACEHOLDER,
+ );
+ });
+ },
+ );
+ });
});
describe('events', () => {
diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js
index 808c30436a3..6af1172e4d8 100644
--- a/spec/frontend/super_sidebar/components/help_center_spec.js
+++ b/spec/frontend/super_sidebar/components/help_center_spec.js
@@ -4,7 +4,7 @@ import toggleWhatsNewDrawer from '~/whats_new';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import HelpCenter from '~/super_sidebar/components/help_center.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import { DOCS_URL, FORUM_URL, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { STORAGE_KEY } from '~/whats_new/utils/notification';
import { helpCenterState } from '~/super_sidebar/constants';
@@ -25,6 +25,7 @@ describe('HelpCenter component', () => {
};
const withinComponent = () => within(wrapper.element);
const findButton = (name) => withinComponent().getByRole('button', { name });
+ const findNotificationDot = () => wrapper.findByTestId('notification-dot');
// eslint-disable-next-line no-shadow
const createWrapper = (sidebarData) => {
@@ -52,7 +53,7 @@ describe('HelpCenter component', () => {
},
{
text: HelpCenter.i18n.docs,
- href: `https://docs.${DOMAIN}`,
+ href: DOCS_URL,
extraAttrs: trackingAttrs('gitlab_documentation'),
},
{
@@ -62,7 +63,7 @@ describe('HelpCenter component', () => {
},
{
text: HelpCenter.i18n.forum,
- href: `https://forum.${DOMAIN}/`,
+ href: FORUM_URL,
extraAttrs: trackingAttrs('community_forum'),
},
{
@@ -91,22 +92,22 @@ describe('HelpCenter component', () => {
]);
});
- it('passes popper options to the dropdown', () => {
- expect(findDropdown().props('popperOptions')).toEqual({
- modifiers: [{ name: 'offset', options: { offset: [-4, 4] } }],
+ it('passes custom offset to the dropdown', () => {
+ expect(findDropdown().props('dropdownOffset')).toEqual({
+ crossAxis: -4,
+ mainAxis: 4,
});
});
describe('with show_tanuki_bot true', () => {
beforeEach(() => {
createWrapper({ ...sidebarData, show_tanuki_bot: true });
- jest.spyOn(wrapper.vm.$refs.dropdown, 'close');
});
it('shows Ask GitLab Chat with the help items', () => {
expect(findDropdownGroup(0).props('group').items).toEqual([
expect.objectContaining({
- icon: 'tanuki',
+ icon: 'tanuki-ai',
text: HelpCenter.i18n.chat,
extraAttrs: trackingAttrs('tanuki_bot_help_dropdown'),
}),
@@ -119,10 +120,6 @@ describe('HelpCenter component', () => {
findButton('Ask GitLab Chat').click();
});
- it('closes the dropdown', () => {
- expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled();
- });
-
it('sets helpCenterState.showTanukiBotChatDrawer to true', () => {
expect(helpCenterState.showTanukiBotChatDrawer).toBe(true);
});
@@ -150,16 +147,9 @@ describe('HelpCenter component', () => {
let button;
beforeEach(() => {
- jest.spyOn(wrapper.vm.$refs.dropdown, 'close');
-
button = findButton('Keyboard shortcuts ?');
});
- it('closes the dropdown', () => {
- button.click();
- expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled();
- });
-
it('shows the keyboard shortcuts modal', () => {
// This relies on the event delegation set up by the Shortcuts class in
// ~/behaviors/shortcuts/shortcuts.js.
@@ -179,17 +169,12 @@ describe('HelpCenter component', () => {
describe('showWhatsNew', () => {
beforeEach(() => {
- jest.spyOn(wrapper.vm.$refs.dropdown, 'close');
beforeEach(() => {
createWrapper({ ...sidebarData, show_version_check: true });
});
findButton("What's new 5").click();
});
- it('closes the dropdown', () => {
- expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled();
- });
-
it('shows the "What\'s new" slideout', () => {
expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(expect.any(Object));
});
@@ -219,8 +204,8 @@ describe('HelpCenter component', () => {
createWrapper({ ...sidebarData, display_whats_new: false });
});
- it('is false', () => {
- expect(wrapper.vm.showWhatsNewNotification).toBe(false);
+ it('does not render notification dot', () => {
+ expect(findNotificationDot().exists()).toBe(false);
});
});
@@ -231,8 +216,8 @@ describe('HelpCenter component', () => {
createWrapper({ ...sidebarData, display_whats_new: true });
});
- it('is true', () => {
- expect(wrapper.vm.showWhatsNewNotification).toBe(true);
+ it('renders notification dot', () => {
+ expect(findNotificationDot().exists()).toBe(true);
});
describe('when "What\'s new" drawer got opened', () => {
@@ -240,8 +225,8 @@ describe('HelpCenter component', () => {
findButton("What's new 5").click();
});
- it('is false', () => {
- expect(wrapper.vm.showWhatsNewNotification).toBe(false);
+ it('does not render notification dot', () => {
+ expect(findNotificationDot().exists()).toBe(false);
});
});
@@ -251,8 +236,8 @@ describe('HelpCenter component', () => {
createWrapper({ ...sidebarData, display_whats_new: true });
});
- it('is false', () => {
- expect(wrapper.vm.showWhatsNewNotification).toBe(false);
+ it('does not render notification dot', () => {
+ expect(findNotificationDot().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/super_sidebar/components/items_list_spec.js b/spec/frontend/super_sidebar/components/items_list_spec.js
index d5e8043cce9..8e00984f500 100644
--- a/spec/frontend/super_sidebar/components/items_list_spec.js
+++ b/spec/frontend/super_sidebar/components/items_list_spec.js
@@ -1,5 +1,4 @@
-import { GlIcon } from '@gitlab/ui';
-import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ItemsList from '~/super_sidebar/components/items_list.vue';
import NavItem from '~/super_sidebar/components/nav_item.vue';
import { cachedFrequentProjects } from '../mock_data';
@@ -12,8 +11,8 @@ describe('ItemsList component', () => {
const findNavItems = () => wrapper.findAllComponents(NavItem);
- const createWrapper = ({ props = {}, slots = {}, mountFn = shallowMountExtended } = {}) => {
- wrapper = mountFn(ItemsList, {
+ const createWrapper = ({ props = {}, slots = {} } = {}) => {
+ wrapper = shallowMountExtended(ItemsList, {
propsData: {
...props,
},
@@ -61,41 +60,4 @@ describe('ItemsList component', () => {
expect(wrapper.findByTestId(testId).exists()).toBe(true);
});
-
- describe('item removal', () => {
- const findRemoveButton = () => wrapper.findByTestId('item-remove');
- const mockProject = {
- ...firstMockedProject,
- title: firstMockedProject.name,
- };
-
- beforeEach(() => {
- createWrapper({
- props: {
- items: [mockProject],
- },
- mountFn: mountExtended,
- });
- });
-
- it('renders the remove button', () => {
- const itemRemoveButton = findRemoveButton();
-
- expect(itemRemoveButton.exists()).toBe(true);
- expect(itemRemoveButton.attributes('title')).toBe('Remove');
- expect(itemRemoveButton.findComponent(GlIcon).props('name')).toBe('dash');
- });
-
- it('emits `remove-item` event with item param when remove button is clicked', () => {
- const itemRemoveButton = findRemoveButton();
-
- itemRemoveButton.vm.$emit(
- 'click',
- { stopPropagation: jest.fn(), preventDefault: jest.fn() },
- mockProject,
- );
-
- expect(wrapper.emitted('remove-item')).toEqual([[mockProject]]);
- });
- });
});
diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
index 9b726b620dd..21e5220edd9 100644
--- a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js
@@ -1,6 +1,8 @@
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue';
import PinnedSection from '~/super_sidebar/components/pinned_section.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import MenuSection from '~/super_sidebar/components/menu_section.vue';
import { PANELS_WITH_PINS } from '~/super_sidebar/constants';
import { sidebarData } from '../mock_data';
@@ -11,174 +13,142 @@ const menuItems = [
{ id: 4, title: 'Also with subitems', items: [{ id: 41, title: 'Subitem' }] },
];
-describe('SidebarMenu component', () => {
+describe('Sidebar Menu', () => {
let wrapper;
- const createWrapper = (mockData) => {
- wrapper = mountExtended(SidebarMenu, {
+ const createWrapper = (extraProps = {}) => {
+ wrapper = shallowMountExtended(SidebarMenu, {
propsData: {
- items: mockData.current_menu_items,
- pinnedItemIds: mockData.pinned_items,
- panelType: mockData.panel_type,
- updatePinsUrl: mockData.update_pins_url,
+ items: sidebarData.current_menu_items,
+ pinnedItemIds: sidebarData.pinned_items,
+ panelType: sidebarData.panel_type,
+ updatePinsUrl: sidebarData.update_pins_url,
+ ...extraProps,
},
});
};
+ const findStaticItemsSection = () => wrapper.findByTestId('static-items-section');
+ const findStaticItems = () => findStaticItemsSection().findAllComponents(NavItem);
const findPinnedSection = () => wrapper.findComponent(PinnedSection);
const findMainMenuSeparator = () => wrapper.findByTestId('main-menu-separator');
-
- describe('computed', () => {
- describe('supportsPins', () => {
- it('is true for the project sidebar', () => {
- createWrapper({ ...sidebarData, panel_type: 'project' });
- expect(wrapper.vm.supportsPins).toBe(true);
- });
-
- it('is true for the group sidebar', () => {
- createWrapper({ ...sidebarData, panel_type: 'group' });
- expect(wrapper.vm.supportsPins).toBe(true);
- });
-
- it('is false for any other sidebar', () => {
- createWrapper({ ...sidebarData, panel_type: 'your_work' });
- expect(wrapper.vm.supportsPins).toEqual(false);
+ const findNonStaticItemsSection = () => wrapper.findByTestId('non-static-items-section');
+ const findNonStaticItems = () => findNonStaticItemsSection().findAllComponents(NavItem);
+ const findNonStaticSectionItems = () =>
+ findNonStaticItemsSection().findAllComponents(MenuSection);
+
+ describe('Static section', () => {
+ describe('when the sidebar supports pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ items: menuItems,
+ panelType: PANELS_WITH_PINS[0],
+ });
});
- });
- describe('flatPinnableItems', () => {
- it('returns all subitems in a flat array', () => {
- createWrapper({ ...sidebarData, current_menu_items: menuItems });
- expect(wrapper.vm.flatPinnableItems).toEqual([
- { id: 21, title: 'Pinned subitem' },
- { id: 41, title: 'Subitem' },
+ it('renders static items section', () => {
+ expect(findStaticItemsSection().exists()).toBe(true);
+ expect(findStaticItems().wrappers.map((w) => w.props('item').title)).toEqual([
+ 'No subitems',
+ 'Empty subitems array',
]);
});
});
- describe('staticItems', () => {
- describe('when the sidebar supports pins', () => {
- beforeEach(() => {
- createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- panel_type: PANELS_WITH_PINS[0],
- });
+ describe('when the sidebar does not support pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ items: menuItems,
+ panelType: 'explore',
});
+ });
- it('makes everything that has no subitems a static item', () => {
- expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([
- 'No subitems',
- 'Empty subitems array',
- ]);
- });
+ it('does not render static items section', () => {
+ expect(findStaticItemsSection().exists()).toBe(false);
});
+ });
+ });
- describe('when the sidebar does not support pins', () => {
- beforeEach(() => {
- createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- panel_type: 'explore',
- });
- });
+ describe('Pinned section', () => {
+ it('is rendered in a project sidebar', () => {
+ createWrapper({ panelType: 'project' });
+ expect(findPinnedSection().exists()).toBe(true);
+ });
- it('returns an empty array', () => {
- expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([]);
- });
- });
+ it('is rendered in a group sidebar', () => {
+ createWrapper({ panelType: 'group' });
+ expect(findPinnedSection().exists()).toBe(true);
});
- describe('nonStaticItems', () => {
- describe('when the sidebar supports pins', () => {
- beforeEach(() => {
- createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- panel_type: PANELS_WITH_PINS[0],
- });
- });
+ it('is not rendered in other sidebars', () => {
+ createWrapper({ panelType: 'your_work' });
+ expect(findPinnedSection().exists()).toBe(false);
+ });
+ });
- it('keeps items that have subitems (aka "sections") as non-static', () => {
- expect(wrapper.vm.nonStaticItems.map((i) => i.title)).toEqual([
- 'With subitems',
- 'Also with subitems',
- ]);
+ describe('Non static items section', () => {
+ describe('when the sidebar supports pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ items: menuItems,
+ panelType: PANELS_WITH_PINS[0],
});
});
- describe('when the sidebar does not support pins', () => {
- beforeEach(() => {
- createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- panel_type: 'explore',
- });
- });
-
- it('keeps all items as non-static', () => {
- expect(wrapper.vm.nonStaticItems).toEqual(menuItems);
- });
+ it('keeps items that have subitems (aka "sections") as non-static', () => {
+ expect(findNonStaticSectionItems().wrappers.map((w) => w.props('item').title)).toEqual([
+ 'With subitems',
+ 'Also with subitems',
+ ]);
});
});
- describe('pinnedItems', () => {
- describe('when user has no pinned item ids stored', () => {
- beforeEach(() => {
- createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- pinned_items: [],
- });
- });
-
- it('returns an empty array', () => {
- expect(wrapper.vm.pinnedItems).toEqual([]);
+ describe('when the sidebar does not support pins', () => {
+ beforeEach(() => {
+ createWrapper({
+ items: menuItems,
+ panelType: 'explore',
});
});
- describe('when user has some pinned item ids stored', () => {
- beforeEach(() => {
- createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- pinned_items: [21],
- });
- });
-
- it('returns the items matching the pinned ids', () => {
- expect(wrapper.vm.pinnedItems).toEqual([{ id: 21, title: 'Pinned subitem' }]);
- });
+ it('keeps all items as non-static', () => {
+ expect(findNonStaticSectionItems().length + findNonStaticItems().length).toBe(
+ menuItems.length,
+ );
});
});
});
- describe('Menu separators', () => {
+ describe('Separators', () => {
it('should add the separator above pinned section', () => {
createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- panel_type: 'project',
+ items: menuItems,
+ panelType: 'project',
});
expect(findPinnedSection().props('separated')).toBe(true);
});
it('should add the separator above main menu items when there is a pinned section', () => {
createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- panel_type: PANELS_WITH_PINS[0],
+ items: menuItems,
+ panelType: PANELS_WITH_PINS[0],
});
expect(findMainMenuSeparator().exists()).toBe(true);
});
it('should NOT add the separator above main menu items when there is no pinned section', () => {
createWrapper({
- ...sidebarData,
- current_menu_items: menuItems,
- panel_type: 'explore',
+ items: menuItems,
+ panelType: 'explore',
});
expect(findMainMenuSeparator().exists()).toBe(false);
});
});
+
+ describe('ARIA attributes', () => {
+ it('adds aria-label attribute to nav element', () => {
+ createWrapper();
+ expect(wrapper.find('nav').attributes('aria-label')).toBe('Main navigation');
+ });
+ });
});
diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js
index 6878e724c65..ae48c0f2a75 100644
--- a/spec/frontend/super_sidebar/components/user_bar_spec.js
+++ b/spec/frontend/super_sidebar/components/user_bar_spec.js
@@ -5,6 +5,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { __ } from '~/locale';
import CreateMenu from '~/super_sidebar/components/create_menu.vue';
import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue';
+import BrandLogo from 'jh_else_ce/super_sidebar/components/brand_logo.vue';
import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue';
import Counter from '~/super_sidebar/components/counter.vue';
import UserBar from '~/super_sidebar/components/user_bar.vue';
@@ -23,7 +24,7 @@ describe('UserBar component', () => {
const findMRsCounter = () => findCounter(1);
const findTodosCounter = () => findCounter(2);
const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu);
- const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo');
+ const findBrandLogo = () => wrapper.findComponent(BrandLogo);
const findCollapseButton = () => wrapper.findByTestId('super-sidebar-collapse-button');
const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button');
const findSearchModal = () => wrapper.findComponent(SearchModal);
@@ -47,7 +48,6 @@ describe('UserBar component', () => {
sidebarData: { ...sidebarData, ...extraSidebarData },
},
provide: {
- rootPath: '/',
toggleNewNavEndpoint: '/-/profile/preferences',
isImpersonating: false,
...provideOverrides,
@@ -116,7 +116,7 @@ describe('UserBar component', () => {
it('renders branding logo', () => {
expect(findBrandLogo().exists()).toBe(true);
- expect(findBrandLogo().attributes('src')).toBe(sidebarData.logo_url);
+ expect(findBrandLogo().props('logoUrl')).toBe(sidebarData.logo_url);
});
it('does not render the "Stop impersonating" button', () => {
diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js
index cf8f650ec8f..f0f18ca9185 100644
--- a/spec/frontend/super_sidebar/components/user_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/user_menu_spec.js
@@ -1,5 +1,6 @@
import { GlAvatar, GlDisclosureDropdown } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
import UserMenu from '~/super_sidebar/components/user_menu.vue';
import UserNameGroup from '~/super_sidebar/components/user_name_group.vue';
import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
@@ -17,7 +18,9 @@ describe('UserMenu component', () => {
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const showDropdown = () => findDropdown().vm.$emit('shown');
- const createWrapper = (userDataChanges = {}) => {
+ const closeDropdownSpy = jest.fn();
+
+ const createWrapper = (userDataChanges = {}, stubs = {}) => {
wrapper = mountExtended(UserMenu, {
propsData: {
data: {
@@ -28,6 +31,7 @@ describe('UserMenu component', () => {
stubs: {
GlEmoji,
GlAvatar: true,
+ ...stubs,
},
provide: {
toggleNewNavEndpoint,
@@ -37,11 +41,12 @@ describe('UserMenu component', () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
};
- it('passes popper options to the dropdown', () => {
+ it('passes custom offset to the dropdown', () => {
createWrapper();
- expect(findDropdown().props('popperOptions')).toEqual({
- modifiers: [{ name: 'offset', options: { offset: [-211, 4] } }],
+ expect(findDropdown().props('dropdownOffset')).toEqual({
+ crossAxis: -211,
+ mainAxis: 4,
});
});
@@ -79,8 +84,8 @@ describe('UserMenu component', () => {
describe('User status item', () => {
let item;
- const setItem = ({ can_update, busy, customized } = {}) => {
- createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } });
+ const setItem = ({ can_update, busy, customized, stubs } = {}) => {
+ createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } }, stubs);
item = wrapper.findByTestId('status-item');
};
@@ -103,11 +108,19 @@ describe('UserMenu component', () => {
});
it('should close the dropdown when status modal opened', () => {
- setItem({ can_update: true });
- wrapper.vm.$refs.userDropdown.close = jest.fn();
- expect(wrapper.vm.$refs.userDropdown.close).not.toHaveBeenCalled();
+ setItem({
+ can_update: true,
+ stubs: {
+ GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
+ methods: {
+ close: closeDropdownSpy,
+ },
+ }),
+ },
+ });
+ expect(closeDropdownSpy).not.toHaveBeenCalled();
item.vm.$emit('action');
- expect(wrapper.vm.$refs.userDropdown.close).toHaveBeenCalled();
+ expect(closeDropdownSpy).toHaveBeenCalled();
});
describe('renders correct label', () => {
diff --git a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
index 909f4249e28..771d1f07fea 100644
--- a/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
+++ b/spec/frontend/super_sidebar/super_sidebar_collapsed_state_manager_spec.js
@@ -42,22 +42,19 @@ describe('Super Sidebar Collapsed State Manager', () => {
describe('toggleSuperSidebarCollapsed', () => {
it.each`
- collapsed | saveCookie | windowWidth | hasClass | superSidebarPeek | isPeekable
- ${true} | ${true} | ${xl} | ${true} | ${false} | ${false}
- ${true} | ${true} | ${xl} | ${true} | ${true} | ${true}
- ${true} | ${false} | ${xl} | ${true} | ${false} | ${false}
- ${true} | ${true} | ${sm} | ${true} | ${false} | ${false}
- ${true} | ${false} | ${sm} | ${true} | ${false} | ${false}
- ${false} | ${true} | ${xl} | ${false} | ${false} | ${false}
- ${false} | ${true} | ${xl} | ${false} | ${true} | ${false}
- ${false} | ${false} | ${xl} | ${false} | ${false} | ${false}
- ${false} | ${true} | ${sm} | ${false} | ${false} | ${false}
- ${false} | ${false} | ${sm} | ${false} | ${false} | ${false}
+ collapsed | saveCookie | windowWidth | hasClass | isPeekable
+ ${true} | ${true} | ${xl} | ${true} | ${true}
+ ${true} | ${false} | ${xl} | ${true} | ${true}
+ ${true} | ${true} | ${sm} | ${true} | ${true}
+ ${true} | ${false} | ${sm} | ${true} | ${true}
+ ${false} | ${true} | ${xl} | ${false} | ${false}
+ ${false} | ${false} | ${xl} | ${false} | ${false}
+ ${false} | ${true} | ${sm} | ${false} | ${false}
+ ${false} | ${false} | ${sm} | ${false} | ${false}
`(
'when collapsed is $collapsed, saveCookie is $saveCookie, and windowWidth is $windowWidth then page class contains `page-with-super-sidebar-collapsed` is $hasClass',
- ({ collapsed, saveCookie, windowWidth, hasClass, superSidebarPeek, isPeekable }) => {
+ ({ collapsed, saveCookie, windowWidth, hasClass, isPeekable }) => {
jest.spyOn(bp, 'windowWidth').mockReturnValue(windowWidth);
- gon.features = { superSidebarPeek };
toggleSuperSidebarCollapsed(collapsed, saveCookie);
diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js
index 1d61d38a488..7c127fd7124 100644
--- a/spec/frontend/tabs/index_spec.js
+++ b/spec/frontend/tabs/index_spec.js
@@ -1,12 +1,11 @@
+import htmlTabs from 'test_fixtures/tabs/tabs.html';
import { GlTabsBehavior, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH } from '~/tabs';
import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants';
import { getLocationHash } from '~/lib/utils/url_utility';
import { NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils';
-import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import setWindowLocation from 'helpers/set_window_location_helper';
-const tabsFixture = getFixture('tabs/tabs.html');
-
global.CSS = {
escape: (val) => val,
};
@@ -107,7 +106,7 @@ describe('GlTabsBehavior', () => {
});
beforeEach(() => {
- setHTMLFixture(tabsFixture);
+ setHTMLFixture(htmlTabs);
const tabsEl = findByTestId('tabs');
tabShownEventSpy = jest.fn();
@@ -247,7 +246,7 @@ describe('GlTabsBehavior', () => {
describe('using aria-controls instead of href to link tabs to panels', () => {
beforeEach(() => {
- setHTMLFixture(tabsFixture);
+ setHTMLFixture(htmlTabs);
const tabsEl = findByTestId('tabs');
['foo', 'bar', 'qux'].forEach((name) => {
@@ -279,7 +278,7 @@ describe('GlTabsBehavior', () => {
let tabsEl;
beforeEach(() => {
- setHTMLFixture(tabsFixture);
+ setHTMLFixture(htmlTabs);
tabsEl = findByTestId('tabs');
});
diff --git a/spec/frontend/tags/components/sort_dropdown_spec.js b/spec/frontend/tags/components/sort_dropdown_spec.js
index e0ff370d313..ebf79c93f9b 100644
--- a/spec/frontend/tags/components/sort_dropdown_spec.js
+++ b/spec/frontend/tags/components/sort_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
+import { GlListboxItem, GlSearchBoxByClick } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import * as urlUtils from '~/lib/utils/url_utility';
@@ -39,9 +39,9 @@ describe('Tags sort dropdown', () => {
});
it('should have a sort order dropdown', () => {
- const branchesDropdown = findTagsDropdown();
+ const tagsDropdown = findTagsDropdown();
- expect(branchesDropdown.exists()).toBe(true);
+ expect(tagsDropdown.exists()).toBe(true);
});
});
@@ -63,9 +63,9 @@ describe('Tags sort dropdown', () => {
});
it('should send a sort parameter', () => {
- const sortDropdownItems = findTagsDropdown().findAllComponents(GlDropdownItem).at(0);
+ const sortDropdownItem = findTagsDropdown().findAllComponents(GlListboxItem).at(0);
- sortDropdownItems.vm.$emit('click');
+ sortDropdownItem.trigger('click');
expect(urlUtils.visitUrl).toHaveBeenCalledWith(
'/root/ci-cd-project-demo/-/tags?sort=name_asc',
diff --git a/spec/frontend/usage_quotas/components/sectioned_percentage_bar_spec.js b/spec/frontend/usage_quotas/components/sectioned_percentage_bar_spec.js
new file mode 100644
index 00000000000..6b022172d46
--- /dev/null
+++ b/spec/frontend/usage_quotas/components/sectioned_percentage_bar_spec.js
@@ -0,0 +1,101 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import SectionedPercentageBar from '~/usage_quotas/components/sectioned_percentage_bar.vue';
+
+describe('SectionedPercentageBar', () => {
+ let wrapper;
+
+ const PERCENTAGE_BAR_SECTION_TESTID_PREFIX = 'percentage-bar-section-';
+ const PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX = 'percentage-bar-legend-section-';
+ const LEGEND_SECTION_COLOR_TESTID = 'legend-section-color';
+ const SECTION_1 = 'section1';
+ const SECTION_2 = 'section2';
+ const SECTION_3 = 'section3';
+ const SECTION_4 = 'section4';
+
+ const defaultPropsData = {
+ sections: [
+ {
+ id: SECTION_1,
+ label: 'Section 1',
+ value: 2000,
+ formattedValue: '1.95 KiB',
+ },
+ {
+ id: SECTION_2,
+ label: 'Section 2',
+ value: 4000,
+ formattedValue: '3.90 KiB',
+ },
+ {
+ id: SECTION_3,
+ label: 'Section 3',
+ value: 3000,
+ formattedValue: '2.93 KiB',
+ },
+ {
+ id: SECTION_4,
+ label: 'Section 4',
+ value: 5000,
+ formattedValue: '4.88 KiB',
+ },
+ ],
+ };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(SectionedPercentageBar, {
+ propsData: { ...defaultPropsData, ...propsData },
+ });
+ };
+
+ it('displays sectioned percentage bar', () => {
+ createComponent();
+
+ const section1 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_1);
+ const section2 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_2);
+ const section3 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_3);
+ const section4 = wrapper.findByTestId(PERCENTAGE_BAR_SECTION_TESTID_PREFIX + SECTION_4);
+
+ expect(section1.attributes('style')).toBe(
+ 'background-color: rgb(97, 122, 226); width: 14.2857%;',
+ );
+ expect(section2.attributes('style')).toBe(
+ 'background-color: rgb(177, 79, 24); width: 28.5714%;',
+ );
+ expect(section3.attributes('style')).toBe(
+ 'background-color: rgb(0, 144, 177); width: 21.4286%;',
+ );
+ expect(section4.attributes('style')).toBe(
+ 'background-color: rgb(78, 127, 14); width: 35.7143%;',
+ );
+ expect(section1.text()).toMatchInterpolatedText('Section 1 14.3%');
+ expect(section2.text()).toMatchInterpolatedText('Section 2 28.6%');
+ expect(section3.text()).toMatchInterpolatedText('Section 3 21.4%');
+ expect(section4.text()).toMatchInterpolatedText('Section 4 35.7%');
+ });
+
+ it('displays sectioned percentage bar legend', () => {
+ createComponent();
+
+ const section1 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_1);
+ const section2 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_2);
+ const section3 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_3);
+ const section4 = wrapper.findByTestId(PERCENTAGE_BAR_LEGEND_SECTION_TESTID_PREFIX + SECTION_4);
+
+ expect(section1.text()).toMatchInterpolatedText('Section 1 1.95 KiB');
+ expect(section2.text()).toMatchInterpolatedText('Section 2 3.90 KiB');
+ expect(section3.text()).toMatchInterpolatedText('Section 3 2.93 KiB');
+ expect(section4.text()).toMatchInterpolatedText('Section 4 4.88 KiB');
+ expect(
+ section1.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'),
+ ).toBe('background-color: rgb(97, 122, 226);');
+ expect(
+ section2.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'),
+ ).toBe('background-color: rgb(177, 79, 24);');
+ expect(
+ section3.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'),
+ ).toBe('background-color: rgb(0, 144, 177);');
+ expect(
+ section4.find(`[data-testid="${LEGEND_SECTION_COLOR_TESTID}"]`).attributes('style'),
+ ).toBe('background-color: rgb(78, 127, 14);');
+ });
+});
diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js
index 15758c94436..37fc9602315 100644
--- a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js
+++ b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js
@@ -26,7 +26,7 @@ describe('ProjectStorageDetail', () => {
);
};
- const generateStorageType = (id = 'buildArtifactsSize') => {
+ const generateStorageType = (id = 'buildArtifacts') => {
return {
storageType: {
id,
@@ -56,7 +56,7 @@ describe('ProjectStorageDetail', () => {
expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description);
expect(wrapper.findByTestId(`${id}-icon`).props('name')).toBe(id);
expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe(
- projectHelpLinks[id.replace(`Size`, ``)],
+ projectHelpLinks[id],
);
},
);
@@ -74,6 +74,14 @@ describe('ProjectStorageDetail', () => {
});
});
+ describe('with details links', () => {
+ it.each(storageTypes)('each $storageType.id', (item) => {
+ const shouldExist = Boolean(item.storageType.detailsPath && item.value);
+ const detailsLink = wrapper.findByTestId(`${item.storageType.id}-details-link`);
+ expect(detailsLink.exists()).toBe(shouldExist);
+ });
+ });
+
describe('without storage types', () => {
beforeEach(() => {
createComponent({ storageTypes: [] });
diff --git a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js
index ebe4c4b7f4e..92c24400e76 100644
--- a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js
+++ b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js
@@ -18,11 +18,11 @@ describe('StorageTypeIcon', () => {
describe('rendering icon', () => {
it.each`
expected | provided
- ${'doc-image'} | ${'lfsObjectsSize'}
- ${'snippet'} | ${'snippetsSize'}
- ${'infrastructure-registry'} | ${'repositorySize'}
- ${'package'} | ${'packagesSize'}
- ${'disk'} | ${'wikiSize'}
+ ${'doc-image'} | ${'lfsObjects'}
+ ${'snippet'} | ${'snippets'}
+ ${'infrastructure-registry'} | ${'repository'}
+ ${'package'} | ${'packages'}
+ ${'disk'} | ${'wiki'}
${'disk'} | ${'anything-else'}
`(
'renders icon with name of $expected when name prop is $provided',
diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js
index b4b02f77b52..8a7f941151b 100644
--- a/spec/frontend/usage_quotas/storage/mock_data.js
+++ b/spec/frontend/usage_quotas/storage/mock_data.js
@@ -9,25 +9,27 @@ export const projectData = {
storageTypes: [
{
storageType: {
- id: 'containerRegistrySize',
+ id: 'containerRegistry',
name: 'Container Registry',
description: 'Gitlab-integrated Docker Container Registry for storing Docker Images.',
helpPath: '/container_registry',
+ detailsPath: 'http://localhost/frontend-fixtures/builds-project/container_registry',
},
- value: 3_900_000,
+ value: 3900000,
},
{
storageType: {
- id: 'buildArtifactsSize',
+ id: 'buildArtifacts',
name: 'Job artifacts',
description: 'Job artifacts created by CI/CD.',
helpPath: '/build-artifacts',
+ detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/artifacts',
},
value: 400000,
},
{
storageType: {
- id: 'pipelineArtifactsSize',
+ id: 'pipelineArtifacts',
name: 'Pipeline artifacts',
description: 'Pipeline artifacts created by CI/CD.',
helpPath: '/pipeline-artifacts',
@@ -36,7 +38,7 @@ export const projectData = {
},
{
storageType: {
- id: 'lfsObjectsSize',
+ id: 'lfsObjects',
name: 'LFS',
description: 'Audio samples, videos, datasets, and graphics.',
helpPath: '/lsf-objects',
@@ -45,37 +47,41 @@ export const projectData = {
},
{
storageType: {
- id: 'packagesSize',
+ id: 'packages',
name: 'Packages',
description: 'Code packages and container images.',
helpPath: '/packages',
+ detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/packages',
},
value: 3800000,
},
{
storageType: {
- id: 'repositorySize',
+ id: 'repository',
name: 'Repository',
description: 'Git repository.',
helpPath: '/repository',
+ detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/tree/master',
},
value: 3900000,
},
{
storageType: {
- id: 'snippetsSize',
+ id: 'snippets',
name: 'Snippets',
description: 'Shared bits of code and text.',
helpPath: '/snippets',
+ detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/snippets',
},
value: 0,
},
{
storageType: {
- id: 'wikiSize',
+ id: 'wiki',
name: 'Wiki',
description: 'Wiki content.',
helpPath: '/wiki',
+ detailsPath: 'http://localhost/frontend-fixtures/builds-project/-/wikis/pages',
},
value: 300000,
},
diff --git a/spec/frontend/usage_quotas/storage/utils_spec.js b/spec/frontend/usage_quotas/storage/utils_spec.js
index 8fdd307c008..e3a271adc57 100644
--- a/spec/frontend/usage_quotas/storage/utils_spec.js
+++ b/spec/frontend/usage_quotas/storage/utils_spec.js
@@ -12,7 +12,10 @@ import {
} from './mock_data';
describe('getStorageTypesFromProjectStatistics', () => {
- const projectStatistics = mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics;
+ const {
+ statistics: projectStatistics,
+ statisticsDetailsPaths,
+ } = mockGetProjectStorageStatisticsGraphQLResponse.data.project;
describe('matches project statistics value with matching storage type', () => {
const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics);
@@ -22,29 +25,39 @@ describe('getStorageTypesFromProjectStatistics', () => {
storageType: expect.objectContaining({
id,
}),
- value: projectStatistics[id],
+ value: projectStatistics[`${id}Size`],
});
});
});
it('adds helpPath to a relevant type', () => {
- const trimTypeId = (id) => id.replace('Size', '');
const helpLinks = PROJECT_STORAGE_TYPES.reduce((acc, { id }) => {
- const key = trimTypeId(id);
return {
...acc,
- [key]: `url://${id}`,
+ [id]: `url://${id}`,
};
}, {});
const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks);
typesWithStats.forEach((type) => {
- const key = trimTypeId(type.storageType.id);
+ const key = type.storageType.id;
expect(type.storageType.helpPath).toBe(helpLinks[key]);
});
});
+
+ it('adds details page path', () => {
+ const typesWithStats = getStorageTypesFromProjectStatistics(
+ projectStatistics,
+ {},
+ statisticsDetailsPaths,
+ );
+ typesWithStats.forEach((type) => {
+ expect(type.storageType.detailsPath).toBe(statisticsDetailsPaths[type.storageType.id]);
+ });
+ });
});
+
describe('parseGetProjectStorageResults', () => {
it('parses project statistics correctly', () => {
expect(
diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js
index 3346735055d..6f39eb9a118 100644
--- a/spec/frontend/user_popovers_spec.js
+++ b/spec/frontend/user_popovers_spec.js
@@ -121,6 +121,8 @@ describe('User Popovers', () => {
expect(findPopovers().length).toBe(0);
});
+ // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/18442
+ // Remove as @all is deprecated.
it('does not initialize the popovers for @all references', () => {
const [projectLink] = Array.from(document.querySelectorAll('.js-user-link[data-project]'));
diff --git a/spec/frontend/users_select/index_spec.js b/spec/frontend/users_select/index_spec.js
index 3757e63c4f9..dc6918ee543 100644
--- a/spec/frontend/users_select/index_spec.js
+++ b/spec/frontend/users_select/index_spec.js
@@ -1,4 +1,5 @@
import { escape } from 'lodash';
+import htmlCeMrSingleAssignees from 'test_fixtures/merge_requests/merge_request_with_single_assignee_feature.html';
import UsersSelect from '~/users_select/index';
import {
createInputsModelExpectation,
@@ -15,9 +16,7 @@ import {
} from './test_helper';
describe('~/users_select/index', () => {
- const context = createTestContext({
- fixturePath: 'merge_requests/merge_request_with_single_assignee_feature.html',
- });
+ const context = createTestContext({ fixture: htmlCeMrSingleAssignees });
beforeEach(() => {
context.setup();
diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js
index 6fb3436100f..b38400446a9 100644
--- a/spec/frontend/users_select/test_helper.js
+++ b/spec/frontend/users_select/test_helper.js
@@ -1,18 +1,16 @@
import MockAdapter from 'axios-mock-adapter';
import { memoize, cloneDeep } from 'lodash';
import usersFixture from 'test_fixtures/autocomplete/users.json';
-import { getFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import UsersSelect from '~/users_select';
// fixtures -------------------------------------------------------------------
-const getUserSearchHTML = memoize((fixturePath) => {
- const html = getFixture(fixturePath);
+const getUserSearchHTML = memoize((fixture) => {
const parser = new DOMParser();
- const el = parser.parseFromString(html, 'text/html').querySelector('.assignee');
+ const el = parser.parseFromString(fixture, 'text/html').querySelector('.assignee');
return el.outerHTML;
});
@@ -22,13 +20,13 @@ const getUsersFixture = () => usersFixture;
export const getUsersFixtureAt = (idx) => getUsersFixture()[idx];
// test context ---------------------------------------------------------------
-export const createTestContext = ({ fixturePath }) => {
+export const createTestContext = ({ fixture }) => {
let mock = null;
let subject = null;
const setup = () => {
const rootEl = document.createElement('div');
- rootEl.innerHTML = getUserSearchHTML(fixturePath);
+ rootEl.innerHTML = getUserSearchHTML(fixture);
document.body.appendChild(rootEl);
mock = new MockAdapter(axios);
diff --git a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js
index a07a60438fb..2aed037be6f 100644
--- a/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js
@@ -57,13 +57,10 @@ describe('MRWidget approvals', () => {
const apolloProvider = createMockApollo(requestHandlers);
const provide = {
...options.provide,
- glFeatures: {
- realtimeApprovals: options.provide?.glFeatures?.realtimeApprovals || false,
- },
};
- subscriptionHandlers.forEach(([document, stream]) => {
- apolloProvider.defaultClient.setRequestHandler(document, stream);
+ subscriptionHandlers.forEach(([query, stream]) => {
+ apolloProvider.defaultClient.setRequestHandler(query, stream);
});
wrapper = shallowMount(Approvals, {
@@ -246,10 +243,6 @@ describe('MRWidget approvals', () => {
it('calls service approve', () => {
expect(service.approveMergeRequest).toHaveBeenCalled();
});
-
- it('emits to eventHub', () => {
- expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
- });
});
describe('and error', () => {
@@ -300,10 +293,6 @@ describe('MRWidget approvals', () => {
it('calls service unapprove', () => {
expect(service.unapproveMergeRequest).toHaveBeenCalled();
});
-
- it('emits to eventHub', () => {
- expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
- });
});
describe('and error', () => {
@@ -386,42 +375,21 @@ describe('MRWidget approvals', () => {
});
describe('realtime approvals update', () => {
- describe('realtime_approvals feature disabled', () => {
- beforeEach(() => {
- jest.spyOn(console, 'warn').mockImplementation();
- createComponent();
- });
+ const subscriptionApproval = { approved: true };
+ const subscriptionResponse = {
+ data: { mergeRequestApprovalStateUpdated: subscriptionApproval },
+ };
- it('does not subscribe to the approvals update socket', () => {
- expect(mr.setApprovals).not.toHaveBeenCalled();
- mockedSubscription.next({});
- // eslint-disable-next-line no-console
- expect(console.warn).toHaveBeenCalledWith(
- expect.stringMatching('Mock subscription has no observer, this will have no effect'),
- );
- expect(mr.setApprovals).not.toHaveBeenCalled();
- });
+ beforeEach(() => {
+ createComponent();
});
- describe('realtime_approvals feature enabled', () => {
- const subscriptionApproval = { approved: true };
- const subscriptionResponse = {
- data: { mergeRequestApprovalStateUpdated: subscriptionApproval },
- };
-
- beforeEach(() => {
- createComponent({
- provide: { glFeatures: { realtimeApprovals: true } },
- });
- });
-
- it('updates approvals when the subscription data is streamed to the Apollo client', () => {
- expect(mr.setApprovals).not.toHaveBeenCalled();
+ it('updates approvals when the subscription data is streamed to the Apollo client', () => {
+ expect(mr.setApprovals).not.toHaveBeenCalled();
- mockedSubscription.next(subscriptionResponse);
+ mockedSubscription.next(subscriptionResponse);
- expect(mr.setApprovals).toHaveBeenCalledWith(subscriptionApproval);
- });
+ expect(mr.setApprovals).toHaveBeenCalledWith(subscriptionApproval);
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js
index c8fa1399dcb..016eac05727 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js
@@ -4,26 +4,15 @@ import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing
describe('NothingToMerge', () => {
let wrapper;
- const newBlobPath = '/foo';
- const defaultProps = {
- mr: {
- newBlobPath,
- },
- };
-
- const createComponent = (props = defaultProps) => {
+ const createComponent = () => {
wrapper = shallowMountExtended(NothingToMerge, {
- propsData: {
- ...props,
- },
stubs: {
GlSprintf,
},
});
};
- const findCreateButton = () => wrapper.findByTestId('createFileButton');
const findNothingToMergeTextBody = () => wrapper.findByTestId('nothing-to-merge-body');
describe('With Blob link', () => {
@@ -32,27 +21,10 @@ describe('NothingToMerge', () => {
});
it('shows the component with the correct text and highlights', () => {
- expect(wrapper.text()).toContain('This merge request contains no changes.');
+ expect(wrapper.text()).toContain('Merge request contains no changes');
expect(findNothingToMergeTextBody().text()).toContain(
- 'Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch.',
+ 'Use merge requests to propose changes to your project and discuss them with your team. To make changes, use the Code dropdown list above, then test them with CI/CD before merging.',
);
});
-
- it('shows the Create file button with the correct attributes', () => {
- const createButton = findCreateButton();
-
- expect(createButton.exists()).toBe(true);
- expect(createButton.attributes('href')).toBe(newBlobPath);
- });
- });
-
- describe('Without Blob link', () => {
- beforeEach(() => {
- createComponent({ mr: { newBlobPath: '' } });
- });
-
- it('does not show the Create file button', () => {
- expect(findCreateButton().exists()).toBe(false);
- });
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js
new file mode 100644
index 00000000000..a54591cdb16
--- /dev/null
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js
@@ -0,0 +1,29 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon } from '@gitlab/ui';
+
+import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue';
+import { MR_WIDGET_PREPARING_ASYNCHRONOUSLY } from '~/vue_merge_request_widget/i18n';
+
+function createComponent() {
+ return shallowMount(Preparing);
+}
+
+function findSpinnerIcon(wrapper) {
+ return wrapper.findComponent(GlLoadingIcon);
+}
+
+describe('Preparing', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('should render a spinner', () => {
+ expect(findSpinnerIcon(wrapper).exists()).toBe(true);
+ });
+
+ it('should render the correct text', () => {
+ expect(wrapper.text()).toBe(MR_WIDGET_PREPARING_ASYNCHRONOUSLY);
+ });
+});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js
index 19825318a4f..d36ad4983c6 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions_spec.js
@@ -4,19 +4,12 @@ import { removeBreakLine } from 'helpers/text_helper';
import notesEventHub from '~/notes/event_hub';
import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue';
-function createComponent({ path = '', propsData = {}, provide = {} } = {}) {
+function createComponent({ path = '' } = {}) {
return mount(UnresolvedDiscussions, {
propsData: {
mr: {
createIssueToResolveDiscussionsPath: path,
},
- ...propsData,
- },
- provide: {
- glFeatures: {
- hideCreateIssueResolveAll: false,
- },
- ...provide,
},
});
}
@@ -46,11 +39,7 @@ describe('UnresolvedDiscussions', () => {
expect(text).toContain('Merge blocked:');
expect(text).toContain('all threads must be resolved.');
- expect(wrapper.element.innerText).toContain('Resolve all with new issue');
expect(wrapper.element.innerText).toContain('Go to first unresolved thread');
- expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual(
- TEST_HOST,
- );
});
});
@@ -60,26 +49,7 @@ describe('UnresolvedDiscussions', () => {
expect(text).toContain('Merge blocked:');
expect(text).toContain('all threads must be resolved.');
- expect(wrapper.element.innerText).not.toContain('Resolve all with new issue');
expect(wrapper.element.innerText).toContain('Go to first unresolved thread');
- expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null);
- });
- });
-
- describe('when `hideCreateIssueResolveAll` is enabled', () => {
- beforeEach(() => {
- wrapper = createComponent({
- path: TEST_HOST,
- provide: {
- glFeatures: {
- hideCreateIssueResolveAll: true,
- },
- },
- });
- });
-
- it('do not show jump to first button', () => {
- expect(wrapper.text()).not.toContain('Create issue to resolve all threads');
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js
index 8dbee9b370c..bf318cd6b88 100644
--- a/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/widget/app_spec.js
@@ -12,8 +12,8 @@ describe('MR Widget App', () => {
});
};
- it('does not mount if widgets array is empty', () => {
+ it('renders widget container', () => {
createComponent();
- expect(wrapper.findByTestId('mr-widget-app').exists()).toBe(false);
+ expect(wrapper.findByTestId('mr-widget-app').exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js
index 785515ae846..2aa4e7c4841 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_action_button_spec.js
@@ -5,6 +5,7 @@ import {
RUNNING,
DEPLOYING,
REDEPLOYING,
+ WILL_DEPLOY,
} from '~/vue_merge_request_widget/components/deployment/constants';
import DeploymentActionButton from '~/vue_merge_request_widget/components/deployment/deployment_action_button.vue';
import { actionButtonMocks } from './deployment_mock_data';
@@ -118,4 +119,20 @@ describe('Deployment action button', () => {
expect(wrapper.findComponent(GlButton).props('disabled')).toBe(false);
});
});
+
+ describe('when the deployment status is will_deploy', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ ...baseProps,
+ actionInProgress: actionButtonMocks[REDEPLOYING].actionName,
+ computedDeploymentStatus: WILL_DEPLOY,
+ },
+ });
+ });
+ it('is disabled and shows the loading icon', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.findComponent(GlButton).props('disabled')).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js
index f2b78dedf3a..b901b80e8bf 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_actions_spec.js
@@ -9,6 +9,7 @@ import {
FAILED,
DEPLOYING,
REDEPLOYING,
+ SUCCESS,
STOPPING,
} from '~/vue_merge_request_widget/components/deployment/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
@@ -35,7 +36,8 @@ describe('DeploymentAction component', () => {
const findStopButton = () => wrapper.find('.js-stop-env');
const findDeployButton = () => wrapper.find('.js-manual-deploy-action');
- const findRedeployButton = () => wrapper.find('.js-manual-redeploy-action');
+ const findManualRedeployButton = () => wrapper.find('.js-manual-redeploy-action');
+ const findRedeployButton = () => wrapper.find('.js-redeploy-action');
beforeEach(() => {
executeActionSpy = jest.spyOn(MRWidgetService, 'executeInlineAction');
@@ -79,17 +81,17 @@ describe('DeploymentAction component', () => {
describe('when there is no retry_path in details', () => {
it('the manual redeploy button does not appear', () => {
- expect(findRedeployButton().exists()).toBe(false);
+ expect(findManualRedeployButton().exists()).toBe(false);
});
});
});
describe('when conditions are met', () => {
describe.each`
- configConst | computedDeploymentStatus | displayConditionChanges | finderFn | endpoint
- ${STOPPING} | ${CREATED} | ${{}} | ${findStopButton} | ${deploymentMockData.stop_url}
- ${DEPLOYING} | ${MANUAL_DEPLOY} | ${playDetails} | ${findDeployButton} | ${playDetails.playable_build.play_path}
- ${REDEPLOYING} | ${FAILED} | ${retryDetails} | ${findRedeployButton} | ${retryDetails.playable_build.retry_path}
+ configConst | computedDeploymentStatus | displayConditionChanges | finderFn | endpoint
+ ${STOPPING} | ${CREATED} | ${{}} | ${findStopButton} | ${deploymentMockData.stop_url}
+ ${DEPLOYING} | ${MANUAL_DEPLOY} | ${playDetails} | ${findDeployButton} | ${playDetails.playable_build.play_path}
+ ${REDEPLOYING} | ${FAILED} | ${retryDetails} | ${findManualRedeployButton} | ${retryDetails.playable_build.retry_path}
`(
'$configConst action',
({ configConst, computedDeploymentStatus, displayConditionChanges, finderFn, endpoint }) => {
@@ -231,4 +233,141 @@ describe('DeploymentAction component', () => {
},
);
});
+
+ describe('with the reviewAppsRedeployMrWidget feature flag turned on', () => {
+ beforeEach(() => {
+ factory({
+ propsData: {
+ computedDeploymentStatus: SUCCESS,
+ deployment: {
+ ...deploymentMockData,
+ details: undefined,
+ retry_url: retryDetails.playable_build.retry_path,
+ environment_available: false,
+ },
+ },
+ provide: {
+ glFeatures: {
+ reviewAppsRedeployMrWidget: true,
+ },
+ },
+ });
+ });
+
+ it('should display the redeploy button', () => {
+ expect(findRedeployButton().exists()).toBe(true);
+ });
+
+ describe('when the redeploy button is clicked', () => {
+ describe('should show a confirm dialog but not call executeInlineAction when declined', () => {
+ beforeEach(() => {
+ executeActionSpy.mockResolvedValueOnce();
+ confirmAction.mockResolvedValueOnce(false);
+ findRedeployButton().trigger('click');
+ });
+
+ it('should show the confirm dialog', () => {
+ expect(confirmAction).toHaveBeenCalled();
+ expect(confirmAction).toHaveBeenCalledWith(
+ actionButtonMocks[REDEPLOYING].confirmMessage,
+ {
+ primaryBtnVariant: actionButtonMocks[REDEPLOYING].buttonVariant,
+ primaryBtnText: actionButtonMocks[REDEPLOYING].buttonText,
+ },
+ );
+ });
+
+ it('should not execute the action', () => {
+ expect(MRWidgetService.executeInlineAction).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('should show a confirm dialog and call executeInlineAction when accepted', () => {
+ beforeEach(() => {
+ executeActionSpy.mockResolvedValueOnce();
+ confirmAction.mockResolvedValueOnce(true);
+ findRedeployButton().trigger('click');
+ });
+
+ it('should show the confirm dialog', () => {
+ expect(confirmAction).toHaveBeenCalled();
+ expect(confirmAction).toHaveBeenCalledWith(
+ actionButtonMocks[REDEPLOYING].confirmMessage,
+ {
+ primaryBtnVariant: actionButtonMocks[REDEPLOYING].buttonVariant,
+ primaryBtnText: actionButtonMocks[REDEPLOYING].buttonText,
+ },
+ );
+ });
+
+ it('should not throw an error', () => {
+ expect(createAlert).not.toHaveBeenCalled();
+ });
+
+ describe('response includes redirect_url', () => {
+ const url = '/root/example';
+ beforeEach(async () => {
+ executeActionSpy.mockResolvedValueOnce({
+ data: { redirect_url: url },
+ });
+
+ await waitForPromises();
+
+ confirmAction.mockResolvedValueOnce(true);
+ findRedeployButton().trigger('click');
+ });
+
+ it('does not call visit url', () => {
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('it should call the executeAction method', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm, 'executeAction').mockImplementation();
+ jest.spyOn(eventHub, '$emit');
+
+ await waitForPromises();
+
+ confirmAction.mockResolvedValueOnce(true);
+ findRedeployButton().trigger('click');
+ });
+
+ it('calls with the expected arguments', () => {
+ expect(wrapper.vm.executeAction).toHaveBeenCalled();
+ expect(wrapper.vm.executeAction).toHaveBeenCalledWith(
+ retryDetails.playable_build.retry_path,
+ actionButtonMocks[REDEPLOYING],
+ );
+ });
+
+ it('emits the FetchDeployments event', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments');
+ });
+ });
+
+ describe('when executeInlineAction errors', () => {
+ beforeEach(async () => {
+ executeActionSpy.mockRejectedValueOnce();
+ jest.spyOn(eventHub, '$emit');
+
+ await waitForPromises();
+
+ confirmAction.mockResolvedValueOnce(true);
+ findRedeployButton().trigger('click');
+ });
+
+ it('should call createAlert with error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: actionButtonMocks[REDEPLOYING].errorMessage,
+ });
+ });
+
+ it('emits the FetchDeployments event', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('FetchDeployments');
+ });
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js b/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js
index e98b1160ae4..374fe4e1b95 100644
--- a/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js
+++ b/spec/frontend/vue_merge_request_widget/deployment/deployment_mock_data.js
@@ -43,6 +43,7 @@ const deploymentMockData = {
external_url_formatted: 'gitlab',
deployed_at: '2017-03-22T22:44:42.258Z',
deployed_at_formatted: 'Mar 22, 2017 10:44pm',
+ environment_available: true,
details: {},
status: SUCCESS,
changes: [
diff --git a/spec/frontend/vue_merge_request_widget/mock_data.js b/spec/frontend/vue_merge_request_widget/mock_data.js
index 46e1919b0ea..47143bb2bb8 100644
--- a/spec/frontend/vue_merge_request_widget/mock_data.js
+++ b/spec/frontend/vue_merge_request_widget/mock_data.js
@@ -427,6 +427,7 @@ export const mockStore = {
external_url: 'https://fake.com',
external_url_formatted: 'https://fake.com',
status: SUCCESS,
+ environment_available: true,
},
{
id: 1,
@@ -434,6 +435,7 @@ export const mockStore = {
external_url: 'https://fake.com',
external_url_formatted: 'https://fake.com',
status: SUCCESS,
+ environment_available: true,
},
],
postMergeDeployments: [
diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
index 64fb2806447..0533471bece 100644
--- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
@@ -3,13 +3,13 @@ import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client';
import * as Sentry from '@sentry/browser';
import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json';
import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json';
import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data';
import api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK, HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
@@ -25,12 +25,16 @@ import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget
import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
+import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue';
+import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue';
import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue';
import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
-import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql';
import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql';
+import getStateSubscription from '~/vue_merge_request_widget/queries/get_state.subscription.graphql';
+import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import approvalsQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql';
+import approvedBySubscription from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql';
import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql';
import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql';
import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data';
@@ -67,13 +71,11 @@ describe('MrWidgetOptions', () => {
let queryResponse;
let wrapper;
let mock;
+ let stateSubscription;
const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits';
- const findWidgetContainer = () => wrapper.findComponent(WidgetContainer);
- const findExtensionToggleButton = () =>
- wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]');
- const findExtensionLink = (linkHref) =>
- wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`);
+ const findApprovalsWidget = () => wrapper.findComponent(Approvals);
+ const findPreparingWidget = () => wrapper.findComponent(Preparing);
beforeEach(() => {
gl.mrWidgetData = { ...mockData };
@@ -94,8 +96,7 @@ describe('MrWidgetOptions', () => {
});
const createComponent = (mrData = mockData, options = {}, data = {}, fullMount = true) => {
- const mounting = fullMount ? mount : shallowMount;
-
+ const mockedApprovalsSubscription = createMockApolloSubscription();
queryResponse = {
data: {
project: {
@@ -103,11 +104,45 @@ describe('MrWidgetOptions', () => {
mergeRequest: {
...getStateQueryResponse.data.project.mergeRequest,
mergeError: mrData.mergeError || null,
+ detailedMergeStatus:
+ mrData.detailedMergeStatus ||
+ getStateQueryResponse.data.project.mergeRequest.detailedMergeStatus,
},
},
},
};
stateQueryHandler = jest.fn().mockResolvedValue(queryResponse);
+ stateSubscription = createMockApolloSubscription();
+
+ const mounting = fullMount ? mount : shallowMount;
+ const queryHandlers = [
+ [approvalsQuery, jest.fn().mockResolvedValue(approvedByCurrentUser)],
+ [getStateQuery, stateQueryHandler],
+ [readyToMergeQuery, jest.fn().mockResolvedValue(readyToMergeResponse)],
+ [
+ userPermissionsQuery,
+ jest.fn().mockResolvedValue({
+ data: { project: { mergeRequest: { userPermissions: {} } } },
+ }),
+ ],
+ [
+ conflictsStateQuery,
+ jest.fn().mockResolvedValue({ data: { project: { mergeRequest: {} } } }),
+ ],
+ ...(options.apolloMock || []),
+ ];
+ const subscriptionHandlers = [
+ [approvedBySubscription, () => mockedApprovalsSubscription],
+ [getStateSubscription, () => stateSubscription],
+ [readyToMergeSubscription, () => createMockApolloSubscription()],
+ ...(options.apolloSubscriptions || []),
+ ];
+ const apolloProvider = createMockApollo(queryHandlers);
+
+ subscriptionHandlers.forEach(([query, stream]) => {
+ apolloProvider.defaultClient.setRequestHandler(query, stream);
+ });
+
wrapper = mounting(MrWidgetOptions, {
propsData: {
mrData: { ...mrData },
@@ -120,30 +155,19 @@ describe('MrWidgetOptions', () => {
},
...options,
- apolloProvider: createMockApollo([
- [approvalsQuery, jest.fn().mockResolvedValue(approvedByCurrentUser)],
- [getStateQuery, stateQueryHandler],
- [readyToMergeQuery, jest.fn().mockResolvedValue(readyToMergeResponse)],
- [
- userPermissionsQuery,
- jest.fn().mockResolvedValue({
- data: { project: { mergeRequest: { userPermissions: {} } } },
- }),
- ],
- [
- conflictsStateQuery,
- jest.fn().mockResolvedValue({ data: { project: { mergeRequest: {} } } }),
- ],
- ...(options.apolloMock || []),
- ]),
+ apolloProvider,
});
return axios.waitForAll();
};
+ const findExtensionToggleButton = () =>
+ wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]');
+ const findExtensionLink = (linkHref) =>
+ wrapper.find(`[data-testid="widget-extension"] [href="${linkHref}"]`);
const findSuggestPipeline = () => wrapper.find('[data-testid="mr-suggest-pipeline"]');
const findSuggestPipelineButton = () => findSuggestPipeline().find('button');
- const findSecurityMrWidget = () => wrapper.find('[data-testid="security-mr-widget"]');
+ const findWidgetContainer = () => wrapper.findComponent(WidgetContainer);
describe('default', () => {
beforeEach(() => {
@@ -626,6 +650,7 @@ describe('MrWidgetOptions', () => {
deployed_at_formatted: 'Mar 22, 2017 10:44pm',
changes,
status: SUCCESS,
+ environment_available: true,
};
beforeEach(() => {
@@ -847,47 +872,6 @@ describe('MrWidgetOptions', () => {
});
});
- describe('security widget', () => {
- const setup = (hasPipeline) => {
- const mrData = {
- ...mockData,
- ...(hasPipeline ? {} : { pipeline: null }),
- };
-
- // Override top-level mocked requests, which always use a fresh copy of
- // mockData, which always includes the full pipeline object.
- mock.onGet(mockData.merge_request_widget_path).reply(() => [HTTP_STATUS_OK, mrData]);
- mock.onGet(mockData.merge_request_cached_widget_path).reply(() => [HTTP_STATUS_OK, mrData]);
-
- return createComponent(mrData, {
- apolloMock: [
- [
- securityReportMergeRequestDownloadPathsQuery,
- jest
- .fn()
- .mockResolvedValue({ data: securityReportMergeRequestDownloadPathsQueryResponse }),
- ],
- ],
- });
- };
-
- describe('with a pipeline', () => {
- it('renders the security widget', async () => {
- await setup(true);
-
- expect(findSecurityMrWidget().exists()).toBe(true);
- });
- });
-
- describe('with no pipeline', () => {
- it('does not render the security widget', async () => {
- await setup(false);
-
- expect(findSecurityMrWidget().exists()).toBe(false);
- });
- });
- });
-
describe('suggestPipeline', () => {
beforeEach(() => {
mock.onAny().reply(HTTP_STATUS_OK);
@@ -1156,7 +1140,7 @@ describe('MrWidgetOptions', () => {
await nextTick();
await waitForPromises();
- expect(Sentry.captureException).toHaveBeenCalledTimes(2);
+ expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
@@ -1248,17 +1232,86 @@ describe('MrWidgetOptions', () => {
expect(api.trackRedisCounterEvent).not.toHaveBeenCalled();
});
});
+ });
- describe('widget container', () => {
- it('should not be displayed when the refactor_security_extension feature flag is turned off', () => {
- createComponent();
- expect(findWidgetContainer().exists()).toBe(false);
+ describe('widget container', () => {
+ it('renders the widget container when there is MR data', async () => {
+ await createComponent(mockData);
+ expect(findWidgetContainer().props('mr')).not.toBeUndefined();
+ });
+ });
+
+ describe('async preparation for a newly opened MR', () => {
+ beforeEach(() => {
+ mock
+ .onGet(mockData.merge_request_widget_path)
+ .reply(() => [HTTP_STATUS_OK, { ...mockData, state: 'opened' }]);
+ });
+
+ it('does not render the Preparing state component by default', async () => {
+ await createComponent();
+
+ expect(findApprovalsWidget().exists()).toBe(true);
+ expect(findPreparingWidget().exists()).toBe(false);
+ });
+
+ it('renders the Preparing state component when the MR state is initially "preparing"', async () => {
+ await createComponent({
+ ...mockData,
+ state: 'opened',
+ detailedMergeStatus: 'PREPARING',
});
- it('should be displayed when the refactor_security_extension feature flag is turned on', () => {
- window.gon.features.refactorSecurityExtension = true;
- createComponent();
- expect(findWidgetContainer().exists()).toBe(true);
+ expect(findApprovalsWidget().exists()).toBe(false);
+ expect(findPreparingWidget().exists()).toBe(true);
+ });
+
+ describe('when the MR is updated by observing its status', () => {
+ beforeEach(() => {
+ window.gon.features.realtimeMrStatusChange = true;
+ });
+
+ it("shows the Preparing widget when the MR reports it's not ready yet", async () => {
+ await createComponent(
+ {
+ ...mockData,
+ state: 'opened',
+ detailedMergeStatus: 'PREPARING',
+ },
+ {},
+ {},
+ false,
+ );
+
+ expect(wrapper.html()).toContain('mr-widget-preparing-stub');
+ });
+
+ it('removes the Preparing widget when the MR indicates it has been prepared', async () => {
+ await createComponent(
+ {
+ ...mockData,
+ state: 'opened',
+ detailedMergeStatus: 'PREPARING',
+ },
+ {},
+ {},
+ false,
+ );
+
+ expect(wrapper.html()).toContain('mr-widget-preparing-stub');
+
+ stateSubscription.next({
+ data: {
+ mergeRequestMergeStatusUpdated: {
+ preparedAt: 'non-null value',
+ },
+ },
+ });
+
+ // Wait for batched DOM updates
+ await nextTick();
+
+ expect(wrapper.html()).not.toContain('mr-widget-preparing-stub');
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js
index a6288b9c725..ca5c9084a62 100644
--- a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js
+++ b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js
@@ -16,10 +16,14 @@ describe('getStateKey', () => {
commitsCount: 2,
hasConflicts: false,
draft: false,
- detailedMergeStatus: null,
+ detailedMergeStatus: 'PREPARING',
};
const bound = getStateKey.bind(context);
+ expect(bound()).toEqual('preparing');
+
+ context.detailedMergeStatus = null;
+
expect(bound()).toEqual('checking');
context.detailedMergeStatus = 'MERGEABLE';
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
deleted file mode 100644
index 30e15595193..00000000000
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ /dev/null
@@ -1,103 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
-<gl-dropdown-stub
- category="primary"
- clearalltext="Clear all"
- clearalltextclass="gl-px-5"
- headertext=""
- hideheaderborder="true"
- highlighteditemstitle="Selected"
- highlighteditemstitleclass="gl-px-5"
- right="true"
- size="medium"
- text="Clone"
- variant="confirm"
->
- <div
- class="pb-2 mx-1"
- >
- <gl-dropdown-section-header-stub>
- Clone with SSH
- </gl-dropdown-section-header-stub>
-
- <div
- class="mx-3"
- >
- <b-input-group-stub
- readonly=""
- tag="div"
- >
- <!---->
-
- <b-form-input-stub
- class="gl-form-input"
- debounce="0"
- formatter="[Function]"
- readonly="true"
- type="text"
- value="ssh://foo.bar"
- />
-
- <b-input-group-append-stub
- tag="div"
- >
- <gl-button-stub
- aria-label="Copy URL"
- buttontextclasses=""
- category="primary"
- class="d-inline-flex"
- data-clipboard-text="ssh://foo.bar"
- data-qa-selector="copy_ssh_url_button"
- icon="copy-to-clipboard"
- size="medium"
- title="Copy URL"
- variant="default"
- />
- </b-input-group-append-stub>
- </b-input-group-stub>
- </div>
-
- <gl-dropdown-section-header-stub>
- Clone with HTTP
- </gl-dropdown-section-header-stub>
-
- <div
- class="mx-3"
- >
- <b-input-group-stub
- readonly=""
- tag="div"
- >
- <!---->
-
- <b-form-input-stub
- class="gl-form-input"
- debounce="0"
- formatter="[Function]"
- readonly="true"
- type="text"
- value="http://foo.bar"
- />
-
- <b-input-group-append-stub
- tag="div"
- >
- <gl-button-stub
- aria-label="Copy URL"
- buttontextclasses=""
- category="primary"
- class="d-inline-flex"
- data-clipboard-text="http://foo.bar"
- data-qa-selector="copy_http_url_button"
- icon="copy-to-clipboard"
- size="medium"
- title="Copy URL"
- variant="default"
- />
- </b-input-group-append-stub>
- </b-input-group-stub>
- </div>
- </div>
-</gl-dropdown-stub>
-`;
diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js
index 8c2f2b52f8e..e7663e2adb2 100644
--- a/spec/frontend/vue_shared/components/actions_button_spec.js
+++ b/spec/frontend/vue_shared/components/actions_button_spec.js
@@ -1,12 +1,15 @@
-import { GlDropdown, GlDropdownDivider, GlButton, GlTooltip } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GlDisclosureDropdownItem,
+} from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
const TEST_ACTION = {
key: 'action1',
text: 'Sample',
secondaryText: 'Lorem ipsum.',
- tooltip: '',
href: '/sample',
attrs: {
'data-test': '123',
@@ -14,191 +17,75 @@ const TEST_ACTION = {
href: '/sample',
variant: 'default',
},
+ handle: jest.fn(),
};
const TEST_ACTION_2 = {
key: 'action2',
text: 'Sample 2',
secondaryText: 'Dolar sit amit.',
- tooltip: 'Dolar sit amit.',
href: '#',
attrs: { 'data-test': '456' },
+ handle: jest.fn(),
};
-const TEST_TOOLTIP = 'Lorem ipsum dolar sit';
-describe('Actions button component', () => {
+describe('vue_shared/components/actions_button', () => {
let wrapper;
function createComponent(props) {
- wrapper = shallowMount(ActionsButton, {
- propsData: { ...props },
+ wrapper = shallowMountExtended(ActionsButton, {
+ propsData: { actions: [TEST_ACTION, TEST_ACTION_2], toggleText: 'Edit', ...props },
+ stubs: {
+ GlDisclosureDropdownItem,
+ },
});
}
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
- const findButton = () => wrapper.findComponent(GlButton);
- const findTooltip = () => wrapper.findComponent(GlTooltip);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const parseDropdownItems = () =>
- findDropdown()
- .findAll('gl-dropdown-item-stub,gl-dropdown-divider-stub')
- .wrappers.map((x) => {
- if (x.is(GlDropdownDivider)) {
- return { type: 'divider' };
- }
-
- const { isCheckItem, isChecked, secondaryText } = x.props();
-
- return {
- type: 'item',
- isCheckItem,
- isChecked,
- secondaryText,
- text: x.text(),
- };
- });
- const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt);
- const clickLink = (...args) => clickOn(findButton(), ...args);
- const clickDropdown = (...args) => clickOn(findDropdown(), ...args);
-
- describe('with 1 action', () => {
- beforeEach(() => {
- createComponent({ actions: [TEST_ACTION] });
- });
-
- it('should not render dropdown', () => {
- expect(findDropdown().exists()).toBe(false);
- });
-
- it('should render single button', () => {
- expect(findButton().attributes()).toMatchObject({
- href: TEST_ACTION.href,
- ...TEST_ACTION.attrs,
- });
- expect(findButton().text()).toBe(TEST_ACTION.text);
- });
-
- it('should not have tooltip', () => {
- expect(findTooltip().exists()).toBe(false);
- });
+ it('dropdown toggle displays provided toggleLabel', () => {
+ createComponent();
- it('should have attrs', () => {
- expect(findButton().attributes()).toMatchObject(TEST_ACTION.attrs);
- });
-
- it('can click', () => {
- expect(clickLink).not.toThrow();
- });
+ expect(findDropdown().props().toggleText).toBe('Edit');
});
- describe('with 1 action with tooltip', () => {
- it('should have tooltip', () => {
- createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] });
+ it('allows customizing variant and category', () => {
+ const variant = 'confirm';
+ const category = 'secondary';
- expect(findTooltip().text()).toBe(TEST_TOOLTIP);
- });
+ createComponent({ variant, category });
+
+ expect(findDropdown().props()).toMatchObject({ category, variant });
});
- describe('when showActionTooltip is false', () => {
- it('should not have tooltip', () => {
- createComponent({
- actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }],
- showActionTooltip: false,
- });
+ it('displays a single dropdown group', () => {
+ createComponent();
- expect(findTooltip().exists()).toBe(false);
- });
+ expect(wrapper.findAllComponents(GlDisclosureDropdownGroup)).toHaveLength(1);
});
- describe('with 1 action with handle', () => {
- it('can click and trigger handle', () => {
- const handleClick = jest.fn();
- createComponent({ actions: [{ ...TEST_ACTION, handle: handleClick }] });
+ it('create dropdown items for every action', () => {
+ createComponent();
- const event = new Event('click');
- clickLink(event);
+ [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => {
+ const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index);
- expect(handleClick).toHaveBeenCalledWith(event);
+ expect(dropdownItem.props().item).toBe(action);
+ expect(dropdownItem.attributes()).toMatchObject(action.attrs);
+ expect(dropdownItem.text()).toContain(action.text);
+ expect(dropdownItem.text()).toContain(action.secondaryText);
});
});
- describe('with multiple actions', () => {
- let handleAction;
+ describe('when clicking a dropdown item', () => {
+ it("invokes the action's handle method", () => {
+ createComponent();
- beforeEach(() => {
- handleAction = jest.fn();
+ [TEST_ACTION, TEST_ACTION_2].forEach((action, index) => {
+ const dropdownItem = wrapper.findAllComponents(GlDisclosureDropdownItem).at(index);
- createComponent({ actions: [{ ...TEST_ACTION, handle: handleAction }, TEST_ACTION_2] });
- });
+ dropdownItem.vm.$emit('action');
- it('should default to selecting first action', () => {
- expect(findDropdown().attributes()).toMatchObject({
- text: TEST_ACTION.text,
- 'split-href': TEST_ACTION.href,
+ expect(action.handle).toHaveBeenCalled();
});
});
-
- it('should handle first action click', () => {
- const event = new Event('click');
-
- clickDropdown(event);
-
- expect(handleAction).toHaveBeenCalledWith(event);
- });
-
- it('should render dropdown items', () => {
- expect(parseDropdownItems()).toEqual([
- {
- type: 'item',
- isCheckItem: true,
- isChecked: true,
- secondaryText: TEST_ACTION.secondaryText,
- text: TEST_ACTION.text,
- },
- { type: 'divider' },
- {
- type: 'item',
- isCheckItem: true,
- isChecked: false,
- secondaryText: TEST_ACTION_2.secondaryText,
- text: TEST_ACTION_2.text,
- },
- ]);
- });
-
- it('should select action 2 when clicked', () => {
- expect(wrapper.emitted('select')).toBeUndefined();
-
- const action2 = wrapper.find(`[data-testid="action_${TEST_ACTION_2.key}"]`);
- action2.vm.$emit('click');
-
- expect(wrapper.emitted('select')).toEqual([[TEST_ACTION_2.key]]);
- });
-
- it('should not have tooltip value', () => {
- expect(findTooltip().exists()).toBe(false);
- });
- });
-
- describe('with multiple actions and selectedKey', () => {
- beforeEach(() => {
- createComponent({ actions: [TEST_ACTION, TEST_ACTION_2], selectedKey: TEST_ACTION_2.key });
- });
-
- it('should show action 2 as selected', () => {
- expect(parseDropdownItems()).toEqual([
- expect.objectContaining({
- type: 'item',
- isChecked: false,
- }),
- { type: 'divider' },
- expect.objectContaining({
- type: 'item',
- isChecked: true,
- }),
- ]);
- });
-
- it('should have tooltip value', () => {
- expect(findTooltip().text()).toBe(TEST_ACTION_2.tooltip);
- });
});
});
diff --git a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
index 2a40511affb..374babe3a97 100644
--- a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
+++ b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
@@ -310,12 +310,11 @@ describe('vue_shared/components/chronic_duration_input', () => {
});
it('passes updated prop via v-model', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ value: MOCK_VALUE });
+ textElement.value = '2hr20min';
+ textElement.dispatchEvent(new Event('input'));
await nextTick();
- expect(textElement.value).toBe('2 hrs 20 mins');
+ expect(textElement.value).toBe('2hr20min');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
});
diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
index afb509b9fe6..8c860c9b06f 100644
--- a/spec/frontend/vue_shared/components/ci_badge_link_spec.js
+++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js
@@ -1,4 +1,4 @@
-import { GlLink } from '@gitlab/ui';
+import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -46,6 +46,13 @@ describe('CI Badge Link Component', () => {
icon: 'status_pending',
details_path: 'status/pending',
},
+ preparing: {
+ text: 'preparing',
+ label: 'preparing',
+ group: 'preparing',
+ icon: 'status_preparing',
+ details_path: 'status/preparing',
+ },
running: {
text: 'running',
label: 'running',
@@ -53,6 +60,13 @@ describe('CI Badge Link Component', () => {
icon: 'status_running',
details_path: 'status/running',
},
+ scheduled: {
+ text: 'scheduled',
+ label: 'scheduled',
+ group: 'scheduled',
+ icon: 'status_scheduled',
+ details_path: 'status/scheduled',
+ },
skipped: {
text: 'skipped',
label: 'skipped',
@@ -61,8 +75,8 @@ describe('CI Badge Link Component', () => {
details_path: 'status/skipped',
},
success_warining: {
- text: 'passed',
- label: 'passed',
+ text: 'warning',
+ label: 'passed with warnings',
group: 'success-with-warnings',
icon: 'status_warning',
details_path: 'status/warning',
@@ -77,6 +91,8 @@ describe('CI Badge Link Component', () => {
};
const findIcon = () => wrapper.findComponent(CiIcon);
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findBadgeText = () => wrapper.find('[data-testid="ci-badge-text"');
const createComponent = (propsData) => {
wrapper = shallowMount(CiBadgeLink, { propsData });
@@ -87,22 +103,50 @@ describe('CI Badge Link Component', () => {
expect(wrapper.attributes('href')).toBe(statuses[status].details_path);
expect(wrapper.text()).toBe(statuses[status].text);
- expect(wrapper.classes()).toContain('ci-status');
- expect(wrapper.classes()).toContain(`ci-${statuses[status].group}`);
+ expect(findBadge().props('size')).toBe('md');
expect(findIcon().exists()).toBe(true);
});
+ it.each`
+ status | textColor | variant
+ ${statuses.success} | ${'gl-text-green-700'} | ${'success'}
+ ${statuses.success_warining} | ${'gl-text-orange-700'} | ${'warning'}
+ ${statuses.failed} | ${'gl-text-red-700'} | ${'danger'}
+ ${statuses.running} | ${'gl-text-blue-700'} | ${'info'}
+ ${statuses.pending} | ${'gl-text-orange-700'} | ${'warning'}
+ ${statuses.preparing} | ${'gl-text-gray-600'} | ${'muted'}
+ ${statuses.canceled} | ${'gl-text-gray-700'} | ${'neutral'}
+ ${statuses.scheduled} | ${'gl-text-gray-600'} | ${'muted'}
+ ${statuses.skipped} | ${'gl-text-gray-600'} | ${'muted'}
+ ${statuses.manual} | ${'gl-text-gray-700'} | ${'neutral'}
+ ${statuses.created} | ${'gl-text-gray-600'} | ${'muted'}
+ `(
+ 'should contain correct badge class and variant for status: $status.text',
+ ({ status, textColor, variant }) => {
+ createComponent({ status });
+
+ expect(findBadgeText().classes()).toContain(textColor);
+ expect(findBadge().props('variant')).toBe(variant);
+ },
+ );
+
it('should not render label', () => {
createComponent({ status: statuses.canceled, showText: false });
expect(wrapper.text()).toBe('');
});
- it('should emit ciStatusBadgeClick event', async () => {
+ it('should emit ciStatusBadgeClick event', () => {
createComponent({ status: statuses.success });
- await wrapper.findComponent(GlLink).vm.$emit('click');
+ findBadge().vm.$emit('click');
expect(wrapper.emitted('ciStatusBadgeClick')).toEqual([[]]);
});
+
+ it('should render dynamic badge size', () => {
+ createComponent({ status: statuses.success, badgeSize: 'lg' });
+
+ expect(findBadge().props('size')).toBe('lg');
+ });
});
diff --git a/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js
new file mode 100644
index 00000000000..e0dfa084f3e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_item_spec.js
@@ -0,0 +1,52 @@
+import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import CloneDropdownItem from '~/vue_shared/components/clone_dropdown/clone_dropdown_item.vue';
+
+describe('Clone Dropdown Button', () => {
+ let wrapper;
+ const link = 'ssh://foo.bar';
+ const label = 'SSH';
+ const qaSelector = 'some-selector';
+ const defaultPropsData = {
+ link,
+ label,
+ qaSelector,
+ };
+
+ const findCopyButton = () => wrapper.findComponent(GlButton);
+
+ const createComponent = (propsData = defaultPropsData) => {
+ wrapper = shallowMount(CloneDropdownItem, {
+ propsData,
+ stubs: {
+ GlFormInputGroup,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('default', () => {
+ it('sets form group label', () => {
+ expect(wrapper.findComponent(GlFormGroup).attributes('label')).toBe(label);
+ });
+
+ it('sets form input group link', () => {
+ expect(wrapper.findComponent(GlFormInputGroup).props('value')).toBe(link);
+ });
+
+ it('sets the copy tooltip text', () => {
+ expect(findCopyButton().attributes('title')).toBe('Copy URL');
+ });
+
+ it('sets the copy tooltip link', () => {
+ expect(findCopyButton().attributes('data-clipboard-text')).toBe(link);
+ });
+
+ it('sets the qa selector', () => {
+ expect(findCopyButton().attributes('data-qa-selector')).toBe(qaSelector);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/clone_dropdown_spec.js b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_spec.js
index 584e29d94c4..48c158d6fa2 100644
--- a/spec/frontend/vue_shared/components/clone_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/clone_dropdown/clone_dropdown_spec.js
@@ -1,6 +1,7 @@
-import { GlFormInputGroup, GlDropdownSectionHeader } from '@gitlab/ui';
+import { GlFormInputGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import CloneDropdown from '~/vue_shared/components/clone_dropdown.vue';
+import CloneDropdown from '~/vue_shared/components/clone_dropdown/clone_dropdown.vue';
+import CloneDropdownItem from '~/vue_shared/components/clone_dropdown/clone_dropdown_item.vue';
describe('Clone Dropdown Button', () => {
let wrapper;
@@ -12,30 +13,28 @@ describe('Clone Dropdown Button', () => {
httpLink,
};
+ const findCloneDropdownItems = () => wrapper.findAllComponents(CloneDropdownItem);
+ const findCloneDropdownItemAtIndex = (index) => findCloneDropdownItems().at(index);
+
const createComponent = (propsData = defaultPropsData) => {
wrapper = shallowMount(CloneDropdown, {
propsData,
stubs: {
- 'gl-form-input-group': GlFormInputGroup,
+ GlFormInputGroup,
},
});
};
describe('rendering', () => {
- it('matches the snapshot', () => {
- createComponent();
- expect(wrapper.element).toMatchSnapshot();
- });
-
it.each`
- name | index | value
+ name | index | link
${'SSH'} | ${0} | ${sshLink}
${'HTTP'} | ${1} | ${httpLink}
- `('renders correct link and a copy-button for $name', ({ index, value }) => {
+ `('renders correct link and a copy-button for $name', ({ index, link }) => {
createComponent();
- const group = wrapper.findAllComponents(GlFormInputGroup).at(index);
- expect(group.props('value')).toBe(value);
- expect(group.findComponent(GlFormInputGroup).exists()).toBe(true);
+
+ const group = findCloneDropdownItemAtIndex(index);
+ expect(group.props('link')).toBe(link);
});
it.each`
@@ -45,8 +44,7 @@ describe('Clone Dropdown Button', () => {
`('does not fail if only $name is set', ({ name, value }) => {
createComponent({ [name]: value });
- expect(wrapper.findComponent(GlFormInputGroup).props('value')).toBe(value);
- expect(wrapper.findAllComponents(GlDropdownSectionHeader).length).toBe(1);
+ expect(findCloneDropdownItemAtIndex(0).props('link')).toBe(value);
});
});
@@ -58,12 +56,13 @@ describe('Clone Dropdown Button', () => {
`('allows null values for the props', ({ name, value }) => {
createComponent({ ...defaultPropsData, [name]: value });
- expect(wrapper.findAllComponents(GlDropdownSectionHeader).length).toBe(1);
+ expect(findCloneDropdownItems().length).toBe(1);
});
it('correctly calculates httpLabel for HTTPS protocol', () => {
createComponent({ httpLink: httpsLink });
- expect(wrapper.findComponent(GlDropdownSectionHeader).text()).toContain('HTTPS');
+
+ expect(findCloneDropdownItemAtIndex(0).attributes('label')).toContain('HTTPS');
});
});
});
diff --git a/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js b/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js
index fbfef5cbe46..97c48a4db74 100644
--- a/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_fork_modal_spec.js
@@ -1,8 +1,17 @@
-import { GlModal } from '@gitlab/ui';
+import { GlLoadingIcon, GlModal } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import getNoWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_none.json';
+import getSomeWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_some.json';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import ConfirmForkModal, { i18n } from '~/vue_shared/components/confirm_fork_modal.vue';
+import ConfirmForkModal, { i18n } from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import getWritableForksQuery from '~/vue_shared/components/web_ide/get_writable_forks.query.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
describe('vue_shared/components/confirm_fork_modal', () => {
+ Vue.use(VueApollo);
+
let wrapper = null;
const forkPath = '/fake/fork/path';
@@ -13,13 +22,18 @@ describe('vue_shared/components/confirm_fork_modal', () => {
const findModalProp = (prop) => findModal().props(prop);
const findModalActionProps = () => findModalProp('actionPrimary');
- const createComponent = (props = {}) =>
- shallowMountExtended(ConfirmForkModal, {
+ const createComponent = (props = {}, getWritableForksResponse = getNoWritableForksResponse) => {
+ const fakeApollo = createMockApollo([
+ [getWritableForksQuery, jest.fn().mockResolvedValue(getWritableForksResponse)],
+ ]);
+ return shallowMountExtended(ConfirmForkModal, {
propsData: {
...defaultProps,
...props,
},
+ apolloProvider: fakeApollo,
});
+ };
describe('visible = false', () => {
beforeEach(() => {
@@ -73,4 +87,45 @@ describe('vue_shared/components/confirm_fork_modal', () => {
expect(wrapper.emitted('change')).toEqual([[false]]);
});
});
+
+ describe('writable forks', () => {
+ describe('when loading', () => {
+ it('shows loading spinner', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('with no writable forks', () => {
+ it('contains `newForkMessage`', async () => {
+ wrapper = createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(i18n.newForkMessage);
+ });
+ });
+
+ describe('with writable forks', () => {
+ it('contains `existingForksMessage`', async () => {
+ wrapper = createComponent(null, getSomeWritableForksResponse);
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(i18n.existingForksMessage);
+ });
+
+ it('renders links to the forks', async () => {
+ wrapper = createComponent(null, getSomeWritableForksResponse);
+
+ await waitForPromises();
+
+ const forks = getSomeWritableForksResponse.data.project.visibleForks.nodes;
+
+ expect(wrapper.findByText(forks[0].fullPath).attributes('href')).toBe(forks[0].webUrl);
+ expect(wrapper.findByText(forks[1].fullPath).attributes('href')).toBe(forks[1].webUrl);
+ });
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
index f576121fc18..c0cb17f0d16 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -36,9 +36,7 @@ import {
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
uniqueTokens: jest.fn().mockImplementation((tokens) => tokens),
- stripQuotes: jest.requireActual(
- '~/vue_shared/components/filtered_search_bar/filtered_search_utils',
- ).stripQuotes,
+ stripQuotes: jest.requireActual('~/lib/utils/text_utility').stripQuotes,
filterEmptySearchTerm: jest.requireActual(
'~/vue_shared/components/filtered_search_bar/filtered_search_utils',
).filterEmptySearchTerm,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
index d85b6e6d115..21a1303ccf3 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
@@ -5,7 +5,6 @@ import AccessorUtilities from '~/lib/utils/accessor';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
- stripQuotes,
uniqueTokens,
prepareTokens,
processFilters,
@@ -29,23 +28,6 @@ function setLocalStorageAvailability(isAvailable) {
}
describe('Filtered Search Utils', () => {
- describe('stripQuotes', () => {
- it.each`
- inputValue | outputValue
- ${'"Foo Bar"'} | ${'Foo Bar'}
- ${"'Foo Bar'"} | ${'Foo Bar'}
- ${'FooBar'} | ${'FooBar'}
- ${"Foo'Bar"} | ${"Foo'Bar"}
- ${'Foo"Bar'} | ${'Foo"Bar'}
- ${'Foo Bar'} | ${'Foo Bar'}
- `(
- 'returns string $outputValue when called with string $inputValue',
- ({ inputValue, outputValue }) => {
- expect(stripQuotes(inputValue)).toBe(outputValue);
- },
- );
- });
-
describe('uniqueTokens', () => {
it('returns tokens array with duplicates removed', () => {
expect(
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index d87aa3194d2..63eacaabd0c 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -31,9 +31,7 @@ import { mockLabelToken } from '../mock_data';
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
getRecentlyUsedSuggestions: jest.fn(),
setTokenValueToRecentlyUsed: jest.fn(),
- stripQuotes: jest.requireActual(
- '~/vue_shared/components/filtered_search_bar/filtered_search_utils',
- ).stripQuotes,
+ stripQuotes: jest.requireActual('~/lib/utils/text_utility').stripQuotes,
}));
const mockStorageKey = 'recent-tokens-label_name';
@@ -71,8 +69,9 @@ const defaultScopedSlots = {
'suggestions-list': `<div data-testid="${mockSuggestionListTestId}" :data-suggestions="JSON.stringify(props.suggestions)"></div>`,
};
+const mockConfig = { ...mockLabelToken, recentSuggestionsStorageKey: mockStorageKey };
const mockProps = {
- config: { ...mockLabelToken, recentSuggestionsStorageKey: mockStorageKey },
+ config: mockConfig,
value: { data: '' },
active: false,
suggestions: [],
@@ -221,6 +220,20 @@ describe('BaseToken', () => {
});
},
);
+
+ it('limits the length of the rendered list using config.maxSuggestions', () => {
+ mockSuggestions = ['a', 'b', 'c', 'd'].map((id) => ({ id }));
+
+ const maxSuggestions = 2;
+ const config = { ...mockConfig, maxSuggestions };
+ const props = { defaultSuggestions: [], suggestions: mockSuggestions, config };
+
+ getRecentlyUsedSuggestions.mockReturnValue([]);
+ wrapper = createComponent({ props, mountFn: shallowMountExtended, stubs: {} });
+
+ expect(findMockSuggestionList().exists()).toBe(true);
+ expect(getMockSuggestionListSuggestions().length).toEqual(maxSuggestions);
+ });
});
describe('with preloaded suggestions', () => {
diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
index 26a74036b10..e54e261b8e4 100644
--- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
@@ -120,17 +120,26 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
});
- it.each`
- desc | supportsQuickActions
- ${'passes render_quick_actions param to renderMarkdownPath if quick actions are enabled'} | ${true}
- ${'does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled'} | ${false}
- `('$desc', async ({ supportsQuickActions }) => {
- buildWrapper({ propsData: { supportsQuickActions } });
+ // quarantine flaky spec:https://gitlab.com/gitlab-org/gitlab/-/issues/412618
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip('passes render_quick_actions param to renderMarkdownPath if quick actions are enabled', async () => {
+ buildWrapper({ propsData: { supportsQuickActions: true } });
+
+ await enableContentEditor();
+
+ expect(mock.history.post).toHaveLength(1);
+ expect(mock.history.post[0].url).toContain(`render_quick_actions=true`);
+ });
+
+ // quarantine flaky spec: https://gitlab.com/gitlab-org/gitlab/-/issues/411565
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip('does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled', async () => {
+ buildWrapper({ propsData: { supportsQuickActions: false } });
await enableContentEditor();
expect(mock.history.post).toHaveLength(1);
- expect(mock.history.post[0].url).toContain(`render_quick_actions=${supportsQuickActions}`);
+ expect(mock.history.post[0].url).toContain(`render_quick_actions=false`);
});
it('enables content editor switcher when contentEditorEnabled prop is true', () => {
@@ -165,6 +174,20 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
});
+ describe('when attachments are disabled', () => {
+ beforeEach(() => {
+ buildWrapper({ propsData: { disableAttachments: true } });
+ });
+
+ it('disables canAttachFile', () => {
+ expect(findMarkdownField().props().canAttachFile).toBe(false);
+ });
+
+ it('passes `attach-file` to restrictedToolBarItems', () => {
+ expect(findMarkdownField().props().restrictedToolBarItems).toContain('attach-file');
+ });
+ });
+
describe('disabled', () => {
it('disables markdown field when disabled prop is true', () => {
buildWrapper({ propsData: { disabled: true } });
@@ -178,7 +201,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
expect(findMarkdownField().find('textarea').attributes('disabled')).toBe(undefined);
});
- it('disables content editor when disabled prop is true', async () => {
+ // quarantine flaky spec: https://gitlab.com/gitlab-org/gitlab/-/issues/404734
+ // eslint-disable-next-line jest/no-disabled-tests
+ it.skip('disables content editor when disabled prop is true', async () => {
buildWrapper({ propsData: { disabled: true } });
await enableContentEditor();
diff --git a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js
index 6f4902e3f96..e916336f21a 100644
--- a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js
+++ b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js
@@ -4,6 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import MarkdownDrawer, { cache } from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue';
import { getRenderedMarkdown } from '~/vue_shared/components/markdown_drawer/utils/fetch';
import { contentTop } from '~/lib/utils/common_utils';
+import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
jest.mock('~/vue_shared/components/markdown_drawer/utils/fetch', () => ({
getRenderedMarkdown: jest.fn().mockReturnValue({
@@ -55,6 +56,10 @@ describe('MarkdownDrawer', () => {
expect(findDrawerTitle().text()).toBe('test title test');
expect(findDrawerBody().text()).toBe('test body');
});
+
+ it(`has proper z-index set for the drawer component`, () => {
+ expect(findDrawer().attributes('zindex')).toBe(DRAWER_Z_INDEX.toString());
+ });
});
describe.each`
diff --git a/spec/frontend/vue_shared/components/mr_more_dropdown_spec.js b/spec/frontend/vue_shared/components/mr_more_dropdown_spec.js
new file mode 100644
index 00000000000..41639725f66
--- /dev/null
+++ b/spec/frontend/vue_shared/components/mr_more_dropdown_spec.js
@@ -0,0 +1,137 @@
+import { shallowMount } from '@vue/test-utils';
+import MRMoreActionsDropdown from '~/vue_shared/components/mr_more_dropdown.vue';
+
+describe('MR More actions sidebar', () => {
+ let wrapper;
+
+ const findNotificationToggle = () => wrapper.find('[data-testid="notification-toggle"]');
+ const findEditMergeRequestOption = () => wrapper.find('[data-testid="edit-merge-request"]');
+ const findMarkAsReadyAndDraftOption = () =>
+ wrapper.find('[data-testid="ready-and-draft-action"]');
+ const findCopyReferenceButton = () => wrapper.find('[data-testid="copy-reference"]');
+ const findReopenMergeRequestOption = () => wrapper.find('[data-testid="reopen-merge-request"]');
+ const findReportAbuseOption = () => wrapper.find('[data-testid="report-abuse-option"]');
+
+ const createComponent = ({
+ movedMrSidebarFlag = false,
+ isCurrentUser = true,
+ isLoggedIn = true,
+ open = false,
+ canUpdateMergeRequest = false,
+ } = {}) => {
+ wrapper = shallowMount(MRMoreActionsDropdown, {
+ propsData: {
+ mr: {
+ iid: 1,
+ },
+ isCurrentUser,
+ isLoggedIn,
+ open,
+ canUpdateMergeRequest,
+ },
+ provide: {
+ glFeatures: { movedMrSidebar: movedMrSidebarFlag },
+ },
+ });
+ };
+
+ describe('Notifications toggle', () => {
+ it.each`
+ movedMrSidebarFlag | isLoggedIn | showNotificationToggle
+ ${false} | ${false} | ${false}
+ ${false} | ${true} | ${false}
+ ${true} | ${false} | ${false}
+ ${true} | ${true} | ${true}
+ `(
+ "when the movedMrSidebar flag is '$movedMrSidebarFlag' and is isLoggedIn as '$isLoggedIn'",
+ ({ movedMrSidebarFlag, isLoggedIn, showNotificationToggle }) => {
+ createComponent({
+ isLoggedIn,
+ movedMrSidebarFlag,
+ });
+
+ expect(findNotificationToggle().exists()).toBe(showNotificationToggle);
+ },
+ );
+ });
+
+ describe('Edit/Draft/Reopen MR', () => {
+ it('should not have the edit option when `canUpdateMergeRequest` is false', () => {
+ createComponent();
+
+ expect(findEditMergeRequestOption().exists()).toBe(false);
+ });
+
+ it('should have the edit option when `canUpdateMergeRequest` is true', () => {
+ createComponent({
+ canUpdateMergeRequest: true,
+ });
+
+ expect(findEditMergeRequestOption().exists()).toBe(true);
+ });
+
+ it('should not have the ready and draft option when the the MR is open and `canUpdateMergeRequest` is false', () => {
+ createComponent({
+ open: true,
+ canUpdateMergeRequest: false,
+ });
+
+ expect(findMarkAsReadyAndDraftOption().exists()).toBe(false);
+ });
+
+ it('should have the ready and draft option when the the MR is open and `canUpdateMergeRequest` is true', () => {
+ createComponent({
+ open: true,
+ canUpdateMergeRequest: true,
+ });
+
+ expect(findMarkAsReadyAndDraftOption().exists()).toBe(true);
+ });
+
+ it('should have the reopen option when the the MR is closed and `canUpdateMergeRequest` is true', () => {
+ createComponent({
+ open: false,
+ canUpdateMergeRequest: true,
+ });
+
+ expect(findReopenMergeRequestOption().exists()).toBe(true);
+ });
+
+ it('should not have the reopen option when the the MR is closed and `canUpdateMergeRequest` is false', () => {
+ createComponent({
+ open: false,
+ canUpdateMergeRequest: false,
+ });
+
+ expect(findReopenMergeRequestOption().exists()).toBe(false);
+ });
+ });
+
+ describe('Copy reference', () => {
+ it('should not be visible by default', () => {
+ createComponent();
+
+ expect(findCopyReferenceButton().exists()).toBe(false);
+ });
+
+ it('should be visible when the movedMrSidebarFlag is on', () => {
+ createComponent({ movedMrSidebarFlag: true });
+
+ expect(findCopyReferenceButton().exists()).toBe(true);
+ });
+ });
+
+ describe('Report abuse action', () => {
+ it('should not have the option by default', () => {
+ createComponent();
+
+ expect(findReportAbuseOption().exists()).toBe(false);
+ });
+
+ it('should have the option when not the current user', () => {
+ createComponent({ isCurrentUser: false });
+
+ expect(findReportAbuseOption().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
index 9b6f5ae3e38..a27877e7ba8 100644
--- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -94,16 +94,15 @@ describe('AlertManagementEmptyState', () => {
const ItemsTable = () => wrapper.find('.gl-table');
const ErrorAlert = () => wrapper.findComponent(GlAlert);
const Pagination = () => wrapper.findComponent(GlPagination);
- const Tabs = () => wrapper.findComponent(GlTabs);
const ActionButton = () => wrapper.find('.header-actions > button');
- const Filters = () => wrapper.findComponent(FilteredSearchBar);
+ const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
const findPagination = () => wrapper.findComponent(GlPagination);
const findStatusFilterTabs = () => wrapper.findAllComponents(GlTab);
const findStatusTabs = () => wrapper.findComponent(GlTabs);
const findStatusFilterBadge = () => wrapper.findAllComponents(GlBadge);
const handleFilterItems = (filters) => {
- Filters().vm.$emit('onFilter', filters);
+ findFilteredSearchBar().vm.$emit('onFilter', filters);
return nextTick();
};
@@ -140,7 +139,7 @@ describe('AlertManagementEmptyState', () => {
},
});
- expect(Tabs().exists()).toBe(true);
+ expect(findStatusTabs().exists()).toBe(true);
});
it('renders the header action buttons if present', () => {
@@ -176,7 +175,7 @@ describe('AlertManagementEmptyState', () => {
props: { filterSearchTokens: [TOKEN_TYPE_ASSIGNEE] },
});
- expect(Filters().exists()).toBe(true);
+ expect(findFilteredSearchBar().exists()).toBe(true);
});
});
@@ -291,8 +290,9 @@ describe('AlertManagementEmptyState', () => {
});
it('renders the search component for incidents', () => {
- expect(Filters().props('searchInputPlaceholder')).toBe('Search or filter results…');
- expect(Filters().props('tokens')).toEqual([
+ const filteredSearchBar = findFilteredSearchBar();
+ expect(filteredSearchBar.props('searchInputPlaceholder')).toBe('Search or filter results…');
+ expect(filteredSearchBar.props('tokens')).toEqual([
{
type: TOKEN_TYPE_AUTHOR,
icon: 'user',
@@ -316,14 +316,14 @@ describe('AlertManagementEmptyState', () => {
fetchUsers: expect.any(Function),
},
]);
- expect(Filters().props('recentSearchesStorageKey')).toBe('items');
+ expect(filteredSearchBar.props('recentSearchesStorageKey')).toBe('items');
});
it('returns correctly applied filter search values', async () => {
const searchTerm = 'foo';
await handleFilterItems([{ type: 'filtered-search-term', value: { data: searchTerm } }]);
await nextTick();
- expect(Filters().props('initialFilterValue')).toEqual([searchTerm]);
+ expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([searchTerm]);
});
it('updates props tied to getIncidents GraphQL query', async () => {
@@ -337,7 +337,7 @@ describe('AlertManagementEmptyState', () => {
value: { data: assigneeUsername },
},
searchTerm,
- ] = Filters().props('initialFilterValue');
+ ] = findFilteredSearchBar().props('initialFilterValue');
expect(authorUsername).toBe('root');
expect(assigneeUsername).toEqual('root2');
@@ -346,7 +346,7 @@ describe('AlertManagementEmptyState', () => {
it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', async () => {
await handleFilterItems([]);
- expect(Filters().props('initialFilterValue')).toEqual([]);
+ expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([]);
});
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_new_spec.js.snap
index 26c9a6f8d5a..26c9a6f8d5a 100644
--- a/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_spec.js.snap
+++ b/spec/frontend/vue_shared/components/source_viewer/components/__snapshots__/chunk_new_spec.js.snap
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js
deleted file mode 100644
index 395ba92d4c6..00000000000
--- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_deprecated_spec.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import { nextTick } from 'vue';
-import { GlIntersectionObserver } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue';
-import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
-import LineHighlighter from '~/blob/line_highlighter';
-
-const lineHighlighter = new LineHighlighter();
-jest.mock('~/blob/line_highlighter', () =>
- jest.fn().mockReturnValue({
- highlightHash: jest.fn(),
- }),
-);
-
-const DEFAULT_PROPS = {
- chunkIndex: 2,
- isHighlighted: false,
- content: '// Line 1 content \n // Line 2 content',
- startingFrom: 140,
- totalLines: 50,
- language: 'javascript',
- blamePath: 'blame/file.js',
-};
-
-const hash = '#L142';
-
-describe('Chunk component', () => {
- let wrapper;
- let idleCallbackSpy;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMountExtended(Chunk, {
- mocks: { $route: { hash } },
- propsData: { ...DEFAULT_PROPS, ...props },
- });
- };
-
- const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
- const findChunkLines = () => wrapper.findAllComponents(ChunkLine);
- const findLineNumbers = () => wrapper.findAllByTestId('line-number');
- const findContent = () => wrapper.findByTestId('content');
-
- beforeEach(() => {
- idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn());
- createComponent();
- });
-
- describe('Intersection observer', () => {
- it('renders an Intersection observer component', () => {
- expect(findIntersectionObserver().exists()).toBe(true);
- });
-
- it('emits an appear event when intersection-observer appears', () => {
- findIntersectionObserver().vm.$emit('appear');
-
- expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]);
- });
-
- it('does not emit an appear event is isHighlighted is true', () => {
- createComponent({ isHighlighted: true });
- findIntersectionObserver().vm.$emit('appear');
-
- expect(wrapper.emitted('appear')).toEqual(undefined);
- });
- });
-
- describe('rendering', () => {
- it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => {
- jest.clearAllMocks();
- createComponent({ isFirstChunk: true });
-
- expect(window.requestIdleCallback).not.toHaveBeenCalled();
- expect(findContent().exists()).toBe(true);
- });
-
- it('does not render a Chunk Line component if isHighlighted is false', () => {
- expect(findChunkLines().length).toBe(0);
- });
-
- it('does not render simplified line numbers and content if browser is not in idle state', () => {
- idleCallbackSpy.mockRestore();
- createComponent();
-
- expect(findLineNumbers()).toHaveLength(0);
- expect(findContent().exists()).toBe(false);
- });
-
- it('renders simplified line numbers and content if isHighlighted is false', () => {
- expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines);
-
- expect(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`);
-
- expect(findContent().text()).toBe(DEFAULT_PROPS.content);
- });
-
- it('renders Chunk Line components if isHighlighted is true', () => {
- const splitContent = DEFAULT_PROPS.content.split('\n');
- createComponent({ isHighlighted: true });
-
- expect(findChunkLines().length).toBe(splitContent.length);
-
- expect(findChunkLines().at(0).props()).toMatchObject({
- number: DEFAULT_PROPS.startingFrom + 1,
- content: splitContent[0],
- language: DEFAULT_PROPS.language,
- blamePath: DEFAULT_PROPS.blamePath,
- });
- });
-
- it('does not scroll to route hash if last chunk is not loaded', () => {
- expect(LineHighlighter).not.toHaveBeenCalled();
- });
-
- it('scrolls to route hash if last chunk is loaded', async () => {
- createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 });
- await nextTick();
- expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
- expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js
new file mode 100644
index 00000000000..919abc26e05
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js
@@ -0,0 +1,84 @@
+import { nextTick } from 'vue';
+import { GlIntersectionObserver } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue';
+import { CHUNK_1, CHUNK_2 } from '../mock_data';
+
+describe('Chunk component', () => {
+ let wrapper;
+ let idleCallbackSpy;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(Chunk, {
+ propsData: { ...CHUNK_1, ...props },
+ });
+ };
+
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
+ const findLineNumbers = () => wrapper.findAllByTestId('line-numbers');
+ const findContent = () => wrapper.findByTestId('content');
+
+ beforeEach(() => {
+ idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn());
+ createComponent();
+ });
+
+ describe('Intersection observer', () => {
+ it('renders an Intersection observer component', () => {
+ expect(findIntersectionObserver().exists()).toBe(true);
+ });
+
+ it('renders highlighted content if appear event is emitted', async () => {
+ createComponent({ chunkIndex: 1, isHighlighted: false });
+ findIntersectionObserver().vm.$emit('appear');
+
+ await nextTick();
+
+ expect(findContent().exists()).toBe(true);
+ });
+ });
+
+ describe('rendering', () => {
+ it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => {
+ jest.clearAllMocks();
+
+ expect(window.requestIdleCallback).not.toHaveBeenCalled();
+ expect(findContent().text()).toBe(CHUNK_1.highlightedContent);
+ });
+
+ it('does not render content if browser is not in idle state', () => {
+ idleCallbackSpy.mockRestore();
+ createComponent({ chunkIndex: 1, ...CHUNK_2 });
+
+ expect(findLineNumbers()).toHaveLength(0);
+ expect(findContent().exists()).toBe(false);
+ });
+
+ describe('isHighlighted is false', () => {
+ beforeEach(() => createComponent(CHUNK_2));
+
+ it('does not render line numbers', () => {
+ expect(findLineNumbers()).toHaveLength(0);
+ });
+
+ it('renders raw content', () => {
+ expect(findContent().text()).toBe(CHUNK_2.rawContent);
+ });
+ });
+
+ describe('isHighlighted is true', () => {
+ beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true }));
+
+ it('renders line numbers', () => {
+ expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines);
+
+ // Opted for a snapshot test here since the output is simple and verifies native HTML elements
+ expect(findLineNumbers().at(0).element).toMatchSnapshot();
+ });
+
+ it('renders highlighted content', () => {
+ expect(findContent().text()).toBe(CHUNK_2.highlightedContent);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
index ff50326917f..9e43aa1d707 100644
--- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
@@ -2,7 +2,27 @@ import { nextTick } from 'vue';
import { GlIntersectionObserver } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
-import { CHUNK_1, CHUNK_2 } from '../mock_data';
+import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
+import LineHighlighter from '~/blob/line_highlighter';
+
+const lineHighlighter = new LineHighlighter();
+jest.mock('~/blob/line_highlighter', () =>
+ jest.fn().mockReturnValue({
+ highlightHash: jest.fn(),
+ }),
+);
+
+const DEFAULT_PROPS = {
+ chunkIndex: 2,
+ isHighlighted: false,
+ content: '// Line 1 content \n // Line 2 content',
+ startingFrom: 140,
+ totalLines: 50,
+ language: 'javascript',
+ blamePath: 'blame/file.js',
+};
+
+const hash = '#L142';
describe('Chunk component', () => {
let wrapper;
@@ -10,12 +30,14 @@ describe('Chunk component', () => {
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(Chunk, {
- propsData: { ...CHUNK_1, ...props },
+ mocks: { $route: { hash } },
+ propsData: { ...DEFAULT_PROPS, ...props },
});
};
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
- const findLineNumbers = () => wrapper.findAllByTestId('line-numbers');
+ const findChunkLines = () => wrapper.findAllComponents(ChunkLine);
+ const findLineNumbers = () => wrapper.findAllByTestId('line-number');
const findContent = () => wrapper.findByTestId('content');
beforeEach(() => {
@@ -28,57 +50,72 @@ describe('Chunk component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
- it('renders highlighted content if appear event is emitted', async () => {
- createComponent({ chunkIndex: 1, isHighlighted: false });
+ it('emits an appear event when intersection-observer appears', () => {
findIntersectionObserver().vm.$emit('appear');
- await nextTick();
+ expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]);
+ });
- expect(findContent().exists()).toBe(true);
+ it('does not emit an appear event is isHighlighted is true', () => {
+ createComponent({ isHighlighted: true });
+ findIntersectionObserver().vm.$emit('appear');
+
+ expect(wrapper.emitted('appear')).toEqual(undefined);
});
});
describe('rendering', () => {
- it('does not register window.requestIdleCallback for the first chunk, renders content immediately', () => {
+ it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => {
jest.clearAllMocks();
+ createComponent({ isFirstChunk: true });
expect(window.requestIdleCallback).not.toHaveBeenCalled();
- expect(findContent().text()).toBe(CHUNK_1.highlightedContent);
+ expect(findContent().exists()).toBe(true);
+ });
+
+ it('does not render a Chunk Line component if isHighlighted is false', () => {
+ expect(findChunkLines().length).toBe(0);
});
- it('does not render content if browser is not in idle state', () => {
+ it('does not render simplified line numbers and content if browser is not in idle state', () => {
idleCallbackSpy.mockRestore();
- createComponent({ chunkIndex: 1, ...CHUNK_2 });
+ createComponent();
expect(findLineNumbers()).toHaveLength(0);
expect(findContent().exists()).toBe(false);
});
- describe('isHighlighted is false', () => {
- beforeEach(() => createComponent(CHUNK_2));
+ it('renders simplified line numbers and content if isHighlighted is false', () => {
+ expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines);
- it('does not render line numbers', () => {
- expect(findLineNumbers()).toHaveLength(0);
- });
+ expect(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`);
- it('renders raw content', () => {
- expect(findContent().text()).toBe(CHUNK_2.rawContent);
- });
+ expect(findContent().text()).toBe(DEFAULT_PROPS.content);
});
- describe('isHighlighted is true', () => {
- beforeEach(() => createComponent({ ...CHUNK_2, isHighlighted: true }));
+ it('renders Chunk Line components if isHighlighted is true', () => {
+ const splitContent = DEFAULT_PROPS.content.split('\n');
+ createComponent({ isHighlighted: true });
- it('renders line numbers', () => {
- expect(findLineNumbers()).toHaveLength(CHUNK_2.totalLines);
+ expect(findChunkLines().length).toBe(splitContent.length);
- // Opted for a snapshot test here since the output is simple and verifies native HTML elements
- expect(findLineNumbers().at(0).element).toMatchSnapshot();
+ expect(findChunkLines().at(0).props()).toMatchObject({
+ number: DEFAULT_PROPS.startingFrom + 1,
+ content: splitContent[0],
+ language: DEFAULT_PROPS.language,
+ blamePath: DEFAULT_PROPS.blamePath,
});
+ });
- it('renders highlighted content', () => {
- expect(findContent().text()).toBe(CHUNK_2.highlightedContent);
- });
+ it('does not scroll to route hash if last chunk is not loaded', () => {
+ expect(LineHighlighter).not.toHaveBeenCalled();
+ });
+
+ it('scrolls to route hash if last chunk is loaded', async () => {
+ createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 });
+ await nextTick();
+ expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
+ expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash);
});
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js
index 8d072c8c8de..9d2bf002d73 100644
--- a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js
@@ -6,9 +6,9 @@ describe('Highlight.js plugin for wrapping _emitter nodes', () => {
_emitter: {
rootNode: {
children: [
- { kind: 'string', children: ['Text 1'] },
- { kind: 'string', children: ['Text 2', { kind: 'comment', children: ['Text 3'] }] },
- { kind: undefined, sublanguage: true, children: ['Text 3 (sublanguage)'] },
+ { scope: 'string', children: ['Text 1'] },
+ { scope: 'string', children: ['Text 2', { scope: 'comment', children: ['Text 3'] }] },
+ { scope: undefined, sublanguage: true, children: ['Text 3 (sublanguage)'] },
'Text4\nText5',
],
},
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js
deleted file mode 100644
index 8419a0c5ddf..00000000000
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_deprecated_spec.js
+++ /dev/null
@@ -1,178 +0,0 @@
-import hljs from 'highlight.js/lib/core';
-import Vue from 'vue';
-import VueRouter from 'vue-router';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_deprecated.vue';
-import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index';
-import Chunk from '~/vue_shared/components/source_viewer/components/chunk_deprecated.vue';
-import {
- EVENT_ACTION,
- EVENT_LABEL_VIEWER,
- EVENT_LABEL_FALLBACK,
- ROUGE_TO_HLJS_LANGUAGE_MAP,
- LINES_PER_CHUNK,
- LEGACY_FALLBACKS,
-} from '~/vue_shared/components/source_viewer/constants';
-import waitForPromises from 'helpers/wait_for_promises';
-import LineHighlighter from '~/blob/line_highlighter';
-import eventHub from '~/notes/event_hub';
-import Tracking from '~/tracking';
-
-jest.mock('~/blob/line_highlighter');
-jest.mock('highlight.js/lib/core');
-jest.mock('~/vue_shared/components/source_viewer/plugins/index');
-Vue.use(VueRouter);
-const router = new VueRouter();
-
-const generateContent = (content, totalLines = 1, delimiter = '\n') => {
- let generatedContent = '';
- for (let i = 0; i < totalLines; i += 1) {
- generatedContent += `Line: ${i + 1} = ${content}${delimiter}`;
- }
- return generatedContent;
-};
-
-const execImmediately = (callback) => callback();
-
-describe('Source Viewer component', () => {
- let wrapper;
- const language = 'docker';
- const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language];
- const chunk1 = generateContent('// Some source code 1', 70);
- const chunk2 = generateContent('// Some source code 2', 70);
- const chunk3 = generateContent('// Some source code 3', 70, '\r\n');
- const chunk3Result = generateContent('// Some source code 3', 70, '\n');
- const content = chunk1 + chunk2 + chunk3;
- const path = 'some/path.js';
- const blamePath = 'some/blame/path.js';
- const fileType = 'javascript';
- const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType };
- const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
-
- const createComponent = async (blob = {}) => {
- wrapper = shallowMountExtended(SourceViewer, {
- router,
- propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } },
- });
- await waitForPromises();
- };
-
- const findChunks = () => wrapper.findAllComponents(Chunk);
-
- beforeEach(() => {
- hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
- hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
- jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
- jest.spyOn(eventHub, '$emit');
- jest.spyOn(Tracking, 'event');
-
- return createComponent();
- });
-
- describe('event tracking', () => {
- it('fires a tracking event when the component is created', () => {
- const eventData = { label: EVENT_LABEL_VIEWER, property: language };
- expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
- });
-
- it('does not emit an error event when the language is supported', () => {
- expect(wrapper.emitted('error')).toBeUndefined();
- });
-
- it('fires a tracking event and emits an error when the language is not supported', () => {
- const unsupportedLanguage = 'apex';
- const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage };
- createComponent({ language: unsupportedLanguage });
-
- expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
- expect(wrapper.emitted('error')).toHaveLength(1);
- });
- });
-
- describe('legacy fallbacks', () => {
- it.each(LEGACY_FALLBACKS)(
- 'tracks a fallback event and emits an error when viewing %s files',
- (fallbackLanguage) => {
- const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage };
- createComponent({ language: fallbackLanguage });
-
- expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
- expect(wrapper.emitted('error')).toHaveLength(1);
- },
- );
- });
-
- describe('highlight.js', () => {
- beforeEach(() => createComponent({ language: mappedLanguage }));
-
- it('registers our plugins for Highlight.js', () => {
- expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content);
- });
-
- it('registers the language definition', async () => {
- const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`);
-
- expect(hljs.registerLanguage).toHaveBeenCalledWith(
- mappedLanguage,
- languageDefinition.default,
- );
- });
-
- it('registers json language definition if fileType is package_json', async () => {
- await createComponent({ language: 'json', fileType: 'package_json' });
- const languageDefinition = await import(`highlight.js/lib/languages/json`);
-
- expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default);
- });
-
- it('correctly maps languages starting with uppercase', async () => {
- await createComponent({ language: 'Ruby' });
- const languageDefinition = await import(`highlight.js/lib/languages/ruby`);
-
- expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default);
- });
-
- it('highlights the first chunk', () => {
- expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage });
- expect(findChunks().at(0).props('isFirstChunk')).toBe(true);
- });
-
- describe('auto-detects if a language cannot be loaded', () => {
- beforeEach(() => createComponent({ language: 'some_unknown_language' }));
-
- it('highlights the content with auto-detection', () => {
- expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim());
- });
- });
- });
-
- describe('rendering', () => {
- it.each`
- chunkIndex | chunkContent | totalChunks
- ${0} | ${chunk1} | ${0}
- ${1} | ${chunk2} | ${3}
- ${2} | ${chunk3Result} | ${3}
- `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => {
- const chunk = findChunks().at(chunkIndex);
-
- expect(chunk.props('content')).toContain(chunkContent.trim());
-
- expect(chunk.props()).toMatchObject({
- totalLines: LINES_PER_CHUNK,
- startingFrom: LINES_PER_CHUNK * chunkIndex,
- totalChunks,
- });
- });
-
- it('emits showBlobInteractionZones on the eventHub when chunk appears', () => {
- findChunks().at(0).vm.$emit('appear');
- expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path);
- });
- });
-
- describe('LineHighlighter', () => {
- it('instantiates the lineHighlighter class', () => {
- expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js
new file mode 100644
index 00000000000..715234e56fd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js
@@ -0,0 +1,45 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer_new.vue';
+import Chunk from '~/vue_shared/components/source_viewer/components/chunk_new.vue';
+import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants';
+import Tracking from '~/tracking';
+import addBlobLinksTracking from '~/blob/blob_links_tracking';
+import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data';
+
+jest.mock('~/blob/blob_links_tracking');
+
+describe('Source Viewer component', () => {
+ let wrapper;
+ const CHUNKS_MOCK = [CHUNK_1, CHUNK_2];
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(SourceViewer, {
+ propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK },
+ });
+ };
+
+ const findChunks = () => wrapper.findAllComponents(Chunk);
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ return createComponent();
+ });
+
+ describe('event tracking', () => {
+ it('fires a tracking event when the component is created', () => {
+ const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK };
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ });
+
+ it('adds blob links tracking', () => {
+ expect(addBlobLinksTracking).toHaveBeenCalled();
+ });
+ });
+
+ describe('rendering', () => {
+ it('renders a Chunk component for each chunk', () => {
+ expect(findChunks().at(0).props()).toMatchObject(CHUNK_1);
+ expect(findChunks().at(1).props()).toMatchObject(CHUNK_2);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
index 46b582c3668..6b1d65c5a6a 100644
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
@@ -1,45 +1,192 @@
+import hljs from 'highlight.js/lib/core';
+import Vue from 'vue';
+import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
+import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index';
import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
-import { EVENT_ACTION, EVENT_LABEL_VIEWER } from '~/vue_shared/components/source_viewer/constants';
+import {
+ EVENT_ACTION,
+ EVENT_LABEL_VIEWER,
+ EVENT_LABEL_FALLBACK,
+ ROUGE_TO_HLJS_LANGUAGE_MAP,
+ LINES_PER_CHUNK,
+ LEGACY_FALLBACKS,
+ CODEOWNERS_FILE_NAME,
+ CODEOWNERS_LANGUAGE,
+} from '~/vue_shared/components/source_viewer/constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import LineHighlighter from '~/blob/line_highlighter';
+import eventHub from '~/notes/event_hub';
import Tracking from '~/tracking';
-import addBlobLinksTracking from '~/blob/blob_links_tracking';
-import { BLOB_DATA_MOCK, CHUNK_1, CHUNK_2, LANGUAGE_MOCK } from './mock_data';
-jest.mock('~/blob/blob_links_tracking');
+jest.mock('~/blob/line_highlighter');
+jest.mock('highlight.js/lib/core');
+jest.mock('~/vue_shared/components/source_viewer/plugins/index');
+Vue.use(VueRouter);
+const router = new VueRouter();
+
+const generateContent = (content, totalLines = 1, delimiter = '\n') => {
+ let generatedContent = '';
+ for (let i = 0; i < totalLines; i += 1) {
+ generatedContent += `Line: ${i + 1} = ${content}${delimiter}`;
+ }
+ return generatedContent;
+};
+
+const execImmediately = (callback) => callback();
describe('Source Viewer component', () => {
let wrapper;
- const CHUNKS_MOCK = [CHUNK_1, CHUNK_2];
+ const language = 'docker';
+ const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language];
+ const chunk1 = generateContent('// Some source code 1', 70);
+ const chunk2 = generateContent('// Some source code 2', 70);
+ const chunk3 = generateContent('// Some source code 3', 70, '\r\n');
+ const chunk3Result = generateContent('// Some source code 3', 70, '\n');
+ const content = chunk1 + chunk2 + chunk3;
+ const path = 'some/path.js';
+ const blamePath = 'some/blame/path.js';
+ const fileType = 'javascript';
+ const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, blamePath, fileType };
+ const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
- const createComponent = () => {
+ const createComponent = async (blob = {}) => {
wrapper = shallowMountExtended(SourceViewer, {
- propsData: { blob: BLOB_DATA_MOCK, chunks: CHUNKS_MOCK },
+ router,
+ propsData: { blob: { ...DEFAULT_BLOB_DATA, ...blob } },
});
+ await waitForPromises();
};
const findChunks = () => wrapper.findAllComponents(Chunk);
beforeEach(() => {
+ hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
+ hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
+ jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
+ jest.spyOn(eventHub, '$emit');
jest.spyOn(Tracking, 'event');
+
return createComponent();
});
describe('event tracking', () => {
it('fires a tracking event when the component is created', () => {
- const eventData = { label: EVENT_LABEL_VIEWER, property: LANGUAGE_MOCK };
+ const eventData = { label: EVENT_LABEL_VIEWER, property: language };
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ });
+
+ it('does not emit an error event when the language is supported', () => {
+ expect(wrapper.emitted('error')).toBeUndefined();
+ });
+
+ it('fires a tracking event and emits an error when the language is not supported', () => {
+ const unsupportedLanguage = 'apex';
+ const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage };
+ createComponent({ language: unsupportedLanguage });
+
expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ expect(wrapper.emitted('error')).toHaveLength(1);
+ });
+ });
+
+ describe('legacy fallbacks', () => {
+ it.each(LEGACY_FALLBACKS)(
+ 'tracks a fallback event and emits an error when viewing %s files',
+ (fallbackLanguage) => {
+ const eventData = { label: EVENT_LABEL_FALLBACK, property: fallbackLanguage };
+ createComponent({ language: fallbackLanguage });
+
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ expect(wrapper.emitted('error')).toHaveLength(1);
+ },
+ );
+ });
+
+ describe('highlight.js', () => {
+ beforeEach(() => createComponent({ language: mappedLanguage }));
+
+ it('registers our plugins for Highlight.js', () => {
+ expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content);
+ });
+
+ it('registers the language definition', async () => {
+ const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`);
+
+ expect(hljs.registerLanguage).toHaveBeenCalledWith(
+ mappedLanguage,
+ languageDefinition.default,
+ );
});
- it('adds blob links tracking', () => {
- expect(addBlobLinksTracking).toHaveBeenCalled();
+ it('registers json language definition if fileType is package_json', async () => {
+ await createComponent({ language: 'json', fileType: 'package_json' });
+ const languageDefinition = await import(`highlight.js/lib/languages/json`);
+
+ expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default);
+ });
+
+ it('correctly maps languages starting with uppercase', async () => {
+ await createComponent({ language: 'Ruby' });
+ const languageDefinition = await import(`highlight.js/lib/languages/ruby`);
+
+ expect(hljs.registerLanguage).toHaveBeenCalledWith('ruby', languageDefinition.default);
+ });
+
+ it('registers codeowners language definition if file name is CODEOWNERS', async () => {
+ await createComponent({ name: CODEOWNERS_FILE_NAME });
+ const languageDefinition = await import(
+ '~/vue_shared/components/source_viewer/languages/codeowners'
+ );
+
+ expect(hljs.registerLanguage).toHaveBeenCalledWith(
+ CODEOWNERS_LANGUAGE,
+ languageDefinition.default,
+ );
+ });
+
+ it('highlights the first chunk', () => {
+ expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage });
+ expect(findChunks().at(0).props('isFirstChunk')).toBe(true);
+ });
+
+ describe('auto-detects if a language cannot be loaded', () => {
+ beforeEach(() => createComponent({ language: 'some_unknown_language' }));
+
+ it('highlights the content with auto-detection', () => {
+ expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim());
+ });
});
});
describe('rendering', () => {
- it('renders a Chunk component for each chunk', () => {
- expect(findChunks().at(0).props()).toMatchObject(CHUNK_1);
- expect(findChunks().at(1).props()).toMatchObject(CHUNK_2);
+ it.each`
+ chunkIndex | chunkContent | totalChunks
+ ${0} | ${chunk1} | ${0}
+ ${1} | ${chunk2} | ${3}
+ ${2} | ${chunk3Result} | ${3}
+ `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => {
+ const chunk = findChunks().at(chunkIndex);
+
+ expect(chunk.props('content')).toContain(chunkContent.trim());
+
+ expect(chunk.props()).toMatchObject({
+ totalLines: LINES_PER_CHUNK,
+ startingFrom: LINES_PER_CHUNK * chunkIndex,
+ totalChunks,
+ });
+ });
+
+ it('emits showBlobInteractionZones on the eventHub when chunk appears', () => {
+ findChunks().at(0).vm.$emit('appear');
+ expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path);
+ });
+ });
+
+ describe('LineHighlighter', () => {
+ it('instantiates the lineHighlighter class', () => {
+ expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
});
});
});
diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
index d8dedd8240b..ecf6a776a4b 100644
--- a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
+import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
@@ -9,7 +9,8 @@ describe('Deploy freeze timezone dropdown', () => {
let wrapper;
let store;
- const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findSearchBox = () => wrapper.findByTestId('listbox-search-input');
const createComponent = async (searchTerm, selectedTimezone) => {
wrapper = shallowMountExtended(TimezoneDropdown, {
@@ -19,15 +20,18 @@ describe('Deploy freeze timezone dropdown', () => {
timezoneData: timezoneDataFixture,
name: 'user[timezone]',
},
+ stubs: {
+ GlCollapsibleListbox,
+ },
});
findSearchBox().vm.$emit('input', searchTerm);
await nextTick();
};
- const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
- const findEmptyResultsItem = () => wrapper.findByTestId('noMatchingResults');
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlListboxItem);
+ const findDropdownItemByIndex = (index) => findAllDropdownItems().at(index);
+ const findEmptyResultsItem = () => wrapper.findByTestId('listbox-no-results-text');
const findHiddenInput = () => wrapper.find('input');
describe('No time zones found', () => {
@@ -36,7 +40,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
it('renders empty results message', () => {
- expect(findDropdownItemByIndex(0).text()).toBe('No matching results');
+ expect(findEmptyResultsItem().exists()).toBe(true);
+ expect(findEmptyResultsItem().text()).toBe('No matching results');
});
});
@@ -69,11 +74,13 @@ describe('Deploy freeze timezone dropdown', () => {
const selectedTz = findTzByName('Alaska');
it('should emit input if a time zone is clicked', () => {
- findDropdownItemByIndex(0).vm.$emit('click');
+ const payload = formatTimezone(selectedTz);
+
+ findDropdown().vm.$emit('select', payload);
expect(wrapper.emitted('input')).toEqual([
[
{
- formattedTimezone: formatTimezone(selectedTz),
+ formattedTimezone: payload,
identifier: selectedTz.identifier,
},
],
@@ -88,7 +95,7 @@ describe('Deploy freeze timezone dropdown', () => {
});
it('renders empty selections', () => {
- expect(wrapper.findComponent(GlDropdown).props().text).toBe('Select timezone');
+ expect(findDropdown().props('toggleText')).toBe('Select timezone');
});
it('preserves initial value in the associated input', () => {
@@ -102,14 +109,14 @@ describe('Deploy freeze timezone dropdown', () => {
});
it('renders selected time zone as dropdown label', () => {
- expect(wrapper.findComponent(GlDropdown).props().text).toBe('[UTC+2] Berlin');
+ expect(findDropdown().props('toggleText')).toBe('[UTC+2] Berlin');
});
it('adds a checkmark to the selected option', async () => {
- const selectedTZOption = findAllDropdownItems().at(0);
- selectedTZOption.vm.$emit('click');
+ findDropdown().vm.$emit('select', formatTimezone(findTzByName('Abu Dhabi')));
await nextTick();
- expect(selectedTZOption.attributes('ischecked')).toBe('true');
+
+ expect(findDropdownItemByIndex(0).props('isSelected')).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js b/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js
deleted file mode 100644
index 76467c185db..00000000000
--- a/spec/frontend/vue_shared/components/truncated_text/truncated_text_spec.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { __ } from '~/locale';
-import TruncatedText from '~/vue_shared/components/truncated_text/truncated_text.vue';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-
-describe('TruncatedText', () => {
- let wrapper;
-
- const findContent = () => wrapper.findComponent({ ref: 'content' }).element;
- const findButton = () => wrapper.findComponent(GlButton);
-
- const createComponent = (propsData = {}) => {
- wrapper = shallowMount(TruncatedText, {
- propsData,
- directives: {
- GlResizeObserver: createMockDirective('gl-resize-observer'),
- },
- stubs: {
- GlButton,
- },
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- describe('when mounted', () => {
- it('the content has class `gl-truncate-text-by-line`', () => {
- expect(findContent().classList).toContain('gl-truncate-text-by-line');
- });
-
- it('the content has style variables for `lines` and `mobile-lines` with the correct values', () => {
- const { style } = findContent();
-
- expect(style).toContain('--lines');
- expect(style.getPropertyValue('--lines')).toBe('3');
- expect(style).toContain('--mobile-lines');
- expect(style.getPropertyValue('--mobile-lines')).toBe('10');
- });
-
- it('the button is not visible', () => {
- expect(findButton().exists()).toBe(false);
- });
- });
-
- describe('when mounted with a value for the lines property', () => {
- const lines = 4;
-
- beforeEach(() => {
- createComponent({ lines });
- });
-
- it('the lines variable has the value of the passed property', () => {
- expect(findContent().style.getPropertyValue('--lines')).toBe(lines.toString());
- });
- });
-
- describe('when mounted with a value for the mobileLines property', () => {
- const mobileLines = 4;
-
- beforeEach(() => {
- createComponent({ mobileLines });
- });
-
- it('the lines variable has the value of the passed property', () => {
- expect(findContent().style.getPropertyValue('--mobile-lines')).toBe(mobileLines.toString());
- });
- });
-
- describe('when resizing and the scroll height is smaller than the offset height', () => {
- beforeEach(() => {
- getBinding(findContent(), 'gl-resize-observer').value({
- target: { scrollHeight: 10, offsetHeight: 20 },
- });
- });
-
- it('the button remains invisible', () => {
- expect(findButton().exists()).toBe(false);
- });
- });
-
- describe('when resizing and the scroll height is greater than the offset height', () => {
- beforeEach(() => {
- getBinding(findContent(), 'gl-resize-observer').value({
- target: { scrollHeight: 20, offsetHeight: 10 },
- });
- });
-
- it('the button becomes visible', () => {
- expect(findButton().exists()).toBe(true);
- });
-
- it('the button text says "show more"', () => {
- expect(findButton().text()).toBe(__('Show more'));
- });
-
- describe('clicking the button', () => {
- beforeEach(() => {
- findButton().trigger('click');
- });
-
- it('removes the `gl-truncate-text-by-line` class on the content', () => {
- expect(findContent().classList).not.toContain('gl-truncate-text-by-line');
- });
-
- it('toggles the button text to "Show less"', () => {
- expect(findButton().text()).toBe(__('Show less'));
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index d888abc19ef..e54de25dc0d 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -1,21 +1,18 @@
-import { GlButton, GlModal } from '@gitlab/ui';
-import { nextTick } from 'vue';
+import { GlModal } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import getWritableForksResponse from 'test_fixtures/graphql/vue_shared/components/web_ide/get_writable_forks.query.graphql_none.json';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import WebIdeLink, {
- i18n,
- PREFERRED_EDITOR_RESET_KEY,
- PREFERRED_EDITOR_KEY,
-} from '~/vue_shared/components/web_ide_link.vue';
-import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
-import { KEY_WEB_IDE } from '~/vue_shared/components/constants';
+import WebIdeLink, { i18n } from '~/vue_shared/components/web_ide_link.vue';
+import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
-import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { visitUrl } from '~/lib/utils/url_utility';
+import getWritableForksQuery from '~/vue_shared/components/web_ide/get_writable_forks.query.graphql';
jest.mock('~/lib/utils/url_utility');
@@ -30,9 +27,8 @@ const forkPath = '/some/fork/path';
const ACTION_EDIT = {
href: TEST_EDIT_URL,
key: 'edit',
- text: 'Edit',
+ text: 'Edit single file',
secondaryText: 'Edit this file only.',
- tooltip: '',
attrs: {
'data-qa-selector': 'edit_button',
'data-track-action': 'click_consolidated_edit',
@@ -45,10 +41,8 @@ const ACTION_EDIT_CONFIRM_FORK = {
handle: expect.any(Function),
};
const ACTION_WEB_IDE = {
- href: TEST_WEB_IDE_URL,
key: 'webide',
secondaryText: i18n.webIdeText,
- tooltip: i18n.webIdeTooltip,
text: 'Web IDE',
attrs: {
'data-qa-selector': 'web_ide_button',
@@ -59,7 +53,6 @@ const ACTION_WEB_IDE = {
};
const ACTION_WEB_IDE_CONFIRM_FORK = {
...ACTION_WEB_IDE,
- href: '#modal-confirm-fork-webide',
handle: expect.any(Function),
};
const ACTION_WEB_IDE_EDIT_FORK = { ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' };
@@ -67,7 +60,6 @@ const ACTION_GITPOD = {
href: TEST_GITPOD_URL,
key: 'gitpod',
secondaryText: 'Launch a ready-to-code development environment for your project.',
- tooltip: 'Launch a ready-to-code development environment for your project.',
text: 'Gitpod',
attrs: {
'data-qa-selector': 'gitpod_button',
@@ -82,19 +74,21 @@ const ACTION_PIPELINE_EDITOR = {
href: TEST_PIPELINE_EDITOR_URL,
key: 'pipeline_editor',
secondaryText: 'Edit, lint, and visualize your pipeline.',
- tooltip: 'Edit, lint, and visualize your pipeline.',
text: 'Edit in pipeline editor',
attrs: {
'data-qa-selector': 'pipeline_editor_button',
},
};
-describe('Web IDE link component', () => {
- useLocalStorageSpy();
+describe('vue_shared/components/web_ide_link', () => {
+ Vue.use(VueApollo);
let wrapper;
function createComponent(props, { mountFn = shallowMountExtended, glFeatures = {} } = {}) {
+ const fakeApollo = createMockApollo([
+ [getWritableForksQuery, jest.fn().mockResolvedValue(getWritableForksResponse)],
+ ]);
wrapper = mountFn(WebIdeLink, {
propsData: {
editUrl: TEST_EDIT_URL,
@@ -117,15 +111,11 @@ describe('Web IDE link component', () => {
</div>`,
}),
},
+ apolloProvider: fakeApollo,
});
}
- beforeEach(() => {
- localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, 'true');
- });
-
const findActionsButton = () => wrapper.findComponent(ActionsButton);
- const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const findModal = () => wrapper.findComponent(GlModal);
const findForkConfirmModal = () => wrapper.findComponent(ConfirmForkModal);
@@ -238,64 +228,16 @@ describe('Web IDE link component', () => {
});
});
- it('selected Pipeline Editor by default', () => {
+ it('displays Pipeline Editor as the first action', () => {
expect(findActionsButton().props()).toMatchObject({
actions: [ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_GITPOD],
- selectedKey: ACTION_PIPELINE_EDITOR.key,
});
});
it('when web ide button is clicked it opens in a new tab', async () => {
- findActionsButton().props('actions')[1].handle({
- preventDefault: jest.fn(),
- });
+ findActionsButton().props('actions')[1].handle();
await nextTick();
- expect(visitUrl).toHaveBeenCalledWith(ACTION_WEB_IDE.href, true);
- });
- });
-
- describe('with multiple actions', () => {
- beforeEach(() => {
- createComponent({
- showEditButton: false,
- showWebIdeButton: true,
- showGitpodButton: true,
- showPipelineEditorButton: false,
- userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH,
- userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH,
- gitpodEnabled: true,
- });
- });
-
- it('selected Web IDE by default', () => {
- expect(findActionsButton().props()).toMatchObject({
- actions: [ACTION_WEB_IDE, ACTION_GITPOD],
- selectedKey: ACTION_WEB_IDE.key,
- });
- });
-
- it('should set selection with local storage value', async () => {
- expect(findActionsButton().props('selectedKey')).toBe(ACTION_WEB_IDE.key);
-
- findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key);
-
- await nextTick();
-
- expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key);
- });
-
- it('should update local storage when selection changes', async () => {
- expect(findLocalStorageSync().props()).toMatchObject({
- asString: true,
- value: ACTION_WEB_IDE.key,
- });
-
- findActionsButton().vm.$emit('select', ACTION_GITPOD.key);
-
- await nextTick();
-
- expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key);
- expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key);
+ expect(visitUrl).toHaveBeenCalledWith(TEST_WEB_IDE_URL, true);
});
});
@@ -348,7 +290,10 @@ describe('Web IDE link component', () => {
it.each(testActions)('opens the modal when the button is clicked', async ({ props }) => {
createComponent({ ...props, needsToFork: true }, { mountFn: mountExtended });
- await findActionsButton().findComponent(GlButton).trigger('click');
+ wrapper.findComponent(ActionsButton).props().actions[0].handle();
+
+ await nextTick();
+ await wrapper.findByRole('button', { name: /Web IDE|Edit/im }).trigger('click');
expect(findForkConfirmModal().props()).toEqual({
visible: true,
@@ -404,10 +349,8 @@ describe('Web IDE link component', () => {
{ mountFn: mountExtended },
);
- findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key);
-
await nextTick();
- await wrapper.findByRole('button', { name: gitpodText }).trigger('click');
+ await wrapper.findByRole('button', { name: new RegExp(gitpodText, 'm') }).trigger('click');
expect(findModal().props('visible')).toBe(true);
});
@@ -425,58 +368,4 @@ describe('Web IDE link component', () => {
expect(findModal().exists()).toBe(false);
});
});
-
- describe('when vscode_web_ide feature flag is enabled', () => {
- describe('when is not showing edit button', () => {
- describe(`when ${PREFERRED_EDITOR_RESET_KEY} is unset`, () => {
- beforeEach(() => {
- localStorage.setItem.mockReset();
- localStorage.getItem.mockReturnValueOnce(null);
- createComponent({ showEditButton: false }, { glFeatures: { vscodeWebIde: true } });
- });
-
- it(`sets ${PREFERRED_EDITOR_KEY} local storage key to ${KEY_WEB_IDE}`, () => {
- expect(localStorage.getItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY);
- expect(localStorage.setItem).toHaveBeenCalledWith(PREFERRED_EDITOR_KEY, KEY_WEB_IDE);
- });
-
- it(`sets ${PREFERRED_EDITOR_RESET_KEY} local storage key to true`, () => {
- expect(localStorage.setItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY, true);
- });
-
- it(`selects ${KEY_WEB_IDE} as the preferred editor`, () => {
- expect(findActionsButton().props().selectedKey).toBe(KEY_WEB_IDE);
- });
- });
-
- describe(`when ${PREFERRED_EDITOR_RESET_KEY} is set to true`, () => {
- beforeEach(() => {
- localStorage.setItem.mockReset();
- localStorage.getItem.mockReturnValueOnce('true');
- createComponent({ showEditButton: false }, { glFeatures: { vscodeWebIde: true } });
- });
-
- it(`does not update the persisted preferred editor`, () => {
- expect(localStorage.getItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY);
- expect(localStorage.setItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY);
- });
- });
- });
-
- describe('when is showing the edit button', () => {
- it(`does not try to reset the ${PREFERRED_EDITOR_KEY}`, () => {
- createComponent({ showEditButton: true }, { glFeatures: { vscodeWebIde: true } });
-
- expect(localStorage.getItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY);
- });
- });
- });
-
- describe('when vscode_web_ide feature flag is disabled', () => {
- it(`does not try to reset the ${PREFERRED_EDITOR_KEY}`, () => {
- createComponent({}, { glFeatures: { vscodeWebIde: false } });
-
- expect(localStorage.getItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY);
- });
- });
});
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
index ec975dfdcb5..68904603f40 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
@@ -7,6 +7,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue';
import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import issuableGrid from '~/vue_shared/issuable/list/components/issuable_grid.vue';
import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
@@ -43,6 +44,7 @@ describe('IssuableListRoot', () => {
const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findGlPagination = () => wrapper.findComponent(GlPagination);
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
+ const findIssuableGrid = () => wrapper.findComponent(issuableGrid);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
const findVueDraggable = () => wrapper.findComponent(VueDraggable);
const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector);
@@ -514,4 +516,18 @@ describe('IssuableListRoot', () => {
expect(wrapper.emitted('page-size-change')).toEqual([[pageSize]]);
});
});
+
+ describe('grid view issue', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ props: {
+ isGridView: true,
+ },
+ });
+ });
+
+ it('renders issuableGrid', () => {
+ expect(findIssuableGrid().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
index b87ae8a232f..abc69da7a58 100644
--- a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
+++ b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
@@ -4,6 +4,7 @@ import { nextTick } from 'vue';
import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue';
import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
+import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue';
import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue';
import { sidebarState } from '~/super_sidebar/constants';
@@ -14,6 +15,7 @@ describe('Experimental new namespace creation app', () => {
const findWelcomePage = () => wrapper.findComponent(WelcomePage);
const findLegacyContainer = () => wrapper.findComponent(LegacyContainer);
const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb);
+ const findNewTopLevelGroupAlert = () => wrapper.findComponent(NewTopLevelGroupAlert);
const findSuperSidebarToggle = () => wrapper.findComponent(SuperSidebarToggle);
const DEFAULT_PROPS = {
@@ -125,4 +127,39 @@ describe('Experimental new namespace creation app', () => {
expect(findSuperSidebarToggle().exists()).toBe(isToggleVisible);
});
});
+
+ describe('top level group alert', () => {
+ beforeEach(() => {
+ window.location.hash = `#${DEFAULT_PROPS.panels[0].name}`;
+ });
+
+ describe('when self-managed', () => {
+ it('does not render alert', () => {
+ createComponent();
+
+ expect(findNewTopLevelGroupAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when on .com', () => {
+ it('does not render alert', () => {
+ createComponent({ propsData: { isSaas: true } });
+
+ expect(findNewTopLevelGroupAlert().exists()).toBe(false);
+ });
+
+ describe('when empty parent group name', () => {
+ it('renders alert', () => {
+ createComponent({
+ propsData: {
+ isSaas: true,
+ panels: [{ ...DEFAULT_PROPS.panels[0], detailProps: { parentGroupName: '' } }],
+ },
+ });
+
+ expect(findNewTopLevelGroupAlert().exists()).toBe(true);
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js
index 000b07f4dfd..b74473b5494 100644
--- a/spec/frontend/whats_new/components/app_spec.js
+++ b/spec/frontend/whats_new/components/app_spec.js
@@ -5,6 +5,7 @@ import Vuex from 'vuex';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import App from '~/whats_new/components/app.vue';
+import SkeletonLoader from '~/whats_new/components/skeleton_loader.vue';
import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height';
const MOCK_DRAWER_BODY_HEIGHT = 42;
@@ -38,6 +39,7 @@ describe('App', () => {
open: true,
features: [],
drawerBodyHeight: null,
+ fetching: false,
};
store = new Vuex.Store({
@@ -55,18 +57,18 @@ describe('App', () => {
};
const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll);
+ const findSkeletonLoader = () => wrapper.findComponent(SkeletonLoader);
- const setup = async () => {
+ const setup = async (features, fetching) => {
document.body.dataset.page = 'test-page';
document.body.dataset.namespaceId = 'namespace-840';
trackingSpy = mockTracking('_category_', null, jest.spyOn);
buildWrapper();
- wrapper.vm.$store.state.features = [
- { name: 'Whats New Drawer', documentation_link: 'www.url.com', release: 3.11 },
- ];
- wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT;
+ store.state.features = features;
+ store.state.fetching = fetching;
+ store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT;
await nextTick();
};
@@ -75,110 +77,144 @@ describe('App', () => {
});
describe('gitlab.com', () => {
- beforeEach(() => {
- setup();
- });
+ describe('with features', () => {
+ beforeEach(() => {
+ setup(
+ [{ name: 'Whats New Drawer', documentation_link: 'www.url.com', release: 3.11 }],
+ false,
+ );
+ });
- const getDrawer = () => wrapper.findComponent(GlDrawer);
- const getBackdrop = () => wrapper.find('.whats-new-modal-backdrop');
+ const getDrawer = () => wrapper.findComponent(GlDrawer);
+ const getBackdrop = () => wrapper.find('.whats-new-modal-backdrop');
- it('contains a drawer', () => {
- expect(getDrawer().exists()).toBe(true);
- });
+ it('contains a drawer', () => {
+ expect(getDrawer().exists()).toBe(true);
+ });
- it('dispatches openDrawer and tracking calls when mounted', () => {
- expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'version-digest');
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
- label: 'namespace_id',
- property: 'navigation_top',
- value: 'namespace-840',
+ it('dispatches openDrawer and tracking calls when mounted', () => {
+ expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'version-digest');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
+ label: 'namespace_id',
+ property: 'navigation_top',
+ value: 'namespace-840',
+ });
});
- });
- it('dispatches closeDrawer when clicking close', () => {
- getDrawer().vm.$emit('close');
- expect(actions.closeDrawer).toHaveBeenCalled();
- });
+ it('dispatches closeDrawer when clicking close', () => {
+ getDrawer().vm.$emit('close');
+ expect(actions.closeDrawer).toHaveBeenCalled();
+ });
- it('dispatches closeDrawer when clicking the backdrop', () => {
- getBackdrop().trigger('click');
- expect(actions.closeDrawer).toHaveBeenCalled();
- });
+ it('dispatches closeDrawer when clicking the backdrop', () => {
+ getBackdrop().trigger('click');
+ expect(actions.closeDrawer).toHaveBeenCalled();
+ });
- it.each([true, false])('passes open property', async (openState) => {
- wrapper.vm.$store.state.open = openState;
+ it.each([true, false])('passes open property', async (openState) => {
+ store.state.open = openState;
- await nextTick();
+ await nextTick();
- expect(getDrawer().props('open')).toBe(openState);
- });
+ expect(getDrawer().props('open')).toBe(openState);
+ });
- it('renders features when provided via ajax', () => {
- expect(actions.fetchItems).toHaveBeenCalled();
- expect(wrapper.find('[data-test-id="feature-name"]').text()).toBe('Whats New Drawer');
- });
+ it('renders features when provided via ajax', () => {
+ expect(actions.fetchItems).toHaveBeenCalled();
+ expect(wrapper.find('[data-test-id="feature-name"]').text()).toBe('Whats New Drawer');
+ });
- it('send an event when feature item is clicked', () => {
- trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ it('send an event when feature item is clicked', () => {
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
- const link = wrapper.find('.whats-new-item-title-link');
- triggerEvent(link.element);
+ const link = wrapper.find('.whats-new-item-title-link');
+ triggerEvent(link.element);
- expect(trackingSpy.mock.calls[1]).toMatchObject([
- '_category_',
- 'click_whats_new_item',
- {
- label: 'Whats New Drawer',
- property: 'www.url.com',
- },
- ]);
- });
+ expect(trackingSpy.mock.calls[1]).toMatchObject([
+ '_category_',
+ 'click_whats_new_item',
+ {
+ label: 'Whats New Drawer',
+ property: 'www.url.com',
+ },
+ ]);
+ });
- it('renders infinite scroll', () => {
- const scroll = findInfiniteScroll();
+ it('renders infinite scroll', () => {
+ const scroll = findInfiniteScroll();
+ const skeletonLoader = findSkeletonLoader();
- expect(scroll.props()).toMatchObject({
- fetchedItems: wrapper.vm.$store.state.features.length,
- maxListHeight: MOCK_DRAWER_BODY_HEIGHT,
+ expect(skeletonLoader.exists()).toBe(false);
+
+ expect(scroll.props()).toMatchObject({
+ fetchedItems: store.state.features.length,
+ maxListHeight: MOCK_DRAWER_BODY_HEIGHT,
+ });
});
- });
- describe('bottomReached', () => {
- const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
+ describe('bottomReached', () => {
+ const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
- beforeEach(() => {
- actions.fetchItems.mockClear();
- });
+ beforeEach(() => {
+ actions.fetchItems.mockClear();
+ });
+
+ it('when nextPage exists it calls fetchItems', () => {
+ store.state.pageInfo = { nextPage: 840 };
+ emitBottomReached();
+
+ expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), {
+ page: 840,
+ versionDigest: 'version-digest',
+ });
+ });
- it('when nextPage exists it calls fetchItems', () => {
- wrapper.vm.$store.state.pageInfo = { nextPage: 840 };
- emitBottomReached();
+ it('when nextPage does not exist it does not call fetchItems', () => {
+ store.state.pageInfo = { nextPage: null };
+ emitBottomReached();
- expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), {
- page: 840,
- versionDigest: 'version-digest',
+ expect(actions.fetchItems).not.toHaveBeenCalled();
});
});
- it('when nextPage does not exist it does not call fetchItems', () => {
- wrapper.vm.$store.state.pageInfo = { nextPage: null };
- emitBottomReached();
+ it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => {
+ const { value } = getBinding(getDrawer().element, 'gl-resize-observer');
- expect(actions.fetchItems).not.toHaveBeenCalled();
+ value();
+
+ expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.findComponent(GlDrawer).element);
+
+ expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith(
+ expect.any(Object),
+ MOCK_DRAWER_BODY_HEIGHT,
+ );
});
});
- it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => {
- const { value } = getBinding(getDrawer().element, 'gl-resize-observer');
+ describe('without features', () => {
+ it('renders skeleton loader when fetching', async () => {
+ setup([], true);
+
+ await nextTick();
+
+ const scroll = findInfiniteScroll();
+ const skeletonLoader = findSkeletonLoader();
- value();
+ expect(scroll.exists()).toBe(false);
+ expect(skeletonLoader.exists()).toBe(true);
+ });
+
+ it('renders infinite scroll loader when NOT fetching', async () => {
+ setup([], false);
- expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.findComponent(GlDrawer).element);
+ await nextTick();
- expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith(
- expect.any(Object),
- MOCK_DRAWER_BODY_HEIGHT,
- );
+ const scroll = findInfiniteScroll();
+ const skeletonLoader = findSkeletonLoader();
+
+ expect(scroll.exists()).toBe(true);
+ expect(skeletonLoader.exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js
index 8b5663ee764..020d833c578 100644
--- a/spec/frontend/whats_new/utils/notification_spec.js
+++ b/spec/frontend/whats_new/utils/notification_spec.js
@@ -38,6 +38,7 @@ describe('~/whats_new/utils/notification', () => {
it('removes class and count element when storage key has current digest', () => {
const notificationEl = findNotificationEl();
+
notificationEl.classList.add('with-notifications');
localStorage.setItem('display-whats-new-notification', 'version-digest');
@@ -48,6 +49,20 @@ describe('~/whats_new/utils/notification', () => {
expect(findNotificationCountEl()).toBe(null);
expect(notificationEl.classList).not.toContain('with-notifications');
});
+
+ it('removes class and count element when no records and digest undefined', () => {
+ const notificationEl = findNotificationEl();
+
+ notificationEl.classList.add('with-notifications');
+ localStorage.setItem('display-whats-new-notification', 'version-digest');
+
+ expect(findNotificationCountEl()).not.toBe(null);
+
+ setNotification(wrapper.querySelector('[data-testid="without-digest"]'));
+
+ expect(findNotificationCountEl()).toBe(null);
+ expect(notificationEl.classList).not.toContain('with-notifications');
+ });
});
describe('getVersionDigest', () => {
diff --git a/spec/frontend/work_items/components/notes/system_note_spec.js b/spec/frontend/work_items/components/notes/system_note_spec.js
index fd5f373d076..03f1aa356ad 100644
--- a/spec/frontend/work_items/components/notes/system_note_spec.js
+++ b/spec/frontend/work_items/components/notes/system_note_spec.js
@@ -1,54 +1,32 @@
import { GlIcon } from '@gitlab/ui';
-import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
-import waitForPromises from 'helpers/wait_for_promises';
-import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import MockAdapter from 'axios-mock-adapter';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import WorkItemSystemNote from '~/work_items/components/notes/system_note.vue';
-import NoteHeader from '~/notes/components/note_header.vue';
+import { workItemSystemNoteWithMetadata } from 'jest/work_items/mock_data';
import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
jest.mock('~/behaviors/markdown/render_gfm');
-describe('system note component', () => {
+describe('Work Items system note component', () => {
let wrapper;
- let props;
let mock;
- const findTimelineIcon = () => wrapper.findComponent(GlIcon);
- const findSystemNoteMessage = () => wrapper.findComponent(NoteHeader);
- const findOutdatedLineButton = () =>
- wrapper.findComponent('[data-testid="outdated-lines-change-btn"]');
- const findOutdatedLines = () => wrapper.findComponent('[data-testid="outdated-lines"]');
+ const createComponent = ({ note = workItemSystemNoteWithMetadata } = {}) => {
+ mock = new MockAdapter(axios);
- const createComponent = (propsData = {}) => {
wrapper = shallowMount(WorkItemSystemNote, {
- propsData,
- slots: {
- 'extra-controls':
- '<gl-button data-testid="outdated-lines-change-btn">Compare with last version</gl-button>',
+ propsData: {
+ note,
},
});
};
- beforeEach(() => {
- props = {
- note: {
- id: '1424',
- author: {
- id: 1,
- name: 'Root',
- username: 'root',
- state: 'active',
- avatarUrl: 'path',
- path: '/root',
- },
- bodyHtml: '<p dir="auto">closed</p>',
- systemNoteIconName: 'status_closed',
- createdAt: '2017-08-02T10:51:58.559Z',
- },
- };
+ const findTimelineIcon = () => wrapper.findComponent(GlIcon);
+ const findComparePreviousVersionButton = () => wrapper.find('[data-testid="compare-btn"]');
+ beforeEach(() => {
+ createComponent();
mock = new MockAdapter(axios);
});
@@ -57,56 +35,16 @@ describe('system note component', () => {
});
it('should render a list item with correct id', () => {
- createComponent(props);
-
- expect(wrapper.attributes('id')).toBe(`note_${props.note.id}`);
- });
-
- // Note: The test case below is to handle a use case related to vuex store but since this does not
- // have a vuex store , disabling it now will be fixing it in the next iteration
- // eslint-disable-next-line jest/no-disabled-tests
- it.skip('should render target class is note is target note', () => {
- createComponent(props);
-
- expect(wrapper.classes()).toContain('target');
+ expect(wrapper.attributes('id')).toBe(
+ `note_${getIdFromGraphQLId(workItemSystemNoteWithMetadata.id)}`,
+ );
});
it('should render svg icon', () => {
- createComponent(props);
-
expect(findTimelineIcon().exists()).toBe(true);
});
- // Redcarpet Markdown renderer wraps text in `<p>` tags
- // we need to strip them because they break layout of commit lists in system notes:
- // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png
- it('removes wrapping paragraph from note HTML', () => {
- createComponent(props);
-
- expect(findSystemNoteMessage().html()).toContain('<span>closed</span>');
- });
-
- it('should renderGFM onMount', () => {
- createComponent(props);
-
- expect(renderGFM).toHaveBeenCalled();
- });
-
- // eslint-disable-next-line jest/no-disabled-tests
- it.skip('renders outdated code lines', async () => {
- mock
- .onGet('/outdated_line_change_path')
- .reply(HTTP_STATUS_OK, [
- { rich_text: 'console.log', type: 'new', line_code: '123', old_line: null, new_line: 1 },
- ]);
-
- createComponent({
- note: { ...props.note, outdated_line_change_path: '/outdated_line_change_path' },
- });
-
- await findOutdatedLineButton().vm.$emit('click');
- await waitForPromises();
-
- expect(findOutdatedLines().exists()).toBe(true);
+ it('should not show compare previous version for FOSS', () => {
+ expect(findComparePreviousVersionButton().exists()).toBe(false);
});
});
diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js
index 739340f4936..e6d20dcb0d9 100644
--- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js
@@ -32,15 +32,18 @@ describe('Work item add note', () => {
const findCommentForm = () => wrapper.findComponent(WorkItemCommentForm);
const findTextarea = () => wrapper.findByTestId('note-reply-textarea');
+ const findWorkItemLockedComponent = () => wrapper.findComponent(WorkItemCommentLocked);
const createComponent = async ({
mutationHandler = mutationSuccessHandler,
canUpdate = true,
+ canCreateNote = true,
workItemIid = '1',
- workItemResponse = workItemByIidResponseFactory({ canUpdate }),
+ workItemResponse = workItemByIidResponseFactory({ canUpdate, canCreateNote }),
signedIn = true,
isEditing = true,
workItemType = 'Task',
+ isInternalThread = false,
} = {}) => {
workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
if (signedIn) {
@@ -65,6 +68,7 @@ describe('Work item add note', () => {
workItemType,
markdownPreviewPath: '/group/project/preview_markdown?target_type=WorkItem',
autocompleteDataSources: {},
+ isInternalThread,
},
stubs: {
WorkItemCommentLocked,
@@ -79,142 +83,170 @@ describe('Work item add note', () => {
};
describe('adding a comment', () => {
- it('calls update widgets mutation', async () => {
- const noteText = 'updated desc';
-
- await createComponent({
- isEditing: true,
- signedIn: true,
+ describe.each`
+ isInternalComment
+ ${false}
+ ${true}
+ `('when internal comment is $isInternalComment', ({ isInternalComment }) => {
+ it('calls update widgets mutation', async () => {
+ const noteText = 'updated desc';
+
+ await createComponent({
+ isEditing: true,
+ signedIn: true,
+ });
+
+ findCommentForm().vm.$emit('submitForm', {
+ commentText: noteText,
+ isNoteInternal: isInternalComment,
+ });
+
+ await waitForPromises();
+
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ noteableId: workItemId,
+ body: noteText,
+ discussionId: null,
+ internal: isInternalComment,
+ },
+ });
});
- findCommentForm().vm.$emit('submitForm', noteText);
+ it('tracks adding comment', async () => {
+ await createComponent();
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- await waitForPromises();
+ findCommentForm().vm.$emit('submitForm', {
+ commentText: 'test',
+ isNoteInternal: isInternalComment,
+ });
- expect(mutationSuccessHandler).toHaveBeenCalledWith({
- input: {
- noteableId: workItemId,
- body: noteText,
- discussionId: null,
- },
- });
- });
+ await waitForPromises();
- it('tracks adding comment', async () => {
- await createComponent();
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_comment',
+ property: 'type_Task',
+ });
+ });
- findCommentForm().vm.$emit('submitForm', 'test');
+ it('emits `replied` event and hides form after successful mutation', async () => {
+ await createComponent({ isEditing: true, signedIn: true });
- await waitForPromises();
+ findCommentForm().vm.$emit('submitForm', {
+ commentText: 'some text',
+ isNoteInternal: isInternalComment,
+ });
+ await waitForPromises();
- expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'add_work_item_comment', {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_comment',
- property: 'type_Task',
+ expect(wrapper.emitted('replied')).toEqual([[]]);
});
- });
-
- it('emits `replied` event and hides form after successful mutation', async () => {
- await createComponent({ isEditing: true, signedIn: true });
- findCommentForm().vm.$emit('submitForm', 'some text');
- await waitForPromises();
+ it('clears a draft after successful mutation', async () => {
+ await createComponent({
+ isEditing: true,
+ signedIn: true,
+ });
- expect(wrapper.emitted('replied')).toEqual([[]]);
- });
+ findCommentForm().vm.$emit('submitForm', {
+ commentText: 'some text',
+ isNoteInternal: isInternalComment,
+ });
+ await waitForPromises();
- it('clears a draft after successful mutation', async () => {
- await createComponent({
- isEditing: true,
- signedIn: true,
+ expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment');
});
- findCommentForm().vm.$emit('submitForm', 'some text');
- await waitForPromises();
-
- expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment');
- });
+ it('emits error when mutation returns error', async () => {
+ const error = 'eror';
- it('emits error when mutation returns error', async () => {
- const error = 'eror';
-
- await createComponent({
- isEditing: true,
- mutationHandler: jest.fn().mockResolvedValue({
- data: {
- createNote: {
- note: {
- id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
- discussion: {
+ await createComponent({
+ isEditing: true,
+ mutationHandler: jest.fn().mockResolvedValue({
+ data: {
+ createNote: {
+ note: {
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
- notes: {
- nodes: [],
- __typename: 'NoteConnection',
+ discussion: {
+ id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
+ notes: {
+ nodes: [],
+ __typename: 'NoteConnection',
+ },
+ __typename: 'Discussion',
},
- __typename: 'Discussion',
+ __typename: 'Note',
},
- __typename: 'Note',
+ __typename: 'CreateNotePayload',
+ errors: [error],
},
- __typename: 'CreateNotePayload',
- errors: [error],
},
- },
- }),
- });
+ }),
+ });
- findCommentForm().vm.$emit('submitForm', 'updated desc');
+ findCommentForm().vm.$emit('submitForm', {
+ commentText: 'updated desc',
+ isNoteInternal: isInternalComment,
+ });
- await waitForPromises();
+ await waitForPromises();
- expect(wrapper.emitted('error')).toEqual([[error]]);
- });
+ expect(wrapper.emitted('error')).toEqual([[error]]);
+ });
- it('emits error when mutation fails', async () => {
- const error = 'eror';
+ it('emits error when mutation fails', async () => {
+ const error = 'eror';
- await createComponent({
- isEditing: true,
- mutationHandler: jest.fn().mockRejectedValue(new Error(error)),
- });
+ await createComponent({
+ isEditing: true,
+ mutationHandler: jest.fn().mockRejectedValue(new Error(error)),
+ });
- findCommentForm().vm.$emit('submitForm', 'updated desc');
+ findCommentForm().vm.$emit('submitForm', {
+ commentText: 'updated desc',
+ isNoteInternal: isInternalComment,
+ });
- await waitForPromises();
+ await waitForPromises();
- expect(wrapper.emitted('error')).toEqual([[error]]);
- });
+ expect(wrapper.emitted('error')).toEqual([[error]]);
+ });
- it('ignores errors when mutation returns additional information as errors for quick actions', async () => {
- await createComponent({
- isEditing: true,
- mutationHandler: jest.fn().mockResolvedValue({
- data: {
- createNote: {
- note: {
- id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
- discussion: {
+ it('ignores errors when mutation returns additional information as errors for quick actions', async () => {
+ await createComponent({
+ isEditing: true,
+ mutationHandler: jest.fn().mockResolvedValue({
+ data: {
+ createNote: {
+ note: {
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
- notes: {
- nodes: [],
- __typename: 'NoteConnection',
+ discussion: {
+ id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
+ notes: {
+ nodes: [],
+ __typename: 'NoteConnection',
+ },
+ __typename: 'Discussion',
},
- __typename: 'Discussion',
+ __typename: 'Note',
},
- __typename: 'Note',
+ __typename: 'CreateNotePayload',
+ errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'],
},
- __typename: 'CreateNotePayload',
- errors: ['Commands only Removed assignee @foobar.', 'Command names ["unassign"]'],
},
- },
- }),
- });
+ }),
+ });
- findCommentForm().vm.$emit('submitForm', 'updated desc');
+ findCommentForm().vm.$emit('submitForm', {
+ commentText: 'updated desc',
+ isNoteInternal: isInternalComment,
+ });
- await waitForPromises();
+ await waitForPromises();
- expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment');
+ expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment');
+ });
});
});
@@ -225,8 +257,23 @@ describe('Work item add note', () => {
});
it('skips calling the work item query when missing workItemIid', async () => {
- await createComponent({ workItemIid: null, isEditing: false });
+ await createComponent({ workItemIid: '', isEditing: false });
expect(workItemResponseHandler).not.toHaveBeenCalled();
});
+
+ it('wrapper adds `internal-note` class when internal thread', async () => {
+ await createComponent({ isInternalThread: true });
+
+ expect(wrapper.attributes('class')).toContain('internal-note');
+ });
+
+ describe('when work item`createNote` permission false', () => {
+ it('cannot add comment', async () => {
+ await createComponent({ isEditing: false, canCreateNote: false });
+
+ expect(findWorkItemLockedComponent().exists()).toBe(true);
+ expect(findCommentForm().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
index 147f2904761..6c00d52aac5 100644
--- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
@@ -1,6 +1,8 @@
+import { GlFormCheckbox, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { createMockDirective } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
import * as autosave from '~/lib/utils/autosave';
import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys';
@@ -40,6 +42,8 @@ describe('Work item comment form component', () => {
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]');
+ const findInternalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+ const findInternalNoteTooltipIcon = () => wrapper.findComponent(GlIcon);
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
@@ -68,6 +72,9 @@ describe('Work item comment form component', () => {
provide: {
fullPath: 'test-project-path',
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
@@ -168,7 +175,9 @@ describe('Work item comment form component', () => {
createComponent();
findConfirmButton().vm.$emit('click');
- expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]);
+ expect(wrapper.emitted('submitForm')).toEqual([
+ [{ commentText: draftComment, isNoteInternal: false }],
+ ]);
});
it('emits `submitForm` event on pressing enter with meta key on markdown editor', () => {
@@ -178,7 +187,9 @@ describe('Work item comment form component', () => {
new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }),
);
- expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]);
+ expect(wrapper.emitted('submitForm')).toEqual([
+ [{ commentText: draftComment, isNoteInternal: false }],
+ ]);
});
it('emits `submitForm` event on pressing ctrl+enter on markdown editor', () => {
@@ -188,7 +199,9 @@ describe('Work item comment form component', () => {
new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }),
);
- expect(wrapper.emitted('submitForm')).toEqual([[draftComment]]);
+ expect(wrapper.emitted('submitForm')).toEqual([
+ [{ commentText: draftComment, isNoteInternal: false }],
+ ]);
});
describe('when used as a top level/is a new discussion', () => {
@@ -249,4 +262,36 @@ describe('Work item comment form component', () => {
});
});
});
+
+ describe('internal note', () => {
+ it('internal note checkbox should not be visible by default', () => {
+ createComponent();
+
+ expect(findInternalNoteCheckbox().exists()).toBe(false);
+ });
+
+ describe('when used as a new discussion', () => {
+ beforeEach(() => {
+ createComponent({ isNewDiscussion: true });
+ });
+
+ it('should have the add as internal note capability', () => {
+ expect(findInternalNoteCheckbox().exists()).toBe(true);
+ });
+
+ it('should have the tooltip explaining the internal note capabilities', () => {
+ expect(findInternalNoteTooltipIcon().exists()).toBe(true);
+ expect(findInternalNoteTooltipIcon().attributes('title')).toBe(
+ WorkItemCommentForm.i18n.internalVisibility,
+ );
+ });
+
+ it('should change the submit button text on change of value', async () => {
+ findInternalNoteCheckbox().vm.$emit('input', true);
+ await nextTick();
+
+ expect(findConfirmButton().text()).toBe(WorkItemCommentForm.i18n.addInternalNote);
+ });
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js
index fac5011b6af..9d22a64f2cb 100644
--- a/spec/frontend/work_items/components/notes/work_item_discussion_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_discussion_spec.js
@@ -90,6 +90,16 @@ describe('Work Item Discussion', () => {
expect(findWorkItemAddNote().exists()).toBe(true);
expect(findWorkItemAddNote().props('autofocus')).toBe(true);
});
+
+ it('should send the correct props is when the main comment is internal', async () => {
+ const mainComment = findThreadAtIndex(0);
+
+ mainComment.vm.$emit('startReplying');
+ await nextTick();
+ expect(findWorkItemAddNote().props('isInternalThread')).toBe(
+ mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes[0].internal,
+ );
+ });
});
describe('When replying to any comment', () => {
@@ -115,6 +125,13 @@ describe('Work Item Discussion', () => {
expect(findToggleRepliesWidget().exists()).toBe(true);
expect(findToggleRepliesWidget().props('collapsed')).toBe(false);
});
+
+ it('should pass `is-internal-note` props to make sure the correct background is set', () => {
+ expect(findWorkItemNoteReplying().exists()).toBe(true);
+ expect(findWorkItemNoteReplying().props('isInternalNote')).toBe(
+ mockWorkItemNotesWidgetResponseWithComments.discussions.nodes[0].notes.nodes[0].internal,
+ );
+ });
});
it('emits `deleteNote` event with correct parameter when child note component emits `deleteNote` event', () => {
diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
index 99bf391e261..2e901783e07 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
@@ -1,8 +1,9 @@
-import { GlDropdown } from '@gitlab/ui';
+import { GlDisclosureDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective } from 'helpers/vue_mock_directive';
import EmojiPicker from '~/emoji/components/picker.vue';
import waitForPromises from 'helpers/wait_for_promises';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
@@ -18,11 +19,14 @@ describe('Work Item Note Actions', () => {
const findReplyButton = () => wrapper.findComponent(ReplyButton);
const findEditButton = () => wrapper.find('[data-testid="edit-work-item-note"]');
const findEmojiButton = () => wrapper.find('[data-testid="note-emoji-button"]');
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]');
const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]');
const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]');
const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]');
+ const findAuthorBadge = () => wrapper.find('[data-testid="author-badge"]');
+ const findMaxAccessLevelBadge = () => wrapper.find('[data-testid="max-access-level-badge"]');
+ const findContributorBadge = () => wrapper.find('[data-testid="contributor-badge"]');
const addEmojiMutationResolver = jest.fn().mockResolvedValue({
data: {
@@ -41,6 +45,11 @@ describe('Work Item Note Actions', () => {
showAwardEmoji = true,
showAssignUnassign = false,
canReportAbuse = false,
+ workItemType = 'Task',
+ isWorkItemAuthor = false,
+ isAuthorContributor = false,
+ maxAccessLevelOfAuthor = '',
+ projectName = 'Project name',
} = {}) => {
wrapper = shallowMount(WorkItemNoteActions, {
propsData: {
@@ -50,6 +59,11 @@ describe('Work Item Note Actions', () => {
showAwardEmoji,
showAssignUnassign,
canReportAbuse,
+ workItemType,
+ isWorkItemAuthor,
+ isAuthorContributor,
+ maxAccessLevelOfAuthor,
+ projectName,
},
provide: {
glFeatures: {
@@ -60,7 +74,11 @@ describe('Work Item Note Actions', () => {
EmojiPicker: EmojiPickerStub,
},
apolloProvider: createMockApollo([[addAwardEmojiMutation, addEmojiMutationResolver]]),
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
+ wrapper.vm.$refs.dropdown.close = jest.fn();
};
describe('reply button', () => {
@@ -152,7 +170,7 @@ describe('Work Item Note Actions', () => {
showEdit: true,
});
- findDeleteNoteButton().vm.$emit('click');
+ findDeleteNoteButton().vm.$emit('action');
expect(wrapper.emitted('deleteNote')).toEqual([[]]);
});
@@ -167,7 +185,7 @@ describe('Work Item Note Actions', () => {
});
it('should emit `notifyCopyDone` event when copy link note action is clicked', () => {
- findCopyLinkButton().vm.$emit('click');
+ findCopyLinkButton().vm.$emit('action');
expect(wrapper.emitted('notifyCopyDone')).toEqual([[]]);
});
@@ -193,7 +211,7 @@ describe('Work Item Note Actions', () => {
showAssignUnassign: true,
});
- findAssignUnassignButton().vm.$emit('click');
+ findAssignUnassignButton().vm.$emit('action');
expect(wrapper.emitted('assignUser')).toEqual([[]]);
});
@@ -219,9 +237,63 @@ describe('Work Item Note Actions', () => {
canReportAbuse: true,
});
- findReportAbuseToAdminButton().vm.$emit('click');
+ findReportAbuseToAdminButton().vm.$emit('action');
expect(wrapper.emitted('reportAbuse')).toEqual([[]]);
});
});
+
+ describe('user role badges', () => {
+ describe('author badge', () => {
+ it('does not show the author badge by default', () => {
+ createComponent();
+
+ expect(findAuthorBadge().exists()).toBe(false);
+ });
+
+ it('shows the author badge when the work item is author by the current User', () => {
+ createComponent({ isWorkItemAuthor: true });
+
+ expect(findAuthorBadge().exists()).toBe(true);
+ expect(findAuthorBadge().text()).toBe('Author');
+ expect(findAuthorBadge().attributes('title')).toBe('This user is the author of this task.');
+ });
+ });
+
+ describe('Max access level badge', () => {
+ it('does not show the access level badge by default', () => {
+ createComponent();
+
+ expect(findMaxAccessLevelBadge().exists()).toBe(false);
+ });
+
+ it('shows the access badge when we have a valid value', () => {
+ createComponent({ maxAccessLevelOfAuthor: 'Owner' });
+
+ expect(findMaxAccessLevelBadge().exists()).toBe(true);
+ expect(findMaxAccessLevelBadge().text()).toBe('Owner');
+ expect(findMaxAccessLevelBadge().attributes('title')).toBe(
+ 'This user has the owner role in the Project name project.',
+ );
+ });
+ });
+
+ describe('Contributor badge', () => {
+ it('does not show the contributor badge by default', () => {
+ createComponent();
+
+ expect(findContributorBadge().exists()).toBe(false);
+ });
+
+ it('shows the contributor badge the note author is a contributor', () => {
+ createComponent({ isAuthorContributor: true });
+
+ expect(findContributorBadge().exists()).toBe(true);
+ expect(findContributorBadge().text()).toBe('Contributor');
+ expect(findContributorBadge().attributes('title')).toBe(
+ 'This user has previously committed to the Project name project.',
+ );
+ });
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js b/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js
index 225cc3bacaf..5a6894400b6 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_replying_spec.js
@@ -10,10 +10,11 @@ describe('Work Item Note Replying', () => {
const findTimelineEntry = () => wrapper.findComponent(TimelineEntryItem);
const findNoteHeader = () => wrapper.findComponent(NoteHeader);
- const createComponent = ({ body = mockNoteBody } = {}) => {
+ const createComponent = ({ body = mockNoteBody, isInternalNote = false } = {}) => {
wrapper = shallowMount(WorkItemNoteReplying, {
propsData: {
body,
+ isInternalNote,
},
});
@@ -31,4 +32,9 @@ describe('Work Item Note Replying', () => {
expect(findTimelineEntry().exists()).toBe(true);
expect(findNoteHeader().html()).toMatchSnapshot();
});
+
+ it('should have the correct class when internal note', () => {
+ createComponent({ isInternalNote: true });
+ expect(findTimelineEntry().classes()).toContain('internal-note');
+ });
});
diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js
index f2cf5171cc1..8dbd2818fc5 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js
@@ -20,6 +20,8 @@ import {
updateWorkItemMutationResponse,
workItemByIidResponseFactory,
workItemQueryResponse,
+ mockWorkItemCommentNoteByContributor,
+ mockWorkItemCommentByMaintainer,
} from 'jest/work_items/mock_data';
import { i18n, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import { mockTracking } from 'helpers/tracking_helper';
@@ -33,6 +35,23 @@ describe('Work Item Note', () => {
const updatedNoteBody = '<h1 data-sourcepos="1:1-1:12" dir="auto">Some title</h1>';
const mockWorkItemId = workItemQueryResponse.data.workItem.id;
+ const mockWorkItemByDifferentUser = {
+ data: {
+ workItem: {
+ ...workItemQueryResponse.data.workItem,
+ author: {
+ avatarUrl:
+ 'http://127.0.0.1:3000/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/2',
+ name: 'User 1',
+ username: 'user1',
+ webUrl: 'http://127.0.0.1:3000/user1',
+ __typename: 'UserCore',
+ },
+ },
+ },
+ };
+
const successHandler = jest.fn().mockResolvedValue({
data: {
updateNote: {
@@ -47,6 +66,9 @@ describe('Work Item Note', () => {
});
const workItemResponseHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory());
+ const workItemByAuthoredByDifferentUser = jest
+ .fn()
+ .mockResolvedValue(mockWorkItemByDifferentUser);
const updateWorkItemMutationSuccessHandler = jest
.fn()
@@ -69,6 +91,7 @@ describe('Work Item Note', () => {
workItemId = mockWorkItemId,
updateWorkItemMutationHandler = updateWorkItemMutationSuccessHandler,
assignees = mockAssignees,
+ workItemByIidResponseHandler = workItemResponseHandler,
} = {}) => {
wrapper = shallowMount(WorkItemNote, {
provide: {
@@ -85,7 +108,7 @@ describe('Work Item Note', () => {
assignees,
},
apolloProvider: mockApollo([
- [workItemByIidQuery, workItemResponseHandler],
+ [workItemByIidQuery, workItemByIidResponseHandler],
[updateWorkItemNoteMutation, updateNoteMutationHandler],
[updateWorkItemMutation, updateWorkItemMutationHandler],
]),
@@ -133,7 +156,7 @@ describe('Work Item Note', () => {
findNoteActions().vm.$emit('startEditing');
await nextTick();
- findCommentForm().vm.$emit('submitForm', updatedNoteText);
+ findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText });
expect(successHandler).toHaveBeenCalledWith({
input: {
@@ -148,7 +171,7 @@ describe('Work Item Note', () => {
findNoteActions().vm.$emit('startEditing');
await nextTick();
- findCommentForm().vm.$emit('submitForm', updatedNoteText);
+ findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText });
await waitForPromises();
expect(findCommentForm().exists()).toBe(false);
@@ -161,7 +184,7 @@ describe('Work Item Note', () => {
findNoteActions().vm.$emit('startEditing');
await nextTick();
- findCommentForm().vm.$emit('submitForm', updatedNoteText);
+ findCommentForm().vm.$emit('submitForm', { commentText: updatedNoteText });
await waitForPromises();
});
@@ -215,8 +238,9 @@ describe('Work Item Note', () => {
});
describe('main comment', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent({ isFirstNote: true });
+ await waitForPromises();
});
it('should have the note header, actions and body', () => {
@@ -229,6 +253,10 @@ describe('Work Item Note', () => {
it('should have the reply button props', () => {
expect(findNoteActions().props('showReply')).toBe(true);
});
+
+ it('should have the project name', () => {
+ expect(findNoteActions().props('projectName')).toBe('Project name');
+ });
});
describe('comment threads', () => {
@@ -318,5 +346,63 @@ describe('Work Item Note', () => {
},
);
});
+
+ describe('internal note', () => {
+ it('does not have the internal note class set by default', () => {
+ createComponent();
+ expect(findTimelineEntryItem().classes()).not.toContain('internal-note');
+ });
+
+ it('timeline entry item and note header has the class for internal notes', () => {
+ createComponent({
+ note: {
+ ...mockWorkItemCommentNote,
+ internal: true,
+ },
+ });
+ expect(findTimelineEntryItem().classes()).toContain('internal-note');
+ expect(findNoteHeader().props('isInternalNote')).toBe(true);
+ });
+ });
+
+ describe('author and user role badges', () => {
+ describe('author badge props', () => {
+ it.each`
+ isWorkItemAuthor | sameAsCurrentUser | workItemByIidResponseHandler
+ ${true} | ${'same as'} | ${workItemResponseHandler}
+ ${false} | ${'not same as'} | ${workItemByAuthoredByDifferentUser}
+ `(
+ 'should pass correct isWorkItemAuthor `$isWorkItemAuthor` to note actions when author is $sameAsCurrentUser as current note',
+ async ({ isWorkItemAuthor, workItemByIidResponseHandler }) => {
+ createComponent({ workItemByIidResponseHandler });
+ await waitForPromises();
+
+ expect(findNoteActions().props('isWorkItemAuthor')).toBe(isWorkItemAuthor);
+ },
+ );
+ });
+
+ describe('Max access level badge', () => {
+ it('should pass the max access badge props', async () => {
+ createComponent({ note: mockWorkItemCommentByMaintainer });
+ await waitForPromises();
+
+ expect(findNoteActions().props('maxAccessLevelOfAuthor')).toBe(
+ mockWorkItemCommentByMaintainer.maxAccessLevelOfAuthor,
+ );
+ });
+ });
+
+ describe('Contributor badge', () => {
+ it('should pass the contributor props', async () => {
+ createComponent({ note: mockWorkItemCommentNoteByContributor });
+ await waitForPromises();
+
+ expect(findNoteActions().props('isAuthorContributor')).toBe(
+ mockWorkItemCommentNoteByContributor.authorIsContributor,
+ );
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js
index 0045abe50d0..e03c6a7e28d 100644
--- a/spec/frontend/work_items/components/work_item_actions_spec.js
+++ b/spec/frontend/work_items/components/work_item_actions_spec.js
@@ -1,9 +1,12 @@
import { GlDropdownDivider, GlModal, GlToggle } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+
import createMockApollo from 'helpers/mock_apollo_helper';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
import { isLoggedIn } from '~/lib/utils/common_utils';
import toast from '~/vue_shared/plugins/global_toast';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
@@ -13,6 +16,8 @@ import {
TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
TEST_ID_DELETE_ACTION,
TEST_ID_PROMOTE_ACTION,
+ TEST_ID_COPY_REFERENCE_ACTION,
+ TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
} from '~/work_items/constants';
import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
@@ -31,8 +36,10 @@ describe('WorkItemActions component', () => {
Vue.use(VueApollo);
let wrapper;
- let glModalDirective;
let mockApollo;
+ const mockWorkItemReference = 'gitlab-org/gitlab-test#1';
+ const mockWorkItemCreateNoteEmail =
+ 'gitlab-incoming+gitlab-org-gitlab-test-2-ddpzuq0zd2wefzofcpcdr3dg7-issue-1@gmail.com';
const findModal = () => wrapper.findComponent(GlModal);
const findConfidentialityToggleButton = () =>
@@ -41,6 +48,9 @@ describe('WorkItemActions component', () => {
wrapper.findByTestId(TEST_ID_NOTIFICATIONS_TOGGLE_ACTION);
const findDeleteButton = () => wrapper.findByTestId(TEST_ID_DELETE_ACTION);
const findPromoteButton = () => wrapper.findByTestId(TEST_ID_PROMOTE_ACTION);
+ const findCopyReferenceButton = () => wrapper.findByTestId(TEST_ID_COPY_REFERENCE_ACTION);
+ const findCopyCreateNoteEmailButton = () =>
+ wrapper.findByTestId(TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION);
const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *');
const findDropdownItemsActual = () =>
findDropdownItems().wrappers.map((x) => {
@@ -55,6 +65,7 @@ describe('WorkItemActions component', () => {
});
const findNotificationsToggle = () => wrapper.findComponent(GlToggle);
+ const modalShowSpy = jest.fn();
const $toast = {
show: jest.fn(),
hide: jest.fn(),
@@ -77,9 +88,10 @@ describe('WorkItemActions component', () => {
notificationsMock = [updateWorkItemNotificationsMutation, jest.fn()],
convertWorkItemMutationHandler = convertWorkItemMutationSuccessHandler,
workItemType = 'Task',
+ workItemReference = mockWorkItemReference,
+ workItemCreateNoteEmail = mockWorkItemCreateNoteEmail,
} = {}) => {
const handlers = [notificationsMock];
- glModalDirective = jest.fn();
mockApollo = createMockApollo([
...handlers,
[convertWorkItemMutation, convertWorkItemMutationHandler],
@@ -96,13 +108,8 @@ describe('WorkItemActions component', () => {
subscribed,
isParentConfidential,
workItemType,
- },
- directives: {
- glModal: {
- bind(_, { value }) {
- glModalDirective(value);
- },
- },
+ workItemReference,
+ workItemCreateNoteEmail,
},
provide: {
fullPath: 'gitlab-org/gitlab',
@@ -111,6 +118,13 @@ describe('WorkItemActions component', () => {
mocks: {
$toast,
},
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ methods: {
+ show: modalShowSpy,
+ },
+ }),
+ },
});
};
@@ -141,6 +155,14 @@ describe('WorkItemActions component', () => {
text: 'Turn on confidentiality',
},
{
+ testId: TEST_ID_COPY_REFERENCE_ACTION,
+ text: 'Copy reference',
+ },
+ {
+ testId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
+ text: 'Copy task email address',
+ },
+ {
divider: true,
},
{
@@ -189,7 +211,7 @@ describe('WorkItemActions component', () => {
findDeleteButton().vm.$emit('click');
- expect(glModalDirective).toHaveBeenCalled();
+ expect(modalShowSpy).toHaveBeenCalled();
});
it('emits event when clicking OK button', () => {
@@ -359,4 +381,37 @@ describe('WorkItemActions component', () => {
]);
});
});
+
+ describe('copy reference action', () => {
+ it('shows toast when user clicks on the action', () => {
+ createComponent();
+
+ expect(findCopyReferenceButton().exists()).toBe(true);
+ findCopyReferenceButton().vm.$emit('click');
+
+ expect(toast).toHaveBeenCalledWith('Reference copied');
+ });
+ });
+
+ describe('copy email address action', () => {
+ it.each(['key result', 'objective'])(
+ 'renders correct button name when work item is %s',
+ (workItemType) => {
+ createComponent({ workItemType });
+
+ expect(findCopyCreateNoteEmailButton().text()).toEqual(
+ `Copy ${workItemType} email address`,
+ );
+ },
+ );
+
+ it('shows toast when user clicks on the action', () => {
+ createComponent();
+
+ expect(findCopyCreateNoteEmailButton().exists()).toBe(true);
+ findCopyCreateNoteEmailButton().vm.$emit('click');
+
+ expect(toast).toHaveBeenCalledWith('Email address copied');
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js
index 25b0b74c217..94d47bfb3be 100644
--- a/spec/frontend/work_items/components/work_item_assignees_spec.js
+++ b/spec/frontend/work_items/components/work_item_assignees_spec.js
@@ -26,6 +26,7 @@ import {
updateWorkItemMutationResponse,
projectMembersResponseWithCurrentUserWithNextPage,
projectMembersResponseWithNoMatchingUsers,
+ projectMembersResponseWithDuplicates,
} from '../mock_data';
Vue.use(VueApollo);
@@ -529,4 +530,14 @@ describe('WorkItemAssignees component', () => {
});
});
});
+
+ it('filters out the users with the same ID from the list of project members', async () => {
+ createComponent({
+ searchQueryHandler: jest.fn().mockResolvedValue(projectMembersResponseWithDuplicates),
+ });
+ findTokenSelector().vm.$emit('focus');
+ await waitForPromises();
+
+ expect(findTokenSelector().props('dropdownItems')).toHaveLength(2);
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_award_emoji_spec.js b/spec/frontend/work_items/components/work_item_award_emoji_spec.js
index f87c0e3f357..82be6d990e4 100644
--- a/spec/frontend/work_items/components/work_item_award_emoji_spec.js
+++ b/spec/frontend/work_items/components/work_item_award_emoji_spec.js
@@ -8,19 +8,15 @@ import waitForPromises from 'helpers/wait_for_promises';
import { isLoggedIn } from '~/lib/utils/common_utils';
import AwardList from '~/vue_shared/components/awards_list.vue';
import WorkItemAwardEmoji from '~/work_items/components/work_item_award_emoji.vue';
-import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
-import {
- EMOJI_ACTION_REMOVE,
- EMOJI_ACTION_ADD,
- EMOJI_THUMBSUP,
- EMOJI_THUMBSDOWN,
-} from '~/work_items/constants';
+import updateAwardEmojiMutation from '~/work_items/graphql/update_award_emoji.mutation.graphql';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import { EMOJI_THUMBSUP, EMOJI_THUMBSDOWN } from '~/work_items/constants';
import {
workItemByIidResponseFactory,
mockAwardsWidget,
- updateWorkItemMutationResponseFactory,
mockAwardEmojiThumbsUp,
+ getAwardEmojiResponse,
} from '../mock_data';
jest.mock('~/lib/utils/common_utils');
@@ -28,43 +24,61 @@ Vue.use(VueApollo);
describe('WorkItemAwardEmoji component', () => {
let wrapper;
+ let mockApolloProvider;
const errorMessage = 'Failed to update the award';
-
const workItemQueryResponse = workItemByIidResponseFactory();
- const workItemSuccessHandler = jest
- .fn()
- .mockResolvedValue(updateWorkItemMutationResponseFactory());
- const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue(
- updateWorkItemMutationResponseFactory({
- awardEmoji: {
- ...mockAwardsWidget,
- nodes: [mockAwardEmojiThumbsUp],
- },
- }),
- );
- const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue(
- updateWorkItemMutationResponseFactory({
- awardEmoji: {
- ...mockAwardsWidget,
- nodes: [],
- },
- }),
- );
- const workItemUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage));
+ const workItemQueryAddAwardEmojiResponse = workItemByIidResponseFactory({
+ awardEmoji: { ...mockAwardsWidget, nodes: [mockAwardEmojiThumbsUp] },
+ });
+ const workItemQueryRemoveAwardEmojiResponse = workItemByIidResponseFactory({
+ awardEmoji: { ...mockAwardsWidget, nodes: [] },
+ });
+ const awardEmojiAddSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(true));
+ const awardEmojiRemoveSuccessHandler = jest.fn().mockResolvedValue(getAwardEmojiResponse(false));
+ const awardEmojiUpdateFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage));
const mockWorkItem = workItemQueryResponse.data.workspace.workItems.nodes[0];
+ const mockAwardEmojiDifferentUserThumbsUp = {
+ name: 'thumbsup',
+ __typename: 'AwardEmoji',
+ user: {
+ id: 'gid://gitlab/User/1',
+ name: 'John Doe',
+ __typename: 'UserCore',
+ },
+ };
const createComponent = ({
- mockWorkItemUpdateMutationHandler = [updateWorkItemMutation, workItemSuccessHandler],
+ awardMutationHandler = awardEmojiAddSuccessHandler,
workItem = mockWorkItem,
+ workItemIid = '1',
awardEmoji = { ...mockAwardsWidget, nodes: [] },
} = {}) => {
+ mockApolloProvider = createMockApollo([[updateAwardEmojiMutation, awardMutationHandler]]);
+
+ mockApolloProvider.clients.defaultClient.writeQuery({
+ query: workItemByIidQuery,
+ variables: { fullPath: workItem.project.fullPath, iid: workItemIid },
+ data: {
+ ...workItemQueryResponse.data,
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/1',
+ workItems: {
+ nodes: [workItem],
+ },
+ },
+ },
+ });
+
wrapper = shallowMount(WorkItemAwardEmoji, {
isLoggedIn: isLoggedIn(),
- apolloProvider: createMockApollo([mockWorkItemUpdateMutationHandler]),
+ apolloProvider: mockApolloProvider,
propsData: {
- workItem,
+ workItemId: workItem.id,
+ workItemFullpath: workItem.project.fullPath,
awardEmoji,
+ workItemIid,
},
});
};
@@ -74,7 +88,8 @@ describe('WorkItemAwardEmoji component', () => {
beforeEach(() => {
isLoggedIn.mockReturnValue(true);
window.gon = {
- current_user_id: 1,
+ current_user_id: 5,
+ current_user_fullname: 'Dave Smith',
};
createComponent();
@@ -85,7 +100,7 @@ describe('WorkItemAwardEmoji component', () => {
expect(findAwardsList().props()).toEqual({
boundary: '',
canAwardEmoji: true,
- currentUserId: 1,
+ currentUserId: 5,
defaultAwards: [EMOJI_THUMBSUP, EMOJI_THUMBSDOWN],
selectedClass: 'selected',
awards: [],
@@ -97,48 +112,70 @@ describe('WorkItemAwardEmoji component', () => {
expect(findAwardsList().props('awards')).toEqual([
{
- id: 1,
name: EMOJI_THUMBSUP,
user: {
id: 5,
+ name: 'Dave Smith',
},
},
{
- id: 2,
name: EMOJI_THUMBSDOWN,
user: {
id: 5,
+ name: 'Dave Smith',
+ },
+ },
+ ]);
+ });
+
+ it('renders awards list given by multiple users', () => {
+ createComponent({
+ awardEmoji: {
+ ...mockAwardsWidget,
+ nodes: [mockAwardEmojiThumbsUp, mockAwardEmojiDifferentUserThumbsUp],
+ },
+ });
+
+ expect(findAwardsList().props('awards')).toEqual([
+ {
+ name: EMOJI_THUMBSUP,
+ user: {
+ id: 5,
+ name: 'Dave Smith',
+ },
+ },
+ {
+ name: EMOJI_THUMBSUP,
+ user: {
+ id: 1,
+ name: 'John Doe',
},
},
]);
});
it.each`
- expectedAssertion | action | successHandler | mockAwardEmojiNodes
- ${'added'} | ${EMOJI_ACTION_ADD} | ${awardEmojiAddSuccessHandler} | ${[]}
- ${'removed'} | ${EMOJI_ACTION_REMOVE} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]}
+ expectedAssertion | awardEmojiMutationHandler | mockAwardEmojiNodes | workItem
+ ${'added'} | ${awardEmojiAddSuccessHandler} | ${[]} | ${workItemQueryRemoveAwardEmojiResponse.data.workspace.workItems.nodes[0]}
+ ${'removed'} | ${awardEmojiRemoveSuccessHandler} | ${[mockAwardEmojiThumbsUp]} | ${workItemQueryAddAwardEmojiResponse.data.workspace.workItems.nodes[0]}
`(
'calls mutation when an award emoji is $expectedAssertion',
- async ({ action, successHandler, mockAwardEmojiNodes }) => {
+ ({ awardEmojiMutationHandler, mockAwardEmojiNodes, workItem }) => {
createComponent({
- mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, successHandler],
+ awardMutationHandler: awardEmojiMutationHandler,
awardEmoji: {
...mockAwardsWidget,
nodes: mockAwardEmojiNodes,
},
+ workItem,
});
findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
- await waitForPromises();
-
- expect(successHandler).toHaveBeenCalledWith({
+ expect(awardEmojiMutationHandler).toHaveBeenCalledWith({
input: {
- id: mockWorkItem.id,
- awardEmojiWidget: {
- action,
- name: EMOJI_THUMBSUP,
- },
+ awardableId: mockWorkItem.id,
+ name: EMOJI_THUMBSUP,
},
});
},
@@ -146,7 +183,7 @@ describe('WorkItemAwardEmoji component', () => {
it('emits error when the update mutation fails', async () => {
createComponent({
- mockWorkItemUpdateMutationHandler: [updateWorkItemMutation, workItemUpdateFailureHandler],
+ awardMutationHandler: awardEmojiUpdateFailureHandler,
});
findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
@@ -167,4 +204,32 @@ describe('WorkItemAwardEmoji component', () => {
expect(findAwardsList().props('canAwardEmoji')).toBe(false);
});
});
+
+ describe('when a different users awards same emoji', () => {
+ beforeEach(() => {
+ window.gon = {
+ current_user_id: 1,
+ current_user_fullname: 'John Doe',
+ };
+ });
+
+ it('calls mutation succesfully and adds the award emoji with proper user details', () => {
+ createComponent({
+ awardMutationHandler: awardEmojiAddSuccessHandler,
+ awardEmoji: {
+ ...mockAwardsWidget,
+ nodes: [mockAwardEmojiThumbsUp],
+ },
+ });
+
+ findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
+
+ expect(awardEmojiAddSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ awardableId: mockWorkItem.id,
+ name: EMOJI_THUMBSUP,
+ },
+ });
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js
index 62cbb1bacb6..b910e9854f8 100644
--- a/spec/frontend/work_items/components/work_item_description_spec.js
+++ b/spec/frontend/work_items/components/work_item_description_spec.js
@@ -1,3 +1,4 @@
+import { GlForm } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -7,7 +8,6 @@ import waitForPromises from 'helpers/wait_for_promises';
import EditedAt from '~/issues/show/components/edited.vue';
import { updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue';
@@ -36,22 +36,18 @@ describe('WorkItemDescription', () => {
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const subscriptionHandler = jest.fn().mockResolvedValue(workItemDescriptionSubscriptionResponse);
let workItemResponseHandler;
- let workItemsMvc;
- const findMarkdownField = () => wrapper.findComponent(MarkdownField);
+ const findForm = () => wrapper.findComponent(GlForm);
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const findRenderedDescription = () => wrapper.findComponent(WorkItemDescriptionRendered);
const findEditedAt = () => wrapper.findComponent(EditedAt);
- const editDescription = (newText) => {
- if (workItemsMvc) {
- return findMarkdownEditor().vm.$emit('input', newText);
- }
- return wrapper.find('textarea').setValue(newText);
- };
+ const editDescription = (newText) => findMarkdownEditor().vm.$emit('input', newText);
- const clickCancel = () => wrapper.find('[data-testid="cancel"]').vm.$emit('click');
- const clickSave = () => wrapper.find('[data-testid="save-description"]').vm.$emit('click', {});
+ const findCancelButton = () => wrapper.find('[data-testid="cancel"]');
+ const findSubmitButton = () => wrapper.find('[data-testid="save-description"]');
+ const clickCancel = () => findForm().vm.$emit('reset', new Event('reset'));
+ const clickSave = () => findForm().vm.$emit('submit', new Event('submit'));
const createComponent = async ({
mutationHandler = mutationSuccessHandler,
@@ -75,12 +71,6 @@ describe('WorkItemDescription', () => {
},
provide: {
fullPath: 'test-project-path',
- glFeatures: {
- workItemsMvc,
- },
- },
- stubs: {
- MarkdownField,
},
});
@@ -93,11 +83,15 @@ describe('WorkItemDescription', () => {
}
};
- describe('editing description with workItemsMvc FF enabled', () => {
- beforeEach(() => {
- workItemsMvc = true;
+ it('has a subscription', async () => {
+ await createComponent();
+
+ expect(subscriptionHandler).toHaveBeenCalledWith({
+ issuableId: workItemQueryResponse.data.workItem.id,
});
+ });
+ describe('editing description', () => {
it('passes correct autocompletion data and preview markdown sources and enables quick actions', async () => {
const {
iid,
@@ -113,196 +107,162 @@ describe('WorkItemDescription', () => {
autocompleteDataSources: autocompleteDataSources(fullPath, iid),
});
});
- });
-
- describe('editing description with workItemsMvc FF disabled', () => {
- beforeEach(() => {
- workItemsMvc = false;
- });
-
- it('passes correct autocompletion data and preview markdown sources', async () => {
- const {
- iid,
- project: { fullPath },
- } = workItemQueryResponse.data.workItem;
-
- await createComponent({ isEditing: true });
+ it('shows edited by text', async () => {
+ const lastEditedAt = '2022-09-21T06:18:42Z';
+ const lastEditedBy = {
+ name: 'Administrator',
+ webPath: '/root',
+ };
+
+ await createComponent({
+ workItemResponse: workItemByIidResponseFactory({ lastEditedAt, lastEditedBy }),
+ });
- expect(findMarkdownField().props()).toMatchObject({
- autocompleteDataSources: autocompleteDataSources(fullPath, iid),
- markdownPreviewPath: markdownPreviewPath(fullPath, iid),
- quickActionsDocsPath: wrapper.vm.$options.quickActionsDocsPath,
+ expect(findEditedAt().props()).toMatchObject({
+ updatedAt: lastEditedAt,
+ updatedByName: lastEditedBy.name,
+ updatedByPath: lastEditedBy.webPath,
});
});
- });
- describe.each([true, false])(
- 'editing description with workItemsMvc %workItemsMvcEnabled',
- (workItemsMvcEnabled) => {
- beforeEach(() => {
- beforeEach(() => {
- workItemsMvc = workItemsMvcEnabled;
- });
- });
+ it('does not show edited by text', async () => {
+ await createComponent();
- it('has a subscription', async () => {
- await createComponent();
+ expect(findEditedAt().exists()).toBe(false);
+ });
- expect(subscriptionHandler).toHaveBeenCalledWith({
- issuableId: workItemQueryResponse.data.workItem.id,
- });
+ it('cancels when clicking cancel', async () => {
+ await createComponent({
+ isEditing: true,
});
- describe('editing description', () => {
- it('shows edited by text', async () => {
- const lastEditedAt = '2022-09-21T06:18:42Z';
- const lastEditedBy = {
- name: 'Administrator',
- webPath: '/root',
- };
+ clickCancel();
- await createComponent({
- workItemResponse: workItemByIidResponseFactory({ lastEditedAt, lastEditedBy }),
- });
+ await nextTick();
- expect(findEditedAt().props()).toMatchObject({
- updatedAt: lastEditedAt,
- updatedByName: lastEditedBy.name,
- updatedByPath: lastEditedBy.webPath,
- });
- });
+ expect(confirmAction).not.toHaveBeenCalled();
+ expect(findMarkdownEditor().exists()).toBe(false);
+ });
- it('does not show edited by text', async () => {
- await createComponent();
+ it('prompts for confirmation when clicking cancel after changes', async () => {
+ await createComponent({
+ isEditing: true,
+ });
- expect(findEditedAt().exists()).toBe(false);
- });
+ editDescription('updated desc');
- it('cancels when clicking cancel', async () => {
- await createComponent({
- isEditing: true,
- });
+ clickCancel();
- clickCancel();
+ await nextTick();
- await nextTick();
+ expect(confirmAction).toHaveBeenCalled();
+ });
- expect(confirmAction).not.toHaveBeenCalled();
- expect(findMarkdownField().exists()).toBe(false);
- });
+ it('calls update widgets mutation', async () => {
+ const updatedDesc = 'updated desc';
- it('prompts for confirmation when clicking cancel after changes', async () => {
- await createComponent({
- isEditing: true,
- });
+ await createComponent({
+ isEditing: true,
+ });
- editDescription('updated desc');
+ editDescription(updatedDesc);
- clickCancel();
+ clickSave();
- await nextTick();
+ await waitForPromises();
- expect(confirmAction).toHaveBeenCalled();
- });
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ id: workItemId,
+ descriptionWidget: {
+ description: updatedDesc,
+ },
+ },
+ });
+ });
- it('calls update widgets mutation', async () => {
- const updatedDesc = 'updated desc';
+ it('tracks editing description', async () => {
+ await createComponent({
+ isEditing: true,
+ markdownPreviewPath: '/preview',
+ });
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- await createComponent({
- isEditing: true,
- });
+ clickSave();
- editDescription(updatedDesc);
+ await waitForPromises();
- clickSave();
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_description',
+ property: 'type_Task',
+ });
+ });
- await waitForPromises();
+ it('emits error when mutation returns error', async () => {
+ const error = 'eror';
- expect(mutationSuccessHandler).toHaveBeenCalledWith({
- input: {
- id: workItemId,
- descriptionWidget: {
- description: updatedDesc,
- },
+ await createComponent({
+ isEditing: true,
+ mutationHandler: jest.fn().mockResolvedValue({
+ data: {
+ workItemUpdate: {
+ workItem: {},
+ errors: [error],
},
- });
- });
-
- it('tracks editing description', async () => {
- await createComponent({
- isEditing: true,
- markdownPreviewPath: '/preview',
- });
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
-
- clickSave();
-
- await waitForPromises();
-
- expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_description', {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_description',
- property: 'type_Task',
- });
- });
-
- it('emits error when mutation returns error', async () => {
- const error = 'eror';
+ },
+ }),
+ });
- await createComponent({
- isEditing: true,
- mutationHandler: jest.fn().mockResolvedValue({
- data: {
- workItemUpdate: {
- workItem: {},
- errors: [error],
- },
- },
- }),
- });
+ editDescription('updated desc');
- editDescription('updated desc');
+ clickSave();
- clickSave();
+ await waitForPromises();
- await waitForPromises();
+ expect(wrapper.emitted('error')).toEqual([[error]]);
+ });
- expect(wrapper.emitted('error')).toEqual([[error]]);
- });
+ it('emits error when mutation fails', async () => {
+ const error = 'eror';
- it('emits error when mutation fails', async () => {
- const error = 'eror';
+ await createComponent({
+ isEditing: true,
+ mutationHandler: jest.fn().mockRejectedValue(new Error(error)),
+ });
- await createComponent({
- isEditing: true,
- mutationHandler: jest.fn().mockRejectedValue(new Error(error)),
- });
+ editDescription('updated desc');
- editDescription('updated desc');
+ clickSave();
- clickSave();
+ await waitForPromises();
- await waitForPromises();
+ expect(wrapper.emitted('error')).toEqual([[error]]);
+ });
- expect(wrapper.emitted('error')).toEqual([[error]]);
- });
+ it('autosaves description', async () => {
+ await createComponent({
+ isEditing: true,
+ });
- it('autosaves description', async () => {
- await createComponent({
- isEditing: true,
- });
+ editDescription('updated desc');
- editDescription('updated desc');
+ expect(updateDraft).toHaveBeenCalled();
+ });
- expect(updateDraft).toHaveBeenCalled();
- });
+ it('maps submit and cancel buttons to form actions', async () => {
+ await createComponent({
+ isEditing: true,
});
- it('calls the work item query', async () => {
- await createComponent();
+ expect(findCancelButton().attributes('type')).toBe('reset');
+ expect(findSubmitButton().attributes('type')).toBe('submit');
+ });
+ });
+
+ it('calls the work item query', async () => {
+ await createComponent();
- expect(workItemResponseHandler).toHaveBeenCalled();
- });
- },
- );
+ expect(workItemResponseHandler).toHaveBeenCalled();
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
index e305cc310bd..6fa3a70c3eb 100644
--- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
@@ -33,7 +33,6 @@ describe('WorkItemDetailModal component', () => {
const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
const createComponent = ({
- error = false,
deleteWorkItemMutationHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse),
} = {}) => {
const apolloProvider = createMockApollo([
@@ -46,19 +45,12 @@ describe('WorkItemDetailModal component', () => {
workItemId,
workItemIid: '1',
},
- data() {
- return {
- error,
- };
- },
provide: {
fullPath: 'group/project',
},
stubs: {
GlModal,
- WorkItemDetail: stubComponent(WorkItemDetail, {
- apollo: {},
- }),
+ WorkItemDetail: stubComponent(WorkItemDetail),
},
});
};
@@ -68,14 +60,18 @@ describe('WorkItemDetailModal component', () => {
expect(findWorkItemDetail().props()).toEqual({
isModal: true,
- workItemId,
workItemIid: '1',
workItemParentId: null,
});
});
- it('renders alert if there is an error', () => {
- createComponent({ error: true });
+ it('renders alert if there is an error', async () => {
+ createComponent({
+ deleteWorkItemMutationHandler: jest.fn().mockRejectedValue({ message: 'message' }),
+ });
+
+ findWorkItemDetail().vm.$emit('deleteWorkItem');
+ await waitForPromises();
expect(findAlert().exists()).toBe(true);
});
@@ -87,7 +83,13 @@ describe('WorkItemDetailModal component', () => {
});
it('dismisses the alert on `dismiss` emitted event', async () => {
- createComponent({ error: true });
+ createComponent({
+ deleteWorkItemMutationHandler: jest.fn().mockRejectedValue({ message: 'message' }),
+ });
+
+ findWorkItemDetail().vm.$emit('deleteWorkItem');
+ await waitForPromises();
+
findAlert().vm.$emit('dismiss');
await nextTick();
@@ -103,24 +105,19 @@ describe('WorkItemDetailModal component', () => {
it('hides the modal when WorkItemDetail emits `close` event', () => {
createComponent();
- const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide');
findWorkItemDetail().vm.$emit('close');
- expect(closeSpy).toHaveBeenCalled();
+ expect(hideModal).toHaveBeenCalled();
});
it('updates the work item when WorkItemDetail emits `update-modal` event', async () => {
createComponent();
- findWorkItemDetail().vm.$emit('update-modal', undefined, {
- id: 'updatedId',
- iid: 'updatedIid',
- });
- await waitForPromises();
+ findWorkItemDetail().vm.$emit('update-modal', undefined, { iid: 'updatedIid' });
+ await nextTick();
- expect(findWorkItemDetail().props().workItemId).toEqual('updatedId');
- expect(findWorkItemDetail().props().workItemIid).toEqual('updatedIid');
+ expect(findWorkItemDetail().props('workItemIid')).toBe('updatedIid');
});
describe('delete work item', () => {
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index 557ae07969e..d8ba8ea74f2 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -100,7 +100,6 @@ describe('WorkItemDetail component', () => {
const createComponent = ({
isModal = false,
updateInProgress = false,
- workItemId = id,
workItemIid = '1',
handler = successHandler,
subscriptionHandler = titleSubscriptionHandler,
@@ -120,7 +119,10 @@ describe('WorkItemDetail component', () => {
wrapper = shallowMount(WorkItemDetail, {
apolloProvider: createMockApollo(handlers),
isLoggedIn: isLoggedIn(),
- propsData: { isModal, workItemId, workItemIid },
+ propsData: {
+ isModal,
+ workItemIid,
+ },
data() {
return {
updateInProgress,
@@ -160,9 +162,9 @@ describe('WorkItemDetail component', () => {
setWindowLocation('');
});
- describe('when there is no `workItemId` and no `workItemIid` prop', () => {
+ describe('when there is no `workItemIid` prop', () => {
beforeEach(() => {
- createComponent({ workItemId: null, workItemIid: null });
+ createComponent({ workItemIid: null });
});
it('skips the work item query', () => {
@@ -437,7 +439,7 @@ describe('WorkItemDetail component', () => {
});
it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => {
- expect(findParentButton().attributes().href).toBe('../../issues/5');
+ expect(findParentButton().attributes().href).toBe('../../-/issues/5');
});
it('sets the parent breadcrumb URL based on parent webUrl when parent type is not `Issue`', async () => {
diff --git a/spec/frontend/work_items/components/work_item_due_date_spec.js b/spec/frontend/work_items/components/work_item_due_date_spec.js
index b4811db8bed..5e8c34d90ee 100644
--- a/spec/frontend/work_items/components/work_item_due_date_spec.js
+++ b/spec/frontend/work_items/components/work_item_due_date_spec.js
@@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
+import { stubComponent } from 'helpers/stub_component';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue';
@@ -33,6 +34,7 @@ describe('WorkItemDueDate component', () => {
dueDate = null,
startDate = null,
mutationHandler = updateWorkItemMutationHandler,
+ stubs = {},
} = {}) => {
wrapper = mountExtended(WorkItemDueDate, {
apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
@@ -43,6 +45,7 @@ describe('WorkItemDueDate component', () => {
workItemId,
workItemType: 'Task',
},
+ stubs,
});
};
@@ -132,11 +135,21 @@ describe('WorkItemDueDate component', () => {
describe('when the start date is later than the due date', () => {
const startDate = new Date('2030-01-01T00:00:00.000Z');
- let datePickerOpenSpy;
+ const datePickerOpenSpy = jest.fn();
beforeEach(() => {
- createComponent({ canUpdate: true, dueDate: '2022-12-31', startDate: '2022-12-31' });
- datePickerOpenSpy = jest.spyOn(wrapper.vm.$refs.dueDatePicker, 'show');
+ createComponent({
+ canUpdate: true,
+ dueDate: '2022-12-31',
+ startDate: '2022-12-31',
+ stubs: {
+ GlDatepicker: stubComponent(GlDatepicker, {
+ methods: {
+ show: datePickerOpenSpy,
+ },
+ }),
+ },
+ });
findStartDatePicker().vm.$emit('input', startDate);
findStartDatePicker().vm.$emit('close');
});
diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js
index 554c9a4f7b8..6894aa236e3 100644
--- a/spec/frontend/work_items/components/work_item_labels_spec.js
+++ b/spec/frontend/work_items/components/work_item_labels_spec.js
@@ -266,7 +266,7 @@ describe('WorkItemLabels component', () => {
});
it('skips calling the work item query when missing workItemIid', async () => {
- createComponent({ workItemIid: null });
+ createComponent({ workItemIid: '' });
await waitForPromises();
expect(workItemQuerySuccess).not.toHaveBeenCalled();
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
index b06be6c8083..cd077fbf705 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_children_wrapper_spec.js
@@ -6,16 +6,28 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { childrenWorkItems, workItemByIidResponseFactory } from '../../mock_data';
+import {
+ changeWorkItemParentMutationResponse,
+ childrenWorkItems,
+ updateWorkItemMutationErrorResponse,
+ workItemByIidResponseFactory,
+} from '../../mock_data';
describe('WorkItemChildrenWrapper', () => {
let wrapper;
+ const $toast = {
+ show: jest.fn(),
+ };
const getWorkItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory());
+ const updateWorkItemMutationHandler = jest
+ .fn()
+ .mockResolvedValue(changeWorkItemParentMutationResponse);
const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild);
@@ -25,18 +37,33 @@ describe('WorkItemChildrenWrapper', () => {
workItemType = 'Objective',
confidential = false,
children = childrenWorkItems,
+ mutationHandler = updateWorkItemMutationHandler,
} = {}) => {
+ const mockApollo = createMockApollo([
+ [workItemByIidQuery, getWorkItemQueryHandler],
+ [updateWorkItemMutation, mutationHandler],
+ ]);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: workItemByIidQuery,
+ variables: { fullPath: 'test/project', iid: '1' },
+ data: workItemByIidResponseFactory().data,
+ });
+
wrapper = shallowMountExtended(WorkItemChildrenWrapper, {
- apolloProvider: createMockApollo([[workItemByIidQuery, getWorkItemQueryHandler]]),
+ apolloProvider: mockApollo,
provide: {
fullPath: 'test/project',
},
propsData: {
workItemType,
workItemId: 'gid://gitlab/WorkItem/515',
+ workItemIid: '1',
confidential,
children,
- fetchByIid: true,
+ },
+ mocks: {
+ $toast,
},
});
};
@@ -51,16 +78,6 @@ describe('WorkItemChildrenWrapper', () => {
);
});
- it('remove event on child triggers `removeChild` event', () => {
- createComponent();
- const workItem = { id: 'gid://gitlab/WorkItem/2' };
- const firstChild = findWorkItemLinkChildItems().at(0);
-
- firstChild.vm.$emit('removeChild', workItem);
-
- expect(wrapper.emitted('removeChild')).toEqual([[workItem]]);
- });
-
it('emits `show-modal` on `click` event', () => {
createComponent();
const firstChild = findWorkItemLinkChildItems().at(0);
@@ -95,4 +112,47 @@ describe('WorkItemChildrenWrapper', () => {
}
},
);
+
+ describe('when removing child work item', () => {
+ const workItem = { id: 'gid://gitlab/WorkItem/2' };
+
+ describe('when successful', () => {
+ beforeEach(async () => {
+ createComponent();
+ findWorkItemLinkChildItems().at(0).vm.$emit('removeChild', workItem);
+ await waitForPromises();
+ });
+
+ it('calls a mutation to update the work item', () => {
+ expect(updateWorkItemMutationHandler).toHaveBeenCalledWith({
+ input: {
+ id: workItem.id,
+ hierarchyWidget: {
+ parentId: null,
+ },
+ },
+ });
+ });
+
+ it('shows a toast', () => {
+ expect($toast.show).toHaveBeenCalledWith('Child removed', {
+ action: { onClick: expect.anything(), text: 'Undo' },
+ });
+ });
+ });
+
+ describe('when not successful', () => {
+ beforeEach(async () => {
+ createComponent({
+ mutationHandler: jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse),
+ });
+ findWorkItemLinkChildItems().at(0).vm.$emit('removeChild', workItem);
+ await waitForPromises();
+ });
+
+ it('emits an error message', () => {
+ expect(wrapper.emitted('error')).toEqual([['Something went wrong while removing child.']]);
+ });
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
index 786f8604039..dd46505bd65 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
@@ -4,7 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import setWindowLocation from 'helpers/set_window_location_helper';
-import { stubComponent } from 'helpers/stub_component';
+import { RENDER_ALL_SLOTS_TEMPLATE, stubComponent } from 'helpers/stub_component';
import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { resolvers } from '~/graphql_shared/issuable_client';
import WidgetWrapper from '~/work_items/components/widget_wrapper.vue';
@@ -13,19 +13,14 @@ import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/wor
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { FORM_TYPES } from '~/work_items/constants';
-import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
getIssueDetailsResponse,
workItemHierarchyResponse,
workItemHierarchyEmptyResponse,
workItemHierarchyNoUpdatePermissionResponse,
- changeWorkItemParentMutationResponse,
workItemByIidResponseFactory,
- workItemQueryResponse,
mockWorkItemCommentNote,
- childrenWorkItems,
} from '../../mock_data';
Vue.use(VueApollo);
@@ -36,66 +31,48 @@ describe('WorkItemLinks', () => {
let wrapper;
let mockApollo;
- const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2';
-
- const $toast = {
- show: jest.fn(),
- };
-
- const mutationChangeParentHandler = jest
- .fn()
- .mockResolvedValue(changeWorkItemParentMutationResponse);
- const childWorkItemByIidHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory());
const responseWithAddChildPermission = jest.fn().mockResolvedValue(workItemHierarchyResponse);
const responseWithoutAddChildPermission = jest
.fn()
.mockResolvedValue(workItemByIidResponseFactory({ adminParentLink: false }));
const createComponent = async ({
- data = {},
fetchHandler = responseWithAddChildPermission,
- mutationHandler = mutationChangeParentHandler,
issueDetailsQueryHandler = jest.fn().mockResolvedValue(getIssueDetailsResponse()),
hasIterationsFeature = false,
} = {}) => {
mockApollo = createMockApollo(
[
- [workItemQuery, fetchHandler],
- [changeWorkItemParentMutation, mutationHandler],
+ [workItemByIidQuery, fetchHandler],
[issueDetailsQuery, issueDetailsQueryHandler],
- [workItemByIidQuery, childWorkItemByIidHandler],
],
resolvers,
{ addTypename: true },
);
wrapper = shallowMountExtended(WorkItemLinks, {
- data() {
- return {
- ...data,
- };
- },
provide: {
fullPath: 'project/path',
hasIterationsFeature,
reportAbusePath: '/report/abuse/path',
},
- propsData: { issuableId: 1 },
- apolloProvider: mockApollo,
- mocks: {
- $toast,
+ propsData: {
+ issuableId: 1,
+ issuableIid: 1,
},
+ apolloProvider: mockApollo,
stubs: {
WorkItemDetailModal: stubComponent(WorkItemDetailModal, {
methods: {
show: showModal,
},
}),
+ WidgetWrapper: stubComponent(WidgetWrapper, {
+ template: RENDER_ALL_SLOTS_TEMPLATE,
+ }),
},
});
- wrapper.vm.$refs.wrapper.show = jest.fn();
-
await waitForPromises();
};
@@ -122,8 +99,7 @@ describe('WorkItemLinks', () => {
`(
'$expectedAssertion "Add" button in hierarchy widget header when "userPermissions.adminParentLink" is $value',
async ({ workItemFetchHandler, value }) => {
- createComponent({ fetchHandler: workItemFetchHandler });
- await waitForPromises();
+ await createComponent({ fetchHandler: workItemFetchHandler });
expect(findToggleFormDropdown().exists()).toBe(value);
},
@@ -159,24 +135,6 @@ describe('WorkItemLinks', () => {
expect(findAddLinksForm().exists()).toBe(false);
});
-
- it('adds work item child from the form', async () => {
- const workItem = {
- ...workItemQueryResponse.data.workItem,
- id: 'gid://gitlab/WorkItem/11',
- };
- await createComponent();
- findToggleFormDropdown().vm.$emit('click');
- findToggleCreateFormButton().vm.$emit('click');
- await nextTick();
-
- expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4);
-
- findAddLinksForm().vm.$emit('addWorkItemChild', workItem);
- await waitForPromises();
-
- expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(5);
- });
});
describe('when no child links', () => {
@@ -230,50 +188,6 @@ describe('WorkItemLinks', () => {
});
});
- describe('remove child', () => {
- let firstChild;
-
- beforeEach(async () => {
- await createComponent({ mutationHandler: mutationChangeParentHandler });
-
- [firstChild] = childrenWorkItems;
- });
-
- it('calls correct mutation with correct variables', async () => {
- findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild);
-
- await waitForPromises();
-
- expect(mutationChangeParentHandler).toHaveBeenCalledWith({
- input: {
- id: WORK_ITEM_ID,
- hierarchyWidget: {
- parentId: null,
- },
- },
- });
- });
-
- it('shows toast when mutation succeeds', async () => {
- findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild);
-
- await waitForPromises();
-
- expect($toast.show).toHaveBeenCalledWith('Child removed', {
- action: { onClick: expect.anything(), text: 'Undo' },
- });
- });
-
- it('renders correct number of children after removal', async () => {
- expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(4);
-
- findWorkItemLinkChildrenWrapper().vm.$emit('removeChild', firstChild);
- await waitForPromises();
-
- expect(findWorkItemLinkChildrenWrapper().props().children).toHaveLength(3);
- });
- });
-
describe('when parent item is confidential', () => {
it('passes correct confidentiality status to form', async () => {
await createComponent({
@@ -289,16 +203,6 @@ describe('WorkItemLinks', () => {
});
});
- it('starts prefetching work item by iid if URL contains work_item_iid query parameter', async () => {
- setWindowLocation('?work_item_iid=5');
- await createComponent();
-
- expect(childWorkItemByIidHandler).toHaveBeenCalledWith({
- iid: '5',
- fullPath: 'project/path',
- });
- });
-
it('does not open the modal if work item iid URL parameter is not found in child items', async () => {
setWindowLocation('?work_item_iid=555');
await createComponent();
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
index 06716584879..f3aa347f389 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
@@ -1,6 +1,7 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WidgetWrapper from '~/work_items/components/widget_wrapper.vue';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
import WorkItemChildrenWrapper from '~/work_items/components/work_item_links/work_item_children_wrapper.vue';
import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue';
@@ -19,6 +20,7 @@ describe('WorkItemTree', () => {
const findEmptyState = () => wrapper.findByTestId('tree-empty');
const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton);
const findForm = () => wrapper.findComponent(WorkItemLinksForm);
+ const findWidgetWrapper = () => wrapper.findComponent(WidgetWrapper);
const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper);
const createComponent = ({
@@ -70,6 +72,16 @@ describe('WorkItemTree', () => {
expect(findForm().exists()).toBe(false);
});
+ it('shows an error message on error', async () => {
+ const errorMessage = 'Some error';
+ createComponent();
+
+ findWorkItemLinkChildrenWrapper().vm.$emit('error', errorMessage);
+ await nextTick();
+
+ expect(findWidgetWrapper().props('error')).toBe(errorMessage);
+ });
+
it.each`
option | event | formType | childType
${'New objective'} | ${'showCreateObjectiveForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE}
diff --git a/spec/frontend/work_items/graphql/cache_utils_spec.js b/spec/frontend/work_items/graphql/cache_utils_spec.js
new file mode 100644
index 00000000000..6d0083790d1
--- /dev/null
+++ b/spec/frontend/work_items/graphql/cache_utils_spec.js
@@ -0,0 +1,153 @@
+import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
+import { addHierarchyChild, removeHierarchyChild } from '~/work_items/graphql/cache_utils';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+
+describe('work items graphql cache utils', () => {
+ const fullPath = 'full/path';
+ const iid = '10';
+ const mockCacheData = {
+ workspace: {
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/10',
+ title: 'Work item',
+ widgets: [
+ {
+ type: WIDGET_TYPE_HIERARCHY,
+ children: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/20',
+ title: 'Child',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ };
+
+ describe('addHierarchyChild', () => {
+ it('updates the work item with a new child', () => {
+ const mockCache = {
+ readQuery: () => mockCacheData,
+ writeQuery: jest.fn(),
+ };
+
+ const child = {
+ id: 'gid://gitlab/WorkItem/30',
+ title: 'New child',
+ };
+
+ addHierarchyChild(mockCache, fullPath, iid, child);
+
+ expect(mockCache.writeQuery).toHaveBeenCalledWith({
+ query: workItemByIidQuery,
+ variables: { fullPath, iid },
+ data: {
+ workspace: {
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/10',
+ title: 'Work item',
+ widgets: [
+ {
+ type: WIDGET_TYPE_HIERARCHY,
+ children: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/20',
+ title: 'Child',
+ },
+ child,
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+
+ it('does not update the work item when there is no cache data', () => {
+ const mockCache = {
+ readQuery: () => {},
+ writeQuery: jest.fn(),
+ };
+
+ const child = {
+ id: 'gid://gitlab/WorkItem/30',
+ title: 'New child',
+ };
+
+ addHierarchyChild(mockCache, fullPath, iid, child);
+
+ expect(mockCache.writeQuery).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('removeHierarchyChild', () => {
+ it('updates the work item with a new child', () => {
+ const mockCache = {
+ readQuery: () => mockCacheData,
+ writeQuery: jest.fn(),
+ };
+
+ const childToRemove = {
+ id: 'gid://gitlab/WorkItem/20',
+ title: 'Child',
+ };
+
+ removeHierarchyChild(mockCache, fullPath, iid, childToRemove);
+
+ expect(mockCache.writeQuery).toHaveBeenCalledWith({
+ query: workItemByIidQuery,
+ variables: { fullPath, iid },
+ data: {
+ workspace: {
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/10',
+ title: 'Work item',
+ widgets: [
+ {
+ type: WIDGET_TYPE_HIERARCHY,
+ children: {
+ nodes: [],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+
+ it('does not update the work item when there is no cache data', () => {
+ const mockCache = {
+ readQuery: () => {},
+ writeQuery: jest.fn(),
+ };
+
+ const childToRemove = {
+ id: 'gid://gitlab/WorkItem/20',
+ title: 'Child',
+ };
+
+ removeHierarchyChild(mockCache, fullPath, iid, childToRemove);
+
+ expect(mockCache.writeQuery).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 05c6a21bb38..a873462ea63 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -51,6 +51,7 @@ export const mockAwardEmojiThumbsUp = {
__typename: 'AwardEmoji',
user: {
id: 'gid://gitlab/User/5',
+ name: 'Dave Smith',
__typename: 'UserCore',
},
};
@@ -60,6 +61,7 @@ export const mockAwardEmojiThumbsDown = {
__typename: 'AwardEmoji',
user: {
id: 'gid://gitlab/User/5',
+ name: 'Dave Smith',
__typename: 'UserCore',
},
};
@@ -95,6 +97,7 @@ export const workItemQueryResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
workItemType: {
__typename: 'WorkItemType',
@@ -107,6 +110,7 @@ export const workItemQueryResponse = {
updateWorkItem: false,
setWorkItemMetadata: false,
adminParentLink: false,
+ createNote: false,
__typename: 'WorkItemPermissions',
},
widgets: [
@@ -198,6 +202,7 @@ export const updateWorkItemMutationResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
workItemType: {
__typename: 'WorkItemType',
@@ -210,8 +215,12 @@ export const updateWorkItemMutationResponse = {
updateWorkItem: false,
setWorkItemMetadata: false,
adminParentLink: false,
+ createNote: false,
__typename: 'WorkItemPermissions',
},
+ reference: 'test-project-path#1',
+ createNoteEmail:
+ 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com',
widgets: [
{
type: 'HIERARCHY',
@@ -302,6 +311,7 @@ export const convertWorkItemMutationResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
workItemType: {
__typename: 'WorkItemType',
@@ -314,8 +324,12 @@ export const convertWorkItemMutationResponse = {
updateWorkItem: false,
setWorkItemMetadata: false,
adminParentLink: false,
+ createNote: false,
__typename: 'WorkItemPermissions',
},
+ reference: 'gitlab-org/gitlab-test#1',
+ createNoteEmail:
+ 'gitlab-incoming+gitlab-org-gitlab-test-2-ddpzuq0zd2wefzofcpcdr3dg7-issue-1@gmail.com',
widgets: [
{
type: 'HIERARCHY',
@@ -407,6 +421,7 @@ export const objectiveType = {
export const workItemResponseFactory = ({
canUpdate = false,
canDelete = false,
+ canCreateNote = false,
adminParentLink = false,
notificationsWidgetPresent = true,
currentUserTodosWidgetPresent = true,
@@ -454,6 +469,7 @@ export const workItemResponseFactory = ({
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
workItemType,
userPermissions: {
@@ -461,8 +477,12 @@ export const workItemResponseFactory = ({
updateWorkItem: canUpdate,
setWorkItemMetadata: canUpdate,
adminParentLink,
+ createNote: canCreateNote,
__typename: 'WorkItemPermissions',
},
+ reference: 'test-project-path#1',
+ createNoteEmail:
+ 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com',
widgets: [
{
__typename: 'WorkItemWidgetDescription',
@@ -723,6 +743,7 @@ export const createWorkItemMutationResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
workItemType: {
__typename: 'WorkItemType',
@@ -735,8 +756,12 @@ export const createWorkItemMutationResponse = {
updateWorkItem: false,
setWorkItemMetadata: false,
adminParentLink: false,
+ createNote: false,
__typename: 'WorkItemPermissions',
},
+ reference: 'test-project-path#1',
+ createNoteEmail:
+ 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com',
widgets: [],
},
errors: [],
@@ -928,49 +953,62 @@ export const workItemMilestoneSubscriptionResponse = {
export const workItemHierarchyEmptyResponse = {
data: {
- workItem: {
- id: 'gid://gitlab/WorkItem/1',
- iid: '1',
- state: 'OPEN',
- workItemType: {
- id: 'gid://gitlab/WorkItems::Type/1',
- name: 'Issue',
- iconName: 'issue-type-issue',
- __typename: 'WorkItemType',
- },
- title: 'New title',
- description: '',
- createdAt: '2022-08-03T12:41:54Z',
- updatedAt: null,
- closedAt: null,
- author: mockAssignees[0],
- project: {
- __typename: 'Project',
- id: '1',
- fullPath: 'test-project-path',
- archived: false,
- },
- userPermissions: {
- deleteWorkItem: false,
- updateWorkItem: false,
- setWorkItemMetadata: false,
- adminParentLink: false,
- __typename: 'WorkItemPermissions',
- },
- confidential: false,
- widgets: [
- {
- type: 'HIERARCHY',
- parent: null,
- hasChildren: false,
- children: {
- nodes: [],
- __typename: 'WorkItemConnection',
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/2',
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/1',
+ iid: '1',
+ state: 'OPEN',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/1',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
+ __typename: 'WorkItemType',
+ },
+ title: 'New title',
+ description: '',
+ createdAt: '2022-08-03T12:41:54Z',
+ updatedAt: null,
+ closedAt: null,
+ author: mockAssignees[0],
+ project: {
+ __typename: 'Project',
+ id: '1',
+ fullPath: 'test-project-path',
+ archived: false,
+ name: 'Project name',
+ },
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ setWorkItemMetadata: false,
+ adminParentLink: false,
+ createNote: false,
+ __typename: 'WorkItemPermissions',
+ },
+ confidential: false,
+ reference: 'test-project-path#1',
+ createNoteEmail:
+ 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com',
+ widgets: [
+ {
+ type: 'HIERARCHY',
+ parent: null,
+ hasChildren: false,
+ children: {
+ nodes: [],
+ __typename: 'WorkItemConnection',
+ },
+ __typename: 'WorkItemWidgetHierarchy',
+ },
+ ],
+ __typename: 'WorkItem',
},
- __typename: 'WorkItemWidgetHierarchy',
- },
- ],
- __typename: 'WorkItem',
+ ],
+ },
},
},
};
@@ -998,6 +1036,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
updateWorkItem: false,
setWorkItemMetadata: false,
adminParentLink: false,
+ createNote: false,
__typename: 'WorkItemPermissions',
},
project: {
@@ -1005,6 +1044,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
confidential: false,
widgets: [
@@ -1126,51 +1166,64 @@ export const childrenWorkItems = [
export const workItemHierarchyResponse = {
data: {
- workItem: {
- id: 'gid://gitlab/WorkItem/1',
- iid: '1',
- workItemType: {
- id: 'gid://gitlab/WorkItems::Type/1',
- name: 'Issue',
- iconName: 'issue-type-issue',
- __typename: 'WorkItemType',
- },
- title: 'New title',
- userPermissions: {
- deleteWorkItem: true,
- updateWorkItem: true,
- setWorkItemMetadata: true,
- adminParentLink: true,
- __typename: 'WorkItemPermissions',
- },
- author: {
- ...mockAssignees[0],
- },
- confidential: false,
- project: {
- __typename: 'Project',
- id: '1',
- fullPath: 'test-project-path',
- archived: false,
- },
- description: 'Issue description',
- state: 'OPEN',
- createdAt: '2022-08-03T12:41:54Z',
- updatedAt: null,
- closedAt: null,
- widgets: [
- {
- type: 'HIERARCHY',
- parent: null,
- hasChildren: true,
- children: {
- nodes: childrenWorkItems,
- __typename: 'WorkItemConnection',
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/2',
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/1',
+ iid: '1',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/1',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
+ __typename: 'WorkItemType',
+ },
+ title: 'New title',
+ userPermissions: {
+ deleteWorkItem: true,
+ updateWorkItem: true,
+ setWorkItemMetadata: true,
+ adminParentLink: true,
+ createNote: true,
+ __typename: 'WorkItemPermissions',
+ },
+ author: {
+ ...mockAssignees[0],
+ },
+ confidential: false,
+ project: {
+ __typename: 'Project',
+ id: '1',
+ fullPath: 'test-project-path',
+ archived: false,
+ name: 'Project name',
+ },
+ description: 'Issue description',
+ state: 'OPEN',
+ createdAt: '2022-08-03T12:41:54Z',
+ updatedAt: null,
+ closedAt: null,
+ reference: 'test-project-path#1',
+ createNoteEmail:
+ 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-1@gmail.com',
+ widgets: [
+ {
+ type: 'HIERARCHY',
+ parent: null,
+ hasChildren: true,
+ children: {
+ nodes: childrenWorkItems,
+ __typename: 'WorkItemConnection',
+ },
+ __typename: 'WorkItemWidgetHierarchy',
+ },
+ ],
+ __typename: 'WorkItem',
},
- __typename: 'WorkItemWidgetHierarchy',
- },
- ],
- __typename: 'WorkItem',
+ ],
+ },
},
},
};
@@ -1226,12 +1279,14 @@ export const workItemObjectiveWithChild = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
userPermissions: {
deleteWorkItem: true,
updateWorkItem: true,
setWorkItemMetadata: true,
adminParentLink: true,
+ createNote: true,
__typename: 'WorkItemPermissions',
},
author: {
@@ -1301,6 +1356,7 @@ export const workItemHierarchyTreeResponse = {
updateWorkItem: true,
setWorkItemMetadata: true,
adminParentLink: true,
+ createNote: true,
__typename: 'WorkItemPermissions',
},
confidential: false,
@@ -1309,6 +1365,7 @@ export const workItemHierarchyTreeResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
widgets: [
{
@@ -1380,6 +1437,7 @@ export const changeIndirectWorkItemParentMutationResponse = {
updateWorkItem: true,
setWorkItemMetadata: true,
adminParentLink: true,
+ createNote: true,
__typename: 'WorkItemPermissions',
},
description: null,
@@ -1399,7 +1457,11 @@ export const changeIndirectWorkItemParentMutationResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
+ reference: 'test-project-path#13',
+ createNoteEmail:
+ 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-13@gmail.com',
widgets: [
{
__typename: 'WorkItemWidgetHierarchy',
@@ -1443,6 +1505,7 @@ export const changeWorkItemParentMutationResponse = {
updateWorkItem: true,
setWorkItemMetadata: true,
adminParentLink: true,
+ createNote: true,
__typename: 'WorkItemPermissions',
},
description: null,
@@ -1462,7 +1525,11 @@ export const changeWorkItemParentMutationResponse = {
id: '1',
fullPath: 'test-project-path',
archived: false,
+ name: 'Project name',
},
+ reference: 'test-project-path#2',
+ createNoteEmail:
+ 'gitlab-incoming+test-project-path-13fp7g6i9agekcv71s0jx9p58-issue-2@gmail.com',
widgets: [
{
__typename: 'WorkItemWidgetHierarchy',
@@ -1561,6 +1628,74 @@ export const projectMembersResponseWithCurrentUser = {
},
};
+export const projectMembersResponseWithDuplicates = {
+ data: {
+ workspace: {
+ id: '1',
+ __typename: 'Project',
+ users: {
+ nodes: [
+ {
+ id: 'user-2',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/5',
+ avatarUrl: '/avatar2',
+ name: 'rookie',
+ username: 'rookie',
+ webUrl: 'rookie',
+ status: null,
+ },
+ },
+ {
+ id: 'user-4',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/5',
+ avatarUrl: '/avatar2',
+ name: 'rookie',
+ username: 'rookie',
+ webUrl: 'rookie',
+ status: null,
+ },
+ },
+ {
+ id: 'user-1',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: '/root',
+ status: null,
+ },
+ },
+ {
+ id: 'user-3',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: '/root',
+ status: null,
+ },
+ },
+ ],
+ pageInfo: {
+ hasNextPage: false,
+ endCursor: null,
+ startCursor: null,
+ },
+ },
+ },
+ },
+};
+
export const projectMembersResponseWithCurrentUserWithNextPage = {
data: {
workspace: {
@@ -1867,6 +2002,8 @@ export const mockWorkItemNotesResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723561234',
},
@@ -1879,6 +2016,10 @@ export const mockWorkItemNotesResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/36',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -1912,6 +2053,8 @@ export const mockWorkItemNotesResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723565678',
},
@@ -1924,6 +2067,10 @@ export const mockWorkItemNotesResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/76',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -1956,6 +2103,8 @@ export const mockWorkItemNotesResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
},
@@ -1968,6 +2117,10 @@ export const mockWorkItemNotesResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/71',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2060,6 +2213,8 @@ export const mockWorkItemNotesByIidResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: null,
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723561234',
@@ -2073,6 +2228,10 @@ export const mockWorkItemNotesByIidResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/72',
+ descriptionVersion: null,
+ },
author: {
id: 'gid://gitlab/User/1',
avatarUrl:
@@ -2107,6 +2266,8 @@ export const mockWorkItemNotesByIidResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: null,
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723568765',
@@ -2120,6 +2281,10 @@ export const mockWorkItemNotesByIidResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/76',
+ descriptionVersion: null,
+ },
author: {
id: 'gid://gitlab/User/1',
avatarUrl:
@@ -2155,6 +2320,8 @@ export const mockWorkItemNotesByIidResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: null,
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
@@ -2168,6 +2335,10 @@ export const mockWorkItemNotesByIidResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/22',
+ descriptionVersion: null,
+ },
author: {
id: 'gid://gitlab/User/1',
avatarUrl:
@@ -2261,6 +2432,8 @@ export const mockMoreWorkItemNotesResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1112356a59e',
@@ -2274,6 +2447,10 @@ export const mockMoreWorkItemNotesResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/16',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2308,6 +2485,8 @@ export const mockMoreWorkItemNotesResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da1272356a59e',
@@ -2321,6 +2500,10 @@ export const mockMoreWorkItemNotesResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/96',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2353,6 +2536,8 @@ export const mockMoreWorkItemNotesResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
@@ -2366,6 +2551,10 @@ export const mockMoreWorkItemNotesResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/56',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2417,6 +2606,8 @@ export const createWorkItemNoteResponse = {
lastEditedAt: null,
url: 'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
__typename: 'Discussion',
@@ -2430,6 +2621,7 @@ export const createWorkItemNoteResponse = {
webUrl: 'http://127.0.0.1:3000/root',
__typename: 'UserCore',
},
+ systemNoteMetadata: null,
userPermissions: {
adminNote: true,
awardEmoji: true,
@@ -2467,6 +2659,8 @@ export const mockWorkItemCommentNote = {
lastEditedBy: null,
system: false,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723569876',
},
@@ -2479,6 +2673,7 @@ export const mockWorkItemCommentNote = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: null,
author: {
avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
id: 'gid://gitlab/User/1',
@@ -2489,6 +2684,16 @@ export const mockWorkItemCommentNote = {
},
};
+export const mockWorkItemCommentNoteByContributor = {
+ ...mockWorkItemCommentNote,
+ authorIsContributor: true,
+};
+
+export const mockWorkItemCommentByMaintainer = {
+ ...mockWorkItemCommentNote,
+ maxAccessLevelOfAuthor: 'Maintainer',
+};
+
export const mockWorkItemNotesResponseWithComments = {
data: {
workspace: {
@@ -2550,6 +2755,8 @@ export const mockWorkItemNotesResponseWithComments = {
url:
'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
@@ -2564,6 +2771,7 @@ export const mockWorkItemNotesResponseWithComments = {
webUrl: 'http://127.0.0.1:3000/root',
__typename: 'UserCore',
},
+ systemNoteMetadata: null,
userPermissions: {
adminNote: true,
awardEmoji: true,
@@ -2587,6 +2795,8 @@ export const mockWorkItemNotesResponseWithComments = {
url:
'http://127.0.0.1:3000/flightjs/Flight/-/work_items/37?iid_path=true#note_191',
lastEditedBy: null,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/2bb1162fd0d39297d1a68fdd7d4083d3780af0f3',
@@ -2601,6 +2811,7 @@ export const mockWorkItemNotesResponseWithComments = {
webUrl: 'http://127.0.0.1:3000/root',
__typename: 'UserCore',
},
+ systemNoteMetadata: null,
userPermissions: {
adminNote: true,
awardEmoji: true,
@@ -2633,6 +2844,8 @@ export const mockWorkItemNotesResponseWithComments = {
lastEditedBy: null,
system: false,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id:
'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
@@ -2646,6 +2859,7 @@ export const mockWorkItemNotesResponseWithComments = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: null,
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2704,6 +2918,8 @@ export const workItemNotesCreateSubscriptionResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
},
@@ -2716,6 +2932,10 @@ export const workItemNotesCreateSubscriptionResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/65',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2739,6 +2959,10 @@ export const workItemNotesCreateSubscriptionResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/26',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2766,6 +2990,8 @@ export const workItemNotesUpdateSubscriptionResponse = {
lastEditedBy: null,
system: true,
internal: false,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
discussion: {
id: 'gid://gitlab/Discussion/9c17769ca29798eddaed539d010da12723560987',
},
@@ -2778,6 +3004,10 @@ export const workItemNotesUpdateSubscriptionResponse = {
repositionNote: true,
__typename: 'NotePermissions',
},
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/46',
+ descriptionVersion: null,
+ },
author: {
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -2801,3 +3031,322 @@ export const workItemNotesDeleteSubscriptionResponse = {
},
},
};
+
+export const workItemSystemNoteWithMetadata = {
+ id: 'gid://gitlab/Note/1651',
+ body: 'changed the description',
+ bodyHtml: '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>',
+ system: true,
+ internal: false,
+ systemNoteIconName: 'pencil',
+ createdAt: '2023-05-05T07:19:37Z',
+ lastEditedAt: '2023-05-05T07:19:37Z',
+ url: 'https://gdk.test:3443/flightjs/Flight/-/work_items/46#note_1651',
+ lastEditedBy: null,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
+ discussion: {
+ id: 'gid://gitlab/Discussion/7d4a46ea0525e2eeed451f7b718b0ebe73205374',
+ __typename: 'Discussion',
+ },
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'https://gdk.test:3443/root',
+ __typename: 'UserCore',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: false,
+ __typename: 'NotePermissions',
+ },
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/670',
+ descriptionVersion: {
+ id: 'gid://gitlab/DescriptionVersion/167',
+ description: '5th May 90 987',
+ diff: '<span class="idiff">5th May 90</span><span class="idiff addition"> 987</span>',
+ diffPath: '/flightjs/Flight/-/issues/46/descriptions/167/diff',
+ deletePath: '/flightjs/Flight/-/issues/46/descriptions/167',
+ canDelete: true,
+ deleted: false,
+ startVersionId: '',
+ __typename: 'DescriptionVersion',
+ },
+ __typename: 'SystemNoteMetadata',
+ },
+ __typename: 'Note',
+};
+
+export const workItemNotesWithSystemNotesWithChangedDescription = {
+ data: {
+ workspace: {
+ id: 'gid://gitlab/Project/4',
+ workItems: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/733',
+ iid: '79',
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetAssignees',
+ },
+ {
+ __typename: 'WorkItemWidgetLabels',
+ },
+ {
+ __typename: 'WorkItemWidgetDescription',
+ },
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ },
+ {
+ __typename: 'WorkItemWidgetMilestone',
+ },
+ {
+ type: 'NOTES',
+ discussions: {
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ endCursor: null,
+ __typename: 'PageInfo',
+ },
+ nodes: [
+ {
+ id: 'gid://gitlab/Discussion/aa72f4c2f3eef66afa6d79a805178801ce4bd89f',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Note/1687',
+ body: 'changed the description',
+ bodyHtml:
+ '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>',
+ system: true,
+ internal: false,
+ systemNoteIconName: 'pencil',
+ createdAt: '2023-05-10T05:21:01Z',
+ lastEditedAt: '2023-05-10T05:21:01Z',
+ url: 'https://gdk.test:3443/gnuwget/Wget2/-/work_items/79#note_1687',
+ lastEditedBy: null,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/aa72f4c2f3eef66afa6d79a805178801ce4bd89f',
+ __typename: 'Discussion',
+ },
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'https://gdk.test:3443/root',
+ __typename: 'UserCore',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: false,
+ __typename: 'NotePermissions',
+ },
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/703',
+ descriptionVersion: {
+ id: 'gid://gitlab/DescriptionVersion/198',
+ description: 'Desc1',
+ diff: '<span class="idiff addition">Desc1</span>',
+ diffPath: '/gnuwget/Wget2/-/issues/79/descriptions/198/diff',
+ deletePath: '/gnuwget/Wget2/-/issues/79/descriptions/198',
+ canDelete: true,
+ deleted: false,
+ __typename: 'DescriptionVersion',
+ },
+ __typename: 'SystemNoteMetadata',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
+ },
+ __typename: 'Discussion',
+ },
+ {
+ id: 'gid://gitlab/Discussion/a7d3cf7bd72f7a98f802845f538af65cb11a02cc',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Note/1688',
+ body: 'changed the description',
+ bodyHtml:
+ '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>',
+ system: true,
+ internal: false,
+ systemNoteIconName: 'pencil',
+ createdAt: '2023-05-10T05:21:05Z',
+ lastEditedAt: '2023-05-10T05:21:05Z',
+ url: 'https://gdk.test:3443/gnuwget/Wget2/-/work_items/79#note_1688',
+ lastEditedBy: null,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/a7d3cf7bd72f7a98f802845f538af65cb11a02cc',
+ __typename: 'Discussion',
+ },
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'https://gdk.test:3443/root',
+ __typename: 'UserCore',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: false,
+ __typename: 'NotePermissions',
+ },
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/704',
+ descriptionVersion: {
+ id: 'gid://gitlab/DescriptionVersion/199',
+ description: 'Desc2',
+ diff:
+ '<span class="idiff">Desc</span><span class="idiff deletion">1</span><span class="idiff addition">2</span>',
+ diffPath: '/gnuwget/Wget2/-/issues/79/descriptions/199/diff',
+ deletePath: '/gnuwget/Wget2/-/issues/79/descriptions/199',
+ canDelete: true,
+ deleted: false,
+ __typename: 'DescriptionVersion',
+ },
+ __typename: 'SystemNoteMetadata',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
+ },
+ __typename: 'Discussion',
+ },
+ {
+ id: 'gid://gitlab/Discussion/391eed1ee0a258cc966a51dde900424f3b51b95d',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Note/1689',
+ body: 'changed the description',
+ bodyHtml:
+ '<p data-sourcepos="1:1-1:23" dir="auto">changed the description</p>',
+ system: true,
+ internal: false,
+ systemNoteIconName: 'pencil',
+ createdAt: '2023-05-10T05:21:08Z',
+ lastEditedAt: '2023-05-10T05:21:08Z',
+ url: 'https://gdk.test:3443/gnuwget/Wget2/-/work_items/79#note_1689',
+ lastEditedBy: null,
+ maxAccessLevelOfAuthor: 'Owner',
+ authorIsContributor: false,
+ discussion: {
+ id:
+ 'gid://gitlab/Discussion/391eed1ee0a258cc966a51dde900424f3b51b95d',
+ __typename: 'Discussion',
+ },
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'https://gdk.test:3443/root',
+ __typename: 'UserCore',
+ },
+ userPermissions: {
+ adminNote: false,
+ awardEmoji: true,
+ readNote: true,
+ createNote: true,
+ resolveNote: true,
+ repositionNote: false,
+ __typename: 'NotePermissions',
+ },
+ systemNoteMetadata: {
+ id: 'gid://gitlab/SystemNoteMetadata/705',
+ descriptionVersion: {
+ id: 'gid://gitlab/DescriptionVersion/200',
+ description: 'Desc3',
+ diff:
+ '<span class="idiff">Desc</span><span class="idiff deletion">2</span><span class="idiff addition">3</span>',
+ diffPath: '/gnuwget/Wget2/-/issues/79/descriptions/200/diff',
+ deletePath: '/gnuwget/Wget2/-/issues/79/descriptions/200',
+ canDelete: true,
+ deleted: false,
+ __typename: 'DescriptionVersion',
+ },
+ __typename: 'SystemNoteMetadata',
+ },
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
+ },
+ __typename: 'Discussion',
+ },
+ ],
+ __typename: 'DiscussionConnection',
+ },
+ __typename: 'WorkItemWidgetNotes',
+ },
+ {
+ __typename: 'WorkItemWidgetHealthStatus',
+ },
+ {
+ __typename: 'WorkItemWidgetProgress',
+ },
+ {
+ __typename: 'WorkItemWidgetNotifications',
+ },
+ {
+ __typename: 'WorkItemWidgetCurrentUserTodos',
+ },
+ {
+ __typename: 'WorkItemWidgetAwardEmoji',
+ },
+ ],
+ __typename: 'WorkItem',
+ },
+ ],
+ __typename: 'WorkItemConnection',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const getAwardEmojiResponse = (toggledOn) => {
+ return {
+ data: {
+ awardEmojiToggle: {
+ errors: [],
+ toggledOn,
+ },
+ },
+ };
+};
diff --git a/spec/frontend/work_items/notes/collapse_utils_spec.js b/spec/frontend/work_items/notes/collapse_utils_spec.js
new file mode 100644
index 00000000000..c26ef891e9f
--- /dev/null
+++ b/spec/frontend/work_items/notes/collapse_utils_spec.js
@@ -0,0 +1,29 @@
+import {
+ isDescriptionSystemNote,
+ getTimeDifferenceInMinutes,
+} from '~/work_items/notes/collapse_utils';
+import { workItemSystemNoteWithMetadata } from '../mock_data';
+
+describe('Work items collapse utils', () => {
+ it('checks if a system note is of a description type', () => {
+ expect(isDescriptionSystemNote(workItemSystemNoteWithMetadata)).toEqual(true);
+ });
+
+ it('returns false when a system note is not a description type', () => {
+ expect(isDescriptionSystemNote({ ...workItemSystemNoteWithMetadata, system: false })).toEqual(
+ false,
+ );
+ });
+
+ it('gets the time difference between two notes', () => {
+ const anotherSystemNote = {
+ ...workItemSystemNoteWithMetadata,
+ createdAt: '2023-05-06T07:19:37Z',
+ };
+
+ // kept the dates 24 hours apart so 24 * 60 mins = 1440
+ expect(getTimeDifferenceInMinutes(workItemSystemNoteWithMetadata, anotherSystemNote)).toEqual(
+ 1440,
+ );
+ });
+});
diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js
index c480affe484..84b10f30418 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -34,7 +34,7 @@ describe('Work items root component', () => {
issuesListPath,
},
propsData: {
- id: '1',
+ iid: '1',
},
mocks: {
$toast: {
@@ -49,7 +49,6 @@ describe('Work items root component', () => {
expect(findWorkItemDetail().props()).toEqual({
isModal: false,
- workItemId: 'gid://gitlab/WorkItem/1',
workItemParentId: null,
workItemIid: '1',
});