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__/mock_observability_client.js1
-rw-r--r--spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap2
-rw-r--r--spec/frontend/access_tokens/components/access_token_table_app_spec.js10
-rw-r--r--spec/frontend/access_tokens/components/new_access_token_app_spec.js14
-rw-r--r--spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js12
-rw-r--r--spec/frontend/admin/abuse_report/components/notes/abuse_report_add_note_spec.js227
-rw-r--r--spec/frontend/admin/abuse_report/components/notes/abuse_report_comment_form_spec.js214
-rw-r--r--spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js72
-rw-r--r--spec/frontend/admin/abuse_report/components/notes/abuse_report_edit_note_spec.js129
-rw-r--r--spec/frontend/admin/abuse_report/components/notes/abuse_report_note_actions_spec.js79
-rw-r--r--spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js126
-rw-r--r--spec/frontend/admin/abuse_report/mock_data.js90
-rw-r--r--spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js4
-rw-r--r--spec/frontend/analytics/cycle_analytics/mock_data.js12
-rw-r--r--spec/frontend/analytics/cycle_analytics/store/actions_spec.js14
-rw-r--r--spec/frontend/analytics/cycle_analytics/utils_spec.js14
-rw-r--r--spec/frontend/api/user_api_spec.js28
-rw-r--r--spec/frontend/authentication/password/components/password_input_spec.js1
-rw-r--r--spec/frontend/badges/store/actions_spec.js12
-rw-r--r--spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js12
-rw-r--r--spec/frontend/behaviors/shortcuts/shortcuts_spec.js (renamed from spec/frontend/shortcuts_spec.js)124
-rw-r--r--spec/frontend/blob_edit/edit_blob_spec.js29
-rw-r--r--spec/frontend/boards/board_card_inner_spec.js42
-rw-r--r--spec/frontend/boards/board_list_helper.js61
-rw-r--r--spec/frontend/boards/board_list_spec.js35
-rw-r--r--spec/frontend/boards/components/board_add_new_column_spec.js193
-rw-r--r--spec/frontend/boards/components/board_app_spec.js87
-rw-r--r--spec/frontend/boards/components/board_card_move_to_position_spec.js69
-rw-r--r--spec/frontend/boards/components/board_card_spec.js101
-rw-r--r--spec/frontend/boards/components/board_column_spec.js52
-rw-r--r--spec/frontend/boards/components/board_content_sidebar_spec.js79
-rw-r--r--spec/frontend/boards/components/board_content_spec.js139
-rw-r--r--spec/frontend/boards/components/board_filtered_search_spec.js73
-rw-r--r--spec/frontend/boards/components/board_form_spec.js74
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js137
-rw-r--r--spec/frontend/boards/components/board_new_issue_spec.js117
-rw-r--r--spec/frontend/boards/components/board_settings_sidebar_spec.js113
-rw-r--r--spec/frontend/boards/components/board_top_bar_spec.js83
-rw-r--r--spec/frontend/boards/components/boards_selector_spec.js29
-rw-r--r--spec/frontend/boards/components/issue_board_filtered_search_spec.js3
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js93
-rw-r--r--spec/frontend/boards/mock_data.js71
-rw-r--r--spec/frontend/boards/stores/actions_spec.js58
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js672
-rw-r--r--spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js20
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js16
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js2
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js8
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js3
-rw-r--r--spec/frontend/ci/catalog/components/list/catalog_header_spec.js18
-rw-r--r--spec/frontend/ci/catalog/components/list/catalog_search_spec.js103
-rw-r--r--spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js118
-rw-r--r--spec/frontend/ci/catalog/components/list/empty_state_spec.js64
-rw-r--r--spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js8
-rw-r--r--spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js118
-rw-r--r--spec/frontend/ci/catalog/mock.js194
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js195
-rw-r--r--spec/frontend/ci/common/pipelines_table_spec.js2
-rw-r--r--spec/frontend/ci/job_details/components/job_header_spec.js2
-rw-r--r--spec/frontend/ci/job_details/components/job_log_controllers_spec.js76
-rw-r--r--spec/frontend/ci/job_details/components/log/collapsible_section_spec.js103
-rw-r--r--spec/frontend/ci/job_details/components/log/line_header_spec.js8
-rw-r--r--spec/frontend/ci/job_details/components/log/log_spec.js96
-rw-r--r--spec/frontend/ci/job_details/components/log/mock_data.js243
-rw-r--r--spec/frontend/ci/job_details/components/manual_variables_form_spec.js31
-rw-r--r--spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js2
-rw-r--r--spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js37
-rw-r--r--spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js2
-rw-r--r--spec/frontend/ci/job_details/job_app_spec.js10
-rw-r--r--spec/frontend/ci/job_details/store/actions_spec.js252
-rw-r--r--spec/frontend/ci/job_details/store/mutations_spec.js56
-rw-r--r--spec/frontend/ci/job_details/store/utils_spec.js683
-rw-r--r--spec/frontend/ci/jobs_mock_data.js50
-rw-r--r--spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js2
-rw-r--r--spec/frontend/ci/jobs_page/components/jobs_table_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js130
-rw-r--r--spec/frontend/ci/pipeline_details/mock_data.js3
-rw-r--r--spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js28
-rw-r--r--spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js22
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js45
-rw-r--r--spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js1
-rw-r--r--spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js15
-rw-r--r--spec/frontend/ci/pipeline_editor/mock_data.js3
-rw-r--r--spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js23
-rw-r--r--spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js2
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js47
-rw-r--r--spec/frontend/ci/pipelines_page/pipelines_spec.js1
-rw-r--r--spec/frontend/ci/reports/codequality_report/store/actions_spec.js190
-rw-r--r--spec/frontend/ci/reports/codequality_report/store/getters_spec.js94
-rw-r--r--spec/frontend/ci/reports/codequality_report/store/mutations_spec.js100
-rw-r--r--spec/frontend/ci/reports/codequality_report/utils/codequality_parser_spec.js (renamed from spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js)2
-rw-r--r--spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js6
-rw-r--r--spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js21
-rw-r--r--spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js27
-rw-r--r--spec/frontend/ci/runner/components/runner_job_count_spec.js74
-rw-r--r--spec/frontend/ci/runner/components/runner_managers_detail_spec.js8
-rw-r--r--spec/frontend/ci/runner/group_runners/group_runners_app_spec.js6
-rw-r--r--spec/frontend/ci/runner/mock_data.js10
-rw-r--r--spec/frontend/clusters/agents/components/integration_status_spec.js4
-rw-r--r--spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap2
-rw-r--r--spec/frontend/commit/commit_pipeline_status_spec.js2
-rw-r--r--spec/frontend/commit/components/commit_box_pipeline_status_spec.js2
-rw-r--r--spec/frontend/commons/nav/user_merge_requests_spec.js154
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap2
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js1
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js14
-rw-r--r--spec/frontend/content_editor/components/suggestions_dropdown_spec.js188
-rw-r--r--spec/frontend/content_editor/components/wrappers/code_block_spec.js7
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js3
-rw-r--r--spec/frontend/content_editor/extensions/copy_paste_spec.js19
-rw-r--r--spec/frontend/content_editor/extensions/reference_spec.js56
-rw-r--r--spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap256
-rw-r--r--spec/frontend/content_editor/services/asset_resolver_spec.js55
-rw-r--r--spec/frontend/content_editor/services/autocomplete_mock_data.js967
-rw-r--r--spec/frontend/content_editor/services/data_source_factory_spec.js202
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js107
-rw-r--r--spec/frontend/content_editor/services/markdown_sourcemap_spec.js109
-rw-r--r--spec/frontend/content_editor/test_constants.js15
-rw-r--r--spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap60
-rw-r--r--spec/frontend/contributors/component/contributor_area_chart_spec.js92
-rw-r--r--spec/frontend/contributors/component/contributors_spec.js2
-rw-r--r--spec/frontend/deploy_freeze/store/actions_spec.js8
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js6
-rw-r--r--spec/frontend/deploy_keys/graphql/resolvers_spec.js249
-rw-r--r--spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap4
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js24
-rw-r--r--spec/frontend/diffs/components/diff_row_spec.js6
-rw-r--r--spec/frontend/diffs/components/merge_conflict_warning_spec.js58
-rw-r--r--spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap225
-rw-r--r--spec/frontend/diffs/components/shared/findings_drawer_spec.js118
-rw-r--r--spec/frontend/diffs/mock_data/findings_drawer.js51
-rw-r--r--spec/frontend/diffs/mock_data/inline_findings.js10
-rw-r--r--spec/frontend/diffs/store/actions_spec.js54
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js11
-rw-r--r--spec/frontend/dropzone_input_spec.js40
-rw-r--r--spec/frontend/editor/schema/ci/ci_schema_spec.js10
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml4
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml38
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml14
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml4
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml4
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml41
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml7
-rw-r--r--spec/frontend/editor/source_editor_security_policy_schema_ext_spec.js181
-rw-r--r--spec/frontend/emoji/components/emoji_group_spec.js15
-rw-r--r--spec/frontend/emoji/index_spec.js27
-rw-r--r--spec/frontend/environments/deploy_board_wrapper_spec.js4
-rw-r--r--spec/frontend/environments/deployment_spec.js10
-rw-r--r--spec/frontend/environments/environment_flux_resource_selector_spec.js2
-rw-r--r--spec/frontend/environments/environment_folder_spec.js4
-rw-r--r--spec/frontend/environments/environment_form_spec.js168
-rw-r--r--spec/frontend/environments/environment_namespace_selector_spec.js217
-rw-r--r--spec/frontend/environments/folder/environments_folder_app_spec.js131
-rw-r--r--spec/frontend/environments/folder/environments_folder_view_spec.js5
-rw-r--r--spec/frontend/environments/graphql/mock_data.js157
-rw-r--r--spec/frontend/environments/graphql/resolvers/base_spec.js34
-rw-r--r--spec/frontend/environments/graphql/resolvers/kubernetes_spec.js297
-rw-r--r--spec/frontend/environments/helpers/k8s_integration_helper_spec.js225
-rw-r--r--spec/frontend/environments/kubernetes_overview_spec.js23
-rw-r--r--spec/frontend/environments/kubernetes_pods_spec.js57
-rw-r--r--spec/frontend/environments/kubernetes_summary_spec.js23
-rw-r--r--spec/frontend/environments/kubernetes_tabs_spec.js7
-rw-r--r--spec/frontend/environments/new_environment_item_spec.js10
-rw-r--r--spec/frontend/error_tracking/components/error_details_spec.js2
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js30
-rw-r--r--spec/frontend/error_tracking/store/list/actions_spec.js12
-rw-r--r--spec/frontend/feature_flags/mock_data.js2
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_helper_spec.js42
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_popover_spec.js75
-rw-r--r--spec/frontend/filtered_search/add_extra_tokens_for_merge_requests_spec.js30
-rw-r--r--spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js22
-rw-r--r--spec/frontend/fixtures/deploy_keys.rb42
-rw-r--r--spec/frontend/fixtures/pipeline_header.rb35
-rw-r--r--spec/frontend/fixtures/runner.rb2
-rw-r--r--spec/frontend/fixtures/static/whats_new_notification.html7
-rw-r--r--spec/frontend/frequent_items/components/app_spec.js286
-rw-r--r--spec/frontend/frequent_items/components/frequent_items_list_item_spec.js161
-rw-r--r--spec/frontend/frequent_items/components/frequent_items_list_spec.js121
-rw-r--r--spec/frontend/frequent_items/components/frequent_items_search_input_spec.js74
-rw-r--r--spec/frontend/frequent_items/mock_data.js169
-rw-r--r--spec/frontend/frequent_items/store/actions_spec.js304
-rw-r--r--spec/frontend/frequent_items/store/getters_spec.js24
-rw-r--r--spec/frontend/frequent_items/store/mutations_spec.js152
-rw-r--r--spec/frontend/frequent_items/utils_spec.js131
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js80
-rw-r--r--spec/frontend/groups/components/app_spec.js6
-rw-r--r--spec/frontend/groups/components/groups_spec.js13
-rw-r--r--spec/frontend/groups/service/archived_projects_service_spec.js2
-rw-r--r--spec/frontend/groups_projects/components/more_actions_dropdown_spec.js173
-rw-r--r--spec/frontend/header_search/components/app_spec.js517
-rw-r--r--spec/frontend/header_search/components/header_search_autocomplete_items_spec.js236
-rw-r--r--spec/frontend/header_search/components/header_search_default_items_spec.js103
-rw-r--r--spec/frontend/header_search/components/header_search_scoped_items_spec.js121
-rw-r--r--spec/frontend/header_search/init_spec.js54
-rw-r--r--spec/frontend/header_search/mock_data.js400
-rw-r--r--spec/frontend/header_search/store/actions_spec.js113
-rw-r--r--spec/frontend/header_search/store/getters_spec.js333
-rw-r--r--spec/frontend/header_search/store/mutations_spec.js63
-rw-r--r--spec/frontend/header_spec.js107
-rw-r--r--spec/frontend/ide/components/ide_sidebar_nav_spec.js2
-rw-r--r--spec/frontend/ide/components/ide_status_bar_spec.js6
-rw-r--r--spec/frontend/ide/components/panes/collapsible_sidebar_spec.js68
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js2
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js7
-rw-r--r--spec/frontend/ide/init_gitlab_web_ide_spec.js28
-rw-r--r--spec/frontend/ide/lib/gitlab_web_ide/get_oauth_config_spec.js16
-rw-r--r--spec/frontend/ide/mock_data.js1
-rw-r--r--spec/frontend/ide/mount_oauth_callback_spec.js53
-rw-r--r--spec/frontend/ide/stores/modules/editor/actions_spec.js6
-rw-r--r--spec/frontend/import/details/components/bulk_import_details_app_spec.js14
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_history_link_spec.js34
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js92
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js4
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/fixtures.js3
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js4
-rw-r--r--spec/frontend/import_entities/import_groups/utils_spec.js2
-rw-r--r--spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js37
-rw-r--r--spec/frontend/import_entities/import_projects/store/actions_spec.js39
-rw-r--r--spec/frontend/import_entities/import_projects/store/mutations_spec.js10
-rw-r--r--spec/frontend/integrations/edit/components/dynamic_field_spec.js2
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_actions_spec.js2
-rw-r--r--spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js18
-rw-r--r--spec/frontend/integrations/edit/components/trigger_field_spec.js17
-rw-r--r--spec/frontend/integrations/edit/components/trigger_fields_spec.js17
-rw-r--r--spec/frontend/invite_members/components/invite_groups_modal_spec.js10
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js17
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js13
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js55
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js94
-rw-r--r--spec/frontend/invite_members/mock_data/member_modal.js3
-rw-r--r--spec/frontend/invite_members/mock_data/modal_base.js2
-rw-r--r--spec/frontend/issuable/popover/components/mr_popover_spec.js2
-rw-r--r--spec/frontend/issues/dashboard/components/index_spec.js18
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js8
-rw-r--r--spec/frontend/issues/list/mock_data.js33
-rw-r--r--spec/frontend/issues/list/utils_spec.js13
-rw-r--r--spec/frontend/issues/show/components/app_spec.js27
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js18
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js9
-rw-r--r--spec/frontend/kubernetes_dashboard/components/page_title_spec.js35
-rw-r--r--spec/frontend/kubernetes_dashboard/components/workload_details_item_spec.js34
-rw-r--r--spec/frontend/kubernetes_dashboard/components/workload_details_spec.js53
-rw-r--r--spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js141
-rw-r--r--spec/frontend/kubernetes_dashboard/components/workload_stats_spec.js43
-rw-r--r--spec/frontend/kubernetes_dashboard/components/workload_table_spec.js128
-rw-r--r--spec/frontend/kubernetes_dashboard/graphql/mock_data.js353
-rw-r--r--spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js459
-rw-r--r--spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js93
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/app_spec.js40
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/daemon_sets_page_spec.js106
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/deployments_page_spec.js106
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js102
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js106
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/stateful_sets_page_spec.js106
-rw-r--r--spec/frontend/lib/utils/breadcrumbs_spec.js22
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js2
-rw-r--r--spec/frontend/lib/utils/datetime/date_format_utility_spec.js12
-rw-r--r--spec/frontend/lib/utils/datetime/locale_dateformat_spec.js177
-rw-r--r--spec/frontend/lib/utils/datetime/timeago_utility_spec.js6
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js15
-rw-r--r--spec/frontend/lib/utils/secret_detection_spec.js1
-rw-r--r--spec/frontend/lib/utils/vuex_module_mappers_spec.js133
-rw-r--r--spec/frontend/loading_icon_for_legacy_js_spec.js4
-rw-r--r--spec/frontend/logo_spec.js55
-rw-r--r--spec/frontend/members/components/table/max_role_spec.js (renamed from spec/frontend/members/components/table/role_dropdown_spec.js)93
-rw-r--r--spec/frontend/members/components/table/members_table_spec.js18
-rw-r--r--spec/frontend/members/mock_data.js1
-rw-r--r--spec/frontend/members/store/actions_spec.js29
-rw-r--r--spec/frontend/members/store/mutations_spec.js13
-rw-r--r--spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js17
-rw-r--r--spec/frontend/merge_conflicts/store/actions_spec.js10
-rw-r--r--spec/frontend/milestones/stores/actions_spec.js29
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js203
-rw-r--r--spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js41
-rw-r--r--spec/frontend/ml/model_registry/apps/index_ml_models_spec.js20
-rw-r--r--spec/frontend/ml/model_registry/apps/show_ml_model_spec.js45
-rw-r--r--spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js16
-rw-r--r--spec/frontend/ml/model_registry/components/candidate_detail_row_spec.js (renamed from spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js)2
-rw-r--r--spec/frontend/ml/model_registry/components/candidate_detail_spec.js191
-rw-r--r--spec/frontend/ml/model_registry/components/candidate_list_row_spec.js39
-rw-r--r--spec/frontend/ml/model_registry/components/candidate_list_spec.js182
-rw-r--r--spec/frontend/ml/model_registry/components/empty_state_spec.js47
-rw-r--r--spec/frontend/ml/model_registry/components/model_version_detail_spec.js66
-rw-r--r--spec/frontend/ml/model_registry/components/model_version_list_spec.js184
-rw-r--r--spec/frontend/ml/model_registry/components/model_version_row_spec.js37
-rw-r--r--spec/frontend/ml/model_registry/graphql_mock_data.js116
-rw-r--r--spec/frontend/ml/model_registry/mock_data.js58
-rw-r--r--spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js29
-rw-r--r--spec/frontend/nav/components/new_nav_toggle_spec.js214
-rw-r--r--spec/frontend/nav/components/responsive_app_spec.js122
-rw-r--r--spec/frontend/nav/components/responsive_header_spec.js63
-rw-r--r--spec/frontend/nav/components/responsive_home_spec.js133
-rw-r--r--spec/frontend/nav/components/top_nav_app_spec.js68
-rw-r--r--spec/frontend/nav/components/top_nav_container_view_spec.js120
-rw-r--r--spec/frontend/nav/components/top_nav_dropdown_menu_spec.js146
-rw-r--r--spec/frontend/nav/components/top_nav_menu_item_spec.js145
-rw-r--r--spec/frontend/nav/components/top_nav_menu_sections_spec.js138
-rw-r--r--spec/frontend/nav/components/top_nav_new_dropdown_spec.js142
-rw-r--r--spec/frontend/nav/mock_data.js39
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js6
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js49
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js12
-rw-r--r--spec/frontend/notes/stores/actions_spec.js9
-rw-r--r--spec/frontend/observability/client_spec.js122
-rw-r--r--spec/frontend/organizations/index/components/app_spec.js202
-rw-r--r--spec/frontend/organizations/index/components/organizations_list_spec.js68
-rw-r--r--spec/frontend/organizations/index/components/organizations_view_spec.js28
-rw-r--r--spec/frontend/organizations/settings/general/components/advanced_settings_spec.js25
-rw-r--r--spec/frontend/organizations/settings/general/components/app_spec.js7
-rw-r--r--spec/frontend/organizations/settings/general/components/change_url_spec.js191
-rw-r--r--spec/frontend/organizations/settings/general/components/organization_settings_spec.js108
-rw-r--r--spec/frontend/organizations/shared/components/new_edit_form_spec.js28
-rw-r--r--spec/frontend/organizations/shared/components/organization_url_field_spec.js66
-rw-r--r--spec/frontend/organizations/users/components/app_spec.js84
-rw-r--r--spec/frontend/organizations/users/components/users_view_spec.js68
-rw-r--r--spec/frontend/organizations/users/mock_data.js34
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js7
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap6
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap6
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js9
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js10
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap6
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js17
-rw-r--r--spec/frontend/packages_and_registries/package_registry/mock_data.js3
-rw-r--r--spec/frontend/packages_and_registries/package_registry/pages/list_spec.js9
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js18
-rw-r--r--spec/frontend/packages_and_registries/shared/utils_spec.js17
-rw-r--r--spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js99
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js26
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js177
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/mock_data.js13
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js8
-rw-r--r--spec/frontend/pages/shared/nav/sidebar_tracking_spec.js160
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_export_spec.js48
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_more_dropdown_spec.js83
-rw-r--r--spec/frontend/persistent_user_callout_spec.js52
-rw-r--r--spec/frontend/profile/edit/components/profile_edit_app_spec.js6
-rw-r--r--spec/frontend/profile/edit/components/user_avatar_spec.js3
-rw-r--r--spec/frontend/projects/commit/store/actions_spec.js12
-rw-r--r--spec/frontend/projects/commits/store/actions_spec.js4
-rw-r--r--spec/frontend/projects/components/shared/delete_modal_spec.js2
-rw-r--r--spec/frontend/projects/settings/components/default_branch_selector_spec.js3
-rw-r--r--spec/frontend/projects/settings/components/new_access_dropdown_spec.js16
-rw-r--r--spec/frontend/projects/settings_service_desk/components/custom_email_spec.js23
-rw-r--r--spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js27
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js21
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js38
-rw-r--r--spec/frontend/read_more_spec.js8
-rw-r--r--spec/frontend/ref/components/ref_selector_spec.js28
-rw-r--r--spec/frontend/ref/stores/actions_spec.js14
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js8
-rw-r--r--spec/frontend/repository/commits_service_spec.js19
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js31
-rw-r--r--spec/frontend/repository/components/blob_controls_spec.js3
-rw-r--r--spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap6
-rw-r--r--spec/frontend/repository/components/tree_content_spec.js21
-rw-r--r--spec/frontend/repository/mixins/highlight_mixin_spec.js74
-rw-r--r--spec/frontend/search/sidebar/components/all_scopes_start_filters_spec.js28
-rw-r--r--spec/frontend/search/sidebar/components/app_spec.js121
-rw-r--r--spec/frontend/search/sidebar/components/archived_filter_spec.js10
-rw-r--r--spec/frontend/search/sidebar/components/blobs_filters_spec.js34
-rw-r--r--spec/frontend/search/sidebar/components/confidentiality_filter_spec.js20
-rw-r--r--spec/frontend/search/sidebar/components/filters_template_spec.js5
-rw-r--r--spec/frontend/search/sidebar/components/group_filter_spec.js (renamed from spec/frontend/search/topbar/components/group_filter_spec.js)34
-rw-r--r--spec/frontend/search/sidebar/components/issues_filters_spec.js71
-rw-r--r--spec/frontend/search/sidebar/components/label_filter_spec.js5
-rw-r--r--spec/frontend/search/sidebar/components/merge_requests_filters_spec.js34
-rw-r--r--spec/frontend/search/sidebar/components/project_filter_spec.js (renamed from spec/frontend/search/topbar/components/project_filter_spec.js)36
-rw-r--r--spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js145
-rw-r--r--spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js7
-rw-r--r--spec/frontend/search/sidebar/components/searchable_dropdown_spec.js117
-rw-r--r--spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js68
-rw-r--r--spec/frontend/search/sidebar/components/status_filter_spec.js20
-rw-r--r--spec/frontend/search/store/mutations_spec.js4
-rw-r--r--spec/frontend/search/topbar/components/app_spec.js63
-rw-r--r--spec/frontend/search/topbar/components/search_type_indicator_spec.js128
-rw-r--r--spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js93
-rw-r--r--spec/frontend/search/topbar/components/searchable_dropdown_spec.js220
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js6
-rw-r--r--spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js124
-rw-r--r--spec/frontend/security_configuration/components/feature_card_spec.js20
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js7
-rw-r--r--spec/frontend/security_configuration/mock_data.js2
-rw-r--r--spec/frontend/security_configuration/utils_spec.js2
-rw-r--r--spec/frontend/set_status_modal/set_status_form_spec.js18
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js4
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js23
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js2
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js25
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js16
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js10
-rw-r--r--spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js18
-rw-r--r--spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js27
-rw-r--r--spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js93
-rw-r--r--spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js8
-rw-r--r--spec/frontend/sidebar/sidebar_mediator_spec.js1
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap2
-rw-r--r--spec/frontend/snippets/components/snippet_blob_edit_spec.js10
-rw-r--r--spec/frontend/snippets/components/snippet_title_spec.js123
-rw-r--r--spec/frontend/snippets/test_utils.js1
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js34
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js43
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js157
-rw-r--r--spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js34
-rw-r--r--spec/frontend/super_sidebar/components/help_center_spec.js8
-rw-r--r--spec/frontend/super_sidebar/components/nav_item_link_spec.js2
-rw-r--r--spec/frontend/super_sidebar/components/nav_item_router_link_spec.js4
-rw-r--r--spec/frontend/super_sidebar/components/scroll_scrim_spec.js60
-rw-r--r--spec/frontend/super_sidebar/components/super_sidebar_spec.js66
-rw-r--r--spec/frontend/super_sidebar/components/user_menu_spec.js157
-rw-r--r--spec/frontend/super_sidebar/mock_data.js51
-rw-r--r--spec/frontend/super_sidebar/user_counts_manager_spec.js30
-rw-r--r--spec/frontend/super_sidebar/utils_spec.js134
-rw-r--r--spec/frontend/task_list_spec.js56
-rw-r--r--spec/frontend/tracking/internal_events_spec.js48
-rw-r--r--spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js34
-rw-r--r--spec/frontend/user_lists/components/user_lists_table_spec.js3
-rw-r--r--spec/frontend/vue_merge_request_widget/components/approvals/approvals_spec.js36
-rw-r--r--spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js196
-rw-r--r--spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js2
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js3
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js7
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap4
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js173
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js23
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge_spec.js8
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js82
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js16
-rw-r--r--spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap7
-rw-r--r--spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js224
-rw-r--r--spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js101
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_details_spec.js103
-rw-r--r--spec/frontend/vue_shared/components/ci_icon/ci_icon_spec.js (renamed from spec/frontend/vue_shared/components/ci_icon_spec.js)2
-rw-r--r--spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/entity_select/entity_select_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/entity_select/organization_select_spec.js155
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js193
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/keep_alive_slots_spec.js118
-rw-r--r--spec/frontend/vue_shared/components/list_selector/deploy_key_item_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/list_selector/index_spec.js51
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/metric_images/store/actions_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/number_to_human_size/number_to_human_size_spec.js47
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_new_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js35
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/mock_data.js1
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/time_ago_tooltip_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/vuex_module_provider_spec.js39
-rw-r--r--spec/frontend/vue_shared/directives/track_event_spec.js55
-rw-r--r--spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js2
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js31
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js6
-rw-r--r--spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js16
-rw-r--r--spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js37
-rw-r--r--spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js2
-rw-r--r--spec/frontend/webhooks/components/form_url_app_spec.js20
-rw-r--r--spec/frontend/whats_new/store/actions_spec.js22
-rw-r--r--spec/frontend/whats_new/utils/notification_spec.js73
-rw-r--r--spec/frontend/work_items/components/item_title_spec.js9
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_actions_spec.js5
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js82
-rw-r--r--spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js18
-rw-r--r--spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_item_spec.js53
-rw-r--r--spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_spec.js99
-rw-r--r--spec/frontend/work_items/components/work_item_ancestors/mock_data.js197
-rw-r--r--spec/frontend/work_items/components/work_item_ancestors/work_item_ancestors_spec.js117
-rw-r--r--spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js73
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js1
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js219
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_actions_split_button_spec.js (renamed from spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js)34
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js44
-rw-r--r--spec/frontend/work_items/components/work_item_notes_spec.js27
-rw-r--r--spec/frontend/work_items/components/work_item_parent_inline_spec.js (renamed from spec/frontend/work_items/components/work_item_parent_spec.js)6
-rw-r--r--spec/frontend/work_items/components/work_item_parent_with_edit_spec.js409
-rw-r--r--spec/frontend/work_items/components/work_item_state_toggle_spec.js (renamed from spec/frontend/work_items/components/work_item_state_toggle_button_spec.js)0
-rw-r--r--spec/frontend/work_items/components/work_item_sticky_header_spec.js59
-rw-r--r--spec/frontend/work_items/components/work_item_title_spec.js34
-rw-r--r--spec/frontend/work_items/mock_data.js51
-rw-r--r--spec/frontend/work_items/notes/award_utils_spec.js18
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js2
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js1
497 files changed, 17272 insertions, 13276 deletions
diff --git a/spec/frontend/__helpers__/mock_observability_client.js b/spec/frontend/__helpers__/mock_observability_client.js
index 82425aa2842..a65b5233b73 100644
--- a/spec/frontend/__helpers__/mock_observability_client.js
+++ b/spec/frontend/__helpers__/mock_observability_client.js
@@ -7,6 +7,7 @@ export function createMockClient() {
servicesUrl: 'services-url',
operationsUrl: 'operations-url',
metricsUrl: 'metrics-url',
+ metricsSearchUrl: 'metrics-search-url',
});
Object.getOwnPropertyNames(mockClient)
diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
index 2bd2b17a12d..7785693ff2a 100644
--- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
+++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
@@ -11,7 +11,7 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi
arialabel=""
autocomplete=""
container=""
- data-qa-selector="expiry_date_field"
+ data-testid="expiry-date-field"
defaultdate="Wed Aug 05 2020 00:00:00 GMT+0000 (Greenwich Mean Time)"
displayfield="true"
firstday="0"
diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
index ae767f8b3f5..dd3fc3a9d98 100644
--- a/spec/frontend/access_tokens/components/access_token_table_app_spec.js
+++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
@@ -25,7 +25,7 @@ describe('~/access_tokens/components/access_token_table_app', () => {
expires_soon: true,
expires_at: null,
revoked: false,
- revoke_path: '/-/profile/personal_access_tokens/1/revoke',
+ revoke_path: '/-/user_settings/personal_access_tokens/1/revoke',
role: 'Maintainer',
},
{
@@ -37,7 +37,7 @@ describe('~/access_tokens/components/access_token_table_app', () => {
expires_soon: false,
expires_at: new Date().toISOString(),
revoked: false,
- revoke_path: '/-/profile/personal_access_tokens/2/revoke',
+ revoke_path: '/-/user_settings/personal_access_tokens/2/revoke',
role: 'Maintainer',
},
];
@@ -153,8 +153,8 @@ describe('~/access_tokens/components/access_token_table_app', () => {
let button = cells.at(6).findComponent(GlButton);
expect(button.attributes()).toMatchObject({
'aria-label': __('Revoke'),
- 'data-qa-selector': __('revoke_button'),
- href: '/-/profile/personal_access_tokens/1/revoke',
+ 'data-testid': 'revoke-button',
+ href: '/-/user_settings/personal_access_tokens/1/revoke',
'data-confirm': sprintf(
__(
'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.',
@@ -172,7 +172,7 @@ describe('~/access_tokens/components/access_token_table_app', () => {
expect(cells.at(11).text()).toBe(__('Expired'));
expect(cells.at(12).text()).toBe('Maintainer');
button = cells.at(13).findComponent(GlButton);
- expect(button.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke');
+ expect(button.attributes('href')).toBe('/-/user_settings/personal_access_tokens/2/revoke');
expect(button.props('category')).toBe('tertiary');
});
diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
index d51ac638f0e..966a69fa60a 100644
--- a/spec/frontend/access_tokens/components/new_access_token_app_spec.js
+++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
@@ -81,20 +81,6 @@ describe('~/access_tokens/components/new_access_token_app', () => {
);
});
- it('input field should contain QA-related selectors', async () => {
- const newToken = '12345';
- await triggerSuccess(newToken);
-
- expect(findGlAlertError().exists()).toBe(false);
-
- const inputAttributes = wrapper
- .findByLabelText(sprintf(__('Your new %{accessTokenType}'), { accessTokenType }))
- .attributes();
- expect(inputAttributes).toMatchObject({
- 'data-qa-selector': 'created_access_token_field',
- });
- });
-
it('should render an info alert', async () => {
await triggerSuccess();
diff --git a/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js b/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js
index 166c735ffbd..a1993e2bde3 100644
--- a/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js
+++ b/spec/frontend/admin/abuse_report/components/abuse_report_notes_spec.js
@@ -8,6 +8,7 @@ import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_not
import abuseReportNotesQuery from '~/admin/abuse_report/graphql/notes/abuse_report_notes.query.graphql';
import AbuseReportNotes from '~/admin/abuse_report/components/abuse_report_notes.vue';
import AbuseReportDiscussion from '~/admin/abuse_report/components/notes/abuse_report_discussion.vue';
+import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue';
import { mockAbuseReport, mockNotesByIdResponse } from '../mock_data';
@@ -24,6 +25,7 @@ describe('Abuse Report Notes', () => {
const findSkeletonLoaders = () => wrapper.findAllComponents(SkeletonLoadingContainer);
const findAbuseReportDiscussions = () => wrapper.findAllComponents(AbuseReportDiscussion);
+ const findAbuseReportAddNote = () => wrapper.findComponent(AbuseReportAddNote);
const createComponent = ({
queryHandler = notesQueryHandler,
@@ -78,6 +80,16 @@ describe('Abuse Report Notes', () => {
discussion: discussions[1].notes.nodes,
});
});
+
+ it('should show the comment form', () => {
+ expect(findAbuseReportAddNote().exists()).toBe(true);
+
+ expect(findAbuseReportAddNote().props()).toMatchObject({
+ abuseReportId: mockAbuseReportId,
+ discussionId: '',
+ isNewDiscussion: true,
+ });
+ });
});
describe('When there is an error fetching the notes', () => {
diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_add_note_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_add_note_spec.js
new file mode 100644
index 00000000000..959b52beaef
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_add_note_spec.js
@@ -0,0 +1,227 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createAlert } from '~/alert';
+import { clearDraft } from '~/lib/utils/autosave';
+import waitForPromises from 'helpers/wait_for_promises';
+import createNoteMutation from '~/admin/abuse_report/graphql/notes/create_abuse_report_note.mutation.graphql';
+import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue';
+import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue';
+
+import { mockAbuseReport, createAbuseReportNoteResponse } from '../../mock_data';
+
+jest.mock('~/alert');
+jest.mock('~/lib/utils/autosave');
+Vue.use(VueApollo);
+
+describe('Abuse Report Add Note', () => {
+ let wrapper;
+
+ const mockAbuseReportId = mockAbuseReport.report.globalId;
+ const mockDiscussionId = 'gid://gitlab/Discussion/9c7228e06fb0339a3d1440fcda960acfd8baa43a';
+
+ const mutationSuccessHandler = jest.fn().mockResolvedValue(createAbuseReportNoteResponse);
+
+ const findTimelineEntry = () => wrapper.findByTestId('abuse-report-note-timeline-entry');
+ const findTimelineEntryInner = () =>
+ wrapper.findByTestId('abuse-report-note-timeline-entry-inner');
+ const findCommentFormWrapper = () => wrapper.findByTestId('abuse-report-comment-form-wrapper');
+
+ const findAbuseReportCommentForm = () => wrapper.findComponent(AbuseReportCommentForm);
+ const findReplyTextarea = () => wrapper.findByTestId('abuse-report-note-reply-textarea');
+
+ const createComponent = ({
+ mutationHandler = mutationSuccessHandler,
+ abuseReportId = mockAbuseReportId,
+ discussionId = '',
+ isNewDiscussion = true,
+ showCommentForm = false,
+ } = {}) => {
+ wrapper = shallowMountExtended(AbuseReportAddNote, {
+ apolloProvider: createMockApollo([[createNoteMutation, mutationHandler]]),
+ propsData: {
+ abuseReportId,
+ discussionId,
+ isNewDiscussion,
+ showCommentForm,
+ },
+ });
+ };
+
+ describe('Default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should show the comment form', () => {
+ expect(findAbuseReportCommentForm().exists()).toBe(true);
+ expect(findAbuseReportCommentForm().props()).toMatchObject({
+ abuseReportId: mockAbuseReportId,
+ isSubmitting: false,
+ autosaveKey: `${mockAbuseReportId}-comment`,
+ commentButtonText: 'Comment',
+ initialValue: '',
+ });
+ });
+
+ it('should not show the reply textarea', () => {
+ expect(findReplyTextarea().exists()).toBe(false);
+ });
+
+ it('should add the correct classList to timeline-entry', () => {
+ expect(findTimelineEntry().classes()).toEqual(
+ expect.arrayContaining(['timeline-entry', 'note-form']),
+ );
+
+ expect(findTimelineEntryInner().classes()).toEqual(['timeline-entry-inner']);
+ });
+ });
+
+ describe('When the main comments has replies', () => {
+ beforeEach(() => {
+ createComponent({
+ discussionId: 'gid://gitlab/Discussion/9c7228e06fb0339a3d1440fcda960acfd8baa43a',
+ isNewDiscussion: false,
+ });
+ });
+
+ it('should add the correct classLists', () => {
+ expect(findTimelineEntry().classes()).toEqual(
+ expect.arrayContaining([
+ 'note',
+ 'note-wrapper',
+ 'note-comment',
+ 'discussion-reply-holder',
+ 'gl-border-t-0!',
+ 'clearfix',
+ ]),
+ );
+
+ expect(findTimelineEntryInner().classes()).toEqual([]);
+
+ expect(findCommentFormWrapper().classes()).toEqual(
+ expect.arrayContaining([
+ 'gl-relative',
+ 'gl-display-flex',
+ 'gl-align-items-flex-start',
+ 'gl-flex-nowrap',
+ ]),
+ );
+ });
+
+ it('should show not the comment form', () => {
+ expect(findAbuseReportCommentForm().exists()).toBe(false);
+ });
+
+ it('should show the reply textarea', () => {
+ expect(findReplyTextarea().exists()).toBe(true);
+ expect(findReplyTextarea().attributes()).toMatchObject({
+ rows: '1',
+ placeholder: 'Reply…',
+ 'aria-label': 'Reply to comment',
+ });
+ });
+ });
+
+ describe('Adding a comment', () => {
+ const noteText = 'mock note';
+
+ beforeEach(() => {
+ createComponent();
+
+ findAbuseReportCommentForm().vm.$emit('submitForm', {
+ commentText: noteText,
+ });
+ });
+
+ it('should call the mutation with provided noteText', async () => {
+ expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(true);
+
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ noteableId: mockAbuseReportId,
+ body: noteText,
+ discussionId: null,
+ },
+ });
+
+ await waitForPromises();
+
+ expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(false);
+ });
+
+ it('should add the correct classList to comment-form wrapper', () => {
+ expect(findCommentFormWrapper().classes()).toEqual([]);
+ });
+
+ it('should clear draft from local storage', async () => {
+ await waitForPromises();
+
+ expect(clearDraft).toHaveBeenCalledWith(`${mockAbuseReportId}-comment`);
+ });
+
+ it('should emit `cancelEditing` event', async () => {
+ await waitForPromises();
+
+ expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
+ });
+
+ it.each`
+ description | errorResponse
+ ${'with an error response'} | ${new Error('The discussion could not be found')}
+ ${'without an error ressponse'} | ${null}
+ `('should show an error when mutation fails $description', async ({ errorResponse }) => {
+ createComponent({
+ mutationHandler: jest.fn().mockRejectedValue(errorResponse),
+ });
+
+ findAbuseReportCommentForm().vm.$emit('submitForm', {
+ commentText: noteText,
+ });
+
+ await waitForPromises();
+
+ const errorMessage = errorResponse
+ ? 'Your comment could not be submitted because the discussion could not be found.'
+ : 'Your comment could not be submitted! Please check your network connection and try again.';
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: errorMessage,
+ captureError: true,
+ parent: expect.anything(),
+ });
+ });
+ });
+
+ describe('Replying to a comment', () => {
+ beforeEach(() => {
+ createComponent({
+ discussionId: mockDiscussionId,
+ isNewDiscussion: false,
+ showCommentForm: false,
+ });
+ });
+
+ it('should not show the comment form', () => {
+ expect(findAbuseReportCommentForm().exists()).toBe(false);
+ });
+
+ it('should show comment form when reply textarea is clicked on', async () => {
+ await findReplyTextarea().trigger('click');
+
+ expect(findAbuseReportCommentForm().exists()).toBe(true);
+ expect(findAbuseReportCommentForm().props('commentButtonText')).toBe('Reply');
+ });
+
+ it('should show comment form if `showCommentForm` is true', () => {
+ createComponent({
+ discussionId: mockDiscussionId,
+ isNewDiscussion: false,
+ showCommentForm: true,
+ });
+
+ expect(findAbuseReportCommentForm().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_comment_form_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_comment_form_spec.js
new file mode 100644
index 00000000000..2265ef7d441
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_comment_form_spec.js
@@ -0,0 +1,214 @@
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys';
+import * as autosave from '~/lib/utils/autosave';
+import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+
+import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+
+import { mockAbuseReport } from '../../mock_data';
+
+jest.mock('~/lib/utils/autosave', () => ({
+ updateDraft: jest.fn(),
+ clearDraft: jest.fn(),
+ getDraft: jest.fn().mockReturnValue(''),
+}));
+
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => ({
+ confirmAction: jest.fn().mockResolvedValue(true),
+}));
+
+describe('Abuse Report Comment Form', () => {
+ let wrapper;
+
+ const mockAbuseReportId = mockAbuseReport.report.globalId;
+ const mockAutosaveKey = `${mockAbuseReportId}-comment`;
+ const mockInitialValue = 'note text';
+
+ const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
+ const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
+ const findCommentButton = () => wrapper.find('[data-testid="comment-button"]');
+
+ const createComponent = ({
+ abuseReportId = mockAbuseReportId,
+ isSubmitting = false,
+ initialValue = mockInitialValue,
+ autosaveKey = mockAutosaveKey,
+ commentButtonText = 'Comment',
+ } = {}) => {
+ wrapper = shallowMount(AbuseReportCommentForm, {
+ propsData: {
+ abuseReportId,
+ isSubmitting,
+ initialValue,
+ autosaveKey,
+ commentButtonText,
+ },
+ provide: {
+ uploadNoteAttachmentPath: 'test-upload-path',
+ },
+ });
+ };
+
+ describe('Markdown editor', () => {
+ it('should show markdown editor', () => {
+ createComponent();
+
+ expect(findMarkdownEditor().exists()).toBe(true);
+
+ expect(findMarkdownEditor().props()).toMatchObject({
+ value: mockInitialValue,
+ renderMarkdownPath: '',
+ uploadsPath: 'test-upload-path',
+ enableContentEditor: false,
+ formFieldProps: {
+ 'aria-label': 'Add a reply',
+ placeholder: 'Write a comment or drag your files here…',
+ id: 'abuse-report-add-or-edit-comment',
+ name: 'abuse-report-add-or-edit-comment',
+ },
+ markdownDocsPath: '/help/user/markdown',
+ });
+ });
+
+ it('should pass the draft from local storage if it exists', () => {
+ jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment');
+ createComponent();
+
+ expect(findMarkdownEditor().props('value')).toBe('draft comment');
+ });
+
+ it('should pass an empty string if both draft and initialValue are empty', () => {
+ jest.spyOn(autosave, 'getDraft').mockImplementation(() => '');
+ createComponent({ initialValue: '' });
+
+ expect(findMarkdownEditor().props('value')).toBe('');
+ });
+ });
+
+ describe('Markdown Editor input', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should set the correct comment text value', async () => {
+ findMarkdownEditor().vm.$emit('input', 'new comment');
+ await nextTick();
+
+ expect(findMarkdownEditor().props('value')).toBe('new comment');
+ });
+
+ it('should call `updateDraft` with correct parameters', () => {
+ findMarkdownEditor().vm.$emit('input', 'new comment');
+
+ expect(autosave.updateDraft).toHaveBeenCalledWith(mockAutosaveKey, 'new comment');
+ });
+ });
+
+ describe('Submitting a comment', () => {
+ beforeEach(() => {
+ jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment');
+ createComponent();
+ });
+
+ it('should show comment button', () => {
+ expect(findCommentButton().exists()).toBe(true);
+ expect(findCommentButton().text()).toBe('Comment');
+ });
+
+ it('should show `Reply` button if its not a new discussion', () => {
+ createComponent({ commentButtonText: 'Reply' });
+ expect(findCommentButton().text()).toBe('Reply');
+ });
+
+ describe('when enter with meta key is pressed', () => {
+ beforeEach(() => {
+ findMarkdownEditor().vm.$emit(
+ 'keydown',
+ new KeyboardEvent('keydown', { key: ENTER_KEY, metaKey: true }),
+ );
+ });
+
+ it('should emit `submitForm` event with correct parameters', () => {
+ expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]);
+ });
+ });
+
+ describe('when ctrl+enter is pressed', () => {
+ beforeEach(() => {
+ findMarkdownEditor().vm.$emit(
+ 'keydown',
+ new KeyboardEvent('keydown', { key: ENTER_KEY, ctrlKey: true }),
+ );
+ });
+
+ it('should emit `submitForm` event with correct parameters', () => {
+ expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]);
+ });
+ });
+
+ describe('when comment button is clicked', () => {
+ beforeEach(() => {
+ findCommentButton().vm.$emit('click');
+ });
+
+ it('should emit `submitForm` event with correct parameters', () => {
+ expect(wrapper.emitted('submitForm')).toEqual([[{ commentText: 'draft comment' }]]);
+ });
+ });
+ });
+
+ describe('Cancel editing', () => {
+ beforeEach(() => {
+ jest.spyOn(autosave, 'getDraft').mockImplementation(() => 'draft comment');
+ createComponent();
+ });
+
+ it('should show cancel button', () => {
+ expect(findCancelButton().exists()).toBe(true);
+ expect(findCancelButton().text()).toBe('Cancel');
+ });
+
+ describe('when escape key is pressed', () => {
+ beforeEach(() => {
+ findMarkdownEditor().vm.$emit('keydown', new KeyboardEvent('keydown', { key: ESC_KEY }));
+
+ return waitForPromises();
+ });
+
+ it('should confirm a user action if comment text is not empty', () => {
+ expect(confirmViaGlModal.confirmAction).toHaveBeenCalled();
+ });
+
+ it('should clear draft from local storage', () => {
+ expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey);
+ });
+
+ it('should emit `cancelEditing` event', () => {
+ expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
+ });
+ });
+
+ describe('when cancel button is clicked', () => {
+ beforeEach(() => {
+ findCancelButton().vm.$emit('click');
+
+ return waitForPromises();
+ });
+
+ it('should confirm a user action if comment text is not empty', () => {
+ expect(confirmViaGlModal.confirmAction).toHaveBeenCalled();
+ });
+
+ it('should clear draft from local storage', () => {
+ expect(autosave.clearDraft).toHaveBeenCalledWith(mockAutosaveKey);
+ });
+
+ it('should emit `cancelEditing` event', () => {
+ expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js
index 86f0939a938..fdc049725a4 100644
--- a/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js
+++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_discussion_spec.js
@@ -4,6 +4,7 @@ import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import AbuseReportDiscussion from '~/admin/abuse_report/components/notes/abuse_report_discussion.vue';
import AbuseReportNote from '~/admin/abuse_report/components/notes/abuse_report_note.vue';
+import AbuseReportAddNote from '~/admin/abuse_report/components/notes/abuse_report_add_note.vue';
import {
mockAbuseReport,
@@ -19,6 +20,7 @@ describe('Abuse Report Discussion', () => {
const findAbuseReportNotes = () => wrapper.findAllComponents(AbuseReportNote);
const findTimelineEntryItem = () => wrapper.findComponent(TimelineEntryItem);
const findToggleRepliesWidget = () => wrapper.findComponent(ToggleRepliesWidget);
+ const findAbuseReportAddNote = () => wrapper.findComponent(AbuseReportAddNote);
const createComponent = ({
discussion = mockDiscussionWithNoReplies,
@@ -43,6 +45,7 @@ describe('Abuse Report Discussion', () => {
expect(findAbuseReportNote().props()).toMatchObject({
abuseReportId: mockAbuseReportId,
note: mockDiscussionWithNoReplies[0],
+ showReplyButton: true,
});
});
@@ -50,9 +53,13 @@ describe('Abuse Report Discussion', () => {
expect(findTimelineEntryItem().exists()).toBe(false);
});
- it('should not show the the toggle replies widget wrapper when no replies', () => {
+ it('should not show the toggle replies widget wrapper when there are no replies', () => {
expect(findToggleRepliesWidget().exists()).toBe(false);
});
+
+ it('should not show the comment form there are no replies', () => {
+ expect(findAbuseReportAddNote().exists()).toBe(false);
+ });
});
describe('When the main comments has replies', () => {
@@ -75,5 +82,68 @@ describe('Abuse Report Discussion', () => {
await nextTick();
expect(findAbuseReportNotes()).toHaveLength(1);
});
+
+ it('should show the comment form', () => {
+ expect(findAbuseReportAddNote().exists()).toBe(true);
+
+ expect(findAbuseReportAddNote().props()).toMatchObject({
+ abuseReportId: mockAbuseReportId,
+ discussionId: mockDiscussionWithReplies[0].discussion.id,
+ isNewDiscussion: false,
+ });
+ });
+
+ it('should show the reply button only for the main comment', () => {
+ expect(findAbuseReportNotes().at(0).props('showReplyButton')).toBe(true);
+
+ expect(findAbuseReportNotes().at(1).props('showReplyButton')).toBe(false);
+ expect(findAbuseReportNotes().at(2).props('showReplyButton')).toBe(false);
+ });
+ });
+
+ describe('Replying to a comment when it has no replies', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should show comment form when `startReplying` is emitted', async () => {
+ expect(findAbuseReportAddNote().exists()).toBe(false);
+
+ findAbuseReportNote().vm.$emit('startReplying');
+ await nextTick();
+
+ expect(findAbuseReportAddNote().exists()).toBe(true);
+ expect(findAbuseReportAddNote().props('showCommentForm')).toBe(true);
+ });
+
+ it('should hide the comment form when `cancelEditing` is emitted', async () => {
+ findAbuseReportNote().vm.$emit('startReplying');
+ await nextTick();
+
+ findAbuseReportAddNote().vm.$emit('cancelEditing');
+ await nextTick();
+
+ expect(findAbuseReportAddNote().exists()).toBe(false);
+ });
+ });
+
+ describe('Replying to a comment with replies', () => {
+ beforeEach(() => {
+ createComponent({
+ discussion: mockDiscussionWithReplies,
+ });
+ });
+
+ it('should show reply textarea, but not comment form', () => {
+ expect(findAbuseReportAddNote().exists()).toBe(true);
+ expect(findAbuseReportAddNote().props('showCommentForm')).toBe(false);
+ });
+
+ it('should show comment form when reply button on main comment is clicked', async () => {
+ findAbuseReportNotes().at(0).vm.$emit('startReplying');
+ await nextTick();
+
+ expect(findAbuseReportAddNote().props('showCommentForm')).toBe(true);
+ });
});
});
diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_edit_note_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_edit_note_spec.js
new file mode 100644
index 00000000000..88f243b2501
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_edit_note_spec.js
@@ -0,0 +1,129 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createAlert } from '~/alert';
+import { clearDraft } from '~/lib/utils/autosave';
+import waitForPromises from 'helpers/wait_for_promises';
+import updateNoteMutation from '~/admin/abuse_report/graphql/notes/update_abuse_report_note.mutation.graphql';
+import AbuseReportEditNote from '~/admin/abuse_report/components/notes/abuse_report_edit_note.vue';
+import AbuseReportCommentForm from '~/admin/abuse_report/components/notes/abuse_report_comment_form.vue';
+
+import {
+ mockAbuseReport,
+ mockDiscussionWithNoReplies,
+ editAbuseReportNoteResponse,
+} from '../../mock_data';
+
+jest.mock('~/alert');
+jest.mock('~/lib/utils/autosave');
+Vue.use(VueApollo);
+
+describe('Abuse Report Edit Note', () => {
+ let wrapper;
+
+ const mockAbuseReportId = mockAbuseReport.report.globalId;
+ const mockNote = mockDiscussionWithNoReplies[0];
+
+ const mutationSuccessHandler = jest.fn().mockResolvedValue(editAbuseReportNoteResponse);
+
+ const findAbuseReportCommentForm = () => wrapper.findComponent(AbuseReportCommentForm);
+
+ const createComponent = ({
+ mutationHandler = mutationSuccessHandler,
+ abuseReportId = mockAbuseReportId,
+ discussionId = '',
+ note = mockNote,
+ } = {}) => {
+ wrapper = shallowMountExtended(AbuseReportEditNote, {
+ apolloProvider: createMockApollo([[updateNoteMutation, mutationHandler]]),
+ propsData: {
+ abuseReportId,
+ discussionId,
+ note,
+ },
+ });
+ };
+
+ describe('Default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should show the comment form', () => {
+ expect(findAbuseReportCommentForm().exists()).toBe(true);
+ expect(findAbuseReportCommentForm().props()).toMatchObject({
+ abuseReportId: mockAbuseReportId,
+ isSubmitting: false,
+ autosaveKey: `${mockNote.id}-comment`,
+ commentButtonText: 'Save comment',
+ initialValue: mockNote.body,
+ });
+ });
+ });
+
+ describe('Editing a comment', () => {
+ const noteText = 'Updated comment';
+
+ beforeEach(() => {
+ createComponent();
+
+ findAbuseReportCommentForm().vm.$emit('submitForm', {
+ commentText: noteText,
+ });
+ });
+
+ it('should call the mutation with provided noteText', async () => {
+ expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(true);
+
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ id: mockNote.id,
+ body: noteText,
+ },
+ });
+
+ await waitForPromises();
+
+ expect(findAbuseReportCommentForm().props('isSubmitting')).toBe(false);
+ });
+
+ it('should clear draft from local storage', async () => {
+ await waitForPromises();
+
+ expect(clearDraft).toHaveBeenCalledWith(`${mockNote.id}-comment`);
+ });
+
+ it('should emit `cancelEditing` event', async () => {
+ await waitForPromises();
+
+ expect(wrapper.emitted('cancelEditing')).toHaveLength(1);
+ });
+
+ it.each`
+ description | errorResponse
+ ${'with an error response'} | ${new Error('The note could not be found')}
+ ${'without an error ressponse'} | ${null}
+ `('should show an error when mutation fails $description', async ({ errorResponse }) => {
+ createComponent({
+ mutationHandler: jest.fn().mockRejectedValue(errorResponse),
+ });
+
+ findAbuseReportCommentForm().vm.$emit('submitForm', {
+ commentText: noteText,
+ });
+
+ await waitForPromises();
+
+ const errorMessage = errorResponse
+ ? 'Your comment could not be updated because the note could not be found.'
+ : 'Something went wrong while editing your comment. Please try again.';
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: errorMessage,
+ captureError: true,
+ parent: wrapper.vm.$el,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_actions_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_actions_spec.js
new file mode 100644
index 00000000000..1ddfb6145fc
--- /dev/null
+++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_actions_spec.js
@@ -0,0 +1,79 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective } from 'helpers/vue_mock_directive';
+import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
+import AbuseReportNoteActions from '~/admin/abuse_report/components/notes/abuse_report_note_actions.vue';
+
+describe('Abuse Report Note Actions', () => {
+ let wrapper;
+
+ const findReplyButton = () => wrapper.findComponent(ReplyButton);
+ const findEditButton = () => wrapper.findComponent(GlButton);
+
+ const createComponent = ({ showReplyButton = true, showEditButton = true } = {}) => {
+ wrapper = shallowMount(AbuseReportNoteActions, {
+ propsData: {
+ showReplyButton,
+ showEditButton,
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ });
+ };
+
+ describe('Default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should show reply button', () => {
+ expect(findReplyButton().exists()).toBe(true);
+ });
+
+ it('should emit `startReplying` when reply button is clicked', () => {
+ findReplyButton().vm.$emit('startReplying');
+
+ expect(wrapper.emitted('startReplying')).toHaveLength(1);
+ });
+
+ it('should show edit button', () => {
+ expect(findEditButton().exists()).toBe(true);
+ expect(findEditButton().attributes()).toMatchObject({
+ icon: 'pencil',
+ title: 'Edit comment',
+ 'aria-label': 'Edit comment',
+ });
+ });
+
+ it('should emit `startEditing` when edit button is clicked', () => {
+ findEditButton().vm.$emit('click');
+
+ expect(wrapper.emitted('startEditing')).toHaveLength(1);
+ });
+ });
+
+ describe('When `showReplyButton` is false', () => {
+ beforeEach(() => {
+ createComponent({
+ showReplyButton: false,
+ });
+ });
+
+ it('should not show reply button', () => {
+ expect(findReplyButton().exists()).toBe(false);
+ });
+ });
+
+ describe('When `showEditButton` is false', () => {
+ beforeEach(() => {
+ createComponent({
+ showEditButton: false,
+ });
+ });
+
+ it('should not show edit button', () => {
+ expect(findEditButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js
index b6908853e46..bc7aa8ef5de 100644
--- a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js
+++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js
@@ -2,7 +2,10 @@ import { shallowMount } from '@vue/test-utils';
import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import AbuseReportNote from '~/admin/abuse_report/components/notes/abuse_report_note.vue';
import NoteHeader from '~/notes/components/note_header.vue';
-import NoteBody from '~/admin/abuse_report/components/notes/abuse_report_note_body.vue';
+import EditedAt from '~/issues/show/components/edited.vue';
+import AbuseReportNoteBody from '~/admin/abuse_report/components/notes/abuse_report_note_body.vue';
+import AbuseReportEditNote from '~/admin/abuse_report/components/notes/abuse_report_edit_note.vue';
+import AbuseReportNoteActions from '~/admin/abuse_report/components/notes/abuse_report_note_actions.vue';
import { mockAbuseReport, mockDiscussionWithNoReplies } from '../../mock_data';
@@ -10,18 +13,29 @@ describe('Abuse Report Note', () => {
let wrapper;
const mockAbuseReportId = mockAbuseReport.report.globalId;
const mockNote = mockDiscussionWithNoReplies[0];
+ const mockShowReplyButton = true;
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
const findNoteHeader = () => wrapper.findComponent(NoteHeader);
- const findNoteBody = () => wrapper.findComponent(NoteBody);
+ const findNoteBody = () => wrapper.findComponent(AbuseReportNoteBody);
- const createComponent = ({ note = mockNote, abuseReportId = mockAbuseReportId } = {}) => {
+ const findEditNote = () => wrapper.findComponent(AbuseReportEditNote);
+ const findEditedAt = () => wrapper.findComponent(EditedAt);
+
+ const findNoteActions = () => wrapper.findComponent(AbuseReportNoteActions);
+
+ const createComponent = ({
+ note = mockNote,
+ abuseReportId = mockAbuseReportId,
+ showReplyButton = mockShowReplyButton,
+ } = {}) => {
wrapper = shallowMount(AbuseReportNote, {
propsData: {
note,
abuseReportId,
+ showReplyButton,
},
});
};
@@ -77,4 +91,110 @@ describe('Abuse Report Note', () => {
});
});
});
+
+ describe('Editing', () => {
+ it('should show edit button when resolveNote is true', () => {
+ createComponent({
+ note: { ...mockNote, userPermissions: { resolveNote: true } },
+ });
+
+ expect(findNoteActions().props()).toMatchObject({
+ showEditButton: true,
+ });
+ });
+
+ it('should not show edit button when resolveNote is false', () => {
+ createComponent({
+ note: { ...mockNote, userPermissions: { resolveNote: false } },
+ });
+
+ expect(findNoteActions().props()).toMatchObject({
+ showEditButton: false,
+ });
+ });
+
+ it('should not be in edit mode by default', () => {
+ expect(findEditNote().exists()).toBe(false);
+ });
+
+ it('should trigger edit mode when `startEditing` event is emitted', async () => {
+ await findNoteActions().vm.$emit('startEditing');
+
+ expect(findEditNote().exists()).toBe(true);
+ expect(findEditNote().props()).toMatchObject({
+ abuseReportId: mockAbuseReportId,
+ note: mockNote,
+ });
+
+ expect(findNoteHeader().exists()).toBe(false);
+ expect(findNoteBody().exists()).toBe(false);
+ });
+
+ it('should hide edit mode when `cancelEditing` event is emitted', async () => {
+ await findNoteActions().vm.$emit('startEditing');
+ await findEditNote().vm.$emit('cancelEditing');
+
+ expect(findEditNote().exists()).toBe(false);
+
+ expect(findNoteHeader().exists()).toBe(true);
+ expect(findNoteBody().exists()).toBe(true);
+ });
+ });
+
+ describe('Edited At', () => {
+ it('should not show edited-at if lastEditedBy is null', () => {
+ expect(findEditedAt().exists()).toBe(false);
+ });
+
+ it('should show edited-at if lastEditedBy is not null', () => {
+ createComponent({
+ note: {
+ ...mockNote,
+ lastEditedBy: { name: 'user', webPath: '/user' },
+ lastEditedAt: '2023-10-20T02:46:50Z',
+ },
+ });
+
+ expect(findEditedAt().exists()).toBe(true);
+
+ expect(findEditedAt().props()).toMatchObject({
+ updatedAt: '2023-10-20T02:46:50Z',
+ updatedByName: 'user',
+ updatedByPath: '/user',
+ });
+
+ expect(findEditedAt().classes()).toEqual(
+ expect.arrayContaining(['gl-text-secondary', 'gl-pl-3']),
+ );
+ });
+
+ it('should add the correct classList when showReplyButton is false', () => {
+ createComponent({
+ note: {
+ ...mockNote,
+ lastEditedBy: { name: 'user', webPath: '/user' },
+ lastEditedAt: '2023-10-20T02:46:50Z',
+ },
+ showReplyButton: false,
+ });
+
+ expect(findEditedAt().classes()).toEqual(
+ expect.arrayContaining(['gl-text-secondary', 'gl-pl-8']),
+ );
+ });
+ });
+
+ describe('Replying', () => {
+ it('should show reply button', () => {
+ expect(findNoteActions().props()).toMatchObject({
+ showReplyButton: true,
+ });
+ });
+
+ it('should bubble up `startReplying` event', () => {
+ findNoteActions().vm.$emit('startReplying');
+
+ expect(wrapper.emitted('startReplying')).toHaveLength(1);
+ });
+ });
});
diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js
index 44c8cbdad7f..9790b44c976 100644
--- a/spec/frontend/admin/abuse_report/mock_data.js
+++ b/spec/frontend/admin/abuse_report/mock_data.js
@@ -139,7 +139,7 @@ export const mockDiscussionWithNoReplies = [
body: 'Comment 1',
bodyHtml: '\u003cp data-sourcepos="1:1-1:9" dir="auto"\u003eComment 1\u003c/p\u003e',
createdAt: '2023-10-19T06:11:13Z',
- lastEditedAt: '2023-10-20T02:46:50Z',
+ lastEditedAt: null,
url: 'http://127.0.0.1:3000/admin/abuse_reports/1#note_1',
resolved: false,
author: {
@@ -153,7 +153,7 @@ export const mockDiscussionWithNoReplies = [
},
lastEditedBy: null,
userPermissions: {
- adminNote: true,
+ resolveNote: true,
__typename: 'NotePermissions',
},
discussion: {
@@ -192,7 +192,7 @@ export const mockDiscussionWithReplies = [
},
lastEditedBy: null,
userPermissions: {
- adminNote: true,
+ resolveNote: true,
__typename: 'NotePermissions',
},
discussion: {
@@ -237,7 +237,7 @@ export const mockDiscussionWithReplies = [
},
lastEditedBy: null,
userPermissions: {
- adminNote: true,
+ resolveNote: true,
__typename: 'NotePermissions',
},
discussion: {
@@ -282,7 +282,7 @@ export const mockDiscussionWithReplies = [
},
lastEditedBy: null,
userPermissions: {
- adminNote: true,
+ resolveNote: true,
__typename: 'NotePermissions',
},
discussion: {
@@ -340,3 +340,83 @@ export const mockNotesByIdResponse = {
},
},
};
+
+export const createAbuseReportNoteResponse = {
+ data: {
+ createNote: {
+ note: {
+ id: 'gid://gitlab/Note/6',
+ discussion: {
+ id: 'gid://gitlab/Discussion/90ca230051611e6e1676c50ba7178e0baeabd98d',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Note/6',
+ body: 'Another comment',
+ bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Another comment</p>',
+ createdAt: '2023-11-02T02:45:46Z',
+ lastEditedAt: null,
+ url: 'http://127.0.0.1:3000/admin/abuse_reports/20#note_6',
+ resolved: false,
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ },
+ lastEditedBy: null,
+ userPermissions: {
+ resolveNote: true,
+ },
+ discussion: {
+ id: 'gid://gitlab/Discussion/90ca230051611e6e1676c50ba7178e0baeabd98d',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Note/6',
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ errors: [],
+ },
+ },
+};
+
+export const editAbuseReportNoteResponse = {
+ data: {
+ updateNote: {
+ errors: [],
+ note: {
+ id: 'gid://gitlab/Note/1',
+ body: 'Updated comment',
+ bodyHtml: '<p data-sourcepos="1:1-1:15" dir="auto">Updated comment</p>',
+ createdAt: '2023-10-20T07:47:42Z',
+ lastEditedAt: '2023-10-20T07:47:42Z',
+ url: 'http://127.0.0.1:3000/admin/abuse_reports/1#note_1',
+ resolved: false,
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ lastEditedBy: 'root',
+ userPermissions: {
+ resolveNote: true,
+ __typename: 'NotePermissions',
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js
index 9e55716cc30..463455573ee 100644
--- a/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js
+++ b/spec/frontend/admin/signup_restrictions/components/signup_checkbox_spec.js
@@ -10,7 +10,7 @@ describe('Signup Form', () => {
helpText: 'some help text',
label: 'a label',
value: true,
- dataQaSelector: 'qa_selector',
+ dataTestId: 'test-id',
};
const mountComponent = () => {
@@ -55,7 +55,7 @@ describe('Signup Form', () => {
});
it('gets passed data qa selector', () => {
- expect(findCheckbox().attributes('data-qa-selector')).toBe(props.dataQaSelector);
+ expect(findCheckbox().attributes('data-testid')).toBe(props.dataTestId);
});
});
});
diff --git a/spec/frontend/analytics/cycle_analytics/mock_data.js b/spec/frontend/analytics/cycle_analytics/mock_data.js
index e0b6f4aa8c4..73387606433 100644
--- a/spec/frontend/analytics/cycle_analytics/mock_data.js
+++ b/spec/frontend/analytics/cycle_analytics/mock_data.js
@@ -159,12 +159,12 @@ export const stageMedians = {
};
export const formattedStageMedians = {
- issue: '2d',
- plan: '1d',
- review: '1w',
- code: '1d',
- test: '3d',
- staging: '4d',
+ issue: '2 days',
+ plan: '1 day',
+ review: '1 week',
+ code: '1 day',
+ test: '3 days',
+ staging: '4 days',
};
export const allowedStages = [issueStage, planStage, codeStage];
diff --git a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
index c3551d3da6f..897d75573f0 100644
--- a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
+++ b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
@@ -141,7 +141,7 @@ describe('Project Value Stream Analytics actions', () => {
describe('without a selected stage', () => {
it('will select the first stage from the value stream', () => {
const [firstStage] = allowedStages;
- testAction({
+ return testAction({
action: actions.setInitialStage,
state,
payload: null,
@@ -154,7 +154,7 @@ describe('Project Value Stream Analytics actions', () => {
describe('with no value stream stages available', () => {
it('will return SET_NO_ACCESS_ERROR', () => {
state = { ...state, stages: [] };
- testAction({
+ return testAction({
action: actions.setInitialStage,
state,
payload: null,
@@ -299,25 +299,23 @@ describe('Project Value Stream Analytics actions', () => {
name: 'mock default',
};
const mockValueStreams = [mockValueStream, selectedValueStream];
- it('with data, will set the first value stream', () => {
+ it('with data, will set the first value stream', () =>
testAction({
action: actions.receiveValueStreamsSuccess,
state,
payload: mockValueStreams,
expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: mockValueStreams }],
expectedActions: [{ type: 'setSelectedValueStream', payload: mockValueStream }],
- });
- });
+ }));
- it('without data, will set the default value stream', () => {
+ it('without data, will set the default value stream', () =>
testAction({
action: actions.receiveValueStreamsSuccess,
state,
payload: [],
expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: [] }],
expectedActions: [{ type: 'setSelectedValueStream', payload: selectedValueStream }],
- });
- });
+ }));
});
describe('fetchValueStreamStages', () => {
diff --git a/spec/frontend/analytics/cycle_analytics/utils_spec.js b/spec/frontend/analytics/cycle_analytics/utils_spec.js
index ab5d78bde51..5d2fcf97a76 100644
--- a/spec/frontend/analytics/cycle_analytics/utils_spec.js
+++ b/spec/frontend/analytics/cycle_analytics/utils_spec.js
@@ -45,13 +45,13 @@ describe('Value stream analytics utils', () => {
describe('medianTimeToParsedSeconds', () => {
it.each`
value | result
- ${1036800} | ${'1w'}
- ${259200} | ${'3d'}
- ${172800} | ${'2d'}
- ${86400} | ${'1d'}
- ${1000} | ${'16m'}
- ${61} | ${'1m'}
- ${59} | ${'<1m'}
+ ${1036800} | ${'1 week'}
+ ${259200} | ${'3 days'}
+ ${172800} | ${'2 days'}
+ ${86400} | ${'1 day'}
+ ${1000} | ${'16 minutes'}
+ ${61} | ${'1 minute'}
+ ${59} | ${'<1 minute'}
${0} | ${'-'}
`('will correctly parse $value seconds into $result', ({ value, result }) => {
expect(medianTimeToParsedSeconds(value)).toBe(result);
diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js
index a6e08e1cf4b..aeddf6b9ae1 100644
--- a/spec/frontend/api/user_api_spec.js
+++ b/spec/frontend/api/user_api_spec.js
@@ -4,6 +4,8 @@ import projects from 'test_fixtures/api/users/projects/get.json';
import followers from 'test_fixtures/api/users/followers/get.json';
import following from 'test_fixtures/api/users/following/get.json';
import {
+ getUsers,
+ getGroupUsers,
followUser,
unfollowUser,
associationsCount,
@@ -36,6 +38,32 @@ describe('~/api/user_api', () => {
axiosMock.resetHistory();
});
+ describe('getUsers', () => {
+ it('calls correct URL with expected query parameters', async () => {
+ const expectedUrl = '/api/v4/users.json';
+ axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK);
+
+ await getUsers('den', { without_project_bots: true });
+
+ const { url, params } = axiosMock.history.get[0];
+ expect(url).toBe(expectedUrl);
+ expect(params).toMatchObject({ search: 'den', without_project_bots: true });
+ });
+ });
+
+ describe('getSAMLUsers', () => {
+ it('calls correct URL with expected query parameters', async () => {
+ const expectedUrl = '/api/v4/groups/34/users.json';
+ axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK);
+
+ await getGroupUsers('den', '34', { include_service_accounts: true });
+
+ const { url, params } = axiosMock.history.get[0];
+ expect(url).toBe(expectedUrl);
+ expect(params).toMatchObject({ search: 'den', include_service_accounts: true });
+ });
+ });
+
describe('followUser', () => {
it('calls correct URL and returns expected response', async () => {
const expectedUrl = '/api/v4/users/1/follow';
diff --git a/spec/frontend/authentication/password/components/password_input_spec.js b/spec/frontend/authentication/password/components/password_input_spec.js
index 5b2a9da993b..62438e824cf 100644
--- a/spec/frontend/authentication/password/components/password_input_spec.js
+++ b/spec/frontend/authentication/password/components/password_input_spec.js
@@ -9,7 +9,6 @@ describe('PasswordInput', () => {
title: 'This field is required',
id: 'new_user_password',
minimumPasswordLength: '8',
- qaSelector: 'new_user_password_field',
testid: 'new_user_password',
autocomplete: 'new-password',
name: 'new_user',
diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js
index 5ca199357f9..1900ebc1e08 100644
--- a/spec/frontend/badges/store/actions_spec.js
+++ b/spec/frontend/badges/store/actions_spec.js
@@ -258,7 +258,7 @@ describe('Badges store actions', () => {
it('dispatches requestLoadBadges and receiveLoadBadges for successful response', async () => {
const dummyData = 'this is just some data';
- const dummyReponse = [
+ const dummyResponse = [
createDummyBadgeResponse(),
createDummyBadgeResponse(),
createDummyBadgeResponse(),
@@ -266,11 +266,11 @@ describe('Badges store actions', () => {
endpointMock.replyOnce(() => {
expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]);
dispatch.mockClear();
- return [HTTP_STATUS_OK, dummyReponse];
+ return [HTTP_STATUS_OK, dummyResponse];
});
await actions.loadBadges({ state, dispatch }, dummyData);
- const badges = dummyReponse.map(transformBackendBadge);
+ const badges = dummyResponse.map(transformBackendBadge);
expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]);
});
@@ -377,15 +377,15 @@ describe('Badges store actions', () => {
});
it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => {
- const dummyReponse = createDummyBadgeResponse();
+ const dummyResponse = createDummyBadgeResponse();
endpointMock.replyOnce(() => {
expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]);
dispatch.mockClear();
- return [HTTP_STATUS_OK, dummyReponse];
+ return [HTTP_STATUS_OK, dummyResponse];
});
await actions.renderBadge({ state, dispatch });
- const renderedBadge = transformBackendBadge(dummyReponse);
+ const renderedBadge = transformBackendBadge(dummyResponse);
expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]);
});
diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
index ae7f5416c0c..6db99e796d6 100644
--- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
+++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
@@ -30,14 +30,11 @@ describe('ShortcutsIssuable', () => {
</div>`,
);
document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
-
- window.shortcut = new ShortcutsIssuable(true);
});
afterEach(() => {
$(FORM_SELECTOR).remove();
- delete window.shortcut;
resetHTMLFixture();
});
@@ -55,6 +52,15 @@ describe('ShortcutsIssuable', () => {
});
};
+ it('sets up commands on instantiation', () => {
+ const mockShortcutsInstance = { addAll: jest.fn() };
+
+ // eslint-disable-next-line no-new
+ new ShortcutsIssuable(mockShortcutsInstance);
+
+ expect(mockShortcutsInstance.addAll).toHaveBeenCalled();
+ });
+
describe('with empty selection', () => {
it('does not return an error', () => {
ShortcutsIssuable.replyWithSelectedText(true);
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_spec.js
index ca72426cb44..5f71eb24758 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/behaviors/shortcuts/shortcuts_spec.js
@@ -37,6 +37,16 @@ describe('Shortcuts', () => {
resetHTMLFixture();
});
+ it('does not allow subclassing', () => {
+ const createSubclass = () => {
+ class Subclass extends Shortcuts {}
+
+ return new Subclass();
+ };
+
+ expect(createSubclass).toThrow(/cannot be subclassed/);
+ });
+
describe('markdown shortcuts', () => {
let shortcutElements;
@@ -106,7 +116,6 @@ describe('Shortcuts', () => {
let event;
beforeEach(() => {
- window.gon.use_new_navigation = true;
event = new KeyboardEvent('keydown', { cancelable: true });
Shortcuts.focusSearch(event);
});
@@ -122,12 +131,12 @@ describe('Shortcuts', () => {
});
});
- describe('bindCommand(s)', () => {
- it('bindCommand calls Mousetrap.bind correctly', () => {
+ describe('adding shortcuts', () => {
+ it('add calls Mousetrap.bind correctly', () => {
const mockCommand = { defaultKeys: ['m'] };
const mockCallback = () => {};
- shortcuts.bindCommand(mockCommand, mockCallback);
+ shortcuts.add(mockCommand, mockCallback);
expect(Mousetrap.prototype.bind).toHaveBeenCalledTimes(1);
const [callArguments] = Mousetrap.prototype.bind.mock.calls;
@@ -135,13 +144,13 @@ describe('Shortcuts', () => {
expect(callArguments[1]).toBe(mockCallback);
});
- it('bindCommands calls Mousetrap.bind correctly', () => {
+ it('addAll calls Mousetrap.bind correctly', () => {
const mockCommandsAndCallbacks = [
[{ defaultKeys: ['1'] }, () => {}],
[{ defaultKeys: ['2'] }, () => {}],
];
- shortcuts.bindCommands(mockCommandsAndCallbacks);
+ shortcuts.addAll(mockCommandsAndCallbacks);
expect(Mousetrap.prototype.bind).toHaveBeenCalledTimes(mockCommandsAndCallbacks.length);
const { calls } = Mousetrap.prototype.bind.mock;
@@ -152,4 +161,107 @@ describe('Shortcuts', () => {
});
});
});
+
+ describe('addExtension', () => {
+ it('instantiates the given extension', () => {
+ const MockExtension = jest.fn();
+
+ const returnValue = shortcuts.addExtension(MockExtension, ['foo']);
+
+ expect(MockExtension).toHaveBeenCalledTimes(1);
+ expect(MockExtension).toHaveBeenCalledWith(shortcuts, 'foo');
+ expect(returnValue).toBe(MockExtension.mock.instances[0]);
+ });
+
+ it('instantiates declared dependencies', () => {
+ const MockDependency = jest.fn();
+ const MockExtension = jest.fn();
+
+ MockExtension.dependencies = [MockDependency];
+
+ const returnValue = shortcuts.addExtension(MockExtension, ['foo']);
+
+ expect(MockDependency).toHaveBeenCalledTimes(1);
+ expect(MockDependency.mock.instances).toHaveLength(1);
+ expect(MockDependency).toHaveBeenCalledWith(shortcuts);
+
+ expect(returnValue).toBe(MockExtension.mock.instances[0]);
+ });
+
+ it('does not instantiate an extension more than once', () => {
+ const MockExtension = jest.fn();
+
+ const returnValue = shortcuts.addExtension(MockExtension, ['foo']);
+ const secondReturnValue = shortcuts.addExtension(MockExtension, ['bar']);
+
+ expect(MockExtension).toHaveBeenCalledTimes(1);
+ expect(MockExtension).toHaveBeenCalledWith(shortcuts, 'foo');
+ expect(returnValue).toBe(MockExtension.mock.instances[0]);
+ expect(secondReturnValue).toBe(MockExtension.mock.instances[0]);
+ });
+
+ it('allows extensions to redundantly depend on Shortcuts', () => {
+ const MockExtension = jest.fn();
+ MockExtension.dependencies = [Shortcuts];
+
+ shortcuts.addExtension(MockExtension);
+
+ expect(MockExtension).toHaveBeenCalledTimes(1);
+ expect(MockExtension).toHaveBeenCalledWith(shortcuts);
+
+ // Ensure it wasn't instantiated
+ expect(shortcuts.extensions.has(Shortcuts)).toBe(false);
+ });
+
+ it('allows extensions to incorrectly depend on themselves', () => {
+ const A = jest.fn();
+ A.dependencies = [A];
+ shortcuts.addExtension(A);
+ expect(A).toHaveBeenCalledTimes(1);
+ expect(A).toHaveBeenCalledWith(shortcuts);
+ });
+
+ it('handles extensions with circular dependencies', () => {
+ const A = jest.fn();
+ const B = jest.fn();
+ const C = jest.fn();
+
+ A.dependencies = [B];
+ B.dependencies = [C];
+ C.dependencies = [A];
+
+ shortcuts.addExtension(A);
+
+ expect(A).toHaveBeenCalledTimes(1);
+ expect(B).toHaveBeenCalledTimes(1);
+ expect(C).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles complex (diamond) dependency graphs', () => {
+ const X = jest.fn();
+ const A = jest.fn();
+ const C = jest.fn();
+ const D = jest.fn();
+ const E = jest.fn();
+
+ // Form this dependency graph:
+ //
+ // X ───► A ───► C
+ // │ ▲
+ // └────► D ─────┘
+ // │
+ // └────► E
+ X.dependencies = [A, D];
+ A.dependencies = [C];
+ D.dependencies = [C, E];
+
+ shortcuts.addExtension(X);
+
+ expect(X).toHaveBeenCalledTimes(1);
+ expect(A).toHaveBeenCalledTimes(1);
+ expect(C).toHaveBeenCalledTimes(1);
+ expect(D).toHaveBeenCalledTimes(1);
+ expect(E).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js
index e58ad4040a9..31be1a86de4 100644
--- a/spec/frontend/blob_edit/edit_blob_spec.js
+++ b/spec/frontend/blob_edit/edit_blob_spec.js
@@ -6,6 +6,7 @@ import EditBlob from '~/blob_edit/edit_blob';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
+import { SecurityPolicySchemaExtension } from '~/editor/extensions/source_editor_security_policy_schema_ext';
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext';
import SourceEditor from '~/editor/source_editor';
@@ -17,6 +18,7 @@ jest.mock('~/editor/extensions/source_editor_file_template_ext');
jest.mock('~/editor/extensions/source_editor_markdown_ext');
jest.mock('~/editor/extensions/source_editor_markdown_livepreview_ext');
jest.mock('~/editor/extensions/source_editor_toolbar_ext');
+jest.mock('~/editor/extensions/source_editor_security_policy_schema_ext');
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
const defaultExtensions = [
@@ -67,16 +69,18 @@ describe('Blob Editing', () => {
resetHTMLFixture();
});
- const editorInst = (isMarkdown) => {
+ const editorInst = ({ isMarkdown = false, isSecurityPolicy = false }) => {
blobInstance = new EditBlob({
isMarkdown,
previewMarkdownPath: PREVIEW_MARKDOWN_PATH,
+ filePath: isSecurityPolicy ? '.gitlab/security-policies/policy.yml' : '',
+ projectPath: 'path/to/project',
});
return blobInstance;
};
- const initEditor = async (isMarkdown = false) => {
- editorInst(isMarkdown);
+ const initEditor = async ({ isMarkdown = false, isSecurityPolicy = false } = {}) => {
+ editorInst({ isMarkdown, isSecurityPolicy });
await waitForPromises();
};
@@ -93,13 +97,13 @@ describe('Blob Editing', () => {
});
it('loads MarkdownExtension only for the markdown files', async () => {
- await initEditor(true);
+ await initEditor({ isMarkdown: true });
expect(useMock).toHaveBeenCalledTimes(2);
expect(useMock.mock.calls[1]).toEqual([markdownExtensions]);
});
it('correctly handles switching from markdown and un-uses markdown extensions', async () => {
- await initEditor(true);
+ await initEditor({ isMarkdown: true });
expect(unuseMock).not.toHaveBeenCalled();
await emitter.fire({ newLanguage: 'plaintext', oldLanguage: 'markdown' });
expect(unuseMock).toHaveBeenCalledWith(markdownExtensions);
@@ -115,6 +119,19 @@ describe('Blob Editing', () => {
});
});
+ describe('Security Policy Yaml', () => {
+ it('does not load SecurityPolicySchemaExtension by default', async () => {
+ await initEditor();
+ expect(SecurityPolicySchemaExtension).not.toHaveBeenCalled();
+ });
+
+ it('loads SecurityPolicySchemaExtension only for the security policies yml', async () => {
+ await initEditor({ isSecurityPolicy: true });
+ expect(useMock).toHaveBeenCalledTimes(2);
+ expect(useMock.mock.calls[1]).toEqual([[{ definition: SecurityPolicySchemaExtension }]]);
+ });
+ });
+
describe('correctly handles toggling the live-preview panel for different file types', () => {
it.each`
desc | isMarkdown | isPreviewOpened | tabToClick | shouldOpenPreview | shouldClosePreview | expectedDesc
@@ -142,7 +159,7 @@ describe('Blob Editing', () => {
},
},
});
- await initEditor(isMarkdown);
+ await initEditor({ isMarkdown });
blobInstance.markdownLivePreviewOpened = isPreviewOpened;
const elToClick = document.querySelector(`a[href='${tabToClick}']`);
elToClick.dispatchEvent(new Event('click'));
diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js
index c70e461da83..8f2752b6bd8 100644
--- a/spec/frontend/boards/board_card_inner_spec.js
+++ b/spec/frontend/boards/board_card_inner_spec.js
@@ -2,8 +2,6 @@ import { GlLabel, GlLoadingIcon } from '@gitlab/ui';
import { range } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -13,15 +11,13 @@ import BoardCardInner from '~/boards/components/board_card_inner.vue';
import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import eventHub from '~/boards/eventhub';
-import defaultStore from '~/boards/stores';
import { TYPE_ISSUE } from '~/issues/constants';
import { updateHistory } from '~/lib/utils/url_utility';
-import { mockLabelList, mockIssue, mockIssueFullPath } from './mock_data';
+import { mockLabelList, mockIssue, mockIssueFullPath, mockIssueDirectNamespace } from './mock_data';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/boards/eventhub');
-Vue.use(Vuex);
Vue.use(VueApollo);
describe('Board card component', () => {
@@ -43,24 +39,12 @@ describe('Board card component', () => {
let wrapper;
let issue;
let list;
- let store;
const findIssuableBlockedIcon = () => wrapper.findComponent(IssuableBlockedIcon);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findHiddenIssueIcon = () => wrapper.findByTestId('hidden-icon');
const findWorkItemIcon = () => wrapper.findComponent(WorkItemTypeIcon);
- const performSearchMock = jest.fn();
-
- const createStore = () => {
- store = new Vuex.Store({
- actions: {
- performSearch: performSearchMock,
- },
- state: defaultStore.state,
- });
- };
-
const mockApollo = createMockApollo();
const createWrapper = ({ props = {}, isGroupBoard = true } = {}) => {
@@ -72,7 +56,6 @@ describe('Board card component', () => {
});
wrapper = mountExtended(BoardCardInner, {
- store,
apolloProvider: mockApollo,
propsData: {
list,
@@ -94,7 +77,6 @@ describe('Board card component', () => {
allowSubEpics: false,
issuableType: TYPE_ISSUE,
isGroupBoard,
- isApolloBoard: false,
},
});
};
@@ -108,14 +90,9 @@ describe('Board card component', () => {
weight: 1,
};
- createStore();
createWrapper({ props: { item: issue, list } });
});
- afterEach(() => {
- store = null;
- });
-
it('renders issue title', () => {
expect(wrapper.find('.board-card-title').text()).toContain(issue.title);
});
@@ -159,14 +136,15 @@ describe('Board card component', () => {
});
it('does not render item reference path', () => {
- createStore();
createWrapper({ isGroupBoard: false });
- expect(wrapper.find('.board-card-number').text()).not.toContain(mockIssueFullPath);
+ expect(wrapper.find('.board-card-number').text()).not.toContain(mockIssueDirectNamespace);
+ expect(wrapper.find('.board-item-path').exists()).toBe(false);
});
- it('renders item reference path', () => {
- expect(wrapper.find('.board-card-number').text()).toContain(mockIssueFullPath);
+ it('renders item direct namespace path with full reference path in a tooltip', () => {
+ expect(wrapper.find('.board-item-path').text()).toBe(mockIssueDirectNamespace);
+ expect(wrapper.find('.board-item-path').attributes('title')).toBe(mockIssueFullPath);
});
describe('blocked', () => {
@@ -458,10 +436,6 @@ describe('Board card component', () => {
expect(updateHistory).toHaveBeenCalledTimes(1);
});
- it('dispatches performSearch vuex action', () => {
- expect(performSearchMock).toHaveBeenCalledTimes(1);
- });
-
it('emits updateTokens event', () => {
expect(eventHub.$emit).toHaveBeenCalledTimes(1);
expect(eventHub.$emit).toHaveBeenCalledWith('updateTokens');
@@ -478,10 +452,6 @@ describe('Board card component', () => {
expect(updateHistory).not.toHaveBeenCalled();
});
- it('does not dispatch performSearch vuex action', () => {
- expect(performSearchMock).not.toHaveBeenCalled();
- });
-
it('does not emit updateTokens event', () => {
expect(eventHub.$emit).not.toHaveBeenCalled();
});
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index 5bafd9a8d0e..e3afd2dec2f 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -1,34 +1,22 @@
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import BoardCard from '~/boards/components/board_card.vue';
import BoardList from '~/boards/components/board_list.vue';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import BoardNewItem from '~/boards/components/board_new_item.vue';
-import defaultState from '~/boards/stores/state';
+
import createMockApollo from 'helpers/mock_apollo_helper';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
-import {
- mockList,
- mockIssuesByListId,
- issues,
- mockGroupProjects,
- boardListQueryResponse,
-} from './mock_data';
+import { mockList, boardListQueryResponse } from './mock_data';
export default function createComponent({
- listIssueProps = {},
componentProps = {},
listProps = {},
apolloQueryHandlers = [],
- actions = {},
- getters = {},
provide = {},
data = {},
- state = defaultState,
stubs = {
BoardNewIssue,
BoardNewItem,
@@ -37,60 +25,25 @@ export default function createComponent({
issuesCount,
} = {}) {
Vue.use(VueApollo);
- Vue.use(Vuex);
const fakeApollo = createMockApollo([
[listQuery, jest.fn().mockResolvedValue(boardListQueryResponse({ issuesCount }))],
...apolloQueryHandlers,
]);
- const store = new Vuex.Store({
- state: {
- selectedProject: mockGroupProjects[0],
- boardItemsByListId: mockIssuesByListId,
- boardItems: issues,
- pageInfoByListId: {
- 'gid://gitlab/List/1': { hasNextPage: true },
- 'gid://gitlab/List/2': {},
- },
- listsFlags: {
- 'gid://gitlab/List/1': {},
- 'gid://gitlab/List/2': {},
- },
- selectedBoardItems: [],
- ...state,
- },
- getters: {
- isEpicBoard: () => false,
- ...getters,
- },
- actions,
- });
-
const list = {
...mockList,
...listProps,
};
- const issue = {
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- referencePath: 'gitlab-org/test-subgroup/gitlab-test#1',
- labels: [],
- assignees: [],
- ...listIssueProps,
- };
+
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
list.issuesCount = 1;
}
const component = shallowMount(BoardList, {
apolloProvider: fakeApollo,
- store,
propsData: {
list,
- boardItems: [issue],
canAdminList: true,
boardId: 'gid://gitlab/Board/1',
filterParams: {},
@@ -106,12 +59,12 @@ export default function createComponent({
canAdminList: true,
isIssueBoard: true,
isEpicBoard: false,
- isGroupBoard: false,
- isProjectBoard: true,
+ isGroupBoard: true,
+ isProjectBoard: false,
disabled: false,
boardType: 'group',
issuableType: 'issue',
- isApolloBoard: false,
+ isApolloBoard: true,
...provide,
},
stubs,
@@ -122,7 +75,5 @@ export default function createComponent({
},
});
- jest.spyOn(store, 'dispatch').mockImplementation(() => {});
-
return component;
}
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 30bb4fba4e3..8d59cb2692e 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -8,8 +8,9 @@ import createComponent from 'jest/boards/board_list_helper';
import BoardCard from '~/boards/components/board_card.vue';
import eventHub from '~/boards/eventhub';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
+import listIssuesQuery from '~/boards/graphql/lists_issues.query.graphql';
-import { mockIssues, mockList, mockIssuesMore } from './mock_data';
+import { mockIssues, mockList, mockIssuesMore, mockGroupIssuesResponse } from './mock_data';
describe('Board list component', () => {
let wrapper;
@@ -41,8 +42,13 @@ describe('Board list component', () => {
useFakeRequestAnimationFrame();
describe('When Expanded', () => {
- beforeEach(() => {
- wrapper = createComponent({ issuesCount: 1 });
+ beforeEach(async () => {
+ wrapper = createComponent({
+ apolloQueryHandlers: [
+ [listIssuesQuery, jest.fn().mockResolvedValue(mockGroupIssuesResponse())],
+ ],
+ });
+ await waitForPromises();
});
it('renders component', () => {
@@ -62,7 +68,7 @@ describe('Board list component', () => {
});
it('sets data attribute with issue id', () => {
- expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1');
+ expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('gid://gitlab/Issue/436');
});
it('shows new issue form after eventhub event', async () => {
@@ -107,19 +113,18 @@ describe('Board list component', () => {
describe('load more issues', () => {
describe('when loading is not in progress', () => {
- beforeEach(() => {
+ beforeEach(async () => {
wrapper = createComponent({
- listProps: {
- id: 'gid://gitlab/List/1',
- },
- componentProps: {
- boardItems: mockIssuesMore,
- },
- actions: {
- fetchItemsForList: jest.fn(),
- },
- state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: false } } },
+ apolloQueryHandlers: [
+ [
+ listIssuesQuery,
+ jest
+ .fn()
+ .mockResolvedValue(mockGroupIssuesResponse('gid://gitlab/List/1', mockIssuesMore)),
+ ],
+ ],
});
+ await waitForPromises();
});
it('has intersection observer when the number of board list items are more than 5', () => {
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 1a847d35900..768a93f6970 100644
--- a/spec/frontend/boards/components/board_add_new_column_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_spec.js
@@ -1,14 +1,11 @@
import { GlCollapsibleListbox } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
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 createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import boardLabelsQuery from '~/boards/graphql/board_labels.query.graphql';
import * as cacheUpdates from '~/boards/graphql/cache_updates';
@@ -19,7 +16,6 @@ import {
boardListsQueryResponse,
} from '../mock_data';
-Vue.use(Vuex);
Vue.use(VueApollo);
describe('BoardAddNewColumn', () => {
@@ -39,22 +35,8 @@ describe('BoardAddNewColumn', () => {
findDropdown().vm.$emit('select', id);
};
- const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
- return new Vuex.Store({
- state: {
- ...defaultState,
- ...state,
- },
- actions,
- getters,
- });
- };
-
const mountComponent = ({
selectedId,
- labels = [],
- getListByLabelId = jest.fn(),
- actions = {},
provide = {},
lists = {},
labelsHandler = labelsQueryHandler,
@@ -83,26 +65,12 @@ describe('BoardAddNewColumn', () => {
selectedId,
};
},
- store: createStore({
- actions: {
- fetchLabels: jest.fn(),
- ...actions,
- },
- getters: {
- getListByLabelId: () => getListByLabelId,
- },
- state: {
- labels,
- labelsLoading: false,
- },
- }),
provide: {
scopedLabelsAvailable: true,
isEpicBoard: false,
issuableType: 'issue',
fullPath: 'gitlab-org/gitlab',
boardType: 'project',
- isApolloBoard: false,
...provide,
},
stubs: {
@@ -126,149 +94,94 @@ describe('BoardAddNewColumn', () => {
cacheUpdates.setError = jest.fn();
});
- describe('Add list button', () => {
- it('calls addList', async () => {
- const getListByLabelId = jest.fn().mockReturnValue(null);
- const highlightList = jest.fn();
- const createList = jest.fn();
+ describe('when list is new', () => {
+ beforeEach(() => {
+ mountComponent({ selectedId: mockLabelList.label.id });
+ });
- mountComponent({
- labels: [mockLabelList.label],
- selectedId: mockLabelList.label.id,
- getListByLabelId,
- actions: {
- createList,
- highlightList,
- },
- });
+ 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(highlightList).not.toHaveBeenCalled();
- expect(createList).toHaveBeenCalledWith(expect.anything(), {
+ expect(wrapper.emitted('highlight-list')).toBeUndefined();
+ expect(createBoardListQueryHandler).toHaveBeenCalledWith({
labelId: mockLabelList.label.id,
+ boardId: 'gid://gitlab/Board/1',
});
});
+ });
- it('highlights existing list if trying to re-add', async () => {
- const getListByLabelId = jest.fn().mockReturnValue(mockLabelList);
- const highlightList = jest.fn();
- const createList = jest.fn();
-
+ describe('when list already exists in board', () => {
+ beforeEach(() => {
mountComponent({
- labels: [mockLabelList.label],
- selectedId: mockLabelList.label.id,
- getListByLabelId,
- actions: {
- createList,
- highlightList,
+ lists: {
+ [mockLabelList.id]: mockLabelList,
},
+ selectedId: mockLabelList.label.id,
});
-
- findAddNewColumnForm().vm.$emit('add-list');
-
- await nextTick();
-
- expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id);
- 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');
+ it('highlights existing list if trying to re-add', async () => {
+ findDropdown().vm.$emit('show');
- await nextTick();
- expect(labelsQueryHandler).toHaveBeenCalled();
+ await nextTick();
+ expect(labelsQueryHandler).toHaveBeenCalled();
- selectLabel(mockLabelList.label.id);
+ selectLabel(mockLabelList.label.id);
- findAddNewColumnForm().vm.$emit('add-list');
+ findAddNewColumnForm().vm.$emit('add-list');
- await nextTick();
+ await waitForPromises();
- expect(wrapper.emitted('highlight-list')).toBeUndefined();
- expect(createBoardListQueryHandler).toHaveBeenCalledWith({
- labelId: mockLabelList.label.id,
- boardId: 'gid://gitlab/Board/1',
- });
- });
+ expect(wrapper.emitted('highlight-list')).toEqual([[mockLabelList.id]]);
+ expect(createBoardListQueryHandler).not.toHaveBeenCalledWith();
});
+ });
- 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 waitForPromises();
-
- expect(wrapper.emitted('highlight-list')).toEqual([[mockLabelList.id]]);
- expect(createBoardListQueryHandler).not.toHaveBeenCalledWith();
+ describe('when fetch labels query fails', () => {
+ beforeEach(() => {
+ mountComponent({
+ labelsHandler: labelsQueryHandlerFailure,
});
});
- describe('when fetch labels query fails', () => {
- beforeEach(() => {
- mountComponent({
- provide: { isApolloBoard: true },
- labelsHandler: labelsQueryHandlerFailure,
- });
- });
+ it('sets error', async () => {
+ findDropdown().vm.$emit('show');
- it('sets error', async () => {
- findDropdown().vm.$emit('show');
-
- await waitForPromises();
- expect(cacheUpdates.setError).toHaveBeenCalled();
- });
+ await waitForPromises();
+ expect(cacheUpdates.setError).toHaveBeenCalled();
});
+ });
- describe('when create list mutation fails', () => {
- beforeEach(() => {
- mountComponent({
- selectedId: mockLabelList.label.id,
- provide: { isApolloBoard: true },
- createHandler: createBoardListQueryHandlerFailure,
- });
+ describe('when create list mutation fails', () => {
+ beforeEach(() => {
+ mountComponent({
+ selectedId: mockLabelList.label.id,
+ createHandler: createBoardListQueryHandlerFailure,
});
+ });
- it('sets error', async () => {
- findDropdown().vm.$emit('show');
+ it('sets error', async () => {
+ findDropdown().vm.$emit('show');
- await nextTick();
- expect(labelsQueryHandler).toHaveBeenCalled();
+ await nextTick();
+ expect(labelsQueryHandler).toHaveBeenCalled();
- selectLabel(mockLabelList.label.id);
+ selectLabel(mockLabelList.label.id);
- findAddNewColumnForm().vm.$emit('add-list');
+ findAddNewColumnForm().vm.$emit('add-list');
- await waitForPromises();
+ await waitForPromises();
- expect(cacheUpdates.setError).toHaveBeenCalled();
- });
+ expect(cacheUpdates.setError).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js
index b16f9b26f40..157c76b4fff 100644
--- a/spec/frontend/boards/components/board_app_spec.js
+++ b/spec/frontend/boards/components/board_app_spec.js
@@ -1,8 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -15,34 +13,15 @@ import { rawIssue, boardListsQueryResponse } from '../mock_data';
describe('BoardApp', () => {
let wrapper;
- let store;
let mockApollo;
const errorMessage = 'Failed to fetch lists';
const boardListQueryHandler = jest.fn().mockResolvedValue(boardListsQueryResponse);
const boardListQueryHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage));
- Vue.use(Vuex);
Vue.use(VueApollo);
- const createStore = ({ mockGetters = {} } = {}) => {
- store = new Vuex.Store({
- state: {},
- actions: {
- performSearch: jest.fn(),
- },
- getters: {
- isSidebarOpen: () => true,
- ...mockGetters,
- },
- });
- };
-
- const createComponent = ({
- isApolloBoard = false,
- issue = rawIssue,
- handler = boardListQueryHandler,
- } = {}) => {
+ const createComponent = ({ issue = rawIssue, handler = boardListQueryHandler } = {}) => {
mockApollo = createMockApollo([[boardListsQuery, handler]]);
mockApollo.clients.defaultClient.cache.writeQuery({
query: activeBoardItemQuery,
@@ -53,7 +32,6 @@ describe('BoardApp', () => {
wrapper = shallowMount(BoardApp, {
apolloProvider: mockApollo,
- store,
provide: {
fullPath: 'gitlab-org',
initialBoardId: 'gid://gitlab/Board/1',
@@ -62,69 +40,46 @@ describe('BoardApp', () => {
boardType: 'group',
isIssueBoard: true,
isGroupBoard: true,
- isApolloBoard,
},
});
};
- beforeEach(() => {
+ beforeEach(async () => {
cacheUpdates.setError = jest.fn();
- });
- afterEach(() => {
- store = null;
+ createComponent({ isApolloBoard: true });
+ await nextTick();
});
- it("should have 'is-compact' class when sidebar is open", () => {
- createStore();
- createComponent();
+ it('fetches lists', () => {
+ expect(boardListQueryHandler).toHaveBeenCalled();
+ });
+ it('should have is-compact class when a card is selected', () => {
expect(wrapper.classes()).toContain('is-compact');
});
- it("should not have 'is-compact' class when sidebar is closed", () => {
- createStore({ mockGetters: { isSidebarOpen: () => false } });
- createComponent();
+ it('should not have is-compact class when no card is selected', async () => {
+ createComponent({ isApolloBoard: true, issue: {} });
+ await nextTick();
expect(wrapper.classes()).not.toContain('is-compact');
});
- describe('Apollo boards', () => {
- beforeEach(async () => {
- createComponent({ isApolloBoard: true });
- await nextTick();
- });
+ it('refetches lists when updateBoard event is received', async () => {
+ jest.spyOn(eventHub, '$on').mockImplementation(() => {});
- it('fetches lists', () => {
- expect(boardListQueryHandler).toHaveBeenCalled();
- });
+ createComponent({ isApolloBoard: true });
+ await waitForPromises();
- it('should have is-compact class when a card is selected', () => {
- expect(wrapper.classes()).toContain('is-compact');
- });
-
- it('should not have is-compact class when no card is selected', async () => {
- createComponent({ isApolloBoard: true, issue: {} });
- await nextTick();
-
- expect(wrapper.classes()).not.toContain('is-compact');
- });
-
- it('refetches lists when updateBoard event is received', async () => {
- jest.spyOn(eventHub, '$on').mockImplementation(() => {});
-
- createComponent({ isApolloBoard: true });
- await waitForPromises();
-
- expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists);
- });
+ expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists);
+ });
- it('sets error on fetch lists failure', async () => {
- createComponent({ isApolloBoard: true, handler: boardListQueryHandlerFailure });
+ it('sets error on fetch lists failure', async () => {
+ createComponent({ isApolloBoard: true, handler: boardListQueryHandlerFailure });
- await waitForPromises();
+ await waitForPromises();
- expect(cacheUpdates.setError).toHaveBeenCalled();
- });
+ expect(cacheUpdates.setError).toHaveBeenCalled();
});
});
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 20beaf2e9bd..d3c43a4e054 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
@@ -8,7 +8,7 @@ import {
BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION,
} from '~/boards/constants';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
-import { mockList, mockIssue2, mockIssue, mockIssue3, mockIssue4 } from 'jest/boards/mock_data';
+import { mockList, mockIssue2 } from 'jest/boards/mock_data';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
Vue.use(Vuex);
@@ -28,30 +28,8 @@ describe('Board Card Move to position', () => {
let wrapper;
let trackingSpy;
let store;
- let dispatch;
const itemIndex = 1;
- const createStoreOptions = () => {
- const state = {
- pageInfoByListId: {
- 'gid://gitlab/List/1': {},
- 'gid://gitlab/List/2': { hasNextPage: true },
- },
- };
- const getters = {
- getBoardItemsByList: () => () => [mockIssue, mockIssue2, mockIssue3, mockIssue4],
- };
- const actions = {
- moveItem: jest.fn(),
- };
-
- return {
- state,
- getters,
- actions,
- };
- };
-
const createComponent = (propsData, isApolloBoard = false) => {
wrapper = shallowMount(BoardCardMoveToPosition, {
store,
@@ -73,7 +51,6 @@ describe('Board Card Move to position', () => {
};
beforeEach(() => {
- store = new Vuex.Store(createStoreOptions());
createComponent();
});
@@ -97,50 +74,6 @@ describe('Board Card Move to position', () => {
describe('Dropdown options', () => {
beforeEach(() => {
- createComponent({ index: itemIndex });
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- dispatch = jest.spyOn(store, 'dispatch').mockImplementation(() => {});
- });
-
- 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 should call moveItem action 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(dispatch).toHaveBeenCalledWith('moveItem', {
- fromListId: mockList.id,
- itemId: mockIssue2.id,
- itemIid: mockIssue2.iid,
- itemPath: mockIssue2.referencePath,
- positionInList,
- toListId: mockList.id,
- allItemsLoadedInList: true,
- atIndex: itemIndex,
- });
- },
- );
- });
-
- describe('Apollo boards', () => {
- beforeEach(() => {
createComponent({ index: itemIndex }, true);
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index 11f9a4f6ff2..dae0db27104 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -1,7 +1,5 @@
import { GlLabel } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
@@ -9,17 +7,14 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardCard from '~/boards/components/board_card.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
-import { inactiveId } from '~/boards/constants';
import selectedBoardItemsQuery from '~/boards/graphql/client/selected_board_items.query.graphql';
+import activeBoardItemQuery from '~/boards/graphql/client/active_board_item.query.graphql';
import isShowingLabelsQuery from '~/graphql_shared/client/is_showing_labels.query.graphql';
import { mockLabelList, mockIssue, DEFAULT_COLOR } from '../mock_data';
describe('Board card', () => {
let wrapper;
- let store;
- let mockActions;
- Vue.use(Vuex);
Vue.use(VueApollo);
const mockSetActiveBoardItemResolver = jest.fn();
@@ -31,23 +26,6 @@ describe('Board card', () => {
},
});
- const createStore = ({ initialState = {} } = {}) => {
- mockActions = {
- toggleBoardItem: jest.fn(),
- toggleBoardItemMultiSelection: jest.fn(),
- performSearch: jest.fn(),
- };
-
- store = new Vuex.Store({
- state: {
- activeId: inactiveId,
- selectedBoardItems: [],
- ...initialState,
- },
- actions: mockActions,
- });
- };
-
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
const mountComponent = ({
propsData = {},
@@ -55,6 +33,7 @@ describe('Board card', () => {
stubs = { BoardCardInner },
item = mockIssue,
selectedBoardItems = [],
+ activeBoardItem = {},
} = {}) => {
mockApollo.clients.defaultClient.cache.writeQuery({
query: isShowingLabelsQuery,
@@ -68,6 +47,12 @@ describe('Board card', () => {
selectedBoardItems,
},
});
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: activeBoardItemQuery,
+ data: {
+ activeBoardItem,
+ },
+ });
wrapper = shallowMountExtended(BoardCard, {
apolloProvider: mockApollo,
@@ -75,7 +60,6 @@ describe('Board card', () => {
...stubs,
BoardCardInner,
},
- store,
propsData: {
list: mockLabelList,
item,
@@ -92,7 +76,7 @@ describe('Board card', () => {
isGroupBoard: true,
disabled: false,
allowSubEpics: false,
- isApolloBoard: false,
+ isApolloBoard: true,
...provide,
},
});
@@ -112,47 +96,32 @@ describe('Board card', () => {
window.gon = { features: {} };
});
- afterEach(() => {
- store = null;
- });
-
describe('when GlLabel is clicked in BoardCardInner', () => {
- it('doesnt call toggleBoardItem', () => {
- createStore();
+ it("doesn't call setSelectedBoardItemsMutation", () => {
mountComponent();
wrapper.findComponent(GlLabel).trigger('mouseup');
- expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(0);
+ expect(mockSetSelectedBoardItemsResolver).toHaveBeenCalledTimes(0);
});
});
it('should not highlight the card by default', () => {
- createStore();
mountComponent();
expect(wrapper.classes()).not.toContain('is-active');
expect(wrapper.classes()).not.toContain('multi-select');
});
- it('should highlight the card with a correct style when selected', () => {
- createStore({
- initialState: {
- activeId: mockIssue.id,
- },
- });
- mountComponent();
+ it('should highlight the card with a correct style when selected', async () => {
+ mountComponent({ activeBoardItem: mockIssue });
+ await waitForPromises();
expect(wrapper.classes()).toContain('is-active');
expect(wrapper.classes()).not.toContain('multi-select');
});
it('should highlight the card with a correct style when multi-selected', () => {
- createStore({
- initialState: {
- activeId: inactiveId,
- },
- });
mountComponent({ selectedBoardItems: [mockIssue.id] });
expect(wrapper.classes()).toContain('multi-select');
@@ -161,18 +130,22 @@ describe('Board card', () => {
describe('when mouseup event is called on the card', () => {
beforeEach(() => {
- createStore();
mountComponent();
});
describe('when not using multi-select', () => {
- it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
+ it('set active board item on client when clicking on card', async () => {
await selectCard();
+ await waitForPromises();
- expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
- expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
- boardItem: mockIssue,
- });
+ expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith(
+ {},
+ {
+ boardItem: mockIssue,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
});
});
@@ -199,7 +172,6 @@ describe('Board card', () => {
describe('when card is loading', () => {
it('card is disabled and user cannot drag', () => {
- createStore();
mountComponent({ item: { ...mockIssue, isLoading: true } });
expect(wrapper.classes()).toContain('is-disabled');
@@ -209,7 +181,6 @@ describe('Board card', () => {
describe('when card is not loading', () => {
it('user can drag', () => {
- createStore();
mountComponent();
expect(wrapper.classes()).not.toContain('is-disabled');
@@ -220,7 +191,6 @@ describe('Board card', () => {
describe('when Epic colors are enabled', () => {
it('applies the correct color', () => {
window.gon.features = { epicColorHighlight: true };
- createStore();
mountComponent({
item: {
...mockIssue,
@@ -238,7 +208,6 @@ describe('Board card', () => {
describe('when Epic colors are not enabled', () => {
it('applies the correct color', () => {
window.gon.features = { epicColorHighlight: false };
- createStore();
mountComponent({
item: {
...mockIssue,
@@ -252,26 +221,4 @@ describe('Board card', () => {
expect(wrapper.attributes('style')).toBeUndefined();
});
});
-
- describe('Apollo boards', () => {
- beforeEach(async () => {
- createStore();
- mountComponent({ provide: { isApolloBoard: true } });
- await nextTick();
- });
-
- it('set active board item on client when clicking on card', async () => {
- await selectCard();
- await waitForPromises();
-
- expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith(
- {},
- {
- boardItem: mockIssue,
- },
- expect.anything(),
- expect.anything(),
- );
- });
- });
});
diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js
index 5717031be20..61c53c27187 100644
--- a/spec/frontend/boards/components/board_column_spec.js
+++ b/spec/frontend/boards/components/board_column_spec.js
@@ -4,17 +4,15 @@ import { nextTick } from 'vue';
import { listObj } from 'jest/boards/mock_data';
import BoardColumn from '~/boards/components/board_column.vue';
import { ListType } from '~/boards/constants';
-import { createStore } from '~/boards/stores';
describe('Board Column Component', () => {
let wrapper;
- let store;
- const initStore = () => {
- store = createStore();
- };
-
- const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => {
+ const createComponent = ({
+ listType = ListType.backlog,
+ collapsed = false,
+ highlightedLists = [],
+ } = {}) => {
const listMock = {
...listObj,
listType,
@@ -27,14 +25,11 @@ describe('Board Column Component', () => {
}
wrapper = shallowMount(BoardColumn, {
- store,
propsData: {
list: listMock,
boardId: 'gid://gitlab/Board/1',
filters: {},
- },
- provide: {
- isApolloBoard: false,
+ highlightedLists,
},
});
};
@@ -43,10 +38,6 @@ describe('Board Column Component', () => {
const isCollapsed = () => wrapper.classes('is-collapsed');
describe('Given different list types', () => {
- beforeEach(() => {
- initStore();
- });
-
it('is expandable when List Type is `backlog`', () => {
createComponent({ listType: ListType.backlog });
@@ -70,40 +61,11 @@ describe('Board Column Component', () => {
describe('highlighting', () => {
it('scrolls to column when highlighted', async () => {
- createComponent();
-
- store.state.highlightedLists.push(listObj.id);
+ createComponent({ highlightedLists: [listObj.id] });
await nextTick();
expect(wrapper.element.scrollIntoView).toHaveBeenCalled();
});
});
-
- describe('on mount', () => {
- beforeEach(() => {
- initStore();
- jest.spyOn(store, 'dispatch').mockImplementation();
- });
-
- describe('when list is collapsed', () => {
- it('does not call fetchItemsForList when', async () => {
- createComponent({ collapsed: true });
-
- await nextTick();
-
- expect(store.dispatch).toHaveBeenCalledTimes(0);
- });
- });
-
- describe('when the list is not collapsed', () => {
- it('calls fetchItemsForList when', async () => {
- createComponent({ collapsed: false });
-
- await nextTick();
-
- expect(store.dispatch).toHaveBeenCalledWith('fetchItemsForList', { listId: 300 });
- });
- });
- });
});
diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js
index 01eea12bf0a..5fffd4d0c23 100644
--- a/spec/frontend/boards/components/board_content_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_content_sidebar_spec.js
@@ -2,8 +2,6 @@ import { GlDrawer } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
@@ -13,20 +11,17 @@ import waitForPromises from 'helpers/wait_for_promises';
import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
-import { ISSUABLE } from '~/boards/constants';
import { TYPE_ISSUE } from '~/issues/constants';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severity_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
-import { mockActiveIssue, mockIssue, rawIssue } from '../mock_data';
+import { rawIssue } from '../mock_data';
-Vue.use(Vuex);
Vue.use(VueApollo);
describe('BoardContentSidebar', () => {
let wrapper;
- let store;
const mockSetActiveBoardItemResolver = jest.fn();
const mockApollo = createMockApollo([], {
@@ -35,28 +30,11 @@ describe('BoardContentSidebar', () => {
},
});
- const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => {
- store = new Vuex.Store({
- state: {
- sidebarType: ISSUABLE,
- issues: { [mockIssue.id]: { ...mockIssue, epic: null } },
- activeId: mockIssue.id,
- },
- getters: {
- activeBoardItem: () => {
- return { ...mockActiveIssue, epic: null };
- },
- ...mockGetters,
- },
- actions: mockActions,
- });
- };
-
- const createComponent = ({ isApolloBoard = false } = {}) => {
+ const createComponent = ({ issuable = rawIssue } = {}) => {
mockApollo.clients.defaultClient.cache.writeQuery({
query: activeBoardItemQuery,
data: {
- activeBoardItem: rawIssue,
+ activeBoardItem: issuable,
},
});
@@ -68,9 +46,7 @@ describe('BoardContentSidebar', () => {
groupId: 1,
issuableType: TYPE_ISSUE,
isGroupBoard: false,
- isApolloBoard,
},
- store,
stubs: {
GlDrawer: stubComponent(GlDrawer, {
template: '<div><slot name="header"></slot><slot></slot></div>',
@@ -80,7 +56,6 @@ describe('BoardContentSidebar', () => {
};
beforeEach(() => {
- createStore();
createComponent();
});
@@ -97,8 +72,7 @@ describe('BoardContentSidebar', () => {
});
it('does not render GlDrawer when no active item is set', async () => {
- createStore({ mockGetters: { activeBoardItem: () => ({ id: '', iid: '' }) } });
- createComponent();
+ createComponent({ issuable: {} });
await nextTick();
@@ -155,45 +129,10 @@ describe('BoardContentSidebar', () => {
});
describe('when we emit close', () => {
- let toggleBoardItem;
-
- beforeEach(() => {
- toggleBoardItem = jest.fn();
- createStore({ mockActions: { toggleBoardItem } });
- createComponent();
- });
-
- it('calls toggleBoardItem with correct parameters', () => {
- wrapper.findComponent(GlDrawer).vm.$emit('close');
-
- expect(toggleBoardItem).toHaveBeenCalledTimes(1);
- expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
- boardItem: { ...mockActiveIssue, epic: null },
- sidebarType: ISSUABLE,
- });
- });
- });
-
- describe('incident sidebar', () => {
beforeEach(() => {
- createStore({
- mockGetters: { activeBoardItem: () => ({ ...mockIssue, epic: null, type: 'INCIDENT' }) },
- });
createComponent();
});
- it('renders SidebarSeverityWidget', () => {
- expect(wrapper.findComponent(SidebarSeverityWidget).exists()).toBe(true);
- });
- });
-
- describe('Apollo boards', () => {
- beforeEach(async () => {
- createStore();
- createComponent({ isApolloBoard: true });
- await nextTick();
- });
-
it('calls setActiveBoardItemMutation on close', async () => {
wrapper.findComponent(GlDrawer).vm.$emit('close');
@@ -209,4 +148,14 @@ describe('BoardContentSidebar', () => {
);
});
});
+
+ describe('incident sidebar', () => {
+ beforeEach(() => {
+ createComponent({ issuable: { ...rawIssue, epic: null, type: 'INCIDENT' } });
+ });
+
+ it('renders SidebarSeverityWidget', () => {
+ expect(wrapper.findComponent(SidebarSeverityWidget).exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index 675b79a8b1a..706f84ad319 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -3,14 +3,11 @@ import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue, { nextTick } from 'vue';
import Draggable from 'vuedraggable';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
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';
-import getters from 'ee_else_ce/boards/stores/getters';
import * as cacheUpdates from '~/boards/graphql/cache_updates';
import BoardColumn from '~/boards/components/board_column.vue';
import BoardContent from '~/boards/components/board_content.vue';
@@ -27,11 +24,6 @@ import {
} from '../mock_data';
Vue.use(VueApollo);
-Vue.use(Vuex);
-
-const actions = {
- moveList: jest.fn(),
-};
describe('BoardContent', () => {
let wrapper;
@@ -41,26 +33,9 @@ describe('BoardContent', () => {
const errorMessage = 'Failed to update list';
const updateListHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage));
- const defaultState = {
- isShowingEpicsSwimlanes: false,
- boardLists: mockListsById,
- error: undefined,
- issuableType: 'issue',
- };
-
- const createStore = (state = defaultState) => {
- return new Vuex.Store({
- actions,
- getters,
- state,
- });
- };
-
const createComponent = ({
- state,
props = {},
canAdminList = true,
- isApolloBoard = false,
issuableType = 'issue',
isIssueBoard = true,
isEpicBoard = false,
@@ -75,17 +50,13 @@ describe('BoardContent', () => {
data: boardListsQueryResponse.data,
});
- const store = createStore({
- ...defaultState,
- ...state,
- });
wrapper = shallowMount(BoardContent, {
apolloProvider: mockApollo,
propsData: {
boardId: 'gid://gitlab/Board/1',
filterParams: {},
isSwimlanesOn: false,
- boardListsApollo: mockListsById,
+ boardLists: mockListsById,
listQueryVariables,
addColumnFormVisible: false,
...props,
@@ -98,9 +69,7 @@ describe('BoardContent', () => {
isEpicBoard,
isGroupBoard: true,
disabled: false,
- isApolloBoard,
},
- store,
stubs: {
BoardContentSidebar: stubComponent(BoardContentSidebar, {
template: '<div></div>',
@@ -114,13 +83,26 @@ describe('BoardContent', () => {
const findDraggable = () => wrapper.findComponent(Draggable);
const findError = () => wrapper.findComponent(GlAlert);
+ const moveList = () => {
+ 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 } })),
+ },
+ });
+ };
+
beforeEach(() => {
cacheUpdates.setError = jest.fn();
});
describe('default', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent();
+ await waitForPromises();
});
it('renders a BoardColumn component per list', () => {
@@ -146,63 +128,6 @@ describe('BoardContent', () => {
it('does not show the "add column" form', () => {
expect(findBoardAddNewColumn().exists()).toBe(false);
});
- });
-
- describe('when issuableType is not issue', () => {
- beforeEach(() => {
- createComponent({ issuableType: 'foo', isIssueBoard: false });
- });
-
- it('does not render BoardContentSidebar', () => {
- expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(false);
- });
- });
-
- describe('can admin list', () => {
- beforeEach(() => {
- createComponent({ canAdminList: true });
- });
-
- it('renders draggable component', () => {
- expect(findDraggable().exists()).toBe(true);
- });
- });
-
- describe('can not admin list', () => {
- beforeEach(() => {
- createComponent({ canAdminList: false });
- });
-
- it('does not render draggable component', () => {
- expect(findDraggable().exists()).toBe(false);
- });
- });
-
- describe('when Apollo boards FF is on', () => {
- const moveList = () => {
- 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 } })),
- },
- });
- };
-
- beforeEach(async () => {
- createComponent({ isApolloBoard: true });
- await waitForPromises();
- });
-
- it('renders a BoardColumn component per list', () => {
- expect(wrapper.findAllComponents(BoardColumn)).toHaveLength(mockLists.length);
- });
-
- it('renders BoardContentSidebar', () => {
- expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(true);
- });
it('reorders lists', async () => {
moveList();
@@ -212,7 +137,7 @@ describe('BoardContent', () => {
});
it('sets error on reorder lists failure', async () => {
- createComponent({ isApolloBoard: true, handler: updateListHandlerFailure });
+ createComponent({ handler: updateListHandlerFailure });
moveList();
await waitForPromises();
@@ -222,7 +147,7 @@ describe('BoardContent', () => {
describe('when error is passed', () => {
beforeEach(async () => {
- createComponent({ isApolloBoard: true, props: { apolloError: 'Error' } });
+ createComponent({ props: { apolloError: 'Error' } });
await waitForPromises();
});
@@ -239,6 +164,36 @@ describe('BoardContent', () => {
});
});
+ describe('when issuableType is not issue', () => {
+ beforeEach(() => {
+ createComponent({ issuableType: 'foo', isIssueBoard: false });
+ });
+
+ it('does not render BoardContentSidebar', () => {
+ expect(wrapper.findComponent(BoardContentSidebar).exists()).toBe(false);
+ });
+ });
+
+ describe('can admin list', () => {
+ beforeEach(() => {
+ createComponent({ canAdminList: true });
+ });
+
+ it('renders draggable component', () => {
+ expect(findDraggable().exists()).toBe(true);
+ });
+ });
+
+ describe('can not admin list', () => {
+ beforeEach(() => {
+ createComponent({ canAdminList: false });
+ });
+
+ it('does not render draggable component', () => {
+ expect(findDraggable().exists()).toBe(false);
+ });
+ });
+
describe('when "add column" form is visible', () => {
beforeEach(() => {
createComponent({ props: { addColumnFormVisible: true } });
diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js
index 0bd936c9abd..5e96b508f37 100644
--- a/spec/frontend/boards/components/board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/board_filtered_search_spec.js
@@ -1,7 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
import { updateHistory } from '~/lib/utils/url_utility';
import {
@@ -20,9 +17,6 @@ import {
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
-import { createStore } from '~/boards/stores';
-
-Vue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
updateHistory: jest.fn(),
@@ -32,7 +26,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('BoardFilteredSearch', () => {
let wrapper;
- let store;
const tokens = [
{
icon: 'labels',
@@ -63,10 +56,12 @@ describe('BoardFilteredSearch', () => {
];
const createComponent = ({ initialFilterParams = {}, props = {}, provide = {} } = {}) => {
- store = createStore();
wrapper = shallowMount(BoardFilteredSearch, {
- provide: { initialFilterParams, fullPath: '', isApolloBoard: false, ...provide },
- store,
+ provide: {
+ initialFilterParams,
+ fullPath: '',
+ ...provide,
+ },
propsData: {
...props,
tokens,
@@ -79,8 +74,6 @@ describe('BoardFilteredSearch', () => {
describe('default', () => {
beforeEach(() => {
createComponent();
-
- jest.spyOn(store, 'dispatch').mockImplementation();
});
it('passes the correct tokens to FilteredSearch', () => {
@@ -88,12 +81,6 @@ describe('BoardFilteredSearch', () => {
});
describe('when onFilter is emitted', () => {
- it('calls performSearch', () => {
- findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
-
- expect(store.dispatch).toHaveBeenCalledWith('performSearch');
- });
-
it('calls historyPushState', () => {
findFilteredSearch().vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
@@ -104,10 +91,22 @@ describe('BoardFilteredSearch', () => {
});
});
});
+
+ it('emits setFilters and updates URL when onFilter is emitted', () => {
+ findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
+
+ expect(updateHistory).toHaveBeenCalledWith({
+ title: '',
+ replace: true,
+ url: 'http://test.host/',
+ });
+
+ expect(wrapper.emitted('setFilters')).toHaveLength(1);
+ });
});
describe('when eeFilters is not empty', () => {
- it('passes the correct initialFilterValue to FitleredSearchBarRoot', () => {
+ it('passes the correct initialFilterValue to FilteredSearchBarRoot', () => {
createComponent({ props: { eeFilters: { labelName: ['label'] } } });
expect(findFilteredSearch().props('initialFilterValue')).toEqual([
@@ -125,8 +124,6 @@ describe('BoardFilteredSearch', () => {
describe('when searching', () => {
beforeEach(() => {
createComponent();
-
- jest.spyOn(store, 'dispatch').mockImplementation();
});
it('sets the url params to the correct results', () => {
@@ -146,7 +143,6 @@ describe('BoardFilteredSearch', () => {
findFilteredSearch().vm.$emit('onFilter', mockFilters);
- expect(store.dispatch).toHaveBeenCalledWith('performSearch');
expect(updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
@@ -193,21 +189,42 @@ describe('BoardFilteredSearch', () => {
});
});
- describe('when Apollo boards FF is on', () => {
+ describe('when iteration is passed a wildcard value with a cadence id', () => {
+ const url = (arg) => `http://test.host/?iteration_id=${arg}&iteration_cadence_id=1349`;
+
beforeEach(() => {
- createComponent({ provide: { isApolloBoard: true } });
+ createComponent();
});
- it('emits setFilters and updates URL when onFilter is emitted', () => {
- findFilteredSearch().vm.$emit('onFilter', [{ value: { data: '' } }]);
+ it.each([
+ ['Current&1349', url('Current'), 'Current'],
+ ['Any&1349', url('Any'), 'Any'],
+ ])('sets the url param %s', (iterationParam, expected, wildCardId) => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: new URL(expected),
+ });
+
+ const mockFilters = [
+ { type: TOKEN_TYPE_ITERATION, value: { data: iterationParam, operator: '=' } },
+ ];
+
+ findFilteredSearch().vm.$emit('onFilter', mockFilters);
expect(updateHistory).toHaveBeenCalledWith({
title: '',
replace: true,
- url: 'http://test.host/',
+ url: expected,
});
- expect(wrapper.emitted('setFilters')).toHaveLength(1);
+ expect(wrapper.emitted('setFilters')).toStrictEqual([
+ [
+ {
+ iterationCadenceId: '1349',
+ iterationId: wildCardId,
+ },
+ ],
+ ]);
});
});
});
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index a0dacf085e2..16947a0512d 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -1,7 +1,5 @@
import { GlModal } from '@gitlab/ui';
import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-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';
@@ -23,8 +21,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
}));
jest.mock('~/boards/eventhub');
-Vue.use(Vuex);
-
const currentBoard = {
id: 'gid://gitlab/Board/1',
name: 'test',
@@ -55,14 +51,6 @@ describe('BoardForm', () => {
const findDeleteConfirmation = () => wrapper.findByTestId('delete-confirmation-message');
const findInput = () => wrapper.find('#board-new-name');
- const setBoardMock = jest.fn();
-
- const store = new Vuex.Store({
- actions: {
- setBoard: setBoardMock,
- },
- });
-
const defaultHandlers = {
createBoardMutationHandler: jest.fn().mockResolvedValue({
data: {
@@ -107,7 +95,6 @@ describe('BoardForm', () => {
isProjectBoard: false,
...provide,
},
- store,
attachTo: document.body,
});
};
@@ -220,7 +207,7 @@ describe('BoardForm', () => {
});
await waitForPromises();
- expect(setBoardMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.emitted('addBoard')).toHaveLength(1);
});
it('sets error in state if GraphQL mutation fails', async () => {
@@ -239,31 +226,8 @@ describe('BoardForm', () => {
expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalled();
await waitForPromises();
- expect(setBoardMock).not.toHaveBeenCalled();
expect(cacheUpdates.setError).toHaveBeenCalled();
});
-
- describe('when Apollo boards FF is on', () => {
- it('calls a correct GraphQL mutation and emits addBoard event when creating a board', async () => {
- createComponent({
- props: { canAdminBoard: true, currentPage: formType.new },
- provide: { isApolloBoard: true },
- });
-
- fillForm();
-
- await waitForPromises();
-
- expect(requestHandlers.createBoardMutationHandler).toHaveBeenCalledWith({
- input: expect.objectContaining({
- name: 'test',
- }),
- });
-
- await waitForPromises();
- expect(wrapper.emitted('addBoard')).toHaveLength(1);
- });
- });
});
});
@@ -314,8 +278,12 @@ describe('BoardForm', () => {
});
await waitForPromises();
- expect(setBoardMock).toHaveBeenCalledTimes(1);
expect(global.window.location.href).not.toContain('?group_by=epic');
+ expect(eventHub.$emit).toHaveBeenCalledTimes(1);
+ expect(eventHub.$emit).toHaveBeenCalledWith('updateBoard', {
+ id: 'gid://gitlab/Board/321',
+ webPath: 'test-path',
+ });
});
it('calls GraphQL mutation with correct parameters when issues are grouped by epic', async () => {
@@ -335,7 +303,6 @@ describe('BoardForm', () => {
});
await waitForPromises();
- expect(setBoardMock).toHaveBeenCalledTimes(1);
expect(global.window.location.href).toContain('?group_by=epic');
});
@@ -355,36 +322,8 @@ describe('BoardForm', () => {
expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalled();
await waitForPromises();
- expect(setBoardMock).not.toHaveBeenCalled();
expect(cacheUpdates.setError).toHaveBeenCalled();
});
-
- describe('when Apollo boards FF is on', () => {
- it('calls a correct GraphQL mutation and emits updateBoard event when updating a board', async () => {
- setWindowLocation('https://test/boards/1');
-
- createComponent({
- props: { canAdminBoard: true, currentPage: formType.edit },
- provide: { isApolloBoard: true },
- });
- findInput().trigger('keyup.enter', { metaKey: true });
-
- await waitForPromises();
-
- expect(requestHandlers.updateBoardMutationHandler).toHaveBeenCalledWith({
- input: expect.objectContaining({
- id: currentBoard.id,
- }),
- });
-
- await waitForPromises();
- expect(eventHub.$emit).toHaveBeenCalledTimes(1);
- expect(eventHub.$emit).toHaveBeenCalledWith('updateBoard', {
- id: 'gid://gitlab/Board/321',
- webPath: 'test-path',
- });
- });
- });
});
describe('when deleting a board', () => {
@@ -427,7 +366,6 @@ describe('BoardForm', () => {
destroyBoardMutationHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
},
});
- jest.spyOn(store, 'dispatch').mockImplementation(() => {});
findModal().vm.$emit('primary');
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index 76e969f1725..b59ed8b6abb 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -1,8 +1,6 @@
import { GlButtonGroup } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -18,15 +16,11 @@ import * as cacheUpdates from '~/boards/graphql/cache_updates';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
Vue.use(VueApollo);
-Vue.use(Vuex);
describe('Board List Header Component', () => {
let wrapper;
- let store;
let fakeApollo;
- const updateListSpy = jest.fn();
- const toggleListCollapsedSpy = jest.fn();
const mockClientToggleListCollapsedResolver = jest.fn();
const updateListHandlerSuccess = jest.fn().mockResolvedValue(updateBoardListResponse);
@@ -69,10 +63,6 @@ describe('Board List Header Component', () => {
);
}
- store = new Vuex.Store({
- state: {},
- actions: { updateList: updateListSpy, toggleListCollapsed: toggleListCollapsedSpy },
- });
fakeApollo = createMockApollo(
[
[listQuery, listQueryHandler],
@@ -87,7 +77,6 @@ describe('Board List Header Component', () => {
wrapper = shallowMountExtended(BoardListHeader, {
apolloProvider: fakeApollo,
- store,
propsData: {
list: listMock,
filterParams: {},
@@ -198,26 +187,34 @@ describe('Board List Header Component', () => {
expect(icon.props('icon')).toBe('chevron-lg-right');
});
- it('should dispatch toggleListCollapse when clicking the collapse icon', async () => {
- createComponent();
+ it('set active board item on client when clicking on card', async () => {
+ createComponent({ listType: ListType.label });
+ await nextTick();
findCaret().vm.$emit('click');
-
await nextTick();
- expect(toggleListCollapsedSpy).toHaveBeenCalledTimes(1);
+
+ expect(mockClientToggleListCollapsedResolver).toHaveBeenCalledWith(
+ {},
+ {
+ list: mockLabelList,
+ collapsed: true,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
});
- it("when logged in it calls list update and doesn't set localStorage", async () => {
+ it("when logged in it doesn't set localStorage", async () => {
createComponent({ withLocalStorage: false, currentUserId: 1 });
findCaret().vm.$emit('click');
await nextTick();
- expect(updateListSpy).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(null);
});
- it("when logged out it doesn't call list update and sets localStorage", async () => {
+ it('when logged out it sets localStorage', async () => {
createComponent({
currentUserId: null,
});
@@ -225,7 +222,6 @@ describe('Board List Header Component', () => {
findCaret().vm.$emit('click');
await nextTick();
- expect(updateListSpy).not.toHaveBeenCalled();
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(
String(!isCollapsed()),
);
@@ -252,86 +248,67 @@ describe('Board List Header Component', () => {
});
});
- describe('Apollo boards', () => {
- beforeEach(async () => {
- createComponent({ listType: ListType.label, injectedProps: { isApolloBoard: true } });
- await nextTick();
- });
-
- it('set active board item on client when clicking on card', async () => {
- findCaret().vm.$emit('click');
- await nextTick();
-
- expect(mockClientToggleListCollapsedResolver).toHaveBeenCalledWith(
- {},
- {
- list: mockLabelList,
- collapsed: true,
- },
- expect.anything(),
- expect.anything(),
- );
- });
+ beforeEach(async () => {
+ createComponent({ listType: ListType.label });
+ await nextTick();
+ });
- it('does not call update list mutation when user is not logged in', async () => {
- createComponent({ currentUserId: null, injectedProps: { isApolloBoard: true } });
+ it('does not call update list mutation when user is not logged in', async () => {
+ createComponent({ currentUserId: null });
- findCaret().vm.$emit('click');
- await nextTick();
+ findCaret().vm.$emit('click');
+ await nextTick();
- expect(updateListHandlerSuccess).not.toHaveBeenCalled();
- });
+ expect(updateListHandlerSuccess).not.toHaveBeenCalled();
+ });
- it('calls update list mutation when user is logged in', async () => {
- createComponent({ currentUserId: 1, injectedProps: { isApolloBoard: true } });
+ it('calls update list mutation when user is logged in', async () => {
+ createComponent({ currentUserId: 1 });
- findCaret().vm.$emit('click');
- await nextTick();
+ findCaret().vm.$emit('click');
+ await nextTick();
- expect(updateListHandlerSuccess).toHaveBeenCalledWith({
- listId: mockLabelList.id,
- collapsed: true,
- });
+ expect(updateListHandlerSuccess).toHaveBeenCalledWith({
+ listId: mockLabelList.id,
+ collapsed: true,
});
+ });
- describe('when fetch list query fails', () => {
- const errorMessage = 'Failed to fetch list';
- const listQueryHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage));
+ describe('when fetch list query fails', () => {
+ const errorMessage = 'Failed to fetch list';
+ const listQueryHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage));
- beforeEach(() => {
- createComponent({
- listQueryHandler: listQueryHandlerFailure,
- injectedProps: { isApolloBoard: true },
- });
+ beforeEach(() => {
+ createComponent({
+ listQueryHandler: listQueryHandlerFailure,
});
+ });
- it('sets error', async () => {
- await waitForPromises();
+ it('sets error', async () => {
+ await waitForPromises();
- expect(cacheUpdates.setError).toHaveBeenCalled();
- });
+ expect(cacheUpdates.setError).toHaveBeenCalled();
});
+ });
- describe('when update list mutation fails', () => {
- const errorMessage = 'Failed to update list';
- const updateListHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage));
+ describe('when update list mutation fails', () => {
+ const errorMessage = 'Failed to update list';
+ const updateListHandlerFailure = jest.fn().mockRejectedValue(new Error(errorMessage));
- beforeEach(() => {
- createComponent({
- currentUserId: 1,
- updateListHandler: updateListHandlerFailure,
- injectedProps: { isApolloBoard: true },
- });
+ beforeEach(() => {
+ createComponent({
+ currentUserId: 1,
+ updateListHandler: updateListHandlerFailure,
});
+ });
- it('sets error', async () => {
- await waitForPromises();
+ it('sets error', async () => {
+ await waitForPromises();
- findCaret().vm.$emit('click');
- await waitForPromises();
+ findCaret().vm.$emit('click');
+ await waitForPromises();
- expect(cacheUpdates.setError).toHaveBeenCalled();
- });
+ expect(cacheUpdates.setError).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js
index bf2608d0594..dad0d148449 100644
--- a/spec/frontend/boards/components/board_new_issue_spec.js
+++ b/spec/frontend/boards/components/board_new_issue_spec.js
@@ -1,7 +1,5 @@
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
@@ -15,18 +13,12 @@ import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
import {
mockList,
mockGroupProjects,
- mockIssue,
- mockIssue2,
mockProjectBoardResponse,
mockGroupBoardResponse,
} from '../mock_data';
-Vue.use(Vuex);
Vue.use(VueApollo);
-const addListNewIssuesSpy = jest.fn().mockResolvedValue();
-const mockActions = { addListNewIssue: addListNewIssuesSpy };
-
const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse);
const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse);
@@ -36,20 +28,12 @@ const mockApollo = createMockApollo([
]);
const createComponent = ({
- state = {},
- actions = mockActions,
- getters = { getBoardItemsByList: () => () => [] },
isGroupBoard = true,
data = { selectedProject: mockGroupProjects[0] },
provide = {},
} = {}) =>
shallowMount(BoardNewIssue, {
apolloProvider: mockApollo,
- store: new Vuex.Store({
- state,
- actions,
- getters,
- }),
propsData: {
list: mockList,
boardId: 'gid://gitlab/Board/1',
@@ -63,7 +47,6 @@ const createComponent = ({
isGroupBoard,
boardType: 'group',
isEpicBoard: false,
- isApolloBoard: false,
...provide,
},
stubs: {
@@ -82,6 +65,32 @@ describe('Issue boards new issue form', () => {
await nextTick();
});
+ it.each`
+ boardType | queryHandler | notCalledHandler
+ ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
+ ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
+ `(
+ 'fetches $boardType board and emits addNewIssue event',
+ async ({ boardType, queryHandler, notCalledHandler }) => {
+ wrapper = createComponent({
+ provide: {
+ boardType,
+ isProjectBoard: boardType === WORKSPACE_PROJECT,
+ isGroupBoard: boardType === WORKSPACE_GROUP,
+ },
+ });
+
+ await nextTick();
+ findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' });
+
+ await nextTick();
+
+ expect(queryHandler).toHaveBeenCalled();
+ expect(notCalledHandler).not.toHaveBeenCalled();
+ expect(wrapper.emitted('addNewIssue')[0][0]).toMatchObject({ title: 'Foo' });
+ },
+ );
+
it('renders board-new-item component', () => {
const boardNewItem = findBoardNewItem();
expect(boardNewItem.exists()).toBe(true);
@@ -93,51 +102,6 @@ describe('Issue boards new issue form', () => {
});
});
- it('calls addListNewIssue action when `board-new-item` emits form-submit event', async () => {
- findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' });
-
- await nextTick();
- expect(addListNewIssuesSpy).toHaveBeenCalledWith(expect.any(Object), {
- list: mockList,
- issueInput: {
- title: 'Foo',
- labelIds: [],
- assigneeIds: [],
- milestoneId: undefined,
- projectPath: mockGroupProjects[0].fullPath,
- moveAfterId: undefined,
- },
- });
- });
-
- describe('when list has an existing issues', () => {
- beforeEach(() => {
- wrapper = createComponent({
- getters: {
- getBoardItemsByList: () => () => [mockIssue, mockIssue2],
- },
- isGroupBoard: true,
- });
- });
-
- it('uses the first issue ID as moveAfterId', async () => {
- findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' });
-
- await nextTick();
- expect(addListNewIssuesSpy).toHaveBeenCalledWith(expect.any(Object), {
- list: mockList,
- issueInput: {
- title: 'Foo',
- labelIds: [],
- assigneeIds: [],
- milestoneId: undefined,
- projectPath: mockGroupProjects[0].fullPath,
- moveAfterId: mockIssue.id,
- },
- });
- });
- });
-
it('emits event `toggle-issue-form` with current list Id suffix on eventHub when `board-new-item` emits form-cancel event', async () => {
jest.spyOn(eventHub, '$emit').mockImplementation();
findBoardNewItem().vm.$emit('form-cancel');
@@ -168,33 +132,4 @@ describe('Issue boards new issue form', () => {
expect(projectSelect.exists()).toBe(false);
});
});
-
- describe('Apollo boards', () => {
- it.each`
- boardType | queryHandler | notCalledHandler
- ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
- ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
- `(
- 'fetches $boardType board and emits addNewIssue event',
- async ({ boardType, queryHandler, notCalledHandler }) => {
- wrapper = createComponent({
- provide: {
- boardType,
- isProjectBoard: boardType === WORKSPACE_PROJECT,
- isGroupBoard: boardType === WORKSPACE_GROUP,
- isApolloBoard: true,
- },
- });
-
- await nextTick();
- findBoardNewItem().vm.$emit('form-submit', { title: 'Foo' });
-
- await nextTick();
-
- expect(queryHandler).toHaveBeenCalled();
- expect(notCalledHandler).not.toHaveBeenCalled();
- expect(wrapper.emitted('addNewIssue')[0][0]).toMatchObject({ title: 'Foo' });
- },
- );
- });
});
diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js
index f6ed483dfc5..71c886351b6 100644
--- a/spec/frontend/boards/components/board_settings_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js
@@ -3,32 +3,23 @@ import { shallowMount } from '@vue/test-utils';
import { MountingPortal } from 'portal-vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
-import { inactiveId, LIST } from '~/boards/constants';
import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
import * as cacheUpdates from '~/boards/graphql/cache_updates';
-import actions from '~/boards/stores/actions';
-import getters from '~/boards/stores/getters';
-import mutations from '~/boards/stores/mutations';
-import sidebarEventHub from '~/sidebar/event_hub';
import { mockLabelList, destroyBoardListMutationResponse } from '../mock_data';
Vue.use(VueApollo);
-Vue.use(Vuex);
describe('BoardSettingsSidebar', () => {
let wrapper;
let mockApollo;
const labelTitle = mockLabelList.label.title;
const labelColor = mockLabelList.label.color;
- const listId = mockLabelList.id;
const modalID = 'board-settings-sidebar-modal';
const destroyBoardListMutationHandlerSuccess = jest
@@ -42,26 +33,12 @@ describe('BoardSettingsSidebar', () => {
const createComponent = ({
canAdminList = false,
list = {},
- sidebarType = LIST,
- activeId = inactiveId,
destroyBoardListMutationHandler = destroyBoardListMutationHandlerSuccess,
- isApolloBoard = false,
} = {}) => {
- const boardLists = {
- [listId]: list,
- };
- const store = new Vuex.Store({
- state: { sidebarType, activeId, boardLists },
- getters,
- mutations,
- actions,
- });
-
mockApollo = createMockApollo([[destroyBoardListMutation, destroyBoardListMutationHandler]]);
wrapper = extendedWrapper(
shallowMount(BoardSettingsSidebar, {
- store,
apolloProvider: mockApollo,
provide: {
canAdminList,
@@ -69,7 +46,6 @@ describe('BoardSettingsSidebar', () => {
isIssueBoard: true,
boardType: 'group',
issuableType: 'issue',
- isApolloBoard,
},
propsData: {
listId: list.id || '',
@@ -100,90 +76,50 @@ describe('BoardSettingsSidebar', () => {
cacheUpdates.setError = jest.fn();
});
- it('finds a MountingPortal component', () => {
- createComponent();
-
- expect(wrapper.findComponent(MountingPortal).props()).toMatchObject({
- mountTo: '#js-right-sidebar-portal',
- append: true,
- name: 'board-settings-sidebar',
- });
- });
-
- describe('when sidebarType is "list"', () => {
- it('finds a GlDrawer component', () => {
+ describe('default', () => {
+ beforeEach(() => {
createComponent();
+ });
+ it('renders a MountingPortal component', () => {
+ expect(wrapper.findComponent(MountingPortal).props()).toMatchObject({
+ mountTo: '#js-right-sidebar-portal',
+ append: true,
+ name: 'board-settings-sidebar',
+ });
+ });
+ it('renders a GlDrawer component', () => {
expect(findDrawer().exists()).toBe(true);
});
describe('on close', () => {
it('closes the sidebar', async () => {
- createComponent();
-
findDrawer().vm.$emit('close');
await nextTick();
expect(wrapper.findComponent(GlDrawer).props('open')).toBe(false);
});
-
- it('closes the sidebar when emitting the correct event', async () => {
- createComponent();
-
- sidebarEventHub.$emit('sidebar.closeAll');
-
- await nextTick();
-
- expect(wrapper.findComponent(GlDrawer).props('open')).toBe(false);
- });
});
- describe('when activeId is zero', () => {
+ describe('when there is no active list', () => {
it('renders GlDrawer with open false', () => {
createComponent();
expect(findDrawer().props('open')).toBe(false);
+ expect(findLabel().exists()).toBe(false);
});
});
- describe('when activeId is greater than zero', () => {
- it('renders GlDrawer with open true', () => {
- createComponent({ list: mockLabelList, activeId: listId });
+ describe('when there is an active list', () => {
+ it('renders GlDrawer with list title and label', () => {
+ createComponent({ list: mockLabelList });
expect(findDrawer().props('open')).toBe(true);
- });
- });
-
- describe('when activeId is in state', () => {
- it('renders label title', () => {
- createComponent({ list: mockLabelList, activeId: listId });
-
expect(findLabel().props('title')).toBe(labelTitle);
- });
-
- it('renders label background color', () => {
- createComponent({ list: mockLabelList, activeId: listId });
-
expect(findLabel().props('backgroundColor')).toBe(labelColor);
});
});
-
- describe('when activeId is not in state', () => {
- it('does not render GlLabel', () => {
- createComponent({ list: mockLabelList });
-
- expect(findLabel().exists()).toBe(false);
- });
- });
- });
-
- describe('when sidebarType is not List', () => {
- it('does not render GlDrawer', () => {
- createComponent({ sidebarType: '' });
-
- expect(findDrawer().props('open')).toBe(false);
- });
});
it('does not render "Remove list" when user cannot admin the boards list', () => {
@@ -193,20 +129,15 @@ describe('BoardSettingsSidebar', () => {
});
describe('when user can admin the boards list', () => {
- it('renders "Remove list" button', () => {
- createComponent({ canAdminList: true, activeId: listId, list: mockLabelList });
+ beforeEach(() => {
+ createComponent({ canAdminList: true, list: mockLabelList });
+ });
+ it('renders "Remove list" button', () => {
expect(findRemoveButton().exists()).toBe(true);
});
it('removes the list', () => {
- createComponent({
- canAdminList: true,
- activeId: listId,
- list: mockLabelList,
- isApolloBoard: true,
- });
-
findRemoveButton().vm.$emit('click');
wrapper.findComponent(GlModal).vm.$emit('primary');
@@ -215,23 +146,19 @@ describe('BoardSettingsSidebar', () => {
});
it('has the correct ID on the button', () => {
- createComponent({ canAdminList: true, activeId: listId, list: mockLabelList });
const binding = getBinding(findRemoveButton().element, 'gl-modal');
expect(binding.value).toBe(modalID);
});
it('has the correct ID on the modal', () => {
- createComponent({ canAdminList: true, activeId: listId, list: mockLabelList });
expect(findModal().props('modalId')).toBe(modalID);
});
it('sets error when destroy list mutation fails', async () => {
createComponent({
canAdminList: true,
- activeId: listId,
list: mockLabelList,
destroyBoardListMutationHandler: destroyBoardListMutationHandlerFailure,
- isApolloBoard: true,
});
findRemoveButton().vm.$emit('click');
diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js
index 87abe630688..03526600114 100644
--- a/spec/frontend/boards/components/board_top_bar_spec.js
+++ b/spec/frontend/boards/components/board_top_bar_spec.js
@@ -1,8 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -21,18 +19,11 @@ import projectBoardQuery from '~/boards/graphql/project_board.query.graphql';
import { mockProjectBoardResponse, mockGroupBoardResponse } from '../mock_data';
Vue.use(VueApollo);
-Vue.use(Vuex);
describe('BoardTopBar', () => {
let wrapper;
let mockApollo;
- const createStore = () => {
- return new Vuex.Store({
- state: {},
- });
- };
-
const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse);
const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse);
const errorMessage = 'Failed to fetch board';
@@ -43,14 +34,12 @@ describe('BoardTopBar', () => {
projectBoardQueryHandler = projectBoardQueryHandlerSuccess,
groupBoardQueryHandler = groupBoardQueryHandlerSuccess,
} = {}) => {
- const store = createStore();
mockApollo = createMockApollo([
[projectBoardQuery, projectBoardQueryHandler],
[groupBoardQuery, groupBoardQueryHandler],
]);
wrapper = shallowMount(BoardTopBar, {
- store,
apolloProvider: mockApollo,
propsData: {
boardId: 'gid://gitlab/Board/1',
@@ -67,7 +56,7 @@ describe('BoardTopBar', () => {
isIssueBoard: true,
isEpicBoard: false,
isGroupBoard: true,
- isApolloBoard: false,
+ // isApolloBoard: false,
...provide,
},
stubs: { IssueBoardFilteredSearch },
@@ -127,45 +116,41 @@ describe('BoardTopBar', () => {
});
});
- describe('Apollo boards', () => {
- it.each`
- boardType | queryHandler | notCalledHandler
- ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
- ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
- `('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => {
- createComponent({
- provide: {
- boardType,
- isProjectBoard: boardType === WORKSPACE_PROJECT,
- isGroupBoard: boardType === WORKSPACE_GROUP,
- isApolloBoard: true,
- },
- });
-
- await nextTick();
-
- expect(queryHandler).toHaveBeenCalled();
- expect(notCalledHandler).not.toHaveBeenCalled();
+ it.each`
+ boardType | queryHandler | notCalledHandler
+ ${WORKSPACE_GROUP} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
+ ${WORKSPACE_PROJECT} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
+ `('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => {
+ createComponent({
+ provide: {
+ boardType,
+ isProjectBoard: boardType === WORKSPACE_PROJECT,
+ isGroupBoard: boardType === WORKSPACE_GROUP,
+ },
});
- it.each`
- boardType
- ${WORKSPACE_GROUP}
- ${WORKSPACE_PROJECT}
- `('sets error when $boardType board query fails', async ({ boardType }) => {
- createComponent({
- provide: {
- boardType,
- isProjectBoard: boardType === WORKSPACE_PROJECT,
- isGroupBoard: boardType === WORKSPACE_GROUP,
- isApolloBoard: true,
- },
- groupBoardQueryHandler: boardQueryHandlerFailure,
- projectBoardQueryHandler: boardQueryHandlerFailure,
- });
-
- await waitForPromises();
- expect(cacheUpdates.setError).toHaveBeenCalled();
+ await nextTick();
+
+ expect(queryHandler).toHaveBeenCalled();
+ expect(notCalledHandler).not.toHaveBeenCalled();
+ });
+
+ it.each`
+ boardType
+ ${WORKSPACE_GROUP}
+ ${WORKSPACE_PROJECT}
+ `('sets error when $boardType board query fails', async ({ boardType }) => {
+ createComponent({
+ provide: {
+ boardType,
+ isProjectBoard: boardType === WORKSPACE_PROJECT,
+ isGroupBoard: boardType === WORKSPACE_GROUP,
+ },
+ groupBoardQueryHandler: boardQueryHandlerFailure,
+ projectBoardQueryHandler: boardQueryHandlerFailure,
});
+
+ await waitForPromises();
+ expect(cacheUpdates.setError).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js
index 0a628af9939..8766b1c25f2 100644
--- a/spec/frontend/boards/components/boards_selector_spec.js
+++ b/spec/frontend/boards/components/boards_selector_spec.js
@@ -1,8 +1,6 @@
import { GlCollapsibleListbox } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue';
@@ -29,23 +27,10 @@ import {
const throttleDuration = 1;
Vue.use(VueApollo);
-Vue.use(Vuex);
describe('BoardsSelector', () => {
let wrapper;
let fakeApollo;
- let store;
-
- const createStore = () => {
- store = new Vuex.Store({
- actions: {
- setBoardConfig: jest.fn(),
- },
- state: {
- board: mockBoard,
- },
- });
- };
const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
@@ -91,10 +76,10 @@ describe('BoardsSelector', () => {
]);
wrapper = shallowMountExtended(BoardsSelector, {
- store,
apolloProvider: fakeApollo,
propsData: {
throttleDuration,
+ board: mockBoard,
...props,
},
attachTo: document.body,
@@ -109,7 +94,7 @@ describe('BoardsSelector', () => {
boardType: isGroupBoard ? 'group' : 'project',
isGroupBoard,
isProjectBoard,
- isApolloBoard: false,
+ // isApolloBoard: false,
...provide,
},
});
@@ -125,7 +110,6 @@ describe('BoardsSelector', () => {
describe('template', () => {
beforeEach(() => {
- createStore();
createComponent({ isProjectBoard: true });
});
@@ -137,9 +121,6 @@ describe('BoardsSelector', () => {
it('shows loading spinner', async () => {
createComponent({
- provide: {
- isApolloBoard: true,
- },
props: {
isCurrentBoardLoading: true,
},
@@ -243,7 +224,6 @@ describe('BoardsSelector', () => {
${WORKSPACE_GROUP} | ${groupBoardsQueryHandlerSuccess} | ${projectBoardsQueryHandlerSuccess}
${WORKSPACE_PROJECT} | ${projectBoardsQueryHandlerSuccess} | ${groupBoardsQueryHandlerSuccess}
`('fetches $boardType boards', async ({ boardType, queryHandler, notCalledHandler }) => {
- createStore();
createComponent({
isGroupBoard: boardType === WORKSPACE_GROUP,
isProjectBoard: boardType === WORKSPACE_PROJECT,
@@ -265,7 +245,6 @@ describe('BoardsSelector', () => {
${WORKSPACE_GROUP}
${WORKSPACE_PROJECT}
`('sets error when fetching $boardType boards fails', async ({ boardType }) => {
- createStore();
createComponent({
isGroupBoard: boardType === WORKSPACE_GROUP,
isProjectBoard: boardType === WORKSPACE_PROJECT,
@@ -287,7 +266,6 @@ describe('BoardsSelector', () => {
describe('dropdown visibility', () => {
describe('when multipleIssueBoardsAvailable is enabled', () => {
it('show dropdown', () => {
- createStore();
createComponent({ provide: { multipleIssueBoardsAvailable: true } });
expect(findDropdown().exists()).toBe(true);
expect(findDropdown().props('toggleText')).toBe('Select board');
@@ -296,7 +274,6 @@ describe('BoardsSelector', () => {
describe('when multipleIssueBoardsAvailable is disabled but it hasMissingBoards', () => {
it('show dropdown', () => {
- createStore();
createComponent({
provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: true },
});
@@ -307,7 +284,6 @@ describe('BoardsSelector', () => {
describe("when multipleIssueBoardsAvailable is disabled and it dosn't hasMissingBoards", () => {
it('hide dropdown', () => {
- createStore();
createComponent({
provide: { multipleIssueBoardsAvailable: false, hasMissingBoards: false },
});
@@ -320,7 +296,6 @@ describe('BoardsSelector', () => {
it('displays loading state of dropdown while current board is being fetched', () => {
createComponent({
props: { isCurrentBoardLoading: true },
- provide: { isApolloBoard: true },
});
expect(findDropdown().props('loading')).toBe(true);
expect(findDropdown().props('toggleText')).toBe('Select board');
diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
index 1edb6812af0..39cdde295aa 100644
--- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
@@ -23,6 +23,9 @@ describe('IssueBoardFilter', () => {
fullPath: 'gitlab-org',
isGroupBoard: true,
},
+ mocks: {
+ $apollo: {},
+ },
});
};
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js
index f354067e226..77b557e7ccd 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js
@@ -6,7 +6,6 @@ import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
-import { createStore } from '~/boards/stores';
import issueSetTitleMutation from '~/boards/graphql/issue_set_title.mutation.graphql';
import * as cacheUpdates from '~/boards/graphql/cache_updates';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
@@ -32,11 +31,10 @@ const TEST_ISSUE_B = {
describe('BoardSidebarTitle', () => {
let wrapper;
- let store;
- let storeDispatch;
let mockApollo;
const issueSetTitleMutationHandlerSuccess = jest.fn().mockResolvedValue(updateIssueTitleResponse);
+ const issueSetTitleMutationHandlerFailure = jest.fn().mockRejectedValue(new Error('error'));
const updateEpicTitleMutationHandlerSuccess = jest
.fn()
.mockResolvedValue(updateEpicTitleResponse);
@@ -47,28 +45,25 @@ describe('BoardSidebarTitle', () => {
afterEach(() => {
localStorage.clear();
- store = null;
});
- const createWrapper = ({ item = TEST_ISSUE_A, provide = {} } = {}) => {
- store = createStore();
- store.state.boardItems = { [item.id]: { ...item } };
- store.dispatch('setActiveId', { id: item.id });
+ const createWrapper = ({
+ item = TEST_ISSUE_A,
+ provide = {},
+ issueSetTitleMutationHandler = issueSetTitleMutationHandlerSuccess,
+ } = {}) => {
mockApollo = createMockApollo([
- [issueSetTitleMutation, issueSetTitleMutationHandlerSuccess],
+ [issueSetTitleMutation, issueSetTitleMutationHandler],
[updateEpicTitleMutation, updateEpicTitleMutationHandlerSuccess],
]);
- storeDispatch = jest.spyOn(store, 'dispatch');
wrapper = shallowMountExtended(BoardSidebarTitle, {
- store,
apolloProvider: mockApollo,
provide: {
canUpdate: true,
fullPath: 'gitlab-org',
issuableType: 'issue',
isEpicBoard: false,
- isApolloBoard: false,
...provide,
},
propsData: {
@@ -122,13 +117,6 @@ describe('BoardSidebarTitle', () => {
expect(findCollapsed().isVisible()).toBe(true);
});
- it('commits change to the server', () => {
- expect(storeDispatch).toHaveBeenCalledWith('setActiveItemTitle', {
- projectPath: 'h/b',
- title: 'New item title',
- });
- });
-
it('renders correct title', async () => {
createWrapper({ item: { ...TEST_ISSUE_A, title: TEST_TITLE } });
await waitForPromises();
@@ -137,6 +125,31 @@ describe('BoardSidebarTitle', () => {
});
});
+ it.each`
+ issuableType | isEpicBoard | queryHandler | notCalledHandler
+ ${'issue'} | ${false} | ${issueSetTitleMutationHandlerSuccess} | ${updateEpicTitleMutationHandlerSuccess}
+ ${'epic'} | ${true} | ${updateEpicTitleMutationHandlerSuccess} | ${issueSetTitleMutationHandlerSuccess}
+ `(
+ 'updates $issuableType title',
+ async ({ issuableType, isEpicBoard, queryHandler, notCalledHandler }) => {
+ createWrapper({
+ provide: {
+ issuableType,
+ isEpicBoard,
+ },
+ });
+
+ await nextTick();
+
+ findFormInput().vm.$emit('input', TEST_TITLE);
+ findForm().vm.$emit('submit', { preventDefault: () => {} });
+ await nextTick();
+
+ expect(queryHandler).toHaveBeenCalled();
+ expect(notCalledHandler).not.toHaveBeenCalled();
+ },
+ );
+
describe('when submitting and invalid title', () => {
beforeEach(async () => {
createWrapper();
@@ -146,8 +159,8 @@ describe('BoardSidebarTitle', () => {
await nextTick();
});
- it('commits change to the server', () => {
- expect(storeDispatch).not.toHaveBeenCalled();
+ it('does not update title', () => {
+ expect(issueSetTitleMutationHandlerSuccess).not.toHaveBeenCalled();
});
});
@@ -194,7 +207,7 @@ describe('BoardSidebarTitle', () => {
});
it('collapses sidebar and render former title', () => {
- expect(storeDispatch).not.toHaveBeenCalled();
+ expect(issueSetTitleMutationHandlerSuccess).not.toHaveBeenCalled();
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toBe(TEST_ISSUE_B.title);
});
@@ -202,47 +215,23 @@ describe('BoardSidebarTitle', () => {
describe('when the mutation fails', () => {
beforeEach(async () => {
- createWrapper({ item: TEST_ISSUE_B });
+ createWrapper({
+ item: TEST_ISSUE_B,
+ issueSetTitleMutationHandler: issueSetTitleMutationHandlerFailure,
+ });
findFormInput().vm.$emit('input', 'Invalid title');
findForm().vm.$emit('submit', { preventDefault: () => {} });
await nextTick();
});
- it('collapses sidebar and renders former item title', () => {
+ it('collapses sidebar and renders former item title', async () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findTitle().text()).toContain(TEST_ISSUE_B.title);
+ await waitForPromises();
expect(cacheUpdates.setError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'An error occurred when updating the title' }),
);
});
});
-
- describe('Apollo boards', () => {
- it.each`
- issuableType | isEpicBoard | queryHandler | notCalledHandler
- ${'issue'} | ${false} | ${issueSetTitleMutationHandlerSuccess} | ${updateEpicTitleMutationHandlerSuccess}
- ${'epic'} | ${true} | ${updateEpicTitleMutationHandlerSuccess} | ${issueSetTitleMutationHandlerSuccess}
- `(
- 'updates $issuableType title',
- async ({ issuableType, isEpicBoard, queryHandler, notCalledHandler }) => {
- createWrapper({
- provide: {
- issuableType,
- isEpicBoard,
- isApolloBoard: true,
- },
- });
-
- await nextTick();
-
- findFormInput().vm.$emit('input', TEST_TITLE);
- findForm().vm.$emit('submit', { preventDefault: () => {} });
- await nextTick();
-
- expect(queryHandler).toHaveBeenCalled();
- expect(notCalledHandler).not.toHaveBeenCalled();
- },
- );
- });
});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 0be17db9450..3a5e108ac07 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -275,6 +275,7 @@ export const labels = [
];
export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test';
+export const mockIssueDirectNamespace = 'gitlab-test';
export const mockEpicFullPath = 'gitlab-org/test-subgroup';
export const rawIssue = {
@@ -331,15 +332,17 @@ export const mockIssue = {
confidential: false,
referencePath: `${mockIssueFullPath}#27`,
path: `/${mockIssueFullPath}/-/issues/27`,
- assignees,
- labels: [
- {
- id: 1,
- title: 'test',
- color: '#F0AD4E',
- description: 'testing',
- },
- ],
+ assignees: { nodes: assignees },
+ labels: {
+ nodes: [
+ {
+ id: 1,
+ title: 'test',
+ color: '#F0AD4E',
+ description: 'testing',
+ },
+ ],
+ },
epic: {
id: 'gid://gitlab/Epic/41',
},
@@ -411,6 +414,7 @@ export const mockActiveIssue = {
};
export const mockIssue2 = {
+ ...rawIssue,
id: 'gid://gitlab/Issue/437',
iid: 28,
title: 'Issue 2',
@@ -420,14 +424,13 @@ export const mockIssue2 = {
confidential: false,
referencePath: 'gitlab-org/test-subgroup/gitlab-test#28',
path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/28',
- assignees,
- labels,
epic: {
id: 'gid://gitlab/Epic/40',
},
};
export const mockIssue3 = {
+ ...rawIssue,
id: 'gid://gitlab/Issue/438',
iid: 29,
title: 'Issue 3',
@@ -436,12 +439,11 @@ export const mockIssue3 = {
timeEstimate: 0,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
- assignees,
- labels,
epic: null,
};
export const mockIssue4 = {
+ ...rawIssue,
id: 'gid://gitlab/Issue/439',
iid: 30,
title: 'Issue 4',
@@ -450,12 +452,11 @@ export const mockIssue4 = {
timeEstimate: 0,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
- assignees,
- labels,
epic: null,
};
export const mockIssue5 = {
+ ...rawIssue,
id: 'gid://gitlab/Issue/440',
iid: 40,
title: 'Issue 5',
@@ -464,12 +465,11 @@ export const mockIssue5 = {
timeEstimate: 0,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/40',
- assignees,
- labels,
epic: null,
};
export const mockIssue6 = {
+ ...rawIssue,
id: 'gid://gitlab/Issue/441',
iid: 41,
title: 'Issue 6',
@@ -478,12 +478,11 @@ export const mockIssue6 = {
timeEstimate: 0,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/41',
- assignees,
- labels,
epic: null,
};
export const mockIssue7 = {
+ ...rawIssue,
id: 'gid://gitlab/Issue/442',
iid: 42,
title: 'Issue 6',
@@ -492,8 +491,6 @@ export const mockIssue7 = {
timeEstimate: 0,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/42',
- assignees,
- labels,
epic: null,
};
@@ -1085,4 +1082,36 @@ export const mockGroupProjectsResponse = (projects = mockProjects) => ({
},
});
+export const mockGroupIssuesResponse = (
+ listId = 'gid://gitlab/List/1',
+ rawIssues = [rawIssue],
+) => ({
+ data: {
+ group: {
+ id: 'gid://gitlab/Group/1',
+ board: {
+ __typename: 'Board',
+ id: 'gid://gitlab/Board/1',
+ lists: {
+ nodes: [
+ {
+ id: listId,
+ listType: 'backlog',
+ issues: {
+ nodes: rawIssues,
+ pageInfo: {
+ endCursor: null,
+ hasNextPage: true,
+ },
+ },
+ __typename: 'BoardList',
+ },
+ ],
+ },
+ },
+ __typename: 'Group',
+ },
+ },
+});
+
export const DEFAULT_COLOR = '#1068bf';
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 358cb340802..616bb083211 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -165,7 +165,7 @@ describe('setFilters', () => {
issuableType: TYPE_ISSUE,
};
- testAction(
+ return testAction(
actions.setFilters,
filters,
state,
@@ -441,7 +441,7 @@ describe('fetchMilestones', () => {
describe('createList', () => {
it('should dispatch createIssueList action', () => {
- testAction({
+ return testAction({
action: actions.createList,
payload: { backlog: true },
expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }],
@@ -560,7 +560,7 @@ describe('addList', () => {
};
it('should commit RECEIVE_ADD_LIST_SUCCESS mutation and dispatch fetchItemsForList action', () => {
- testAction({
+ return testAction({
action: actions.addList,
payload: mockLists[1],
state: { ...getters },
@@ -1007,7 +1007,7 @@ describe('moveItem', () => {
it('should dispatch moveIssue action with payload', () => {
const payload = { mock: 'payload' };
- testAction({
+ return testAction({
action: actions.moveItem,
payload,
expectedActions: [{ type: 'moveIssue', payload }],
@@ -1017,7 +1017,7 @@ describe('moveItem', () => {
describe('moveIssue', () => {
it('should dispatch a correct set of actions', () => {
- testAction({
+ return testAction({
action: actions.moveIssue,
payload: mockMoveIssueParams,
state: mockMoveState,
@@ -1092,7 +1092,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => {
});
it('moveIssueCard commits a correct set of actions', () => {
- testAction({
+ return testAction({
action: actions.moveIssueCard,
state,
payload: getMoveData(state, params),
@@ -1101,7 +1101,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => {
});
it('undoMoveIssueCard commits a correct set of actions', () => {
- testAction({
+ return testAction({
action: actions.undoMoveIssueCard,
state,
payload: getMoveData(state, params),
@@ -1169,7 +1169,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => {
});
it('moveIssueCard commits a correct set of actions', () => {
- testAction({
+ return testAction({
action: actions.moveIssueCard,
state,
payload: getMoveData(state, params),
@@ -1178,7 +1178,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => {
});
it('undoMoveIssueCard commits a correct set of actions', () => {
- testAction({
+ return testAction({
action: actions.undoMoveIssueCard,
state,
payload: getMoveData(state, params),
@@ -1244,7 +1244,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => {
});
it('moveIssueCard commits a correct set of actions', () => {
- testAction({
+ return testAction({
action: actions.moveIssueCard,
state,
payload: getMoveData(state, params),
@@ -1253,7 +1253,7 @@ describe('moveIssueCard and undoMoveIssueCard', () => {
});
it('undoMoveIssueCard commits a correct set of actions', () => {
- testAction({
+ return testAction({
action: actions.undoMoveIssueCard,
state,
payload: getMoveData(state, params),
@@ -1298,7 +1298,7 @@ describe('updateMovedIssueCard', () => {
])(
'should commit UPDATE_BOARD_ITEM with a correctly updated issue data when %s',
(_, { state, moveData, updatedIssue }) => {
- testAction({
+ return testAction({
action: actions.updateMovedIssue,
payload: moveData,
state,
@@ -1363,7 +1363,7 @@ describe('updateIssueOrder', () => {
},
});
- testAction(
+ return testAction(
actions.updateIssueOrder,
{ moveData },
state,
@@ -1395,7 +1395,7 @@ describe('updateIssueOrder', () => {
},
});
- testAction(
+ return testAction(
actions.updateIssueOrder,
{ moveData },
state,
@@ -1448,7 +1448,7 @@ describe('addListItem', () => {
inProgress: true,
};
- testAction(
+ return testAction(
actions.addListItem,
payload,
{},
@@ -1475,7 +1475,7 @@ describe('addListItem', () => {
position: 0,
};
- testAction(
+ return testAction(
actions.addListItem,
payload,
{},
@@ -1503,7 +1503,7 @@ describe('removeListItem', () => {
itemId: mockIssue.id,
};
- testAction(actions.removeListItem, payload, {}, [
+ return testAction(actions.removeListItem, payload, {}, [
{ type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload },
{ type: types.REMOVE_BOARD_ITEM, payload: mockIssue.id },
]);
@@ -1608,7 +1608,7 @@ describe('addListNewIssue', () => {
},
});
- testAction({
+ return testAction({
action: actions.addListNewIssue,
payload: {
issueInput: mockIssue,
@@ -1651,7 +1651,7 @@ describe('addListNewIssue', () => {
},
});
- testAction({
+ return testAction({
action: actions.addListNewIssue,
payload: {
issueInput: mockIssue,
@@ -1700,7 +1700,7 @@ describe('setActiveIssueLabels', () => {
value: labels,
};
- testAction(
+ return testAction(
actions.setActiveIssueLabels,
input,
{ ...state, ...getters },
@@ -1721,7 +1721,7 @@ describe('setActiveIssueLabels', () => {
value: [labels[1]],
};
- testAction(
+ return testAction(
actions.setActiveIssueLabels,
{ ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] },
{ ...state, ...getters },
@@ -1962,7 +1962,7 @@ describe('toggleBoardItemMultiSelection', () => {
const boardItem2 = mockIssue2;
it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => {
- testAction(
+ return testAction(
actions.toggleBoardItemMultiSelection,
boardItem,
{ selectedBoardItems: [] },
@@ -1977,7 +1977,7 @@ describe('toggleBoardItemMultiSelection', () => {
});
it('should commit mutation REMOVE_BOARD_ITEM_FROM_SELECTION if item is on selection state', () => {
- testAction(
+ return testAction(
actions.toggleBoardItemMultiSelection,
boardItem,
{ selectedBoardItems: [mockIssue] },
@@ -1992,7 +1992,7 @@ describe('toggleBoardItemMultiSelection', () => {
});
it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => {
- testAction(
+ return testAction(
actions.toggleBoardItemMultiSelection,
boardItem2,
{ activeId: mockActiveIssue.id, activeBoardItem: mockActiveIssue, selectedBoardItems: [] },
@@ -2013,7 +2013,7 @@ describe('toggleBoardItemMultiSelection', () => {
describe('resetBoardItemMultiSelection', () => {
it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => {
- testAction({
+ return testAction({
action: actions.resetBoardItemMultiSelection,
state: { selectedBoardItems: [mockIssue] },
expectedMutations: [
@@ -2027,7 +2027,7 @@ describe('resetBoardItemMultiSelection', () => {
describe('toggleBoardItem', () => {
it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => {
- testAction({
+ return testAction({
action: actions.toggleBoardItem,
payload: { boardItem: mockIssue },
state: {
@@ -2038,7 +2038,7 @@ describe('toggleBoardItem', () => {
});
it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => {
- testAction({
+ return testAction({
action: actions.toggleBoardItem,
payload: { boardItem: mockIssue },
state: {
@@ -2054,7 +2054,7 @@ describe('toggleBoardItem', () => {
describe('setError', () => {
it('should commit mutation SET_ERROR', () => {
- testAction({
+ return testAction({
action: actions.setError,
payload: { message: 'mayday' },
expectedMutations: [
@@ -2085,7 +2085,7 @@ describe('setError', () => {
describe('unsetError', () => {
it('should commit mutation SET_ERROR with undefined as payload', () => {
- testAction({
+ return testAction({
action: actions.unsetError,
expectedMutations: [
{
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
deleted file mode 100644
index 2d68c070b83..00000000000
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ /dev/null
@@ -1,672 +0,0 @@
-import { cloneDeep } from 'lodash';
-import * as types from '~/boards/stores/mutation_types';
-import mutations from '~/boards/stores/mutations';
-import defaultState from '~/boards/stores/state';
-import { TYPE_ISSUE } from '~/issues/constants';
-import {
- mockBoard,
- mockLists,
- rawIssue,
- mockIssue,
- mockIssue2,
- mockGroupProjects,
- labels,
- mockList,
-} from '../mock_data';
-
-describe('Board Store Mutations', () => {
- let state;
-
- const initialBoardListsState = {
- 'gid://gitlab/List/1': mockLists[0],
- 'gid://gitlab/List/2': mockLists[1],
- };
-
- const setBoardsListsState = () => {
- state = cloneDeep({
- ...state,
- boardItemsByListId: { 'gid://gitlab/List/1': [mockIssue.id] },
- boardLists: { 'gid://gitlab/List/1': mockList },
- });
- };
-
- beforeEach(() => {
- state = defaultState();
- });
-
- describe('REQUEST_CURRENT_BOARD', () => {
- it('Should set isBoardLoading state to true', () => {
- mutations[types.REQUEST_CURRENT_BOARD](state);
-
- expect(state.isBoardLoading).toBe(true);
- });
- });
-
- describe('RECEIVE_BOARD_SUCCESS', () => {
- it('Should set board to state', () => {
- mutations[types.RECEIVE_BOARD_SUCCESS](state, mockBoard);
-
- expect(state.board).toEqual({
- ...mockBoard,
- labels: mockBoard.labels.nodes,
- });
- });
- });
-
- describe('RECEIVE_BOARD_FAILURE', () => {
- it('Should set error in state', () => {
- mutations[types.RECEIVE_BOARD_FAILURE](state);
-
- expect(state.error).toEqual(
- 'An error occurred while fetching the board. Please reload the page.',
- );
- });
- });
-
- describe('SET_INITIAL_BOARD_DATA', () => {
- it('Should set initial Boards data to state', () => {
- const allowSubEpics = true;
- const boardId = 1;
- const fullPath = 'gitlab-org';
- const boardType = 'group';
- const disabled = false;
- const issuableType = TYPE_ISSUE;
-
- mutations[types.SET_INITIAL_BOARD_DATA](state, {
- allowSubEpics,
- boardId,
- fullPath,
- boardType,
- disabled,
- issuableType,
- });
-
- expect(state.allowSubEpics).toBe(allowSubEpics);
- expect(state.boardId).toEqual(boardId);
- expect(state.fullPath).toEqual(fullPath);
- expect(state.boardType).toEqual(boardType);
- expect(state.disabled).toEqual(disabled);
- expect(state.issuableType).toEqual(issuableType);
- });
- });
-
- describe('SET_BOARD_CONFIG', () => {
- it('Should set board config data o state', () => {
- const boardConfig = {
- milestoneId: 1,
- milestoneTitle: 'Milestone 1',
- };
-
- mutations[types.SET_BOARD_CONFIG](state, boardConfig);
-
- expect(state.boardConfig).toEqual(boardConfig);
- });
- });
-
- describe('RECEIVE_BOARD_LISTS_SUCCESS', () => {
- it('Should set boardLists to state', () => {
- mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, initialBoardListsState);
-
- expect(state.boardLists).toEqual(initialBoardListsState);
- });
- });
-
- describe('RECEIVE_BOARD_LISTS_FAILURE', () => {
- it('Should set error in state', () => {
- mutations[types.RECEIVE_BOARD_LISTS_FAILURE](state);
-
- expect(state.error).toEqual(
- 'An error occurred while fetching the board lists. Please reload the page.',
- );
- });
- });
-
- describe('SET_ACTIVE_ID', () => {
- const expected = { id: 1, sidebarType: '' };
-
- beforeEach(() => {
- mutations.SET_ACTIVE_ID(state, expected);
- });
-
- it('updates activeListId to be the value that is passed', () => {
- expect(state.activeId).toBe(expected.id);
- });
-
- it('updates sidebarType to be the value that is passed', () => {
- expect(state.sidebarType).toBe(expected.sidebarType);
- });
- });
-
- describe('SET_FILTERS', () => {
- it('updates filterParams to be the value that is passed', () => {
- const filterParams = { labelName: 'label' };
-
- mutations.SET_FILTERS(state, filterParams);
-
- expect(state.filterParams).toBe(filterParams);
- });
- });
-
- describe('CREATE_LIST_FAILURE', () => {
- it('sets error message', () => {
- mutations.CREATE_LIST_FAILURE(state);
-
- expect(state.error).toEqual('An error occurred while creating the list. Please try again.');
- });
- });
-
- describe('RECEIVE_LABELS_REQUEST', () => {
- it('sets labelsLoading on state', () => {
- mutations.RECEIVE_LABELS_REQUEST(state);
-
- expect(state.labelsLoading).toEqual(true);
- });
- });
-
- describe('RECEIVE_LABELS_SUCCESS', () => {
- it('sets labels on state', () => {
- mutations.RECEIVE_LABELS_SUCCESS(state, labels);
-
- expect(state.labels).toEqual(labels);
- expect(state.labelsLoading).toEqual(false);
- });
- });
-
- describe('RECEIVE_LABELS_FAILURE', () => {
- it('sets error message', () => {
- mutations.RECEIVE_LABELS_FAILURE(state);
-
- expect(state.error).toEqual(
- 'An error occurred while fetching labels. Please reload the page.',
- );
- expect(state.labelsLoading).toEqual(false);
- });
- });
-
- describe('GENERATE_DEFAULT_LISTS_FAILURE', () => {
- it('sets error message', () => {
- mutations.GENERATE_DEFAULT_LISTS_FAILURE(state);
-
- expect(state.error).toEqual(
- 'An error occurred while generating lists. Please reload the page.',
- );
- });
- });
-
- describe('RECEIVE_ADD_LIST_SUCCESS', () => {
- it('adds list to boardLists state', () => {
- mutations.RECEIVE_ADD_LIST_SUCCESS(state, mockLists[0]);
-
- expect(state.boardLists).toEqual({
- [mockLists[0].id]: mockLists[0],
- });
- });
- });
-
- describe('MOVE_LISTS', () => {
- it('updates the positions of board lists', () => {
- state = {
- ...state,
- boardLists: initialBoardListsState,
- };
-
- mutations.MOVE_LISTS(state, [
- {
- listId: mockLists[0].id,
- position: 1,
- },
- {
- listId: mockLists[1].id,
- position: 0,
- },
- ]);
-
- expect(state.boardLists[mockLists[0].id].position).toBe(1);
- expect(state.boardLists[mockLists[1].id].position).toBe(0);
- });
- });
-
- describe('TOGGLE_LIST_COLLAPSED', () => {
- it('updates collapsed attribute of list in boardLists state', () => {
- const listId = 'gid://gitlab/List/1';
- state = {
- ...state,
- boardLists: {
- [listId]: mockLists[0],
- },
- };
-
- expect(state.boardLists[listId].collapsed).toEqual(false);
-
- mutations.TOGGLE_LIST_COLLAPSED(state, { listId, collapsed: true });
-
- expect(state.boardLists[listId].collapsed).toEqual(true);
- });
- });
-
- describe('REMOVE_LIST', () => {
- it('removes list from boardLists', () => {
- const [list, secondList] = mockLists;
- const expected = {
- [secondList.id]: secondList,
- };
- state = {
- ...state,
- boardLists: { ...initialBoardListsState },
- };
-
- mutations[types.REMOVE_LIST](state, list.id);
-
- expect(state.boardLists).toEqual(expected);
- });
- });
-
- describe('REMOVE_LIST_FAILURE', () => {
- it('restores lists from backup', () => {
- const backupLists = { ...initialBoardListsState };
-
- mutations[types.REMOVE_LIST_FAILURE](state, backupLists);
-
- expect(state.boardLists).toEqual(backupLists);
- });
-
- it('sets error state', () => {
- const backupLists = { ...initialBoardListsState };
- state = {
- ...state,
- error: undefined,
- };
-
- mutations[types.REMOVE_LIST_FAILURE](state, backupLists);
-
- expect(state.error).toEqual('An error occurred while removing the list. Please try again.');
- });
- });
-
- describe('RESET_ISSUES', () => {
- it('should remove issues from boardItemsByListId state', () => {
- const boardItemsByListId = {
- 'gid://gitlab/List/1': [mockIssue.id],
- };
-
- state = {
- ...state,
- boardItemsByListId,
- };
-
- mutations[types.RESET_ISSUES](state);
-
- expect(state.boardItemsByListId).toEqual({ 'gid://gitlab/List/1': [] });
- });
- });
-
- describe('REQUEST_ITEMS_FOR_LIST', () => {
- const listId = 'gid://gitlab/List/1';
- const boardItemsByListId = {
- [listId]: [mockIssue.id],
- };
-
- it.each`
- fetchNext | isLoading | isLoadingMore
- ${true} | ${undefined} | ${true}
- ${false} | ${true} | ${undefined}
- `(
- 'sets isLoading to $isLoading and isLoadingMore to $isLoadingMore when fetchNext is $fetchNext',
- ({ fetchNext, isLoading, isLoadingMore }) => {
- state = {
- ...state,
- boardItemsByListId,
- listsFlags: {
- [listId]: {},
- },
- };
-
- mutations[types.REQUEST_ITEMS_FOR_LIST](state, { listId, fetchNext });
-
- expect(state.listsFlags[listId].isLoading).toBe(isLoading);
- expect(state.listsFlags[listId].isLoadingMore).toBe(isLoadingMore);
- },
- );
- });
-
- describe('RECEIVE_ITEMS_FOR_LIST_SUCCESS', () => {
- it('updates boardItemsByListId and issues on state', () => {
- const listIssues = {
- 'gid://gitlab/List/1': [mockIssue.id],
- };
- const issues = {
- 1: mockIssue,
- };
-
- state = {
- ...state,
- boardItemsByListId: {
- 'gid://gitlab/List/1': [],
- },
- boardItems: {},
- boardLists: initialBoardListsState,
- };
-
- const listPageInfo = {
- 'gid://gitlab/List/1': {
- endCursor: '',
- hasNextPage: false,
- },
- };
-
- mutations.RECEIVE_ITEMS_FOR_LIST_SUCCESS(state, {
- listItems: { listData: listIssues, boardItems: issues },
- listPageInfo,
- listId: 'gid://gitlab/List/1',
- });
-
- expect(state.boardItemsByListId).toEqual(listIssues);
- expect(state.boardItems).toEqual(issues);
- });
- });
-
- describe('RECEIVE_ITEMS_FOR_LIST_FAILURE', () => {
- it('sets error message', () => {
- state = {
- ...state,
- boardLists: initialBoardListsState,
- error: undefined,
- };
-
- const listId = 'gid://gitlab/List/1';
-
- mutations.RECEIVE_ITEMS_FOR_LIST_FAILURE(state, listId);
-
- expect(state.error).toEqual(
- 'An error occurred while fetching the board issues. Please reload the page.',
- );
- });
- });
-
- describe('UPDATE_BOARD_ITEM_BY_ID', () => {
- const issueId = '1';
- const prop = 'id';
- const value = '2';
- const issue = { [issueId]: { id: 1, title: 'Issue' } };
-
- beforeEach(() => {
- state = {
- ...state,
- error: undefined,
- boardItems: {
- ...issue,
- },
- };
- });
-
- describe('when the issue is in state', () => {
- it('updates the property of the correct issue', () => {
- mutations.UPDATE_BOARD_ITEM_BY_ID(state, {
- itemId: issueId,
- prop,
- value,
- });
-
- expect(state.boardItems[issueId]).toEqual({ ...issue[issueId], id: '2' });
- });
- });
-
- describe('when the issue is not in state', () => {
- it('throws an error', () => {
- expect(() => {
- mutations.UPDATE_BOARD_ITEM_BY_ID(state, {
- itemId: '3',
- prop,
- value,
- });
- }).toThrow(new Error('No issue found.'));
- });
- });
- });
-
- describe('MUTATE_ISSUE_SUCCESS', () => {
- it('updates issue in issues state', () => {
- const issues = {
- [rawIssue.id]: { id: rawIssue.id },
- };
-
- state = {
- ...state,
- boardItems: issues,
- };
-
- mutations.MUTATE_ISSUE_SUCCESS(state, {
- issue: rawIssue,
- });
-
- expect(state.boardItems).toEqual({ [mockIssue.id]: mockIssue });
- });
- });
-
- describe('UPDATE_BOARD_ITEM', () => {
- it('updates the given issue in state.boardItems', () => {
- const updatedIssue = { id: 'some_gid', foo: 'bar' };
- state = { boardItems: { some_gid: { id: 'some_gid' } } };
-
- mutations.UPDATE_BOARD_ITEM(state, updatedIssue);
-
- expect(state.boardItems.some_gid).toEqual(updatedIssue);
- });
- });
-
- describe('REMOVE_BOARD_ITEM', () => {
- it('removes the given issue from state.boardItems', () => {
- state = { boardItems: { some_gid: {}, some_gid2: {} } };
-
- mutations.REMOVE_BOARD_ITEM(state, 'some_gid');
-
- expect(state.boardItems).toEqual({ some_gid2: {} });
- });
- });
-
- describe('ADD_BOARD_ITEM_TO_LIST', () => {
- beforeEach(() => {
- setBoardsListsState();
- });
-
- it.each([
- [
- 'at position 0 by default',
- {
- payload: {
- itemId: mockIssue2.id,
- listId: mockList.id,
- },
- listState: [mockIssue2.id, mockIssue.id],
- },
- ],
- [
- 'at a given position',
- {
- payload: {
- itemId: mockIssue2.id,
- listId: mockList.id,
- atIndex: 1,
- },
- listState: [mockIssue.id, mockIssue2.id],
- },
- ],
- [
- "below the issue with id of 'moveBeforeId'",
- {
- payload: {
- itemId: mockIssue2.id,
- listId: mockList.id,
- moveBeforeId: mockIssue.id,
- },
- listState: [mockIssue.id, mockIssue2.id],
- },
- ],
- [
- "above the issue with id of 'moveAfterId'",
- {
- payload: {
- itemId: mockIssue2.id,
- listId: mockList.id,
- moveAfterId: mockIssue.id,
- },
- listState: [mockIssue2.id, mockIssue.id],
- },
- ],
- [
- 'to the top of the list',
- {
- payload: {
- itemId: mockIssue2.id,
- listId: mockList.id,
- positionInList: 0,
- atIndex: 1,
- },
- listState: [mockIssue2.id, mockIssue.id],
- },
- ],
- [
- 'to the bottom of the list when the list is fully loaded',
- {
- payload: {
- itemId: mockIssue2.id,
- listId: mockList.id,
- positionInList: -1,
- atIndex: 0,
- allItemsLoadedInList: true,
- },
- listState: [mockIssue.id, mockIssue2.id],
- },
- ],
- ])(`inserts an item into a list %s`, (_, { payload, listState }) => {
- mutations.ADD_BOARD_ITEM_TO_LIST(state, payload);
-
- expect(state.boardItemsByListId[payload.listId]).toEqual(listState);
- });
- });
-
- describe('REMOVE_BOARD_ITEM_FROM_LIST', () => {
- beforeEach(() => {
- setBoardsListsState();
- });
-
- it('removes an item from a list', () => {
- expect(state.boardItemsByListId['gid://gitlab/List/1']).toContain(mockIssue.id);
-
- mutations.REMOVE_BOARD_ITEM_FROM_LIST(state, {
- itemId: mockIssue.id,
- listId: mockList.id,
- });
-
- expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue.id);
- });
- });
-
- describe('SET_ASSIGNEE_LOADING', () => {
- it('sets isSettingAssignees to the value passed', () => {
- mutations.SET_ASSIGNEE_LOADING(state, true);
-
- expect(state.isSettingAssignees).toBe(true);
- });
- });
-
- describe('REQUEST_GROUP_PROJECTS', () => {
- it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is false', () => {
- mutations[types.REQUEST_GROUP_PROJECTS](state, false);
-
- expect(state.groupProjectsFlags.isLoading).toBe(true);
- });
-
- it('Should set isLoadingMore in groupProjectsFlags to true in state when fetchNext is true', () => {
- mutations[types.REQUEST_GROUP_PROJECTS](state, true);
-
- expect(state.groupProjectsFlags.isLoadingMore).toBe(true);
- });
- });
-
- describe('RECEIVE_GROUP_PROJECTS_SUCCESS', () => {
- it('Should set groupProjects and pageInfo to state and isLoading in groupProjectsFlags to false', () => {
- mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, {
- projects: mockGroupProjects,
- pageInfo: { hasNextPage: false },
- });
-
- expect(state.groupProjects).toEqual(mockGroupProjects);
- expect(state.groupProjectsFlags.isLoading).toBe(false);
- expect(state.groupProjectsFlags.pageInfo).toEqual({ hasNextPage: false });
- });
-
- it('Should merge projects in groupProjects in state when fetchNext is true', () => {
- state = {
- ...state,
- groupProjects: [mockGroupProjects[0]],
- };
-
- mutations[types.RECEIVE_GROUP_PROJECTS_SUCCESS](state, {
- projects: [mockGroupProjects[1]],
- fetchNext: true,
- });
-
- expect(state.groupProjects).toEqual(mockGroupProjects);
- });
- });
-
- describe('RECEIVE_GROUP_PROJECTS_FAILURE', () => {
- it('Should set error in state and isLoading in groupProjectsFlags to false', () => {
- mutations[types.RECEIVE_GROUP_PROJECTS_FAILURE](state);
-
- expect(state.error).toEqual(
- 'An error occurred while fetching group projects. Please try again.',
- );
- expect(state.groupProjectsFlags.isLoading).toBe(false);
- });
- });
-
- describe('SET_SELECTED_PROJECT', () => {
- it('Should set selectedProject to state', () => {
- mutations[types.SET_SELECTED_PROJECT](state, mockGroupProjects[0]);
-
- expect(state.selectedProject).toEqual(mockGroupProjects[0]);
- });
- });
-
- describe('ADD_BOARD_ITEM_TO_SELECTION', () => {
- it('Should add boardItem to selectedBoardItems state', () => {
- expect(state.selectedBoardItems).toEqual([]);
-
- mutations[types.ADD_BOARD_ITEM_TO_SELECTION](state, mockIssue);
-
- expect(state.selectedBoardItems).toEqual([mockIssue]);
- });
- });
-
- describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => {
- it('Should remove boardItem to selectedBoardItems state', () => {
- state.selectedBoardItems = [mockIssue];
-
- mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue);
-
- expect(state.selectedBoardItems).toEqual([]);
- });
- });
-
- describe('RESET_BOARD_ITEM_SELECTION', () => {
- it('Should reset selectedBoardItems state', () => {
- state.selectedBoardItems = [mockIssue];
-
- mutations[types.RESET_BOARD_ITEM_SELECTION](state, mockIssue);
-
- expect(state.selectedBoardItems).toEqual([]);
- });
- });
-
- describe('SET_ERROR', () => {
- it('Should set error state', () => {
- state.error = undefined;
-
- mutations[types.SET_ERROR](state, 'mayday');
-
- expect(state.error).toBe('mayday');
- });
- });
-});
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 ba77d90f4e2..36f27d1781e 100644
--- a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js
+++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js
@@ -2,7 +2,7 @@ import { GlLoadingIcon, GlTable, GlLink, GlPagination, GlModal, GlFormCheckbox }
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import waitForPromises from 'helpers/wait_for_promises';
import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue';
import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue';
@@ -22,11 +22,12 @@ import {
I18N_FETCH_ERROR,
INITIAL_CURRENT_PAGE,
I18N_BULK_DELETE_ERROR,
- SELECTED_ARTIFACTS_MAX_COUNT,
} from '~/ci/artifacts/constants';
import { totalArtifactsSizeForJob } from '~/ci/artifacts/utils';
import { createAlert } from '~/alert';
+const jobArtifactsCountLimit = 100;
+
jest.mock('~/alert');
Vue.use(VueApollo);
@@ -127,10 +128,10 @@ describe('JobArtifactsTable component', () => {
.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 maxSelectedArtifacts = new Array(jobArtifactsCountLimit).fill('artifact-id');
const maxSelectedArtifactsIncludingCurrentPage = [
...allArtifacts,
- ...new Array(SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length).fill('artifact-id'),
+ ...new Array(jobArtifactsCountLimit - allArtifacts.length).fill('artifact-id'),
];
const createComponent = ({
@@ -151,6 +152,7 @@ describe('JobArtifactsTable component', () => {
projectPath: 'project/path',
projectId,
canDestroyArtifacts,
+ jobArtifactsCountLimit,
},
mocks: {
$toast: {
@@ -665,7 +667,7 @@ describe('JobArtifactsTable component', () => {
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;
+ const selectedArtifactsLength = jobArtifactsCountLimit - 1;
beforeEach(async () => {
createComponent({
@@ -687,9 +689,7 @@ describe('JobArtifactsTable component', () => {
await nextTick();
expect(findSelectAllCheckboxChecked()).toBe(true);
- expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(
- SELECTED_ARTIFACTS_MAX_COUNT,
- );
+ expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(jobArtifactsCountLimit);
expect(findBulkDelete().props('selectedArtifacts')).not.toContain(
allArtifacts[allArtifacts.length - 1],
);
@@ -748,7 +748,7 @@ describe('JobArtifactsTable component', () => {
it('deselects all artifacts when toggled', async () => {
expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(
- SELECTED_ARTIFACTS_MAX_COUNT,
+ jobArtifactsCountLimit,
);
toggleSelectAllCheckbox();
@@ -757,7 +757,7 @@ describe('JobArtifactsTable component', () => {
expect(findSelectAllCheckboxChecked()).toBe(false);
expect(findBulkDelete().props('selectedArtifacts')).toHaveLength(
- SELECTED_ARTIFACTS_MAX_COUNT - allArtifacts.length,
+ jobArtifactsCountLimit - allArtifacts.length,
);
});
});
diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js
index 382f8e46203..330163e9f39 100644
--- a/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js
+++ b/spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js
@@ -2,7 +2,6 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { resolvers } from '~/ci/catalog/graphql/settings';
import CiResourceComponents from '~/ci/catalog/components/details/ci_resource_components.vue';
import getCiCatalogcomponentComponents from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_components.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -17,15 +16,15 @@ describe('CiResourceComponents', () => {
let wrapper;
let mockComponentsResponse;
- const components = mockComponents.data.ciCatalogResource.components.nodes;
+ const components = mockComponents.data.ciCatalogResource.latestVersion.components.nodes;
- const resourceId = 'gid://gitlab/Ci::Catalog::Resource/1';
+ const resourcePath = 'twitter/project-1';
- const defaultProps = { resourceId };
+ const defaultProps = { resourcePath };
const createComponent = async () => {
const handlers = [[getCiCatalogcomponentComponents, mockComponentsResponse]];
- const mockApollo = createMockApollo(handlers, resolvers);
+ const mockApollo = createMockApollo(handlers);
wrapper = mountExtended(CiResourceComponents, {
propsData: {
@@ -113,10 +112,9 @@ describe('CiResourceComponents', () => {
expect(findComponents()).toHaveLength(components.length);
});
- it('renders the component name, description and snippet', () => {
+ it('renders the component name and snippet', () => {
components.forEach((component) => {
expect(wrapper.text()).toContain(component.name);
- expect(wrapper.text()).toContain(component.description);
expect(wrapper.text()).toContain(component.path);
});
});
@@ -134,9 +132,9 @@ describe('CiResourceComponents', () => {
it('renders the component parameter attributes', () => {
const [firstComponent] = components;
- firstComponent.inputs.nodes.forEach((input) => {
+ firstComponent.inputs.forEach((input) => {
expect(findComponents().at(0).text()).toContain(input.name);
- expect(findComponents().at(0).text()).toContain(input.defaultValue);
+ expect(findComponents().at(0).text()).toContain(input.default);
expect(findComponents().at(0).text()).toContain('Yes');
});
});
diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js
index 1f7dcf9d4e5..e4b6c1cd046 100644
--- a/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js
+++ b/spec/frontend/ci/catalog/components/details/ci_resource_details_spec.js
@@ -8,7 +8,7 @@ describe('CiResourceDetails', () => {
let wrapper;
const defaultProps = {
- resourceId: 'gid://gitlab/Ci::Catalog::Resource/1',
+ resourcePath: 'twitter/project-1',
};
const defaultProvide = {
glFeatures: { ciCatalogComponentsTab: true },
diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js
index c061332ba13..6af9daabea0 100644
--- a/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js
+++ b/spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js
@@ -3,7 +3,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CiResourceHeader from '~/ci/catalog/components/details/ci_resource_header.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import CiResourceAbout from '~/ci/catalog/components/details/ci_resource_about.vue';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock';
describe('CiResourceHeader', () => {
@@ -45,9 +45,9 @@ describe('CiResourceHeader', () => {
expect(wrapper.html()).toContain(resource.description);
});
- it('renders the namespace and project path', () => {
- expect(wrapper.html()).toContain(resource.rootNamespace.fullPath);
- expect(wrapper.html()).toContain(resource.rootNamespace.name);
+ it('renders the project path and name', () => {
+ expect(wrapper.html()).toContain(resource.webPath);
+ expect(wrapper.html()).toContain(resource.name);
});
it('renders the avatar', () => {
diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js
index 0dadac236a8..ad76b47db57 100644
--- a/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js
+++ b/spec/frontend/ci/catalog/components/details/ci_resource_readme_spec.js
@@ -23,12 +23,13 @@ describe('CiResourceReadme', () => {
data: {
ciCatalogResource: {
id: resourceId,
+ webPath: 'twitter/project-1',
readmeHtml,
},
},
};
- const defaultProps = { resourceId };
+ const defaultProps = { resourcePath: readmeMockData.data.ciCatalogResource.webPath };
const createComponent = ({ props = {} } = {}) => {
const handlers = [[getCiCatalogResourceReadme, mockReadmeResponse]];
diff --git a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js
index 2a5c24d0515..e9d2e68c1a3 100644
--- a/spec/frontend/ci/catalog/components/list/catalog_header_spec.js
+++ b/spec/frontend/ci/catalog/components/list/catalog_header_spec.js
@@ -1,6 +1,7 @@
import { GlBanner, GlButton } from '@gitlab/ui';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import BetaBadge from '~/vue_shared/components/badges/beta_badge.vue';
import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue';
import { CATALOG_FEEDBACK_DISMISSED_KEY } from '~/ci/catalog/constants';
@@ -16,9 +17,10 @@ describe('CatalogHeader', () => {
};
const findBanner = () => wrapper.findComponent(GlBanner);
+ const findBetaBadge = () => wrapper.findComponent(BetaBadge);
const findFeedbackButton = () => findBanner().findComponent(GlButton);
const findTitle = () => wrapper.find('h1');
- const findDescription = () => wrapper.findByTestId('description');
+ const findDescription = () => wrapper.findByTestId('page-description');
const createComponent = ({ props = {}, provide = {}, stubs = {} } = {}) => {
wrapper = shallowMountExtended(CatalogHeader, {
@@ -33,6 +35,16 @@ describe('CatalogHeader', () => {
});
};
+ describe('Default view', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders a Beta Badge', () => {
+ expect(findBetaBadge().exists()).toBe(true);
+ });
+ });
+
describe('title and description', () => {
describe('when there are no values provided', () => {
beforeEach(() => {
@@ -42,10 +54,11 @@ describe('CatalogHeader', () => {
it('renders the default values', () => {
expect(findTitle().text()).toBe('CI/CD Catalog');
expect(findDescription().text()).toBe(
- 'Discover CI configuration resources for a seamless CI/CD experience.',
+ 'Discover CI/CD components that can improve your pipeline with additional functionality.',
);
});
});
+
describe('when custom values are provided', () => {
beforeEach(() => {
createComponent({ provide: customProvide });
@@ -57,6 +70,7 @@ describe('CatalogHeader', () => {
});
});
});
+
describe('Feedback banner', () => {
describe('when user has never dismissed', () => {
beforeEach(() => {
diff --git a/spec/frontend/ci/catalog/components/list/catalog_search_spec.js b/spec/frontend/ci/catalog/components/list/catalog_search_spec.js
new file mode 100644
index 00000000000..c6f8498f2fd
--- /dev/null
+++ b/spec/frontend/ci/catalog/components/list/catalog_search_spec.js
@@ -0,0 +1,103 @@
+import { GlSearchBoxByClick, GlSorting, GlSortingItem } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CatalogSearch from '~/ci/catalog/components/list/catalog_search.vue';
+import { SORT_ASC, SORT_DESC, SORT_OPTION_CREATED } from '~/ci/catalog/constants';
+
+describe('CatalogSearch', () => {
+ let wrapper;
+
+ const findSearchBar = () => wrapper.findComponent(GlSearchBoxByClick);
+ const findSorting = () => wrapper.findComponent(GlSorting);
+ const findAllSortingItems = () => wrapper.findAllComponents(GlSortingItem);
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(CatalogSearch, {});
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('default UI', () => {
+ it('renders the search bar', () => {
+ expect(findSearchBar().exists()).toBe(true);
+ });
+
+ it('renders the sorting options', () => {
+ expect(findSorting().exists()).toBe(true);
+ expect(findAllSortingItems()).toHaveLength(1);
+ });
+
+ it('renders the `Created at` option as the default', () => {
+ expect(findAllSortingItems().at(0).text()).toBe('Created at');
+ });
+ });
+
+ describe('search', () => {
+ it('passes down the search value to the search component', async () => {
+ const newSearchTerm = 'cat';
+
+ expect(findSearchBar().props().value).toBe('');
+
+ await findSearchBar().vm.$emit('input', newSearchTerm);
+
+ expect(findSearchBar().props().value).toBe(newSearchTerm);
+ });
+
+ it('does not submit only when typing', async () => {
+ expect(wrapper.emitted('update-search-term')).toBeUndefined();
+
+ await findSearchBar().vm.$emit('input', 'new');
+
+ expect(wrapper.emitted('update-search-term')).toBeUndefined();
+ });
+
+ describe('when submitting the search', () => {
+ const newSearchTerm = 'dog';
+
+ beforeEach(async () => {
+ await findSearchBar().vm.$emit('input', newSearchTerm);
+ await findSearchBar().vm.$emit('submit');
+ });
+
+ it('emits the event up with the new payload', () => {
+ expect(wrapper.emitted('update-search-term')).toEqual([[newSearchTerm]]);
+ });
+ });
+
+ describe('when clearing the search', () => {
+ beforeEach(async () => {
+ await findSearchBar().vm.$emit('input', 'new');
+ await findSearchBar().vm.$emit('clear');
+ });
+
+ it('emits an update event with an empty string payload', () => {
+ expect(wrapper.emitted('update-search-term')).toEqual([['']]);
+ });
+ });
+ });
+
+ describe('sort', () => {
+ describe('when changing sort order', () => {
+ it('changes the `isAscending` prop to the sorting component', async () => {
+ expect(findSorting().props().isAscending).toBe(false);
+
+ await findSorting().vm.$emit('sortDirectionChange');
+
+ expect(findSorting().props().isAscending).toBe(true);
+ });
+
+ it('emits an `update-sorting` event with the new direction', async () => {
+ expect(wrapper.emitted('update-sorting')).toBeUndefined();
+
+ await findSorting().vm.$emit('sortDirectionChange');
+ await findSorting().vm.$emit('sortDirectionChange');
+
+ expect(wrapper.emitted('update-sorting')).toEqual([
+ [`${SORT_OPTION_CREATED}_${SORT_ASC}`],
+ [`${SORT_OPTION_CREATED}_${SORT_DESC}`],
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js
index 3862195d8c7..d74b133f386 100644
--- a/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js
+++ b/spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js
@@ -1,21 +1,22 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
-import { GlAvatar, GlBadge, GlButton, GlSprintf } from '@gitlab/ui';
+import { GlAvatar, GlBadge, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
import { createRouter } from '~/ci/catalog/router/index';
import CiResourcesListItem from '~/ci/catalog/components/list/ci_resources_list_item.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants';
import { catalogSinglePageResponse } from '../../mock';
Vue.use(VueRouter);
-let router;
-let routerPush;
+const defaultEvent = { preventDefault: jest.fn, ctrlKey: false, metaKey: false };
describe('CiResourcesListItem', () => {
let wrapper;
+ let routerPush;
+ const router = createRouter();
const resource = catalogSinglePageResponse.data.ciCatalogResources.nodes[0];
const release = {
author: { name: 'author', webUrl: '/user/1' },
@@ -35,22 +36,19 @@ describe('CiResourcesListItem', () => {
},
stubs: {
GlSprintf,
- RouterLink: true,
- RouterView: true,
},
});
};
const findAvatar = () => wrapper.findComponent(GlAvatar);
const findBadge = () => wrapper.findComponent(GlBadge);
- const findResourceName = () => wrapper.findComponent(GlButton);
+ const findResourceName = () => wrapper.findByTestId('ci-resource-link');
const findResourceDescription = () => wrapper.findByText(defaultProps.resource.description);
const findUserLink = () => wrapper.findByTestId('user-link');
const findTimeAgoMessage = () => wrapper.findComponent(GlSprintf);
const findFavorites = () => wrapper.findByTestId('stats-favorites');
beforeEach(() => {
- router = createRouter();
routerPush = jest.spyOn(router, 'push').mockImplementation(() => {});
});
@@ -70,8 +68,9 @@ describe('CiResourcesListItem', () => {
});
});
- it('renders the resource name button', () => {
+ it('renders the resource name and link', () => {
expect(findResourceName().exists()).toBe(true);
+ expect(findResourceName().attributes().href).toBe(defaultProps.resource.webPath);
});
it('renders the resource version badge', () => {
@@ -81,58 +80,69 @@ describe('CiResourcesListItem', () => {
it('renders the resource description', () => {
expect(findResourceDescription().exists()).toBe(true);
});
+ });
- describe('release time', () => {
- describe('when there is no release data', () => {
- beforeEach(() => {
- createComponent({ props: { resource: { ...resource, latestVersion: null } } });
- });
+ describe('release time', () => {
+ describe('when there is no release data', () => {
+ beforeEach(() => {
+ createComponent({ props: { resource: { ...resource, latestVersion: null } } });
+ });
- it('does not render the release', () => {
- expect(findTimeAgoMessage().exists()).toBe(false);
- });
+ it('does not render the release', () => {
+ expect(findTimeAgoMessage().exists()).toBe(false);
+ });
- it('renders the generic `unreleased` badge', () => {
- expect(findBadge().exists()).toBe(true);
- expect(findBadge().text()).toBe('Unreleased');
- });
+ it('renders the generic `unreleased` badge', () => {
+ expect(findBadge().exists()).toBe(true);
+ expect(findBadge().text()).toBe('Unreleased');
});
+ });
- describe('when there is release data', () => {
- beforeEach(() => {
- createComponent({ props: { resource: { ...resource, latestVersion: { ...release } } } });
- });
+ describe('when there is release data', () => {
+ beforeEach(() => {
+ createComponent({ props: { resource: { ...resource, latestVersion: { ...release } } } });
+ });
- it('renders the user link', () => {
- expect(findUserLink().exists()).toBe(true);
- expect(findUserLink().attributes('href')).toBe(release.author.webUrl);
- });
+ it('renders the user link', () => {
+ expect(findUserLink().exists()).toBe(true);
+ expect(findUserLink().attributes('href')).toBe(release.author.webUrl);
+ });
- it('renders the time since the resource was released', () => {
- expect(findTimeAgoMessage().exists()).toBe(true);
- });
+ it('renders the time since the resource was released', () => {
+ expect(findTimeAgoMessage().exists()).toBe(true);
+ });
- it('renders the version badge', () => {
- expect(findBadge().exists()).toBe(true);
- expect(findBadge().text()).toBe(release.tagName);
- });
+ it('renders the version badge', () => {
+ expect(findBadge().exists()).toBe(true);
+ expect(findBadge().text()).toBe(release.tagName);
});
});
});
describe('when clicking on an item title', () => {
- beforeEach(async () => {
- createComponent();
+ describe('without holding down a modifier key', () => {
+ it('navigates to the details page in the same tab', async () => {
+ createComponent();
+ await findResourceName().vm.$emit('click', defaultEvent);
- await findResourceName().vm.$emit('click');
+ expect(routerPush).toHaveBeenCalledWith({
+ path: cleanLeadingSeparator(resource.webPath),
+ });
+ });
});
- it('navigates to the details page', () => {
- expect(routerPush).toHaveBeenCalledWith({
- name: CI_RESOURCE_DETAILS_PAGE_NAME,
- params: {
- id: getIdFromGraphQLId(resource.id),
- },
+ describe.each`
+ keyName
+ ${'ctrlKey'}
+ ${'metaKey'}
+ `('when $keyName is being held down', ({ keyName }) => {
+ beforeEach(async () => {
+ createComponent();
+ await findResourceName().vm.$emit('click', { ...defaultEvent, [keyName]: true });
+ });
+
+ it('does not call VueRouter push', () => {
+ expect(routerPush).not.toHaveBeenCalled();
});
});
});
@@ -141,43 +151,35 @@ describe('CiResourcesListItem', () => {
beforeEach(async () => {
createComponent();
- await findAvatar().vm.$emit('click');
+ await findAvatar().vm.$emit('click', defaultEvent);
});
it('navigates to the details page', () => {
- expect(routerPush).toHaveBeenCalledWith({
- name: CI_RESOURCE_DETAILS_PAGE_NAME,
- params: {
- id: getIdFromGraphQLId(resource.id),
- },
- });
+ expect(routerPush).toHaveBeenCalledWith({ path: cleanLeadingSeparator(resource.webPath) });
});
});
describe('statistics', () => {
describe('when there are no statistics', () => {
- beforeEach(() => {
+ it('render favorites as 0', () => {
createComponent({
props: {
resource: {
+ ...resource,
starCount: 0,
},
},
});
- });
- it('render favorites as 0', () => {
expect(findFavorites().exists()).toBe(true);
expect(findFavorites().text()).toBe('0');
});
});
describe('where there are statistics', () => {
- beforeEach(() => {
+ it('render favorites', () => {
createComponent();
- });
- it('render favorites', () => {
expect(findFavorites().exists()).toBe(true);
expect(findFavorites().text()).toBe(String(defaultProps.resource.starCount));
});
diff --git a/spec/frontend/ci/catalog/components/list/empty_state_spec.js b/spec/frontend/ci/catalog/components/list/empty_state_spec.js
index f589ad96a9d..5db0c61371d 100644
--- a/spec/frontend/ci/catalog/components/list/empty_state_spec.js
+++ b/spec/frontend/ci/catalog/components/list/empty_state_spec.js
@@ -1,27 +1,83 @@
-import { GlEmptyState } from '@gitlab/ui';
+import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import EmptyState from '~/ci/catalog/components/list/empty_state.vue';
+import { COMPONENTS_DOCS_URL } from '~/ci/catalog/constants';
describe('EmptyState', () => {
let wrapper;
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findComponentsDocLink = () => wrapper.findComponent(GlLink);
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMountExtended(EmptyState, {
propsData: {
...props,
},
+ stubs: {
+ GlEmptyState,
+ GlSprintf,
+ },
});
};
- describe('when mounted', () => {
+ describe('default', () => {
beforeEach(() => {
createComponent();
});
- it('renders the empty state', () => {
- expect(findEmptyState().exists()).toBe(true);
+ it('renders the default empty state', () => {
+ const emptyState = findEmptyState();
+
+ expect(emptyState.exists()).toBe(true);
+ expect(emptyState.props().title).toBe('Get started with the CI/CD Catalog');
+ expect(emptyState.props().description).toBe(
+ 'Create a pipeline component repository and make reusing pipeline configurations faster and easier.',
+ );
+ });
+ });
+
+ describe('when there is a search query', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { searchTerm: 'a' },
+ });
+ });
+
+ it('renders the search description', () => {
+ expect(findEmptyState().text()).toContain(
+ 'Edit your search and try again. Or learn to create a component repository.',
+ );
+ });
+
+ it('renders the link to the components documentation', () => {
+ const docsLink = findComponentsDocLink();
+ expect(docsLink.exists()).toBe(true);
+ expect(docsLink.attributes().href).toBe(COMPONENTS_DOCS_URL);
+ });
+
+ describe('and it is less than 3 characters', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { searchTerm: 'a' },
+ });
+ });
+
+ it('render the too few chars empty state title', () => {
+ expect(findEmptyState().props().title).toBe('Search must be at least 3 characters');
+ });
+ });
+
+ describe('and it has more than 3 characters', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { searchTerm: 'my component' },
+ });
+ });
+
+ it('renders the search empty state title', () => {
+ expect(findEmptyState().props().title).toBe('No result found');
+ });
});
});
});
diff --git a/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js
index 40f243ed891..015c6504fa5 100644
--- a/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js
+++ b/spec/frontend/ci/catalog/components/pages/ci_resource_details_page_spec.js
@@ -5,7 +5,8 @@ import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { CI_CATALOG_RESOURCE_TYPE, cacheConfig } from '~/ci/catalog/graphql/settings';
+import { cacheConfig } from '~/ci/catalog/graphql/settings';
+import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
import getCiCatalogResourceSharedData from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_shared_data.query.graphql';
import getCiCatalogResourceDetails from '~/ci/catalog/graphql/queries/get_ci_catalog_resource_details.query.graphql';
@@ -17,7 +18,6 @@ import CiResourceHeaderSkeletonLoader from '~/ci/catalog/components/details/ci_r
import { createRouter } from '~/ci/catalog/router/index';
import { CI_RESOURCE_DETAILS_PAGE_NAME } from '~/ci/catalog/router/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
import { catalogSharedDataMock, catalogAdditionalDetailsMock } from '../../mock';
Vue.use(VueApollo);
@@ -75,7 +75,7 @@ describe('CiResourceDetailsPage', () => {
router = createRouter();
await router.push({
name: CI_RESOURCE_DETAILS_PAGE_NAME,
- params: { id: defaultSharedData.id },
+ params: { id: defaultSharedData.webPath },
});
});
@@ -178,7 +178,7 @@ describe('CiResourceDetailsPage', () => {
it('passes expected props', () => {
expect(findDetailsComponent().props()).toEqual({
- resourceId: convertToGraphQLId(CI_CATALOG_RESOURCE_TYPE, defaultAdditionalData.id),
+ resourcePath: cleanLeadingSeparator(defaultSharedData.webPath),
});
});
});
diff --git a/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js b/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js
index e18b418b155..e6fbd63f307 100644
--- a/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js
+++ b/spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js
@@ -7,10 +7,12 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { createAlert } from '~/alert';
import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue';
+import CatalogSearch from '~/ci/catalog/components/list/catalog_search.vue';
import CiResourcesList from '~/ci/catalog/components/list/ci_resources_list.vue';
import CatalogListSkeletonLoader from '~/ci/catalog/components/list/catalog_list_skeleton_loader.vue';
import EmptyState from '~/ci/catalog/components/list/empty_state.vue';
-import { cacheConfig } from '~/ci/catalog/graphql/settings';
+import { cacheConfig, resolvers } from '~/ci/catalog/graphql/settings';
+import typeDefs from '~/ci/catalog/graphql/typedefs.graphql';
import ciResourcesPage from '~/ci/catalog/components/pages/ci_resources_page.vue';
import getCatalogResources from '~/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql';
@@ -24,9 +26,11 @@ describe('CiResourcesPage', () => {
let wrapper;
let catalogResourcesResponse;
+ const defaultQueryVariables = { first: 20 };
+
const createComponent = () => {
const handlers = [[getCatalogResources, catalogResourcesResponse]];
- const mockApollo = createMockApollo(handlers, {}, cacheConfig);
+ const mockApollo = createMockApollo(handlers, resolvers, { cacheConfig, typeDefs });
wrapper = shallowMountExtended(ciResourcesPage, {
apolloProvider: mockApollo,
@@ -36,6 +40,7 @@ describe('CiResourcesPage', () => {
};
const findCatalogHeader = () => wrapper.findComponent(CatalogHeader);
+ const findCatalogSearch = () => wrapper.findComponent(CatalogSearch);
const findCiResourcesList = () => wrapper.findComponent(CiResourcesList);
const findLoadingState = () => wrapper.findComponent(CatalogListSkeletonLoader);
const findEmptyState = () => wrapper.findComponent(EmptyState);
@@ -71,8 +76,14 @@ describe('CiResourcesPage', () => {
});
it('renders the empty state', () => {
- expect(findLoadingState().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it('renders the search', () => {
+ expect(findCatalogSearch().exists()).toBe(true);
+ });
+
+ it('does not render the list', () => {
expect(findCiResourcesList().exists()).toBe(false);
});
});
@@ -99,6 +110,10 @@ describe('CiResourcesPage', () => {
totalCount: count,
});
});
+
+ it('renders the search and sort', () => {
+ expect(findCatalogSearch().exists()).toBe(true);
+ });
});
});
@@ -121,11 +136,12 @@ describe('CiResourcesPage', () => {
if (eventName === 'onNextPage') {
expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({
+ ...defaultQueryVariables,
after: pageInfo.endCursor,
- first: 20,
});
} else {
expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({
+ ...defaultQueryVariables,
before: pageInfo.startCursor,
last: 20,
first: null,
@@ -134,8 +150,75 @@ describe('CiResourcesPage', () => {
});
});
+ describe('search and sort', () => {
+ describe('on initial load', () => {
+ beforeEach(async () => {
+ catalogResourcesResponse.mockResolvedValue(catalogResponseBody);
+ await createComponent();
+ });
+
+ it('calls the query without search or sort', () => {
+ expect(catalogResourcesResponse).toHaveBeenCalledTimes(1);
+ expect(catalogResourcesResponse.mock.calls[0][0]).toEqual({
+ ...defaultQueryVariables,
+ });
+ });
+ });
+
+ describe('when sorting changes', () => {
+ const newSort = 'MOST_AWESOME_ASC';
+
+ beforeEach(async () => {
+ catalogResourcesResponse.mockResolvedValue(catalogResponseBody);
+ await createComponent();
+ await findCatalogSearch().vm.$emit('update-sorting', newSort);
+ });
+
+ it('passes it to the graphql query', () => {
+ expect(catalogResourcesResponse).toHaveBeenCalledTimes(2);
+ expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({
+ ...defaultQueryVariables,
+ sortValue: newSort,
+ });
+ });
+ });
+
+ describe('when search component emits a new search term', () => {
+ const newSearch = 'sloths';
+
+ describe('and there are no results', () => {
+ beforeEach(async () => {
+ catalogResourcesResponse.mockResolvedValue(emptyCatalogResponseBody);
+ await createComponent();
+ await findCatalogSearch().vm.$emit('update-search-term', newSearch);
+ });
+
+ it('renders the empty state and passes down the search query', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEmptyState().props().searchTerm).toBe(newSearch);
+ });
+ });
+
+ describe('and there are results', () => {
+ beforeEach(async () => {
+ catalogResourcesResponse.mockResolvedValue(catalogResponseBody);
+ await createComponent();
+ await findCatalogSearch().vm.$emit('update-search-term', newSearch);
+ });
+
+ it('passes it to the graphql query', () => {
+ expect(catalogResourcesResponse).toHaveBeenCalledTimes(2);
+ expect(catalogResourcesResponse.mock.calls[1][0]).toEqual({
+ ...defaultQueryVariables,
+ searchTerm: newSearch,
+ });
+ });
+ });
+ });
+ });
+
describe('pages count', () => {
- describe('when the fetchMore call suceeds', () => {
+ describe('when the fetchMore call succeeds', () => {
beforeEach(async () => {
catalogResourcesResponse.mockResolvedValue(catalogResponseBody);
@@ -157,6 +240,31 @@ describe('CiResourcesPage', () => {
});
});
+ describe.each`
+ event | payload
+ ${'update-search-term'} | ${'cat'}
+ ${'update-sorting'} | ${'CREATED_ASC'}
+ `('when $event event is emitted', ({ event, payload }) => {
+ beforeEach(async () => {
+ catalogResourcesResponse.mockResolvedValue(catalogResponseBody);
+ await createComponent();
+ });
+
+ it('resets the page count', async () => {
+ expect(findCiResourcesList().props().currentPage).toBe(1);
+
+ findCiResourcesList().vm.$emit('onNextPage');
+ await waitForPromises();
+
+ expect(findCiResourcesList().props().currentPage).toBe(2);
+
+ await findCatalogSearch().vm.$emit(event, payload);
+ await waitForPromises();
+
+ expect(findCiResourcesList().props().currentPage).toBe(1);
+ });
+ });
+
describe('when the fetchMore call fails', () => {
const errorMessage = 'there was an error';
diff --git a/spec/frontend/ci/catalog/mock.js b/spec/frontend/ci/catalog/mock.js
index 125f003224c..e370ac5054f 100644
--- a/spec/frontend/ci/catalog/mock.js
+++ b/spec/frontend/ci/catalog/mock.js
@@ -1,5 +1,3 @@
-import { componentsMockData } from '~/ci/catalog/constants';
-
export const emptyCatalogResponseBody = {
data: {
ciCatalogResources: {
@@ -39,12 +37,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-42',
__typename: 'CiCatalogResource',
},
@@ -55,12 +47,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-41',
__typename: 'CiCatalogResource',
},
@@ -71,12 +57,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-40',
__typename: 'CiCatalogResource',
},
@@ -87,12 +67,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-39',
__typename: 'CiCatalogResource',
},
@@ -103,12 +77,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-38',
__typename: 'CiCatalogResource',
},
@@ -119,12 +87,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-37',
__typename: 'CiCatalogResource',
},
@@ -135,12 +97,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-36',
__typename: 'CiCatalogResource',
},
@@ -151,12 +107,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-35',
__typename: 'CiCatalogResource',
},
@@ -167,12 +117,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-34',
__typename: 'CiCatalogResource',
},
@@ -183,12 +127,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-33',
__typename: 'CiCatalogResource',
},
@@ -199,12 +137,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-32',
__typename: 'CiCatalogResource',
},
@@ -215,12 +147,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-31',
__typename: 'CiCatalogResource',
},
@@ -231,12 +157,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-30',
__typename: 'CiCatalogResource',
},
@@ -247,12 +167,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-29',
__typename: 'CiCatalogResource',
},
@@ -263,12 +177,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-28',
__typename: 'CiCatalogResource',
},
@@ -279,12 +187,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-27',
__typename: 'CiCatalogResource',
},
@@ -295,12 +197,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-26',
__typename: 'CiCatalogResource',
},
@@ -311,12 +207,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-25',
__typename: 'CiCatalogResource',
},
@@ -327,12 +217,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-24',
__typename: 'CiCatalogResource',
},
@@ -343,12 +227,6 @@ export const catalogResponseBody = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-23',
__typename: 'CiCatalogResource',
},
@@ -379,12 +257,6 @@ export const catalogSinglePageResponse = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-45',
__typename: 'CiCatalogResource',
},
@@ -395,12 +267,6 @@ export const catalogSinglePageResponse = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-44',
__typename: 'CiCatalogResource',
},
@@ -411,12 +277,6 @@ export const catalogSinglePageResponse = {
description: 'A simple component',
starCount: 0,
latestVersion: null,
- rootNamespace: {
- id: 'gid://gitlab/Group/185',
- fullPath: 'frontend-fixtures',
- name: 'frontend-fixtures',
- __typename: 'Namespace',
- },
webPath: '/frontend-fixtures/project-43',
__typename: 'CiCatalogResource',
},
@@ -434,7 +294,6 @@ export const catalogSharedDataMock = {
icon: null,
description: 'This is the description of the repo',
name: 'Ruby',
- rootNamespace: { id: 1, fullPath: '/group/project', name: 'my-dumb-project' },
starCount: 1,
latestVersion: {
__typename: 'Release',
@@ -444,7 +303,7 @@ export const catalogSharedDataMock = {
releasedAt: Date.now(),
author: { id: 1, webUrl: 'profile/1', name: 'username' },
},
- webPath: 'path/to/project',
+ webPath: '/path/to/project',
},
},
};
@@ -454,6 +313,7 @@ export const catalogAdditionalDetailsMock = {
ciCatalogResource: {
__typename: 'CiCatalogResource',
id: `gid://gitlab/CiCatalogResource/1`,
+ webPath: '/twitter/project',
openIssuesCount: 4,
openMergeRequestsCount: 10,
readmeHtml: '<h1>Hello world</h1>',
@@ -502,12 +362,6 @@ const generateResourcesNodes = (count = 20, startId = 0) => {
description: `This is a component that does a bunch of stuff and is really just a number: ${i}`,
icon: 'my-icon',
name: `My component #${i}`,
- rootNamespace: {
- id: 1,
- __typename: 'Namespace',
- name: 'namespaceName',
- path: 'namespacePath',
- },
starCount: 10,
latestVersion: {
__typename: 'Release',
@@ -526,13 +380,47 @@ const generateResourcesNodes = (count = 20, startId = 0) => {
export const mockCatalogResourceItem = generateResourcesNodes(1)[0];
+const componentsMockData = {
+ __typename: 'CiComponentConnection',
+ nodes: [
+ {
+ id: 'gid://gitlab/Ci::Component/1',
+ name: 'Ruby gal',
+ description: 'This is a pretty amazing component that does EVERYTHING ruby.',
+ path: 'gitlab.com/gitlab-org/ruby-gal@~latest',
+ inputs: [{ name: 'version', default: '1.0.0', required: true }],
+ },
+ {
+ id: 'gid://gitlab/Ci::Component/2',
+ name: 'Javascript madness',
+ description: 'Adds some spice to your life.',
+ path: 'gitlab.com/gitlab-org/javascript-madness@~latest',
+ inputs: [
+ { name: 'isFun', default: 'true', required: true },
+ { name: 'RandomNumber', default: '10', required: false },
+ ],
+ },
+ {
+ id: 'gid://gitlab/Ci::Component/3',
+ name: 'Go go go',
+ description: 'When you write Go, you gotta go go go.',
+ path: 'gitlab.com/gitlab-org/go-go-go@~latest',
+ inputs: [{ name: 'version', default: '1.0.0', required: true }],
+ },
+ ],
+};
+
export const mockComponents = {
data: {
ciCatalogResource: {
__typename: 'CiCatalogResource',
id: `gid://gitlab/CiCatalogResource/1`,
- components: {
- ...componentsMockData,
+ webPath: '/twitter/project-1',
+ latestVersion: {
+ id: 'gid://gitlab/Version/1',
+ components: {
+ ...componentsMockData,
+ },
},
},
},
@@ -543,7 +431,11 @@ export const mockComponentsEmpty = {
ciCatalogResource: {
__typename: 'CiCatalogResource',
id: `gid://gitlab/CiCatalogResource/1`,
- components: [],
+ webPath: '/twitter/project-1',
+ latestVersion: {
+ id: 'gid://gitlab/Version/1',
+ components: [],
+ },
},
},
};
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
index 610aae3946f..721e2b831fc 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
@@ -1,6 +1,16 @@
import { nextTick } from 'vue';
-import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect, GlModal } from '@gitlab/ui';
+import {
+ GlDrawer,
+ GlFormCombobox,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
+ GlLink,
+ GlModal,
+ GlSprintf,
+} from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { helpPagePath } from '~/helpers/help_page_helper';
import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
import { awsTokenList } from '~/ci/ci_variable_list/components/ci_variable_autocomplete_tokens';
@@ -20,6 +30,8 @@ describe('CI Variable Drawer', () => {
let wrapper;
let trackingSpy;
+ const itif = (condition) => (condition ? it : it.skip);
+
const mockProjectVariable = mockVariablesWithScopes(projectString)[0];
const mockProjectVariableFileType = mockVariablesWithScopes(projectString)[1];
const mockEnvScope = 'staging';
@@ -74,6 +86,7 @@ describe('CI Variable Drawer', () => {
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown);
const findExpandedCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox');
+ const findFlagsDocsLink = () => wrapper.findByTestId('ci-variable-flags-docs-link');
const findKeyField = () => wrapper.findComponent(GlFormCombobox);
const findMaskedCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox');
const findProtectedCheckbox = () => wrapper.findByTestId('ci-variable-protected-checkbox');
@@ -81,6 +94,26 @@ describe('CI Variable Drawer', () => {
const findValueLabel = () => wrapper.findByTestId('ci-variable-value-label');
const findTitle = () => findDrawer().find('h2');
const findTypeDropdown = () => wrapper.findComponent(GlFormSelect);
+ const findVariablesPrecedenceDocsLink = () =>
+ wrapper.findByTestId('ci-variable-precedence-docs-link');
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent({ stubs: { GlFormGroup, GlLink, GlSprintf } });
+ });
+
+ it('renders docs link for variables precendece', () => {
+ expect(findVariablesPrecedenceDocsLink().attributes('href')).toBe(
+ helpPagePath('ci/variables/index', { anchor: 'cicd-variable-precedence' }),
+ );
+ });
+
+ it('renders docs link for flags', () => {
+ expect(findFlagsDocsLink().attributes('href')).toBe(
+ helpPagePath('ci/variables/index', { anchor: 'define-a-cicd-variable-in-the-ui' }),
+ );
+ });
+ });
describe('validations', () => {
describe('type dropdown', () => {
@@ -263,12 +296,22 @@ describe('CI Variable Drawer', () => {
expect(findKeyField().props('tokenList')).toBe(awsTokenList);
});
- it('cannot submit with empty key', async () => {
- expect(findConfirmBtn().attributes('disabled')).toBeDefined();
-
- await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
-
- expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
+ const keyFeedbackMessage = "A variable key can only contain letters, numbers, and '_'.";
+ describe.each`
+ key | feedbackMessage | submitButtonDisabledState
+ ${'validKey123'} | ${''} | ${undefined}
+ ${'VALID_KEY'} | ${''} | ${undefined}
+ ${''} | ${''} | ${'true'}
+ ${'invalid!!key'} | ${keyFeedbackMessage} | ${'true'}
+ ${'key with whitespace'} | ${keyFeedbackMessage} | ${'true'}
+ ${'multiline\nkey'} | ${keyFeedbackMessage} | ${'true'}
+ `('key validation', ({ key, feedbackMessage, submitButtonDisabledState }) => {
+ it(`validates key ${key} correctly`, async () => {
+ await findKeyField().vm.$emit('input', key);
+
+ expect(findConfirmBtn().attributes('disabled')).toBe(submitButtonDisabledState);
+ expect(wrapper.text()).toContain(feedbackMessage);
+ });
});
});
@@ -284,52 +327,106 @@ describe('CI Variable Drawer', () => {
expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
});
- describe.each`
- value | canSubmit | trackingErrorProperty
- ${'secretValue'} | ${true} | ${null}
- ${'~v@lid:symbols.'} | ${true} | ${null}
- ${'short'} | ${false} | ${null}
- ${'multiline\nvalue'} | ${false} | ${'\n'}
- ${'dollar$ign'} | ${false} | ${'$'}
- ${'unsupported|char'} | ${false} | ${'|'}
- `('masking requirements', ({ value, canSubmit, trackingErrorProperty }) => {
- beforeEach(async () => {
- createComponent();
-
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
- await findValueField().vm.$emit('input', value);
- await findMaskedCheckbox().vm.$emit('input', true);
- });
+ const invalidValues = {
+ short: 'short',
+ multiLine: 'multiline\nvalue',
+ unsupportedChar: 'unsupported|char',
+ twoUnsupportedChars: 'unsupported|chars!',
+ threeUnsupportedChars: '%unsupported|chars!',
+ shortAndMultiLine: 'sho\nrt',
+ shortAndUnsupportedChar: 'short!',
+ shortAndMultiLineAndUnsupportedChar: 'short\n!',
+ multiLineAndUnsupportedChar: 'multiline\nvalue!',
+ };
+ const maskedValidationIssuesText = {
+ short: 'The value must have at least 8 characters.',
+ multiLine:
+ 'This value cannot be masked because it contains the following characters: whitespace characters.',
+ unsupportedChar:
+ 'This value cannot be masked because it contains the following characters: |.',
+ unsupportedDollarChar:
+ 'This value cannot be masked because it contains the following characters: $.',
+ twoUnsupportedChars:
+ 'This value cannot be masked because it contains the following characters: |, !.',
+ threeUnsupportedChars:
+ 'This value cannot be masked because it contains the following characters: %, |, !.',
+ shortAndMultiLine:
+ 'This value cannot be masked because it contains the following characters: whitespace characters. The value must have at least 8 characters.',
+ shortAndUnsupportedChar:
+ 'This value cannot be masked because it contains the following characters: !. The value must have at least 8 characters.',
+ shortAndMultiLineAndUnsupportedChar:
+ 'This value cannot be masked because it contains the following characters: ! and whitespace characters. The value must have at least 8 characters.',
+ multiLineAndUnsupportedChar:
+ 'This value cannot be masked because it contains the following characters: ! and whitespace characters.',
+ };
- it(`${
- canSubmit ? 'can submit' : 'shows validation errors and disables submit button'
- } when value is '${value}'`, () => {
- if (canSubmit) {
+ describe.each`
+ value | canSubmit | trackingErrorProperty | validationIssueKey
+ ${'secretValue'} | ${true} | ${null} | ${''}
+ ${'~v@lid:symbols.'} | ${true} | ${null} | ${''}
+ ${invalidValues.short} | ${false} | ${null} | ${'short'}
+ ${invalidValues.multiLine} | ${false} | ${'\n'} | ${'multiLine'}
+ ${'dollar$ign'} | ${false} | ${'$'} | ${'unsupportedDollarChar'}
+ ${invalidValues.unsupportedChar} | ${false} | ${'|'} | ${'unsupportedChar'}
+ ${invalidValues.twoUnsupportedChars} | ${false} | ${'|!'} | ${'twoUnsupportedChars'}
+ ${invalidValues.threeUnsupportedChars} | ${false} | ${'%|!'} | ${'threeUnsupportedChars'}
+ ${invalidValues.shortAndMultiLine} | ${false} | ${'\n'} | ${'shortAndMultiLine'}
+ ${invalidValues.shortAndUnsupportedChar} | ${false} | ${'!'} | ${'shortAndUnsupportedChar'}
+ ${invalidValues.shortAndMultiLineAndUnsupportedChar} | ${false} | ${'\n!'} | ${'shortAndMultiLineAndUnsupportedChar'}
+ ${invalidValues.multiLineAndUnsupportedChar} | ${false} | ${'\n!'} | ${'multiLineAndUnsupportedChar'}
+ `(
+ 'masking requirements',
+ ({ value, canSubmit, trackingErrorProperty, validationIssueKey }) => {
+ beforeEach(() => {
+ createComponent();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ findKeyField().vm.$emit('input', 'NEW_VARIABLE');
+ findValueField().vm.$emit('input', value);
+ findMaskedCheckbox().vm.$emit('input', true);
+ });
+
+ itif(canSubmit)(`can submit when value is ${value}`, () => {
+ /* eslint-disable jest/no-standalone-expect */
expect(findValueLabel().attributes('invalid-feedback')).toBe('');
expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
- } else {
- expect(findValueLabel().attributes('invalid-feedback')).toBe(
- 'This variable value does not meet the masking requirements.',
- );
- expect(findConfirmBtn().attributes('disabled')).toBeDefined();
- }
- });
+ /* eslint-enable jest/no-standalone-expect */
+ });
+
+ itif(!canSubmit)(
+ `shows validation errors and disables submit button when value is ${value}`,
+ () => {
+ const validationIssueText = maskedValidationIssuesText[validationIssueKey] || '';
+
+ /* eslint-disable jest/no-standalone-expect */
+ expect(findValueLabel().attributes('invalid-feedback')).toBe(validationIssueText);
+ expect(findConfirmBtn().attributes('disabled')).toBeDefined();
+ /* eslint-enable jest/no-standalone-expect */
+ },
+ );
+
+ itif(trackingErrorProperty)(
+ `sends the correct variable validation tracking event when value is ${value}`,
+ () => {
+ /* eslint-disable jest/no-standalone-expect */
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, {
+ label: DRAWER_EVENT_LABEL,
+ property: trackingErrorProperty,
+ });
+ /* eslint-enable jest/no-standalone-expect */
+ },
+ );
- it(`${
- trackingErrorProperty ? 'sends the correct' : 'does not send the'
- } variable validation tracking event when value is '${value}'`, () => {
- const trackingEventSent = trackingErrorProperty ? 1 : 0;
- expect(trackingSpy).toHaveBeenCalledTimes(trackingEventSent);
-
- if (trackingErrorProperty) {
- expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, {
- label: DRAWER_EVENT_LABEL,
- property: trackingErrorProperty,
- });
- }
- });
- });
+ itif(!trackingErrorProperty)(
+ `does not send the the correct variable validation tracking event when value is ${value}`,
+ () => {
+ // eslint-disable-next-line jest/no-standalone-expect
+ expect(trackingSpy).toHaveBeenCalledTimes(0);
+ },
+ );
+ },
+ );
it('only sends the tracking event once', async () => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
diff --git a/spec/frontend/ci/common/pipelines_table_spec.js b/spec/frontend/ci/common/pipelines_table_spec.js
index f6d3121109f..ca07e0ab8c8 100644
--- a/spec/frontend/ci/common/pipelines_table_spec.js
+++ b/spec/frontend/ci/common/pipelines_table_spec.js
@@ -16,7 +16,7 @@ import {
TRACKING_CATEGORIES,
} from '~/ci/constants';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
describe('Pipelines Table', () => {
let wrapper;
diff --git a/spec/frontend/ci/job_details/components/job_header_spec.js b/spec/frontend/ci/job_details/components/job_header_spec.js
index d12267807ac..0b98d5fa935 100644
--- a/spec/frontend/ci/job_details/components/job_header_spec.js
+++ b/spec/frontend/ci/job_details/components/job_header_spec.js
@@ -1,7 +1,7 @@
import { GlButton, GlAvatarLink, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import JobHeader from '~/ci/job_details/components/job_header.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
diff --git a/spec/frontend/ci/job_details/components/job_log_controllers_spec.js b/spec/frontend/ci/job_details/components/job_log_controllers_spec.js
index 84c664aca34..078ad4aee34 100644
--- a/spec/frontend/ci/job_details/components/job_log_controllers_spec.js
+++ b/spec/frontend/ci/job_details/components/job_log_controllers_spec.js
@@ -30,17 +30,12 @@ describe('Job log controllers', () => {
jobLog: mockJobLog,
};
- const createWrapper = (props, { jobLogJumpToFailures = false } = {}) => {
+ const createWrapper = (props) => {
wrapper = mount(JobLogControllers, {
propsData: {
...defaultProps,
...props,
},
- provide: {
- glFeatures: {
- jobLogJumpToFailures,
- },
- },
data() {
return {
searchTerm: '82',
@@ -62,6 +57,10 @@ describe('Job log controllers', () => {
const findJobLogSearch = () => wrapper.findComponent(GlSearchBoxByClick);
const findSearchHelp = () => wrapper.findComponent(HelpPopover);
const findScrollFailure = () => wrapper.find('[data-testid="job-controller-scroll-to-failure"]');
+ const findShowFullScreenButton = () =>
+ wrapper.find('[data-testid="job-controller-enter-fullscreen"]');
+ const findExitFullScreenButton = () =>
+ wrapper.find('[data-testid="job-controller-exit-fullscreen"]');
describe('Truncate information', () => {
describe('with isJobLogSizeVisible', () => {
@@ -199,14 +198,6 @@ describe('Job log controllers', () => {
});
describe('scroll to failure button', () => {
- describe('with feature flag disabled', () => {
- it('does not display button', () => {
- createWrapper();
-
- expect(findScrollFailure().exists()).toBe(false);
- });
- });
-
describe('with red text failures on the page', () => {
let firstFailure;
let secondFailure;
@@ -214,7 +205,7 @@ describe('Job log controllers', () => {
beforeEach(() => {
jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']);
- createWrapper({}, { jobLogJumpToFailures: true });
+ createWrapper();
firstFailure = document.createElement('div');
firstFailure.className = 'term-fg-l-red';
@@ -262,7 +253,7 @@ describe('Job log controllers', () => {
beforeEach(() => {
jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce([]);
- createWrapper({}, { jobLogJumpToFailures: true });
+ createWrapper();
});
it('is disabled', () => {
@@ -274,7 +265,7 @@ describe('Job log controllers', () => {
beforeEach(() => {
jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']);
- createWrapper({ isComplete: false }, { jobLogJumpToFailures: true });
+ createWrapper();
});
it('is enabled', () => {
@@ -286,7 +277,7 @@ describe('Job log controllers', () => {
beforeEach(() => {
jest.spyOn(commonUtils, 'backOff').mockRejectedValueOnce();
- createWrapper({}, { jobLogJumpToFailures: true });
+ createWrapper();
});
it('stays disabled', () => {
@@ -318,4 +309,53 @@ describe('Job log controllers', () => {
expect(wrapper.emitted('searchResults')).toEqual([[[]]]);
});
});
+
+ describe('Fullscreen controls', () => {
+ it('displays a disabled "Show fullscreen" button', () => {
+ createWrapper();
+
+ expect(findShowFullScreenButton().exists()).toBe(true);
+ expect(findShowFullScreenButton().attributes('disabled')).toBe('disabled');
+ });
+
+ it('displays a enabled "Show fullscreen" button', () => {
+ createWrapper({
+ fullScreenModeAvailable: true,
+ });
+
+ expect(findShowFullScreenButton().exists()).toBe(true);
+ expect(findShowFullScreenButton().attributes('disabled')).toBeUndefined();
+ });
+
+ it('emits a enterFullscreen event when the show fullscreen is clicked', async () => {
+ createWrapper({
+ fullScreenModeAvailable: true,
+ });
+
+ await findShowFullScreenButton().trigger('click');
+
+ expect(wrapper.emitted('enterFullscreen')).toHaveLength(1);
+ });
+
+ it('displays a enabled "Exit fullscreen" button', () => {
+ createWrapper({
+ fullScreenModeAvailable: true,
+ fullScreenEnabled: true,
+ });
+
+ expect(findExitFullScreenButton().exists()).toBe(true);
+ expect(findExitFullScreenButton().attributes('disabled')).toBeUndefined();
+ });
+
+ it('emits a exitFullscreen event when the exit fullscreen is clicked', async () => {
+ createWrapper({
+ fullScreenModeAvailable: true,
+ fullScreenEnabled: true,
+ });
+
+ await findExitFullScreenButton().trigger('click');
+
+ expect(wrapper.emitted('exitFullscreen')).toHaveLength(1);
+ });
+ });
});
diff --git a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js
deleted file mode 100644
index 5abf2a5ce53..00000000000
--- a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import CollapsibleSection from '~/ci/job_details/components/log/collapsible_section.vue';
-import LogLine from '~/ci/job_details/components/log/line.vue';
-import LogLineHeader from '~/ci/job_details/components/log/line_header.vue';
-import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data';
-
-describe('Job Log Collapsible Section', () => {
- let wrapper;
-
- const jobLogEndpoint = 'jobs/335';
-
- const findLogLineHeader = () => wrapper.findComponent(LogLineHeader);
- const findLogLineHeaderSvg = () => findLogLineHeader().find('svg');
- const findLogLines = () => wrapper.findAllComponents(LogLine);
-
- const createComponent = (props = {}) => {
- wrapper = mount(CollapsibleSection, {
- propsData: {
- ...props,
- },
- });
- };
-
- describe('with closed section', () => {
- beforeEach(() => {
- createComponent({
- section: collapsibleSectionClosed,
- jobLogEndpoint,
- });
- });
-
- it('renders clickable header line', () => {
- expect(findLogLineHeader().text()).toBe('1 foo');
- expect(findLogLineHeader().attributes('role')).toBe('button');
- });
-
- it('renders an icon with a closed state', () => {
- expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-right-icon');
- });
-
- it('does not render collapsed lines', () => {
- expect(findLogLines()).toHaveLength(0);
- });
- });
-
- describe('with opened section', () => {
- beforeEach(() => {
- createComponent({
- section: collapsibleSectionOpened,
- jobLogEndpoint,
- });
- });
-
- it('renders clickable header line', () => {
- expect(findLogLineHeader().text()).toContain('foo');
- expect(findLogLineHeader().attributes('role')).toBe('button');
- });
-
- it('renders an icon with the open state', () => {
- expect(findLogLineHeaderSvg().attributes('data-testid')).toBe('chevron-lg-down-icon');
- });
-
- it('renders collapsible lines', () => {
- expect(findLogLines().at(0).text()).toContain('this is a collapsible nested section');
- expect(findLogLines()).toHaveLength(collapsibleSectionOpened.lines.length);
- });
- });
-
- it('emits onClickCollapsibleLine on click', async () => {
- createComponent({
- section: collapsibleSectionOpened,
- jobLogEndpoint,
- });
-
- findLogLineHeader().trigger('click');
-
- await nextTick();
- expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1);
- });
-
- describe('with search results', () => {
- it('passes isHighlighted prop correctly', () => {
- const mockSearchResults = [
- {
- content: [{ text: 'foo' }],
- lineNumber: 1,
- offset: 5,
- section: 'prepare-script',
- section_header: true,
- },
- ];
-
- createComponent({
- section: collapsibleSectionOpened,
- jobLogEndpoint,
- searchResults: mockSearchResults,
- });
-
- expect(findLogLineHeader().props('isHighlighted')).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/ci/job_details/components/log/line_header_spec.js b/spec/frontend/ci/job_details/components/log/line_header_spec.js
index c75f5fa30d5..0ac33f5aa5a 100644
--- a/spec/frontend/ci/job_details/components/log/line_header_spec.js
+++ b/spec/frontend/ci/job_details/components/log/line_header_spec.js
@@ -95,12 +95,14 @@ describe('Job Log Header Line', () => {
});
describe('with duration', () => {
- beforeEach(() => {
+ it('renders the duration badge', () => {
createComponent({ ...defaultProps, duration: '00:10' });
+ expect(wrapper.findComponent(DurationBadge).exists()).toBe(true);
});
- it('renders the duration badge', () => {
- expect(wrapper.findComponent(DurationBadge).exists()).toBe(true);
+ it('does not render the duration badge with hidden duration', () => {
+ createComponent({ ...defaultProps, hideDuration: true, duration: '00:10' });
+ expect(wrapper.findComponent(DurationBadge).exists()).toBe(false);
});
});
diff --git a/spec/frontend/ci/job_details/components/log/log_spec.js b/spec/frontend/ci/job_details/components/log/log_spec.js
index 1931d5046dc..de02c7aad6d 100644
--- a/spec/frontend/ci/job_details/components/log/log_spec.js
+++ b/spec/frontend/ci/job_details/components/log/log_spec.js
@@ -6,9 +6,12 @@ import waitForPromises from 'helpers/wait_for_promises';
import { scrollToElement } from '~/lib/utils/common_utils';
import Log from '~/ci/job_details/components/log/log.vue';
import LogLineHeader from '~/ci/job_details/components/log/line_header.vue';
+import LineNumber from '~/ci/job_details/components/log/line_number.vue';
import { logLinesParser } from '~/ci/job_details/store/utils';
import { mockJobLog, mockJobLogLineCount } from './mock_data';
+const mockPagePath = 'project/-/jobs/99';
+
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
scrollToElement: jest.fn(),
@@ -24,7 +27,12 @@ describe('Job Log', () => {
Vue.use(Vuex);
const createComponent = (props) => {
+ store = new Vuex.Store({ actions, state });
+
wrapper = mount(Log, {
+ provide: {
+ pagePath: mockPagePath,
+ },
propsData: {
...props,
},
@@ -36,39 +44,34 @@ describe('Job Log', () => {
toggleCollapsibleLineMock = jest.fn();
actions = {
toggleCollapsibleLine: toggleCollapsibleLineMock,
+ setupFullScreenListeners: jest.fn(),
};
+ const { lines, sections } = logLinesParser(mockJobLog);
+
state = {
- jobLog: logLinesParser(mockJobLog),
- jobLogEndpoint: 'jobs/id',
+ jobLog: lines,
+ jobLogSections: sections,
};
-
- store = new Vuex.Store({
- actions,
- state,
- });
});
- const findCollapsibleLine = () => wrapper.findComponent(LogLineHeader);
- const findAllCollapsibleLines = () => wrapper.findAllComponents(LogLineHeader);
+ const findLineNumbers = () => wrapper.findAllComponents(LineNumber);
+ const findLineHeader = () => wrapper.findComponent(LogLineHeader);
+ const findLineHeaders = () => wrapper.findAllComponents(LogLineHeader);
describe('line numbers', () => {
beforeEach(() => {
createComponent();
});
- it.each([...Array(mockJobLogLineCount).keys()])(
- 'renders a line number for each line %d',
- (index) => {
- const lineNumber = wrapper
- .findAll('.js-log-line')
- .at(index)
- .find(`#L${index + 1}`);
+ it('renders a line number for each line %d with an href', () => {
+ for (let i = 0; i < mockJobLogLineCount; i += 1) {
+ const w = findLineNumbers().at(i);
- expect(lineNumber.text()).toBe(`${index + 1}`);
- expect(lineNumber.attributes('href')).toBe(`${state.jobLogEndpoint}#L${index + 1}`);
- },
- );
+ expect(w.text()).toBe(`${i + 1}`);
+ expect(w.attributes('href')).toBe(`${mockPagePath}#L${i + 1}`);
+ }
+ });
});
describe('collapsible sections', () => {
@@ -77,22 +80,54 @@ describe('Job Log', () => {
});
it('renders a clickable header section', () => {
- expect(findCollapsibleLine().attributes('role')).toBe('button');
+ expect(findLineHeader().attributes('role')).toBe('button');
});
it('renders an icon with the open state', () => {
- expect(findCollapsibleLine().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe(
- true,
- );
+ expect(findLineHeader().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe(true);
});
describe('on click header section', () => {
it('calls toggleCollapsibleLine', () => {
- findCollapsibleLine().trigger('click');
+ findLineHeader().trigger('click');
expect(toggleCollapsibleLineMock).toHaveBeenCalled();
});
});
+
+ describe('duration', () => {
+ it('shows duration', () => {
+ expect(findLineHeader().props('duration')).toBe('00:00');
+ expect(findLineHeader().props('hideDuration')).toBe(false);
+ });
+
+ it('hides duration', () => {
+ state.jobLogSections['resolve-secrets'].hideDuration = true;
+ createComponent();
+
+ expect(findLineHeader().props('duration')).toBe('00:00');
+ expect(findLineHeader().props('hideDuration')).toBe(true);
+ });
+ });
+
+ describe('when a section is collapsed', () => {
+ beforeEach(() => {
+ state.jobLogSections['prepare-executor'].isClosed = true;
+
+ createComponent();
+ });
+
+ it('hides lines in section', () => {
+ expect(findLineNumbers().wrappers.map((w) => w.text())).toEqual([
+ '1',
+ '2',
+ '3',
+ '4',
+ // closed section not shown
+ '7',
+ ]);
+ });
+ });
});
describe('anchor scrolling', () => {
@@ -119,19 +154,19 @@ describe('Job Log', () => {
it('scrolls to line number', async () => {
createComponent();
- state.jobLog = logLinesParser(mockJobLog, [], '#L6');
+ state.jobLog = logLinesParser(mockJobLog, [], '#L6').lines;
await waitForPromises();
expect(scrollToElement).toHaveBeenCalledTimes(1);
- state.jobLog = logLinesParser(mockJobLog, [], '#L7');
+ state.jobLog = logLinesParser(mockJobLog, [], '#L6').lines;
await waitForPromises();
expect(scrollToElement).toHaveBeenCalledTimes(1);
});
it('line number within collapsed section is visible', () => {
- state.jobLog = logLinesParser(mockJobLog, [], '#L6');
+ state.jobLog = logLinesParser(mockJobLog, [], '#L6').lines;
createComponent();
@@ -150,15 +185,14 @@ describe('Job Log', () => {
},
],
section: 'prepare-executor',
- section_header: true,
lineNumber: 3,
},
];
createComponent({ searchResults: mockSearchResults });
- expect(findAllCollapsibleLines().at(0).props('isHighlighted')).toBe(true);
- expect(findAllCollapsibleLines().at(1).props('isHighlighted')).toBe(false);
+ expect(findLineHeaders().at(0).props('isHighlighted')).toBe(true);
+ expect(findLineHeaders().at(1).props('isHighlighted')).toBe(false);
});
});
});
diff --git a/spec/frontend/ci/job_details/components/log/mock_data.js b/spec/frontend/ci/job_details/components/log/mock_data.js
index d9b1354f475..066f783586b 100644
--- a/spec/frontend/ci/job_details/components/log/mock_data.js
+++ b/spec/frontend/ci/job_details/components/log/mock_data.js
@@ -65,141 +65,182 @@ export const mockContentSection = [
},
];
-export const mockJobLog = [...mockJobLines, ...mockEmptySection, ...mockContentSection];
-
-export const mockJobLogLineCount = 6; // `text` entries in mockJobLog
-
-export const originalTrace = [
+export const mockJobLogEnd = [
{
- offset: 1,
- content: [
- {
- text: 'Downloading',
- },
- ],
+ offset: 1008,
+ content: [{ text: 'Job succeeded' }],
},
];
-export const regularIncremental = [
- {
- offset: 2,
- content: [
- {
- text: 'log line',
- },
- ],
- },
+export const mockJobLog = [
+ ...mockJobLines,
+ ...mockEmptySection,
+ ...mockContentSection,
+ ...mockJobLogEnd,
];
-export const regularIncrementalRepeated = [
+export const mockJobLogLineCount = 7; // `text` entries in mockJobLog
+
+export const mockContentSectionClosed = [
{
- offset: 1,
+ offset: 0,
content: [
{
- text: 'log line',
+ text: 'Using Docker executor with image dev.gitlab.org3',
},
],
+ section: 'mock-closed-section',
+ section_header: true,
+ section_options: { collapsed: true },
+ },
+ {
+ offset: 1003,
+ content: [{ text: 'Docker executor with image registry.gitlab.com ...' }],
+ section: 'mock-closed-section',
+ },
+ {
+ offset: 1004,
+ content: [{ text: 'Starting service ...', style: 'term-fg-l-green' }],
+ section: 'mock-closed-section',
+ },
+ {
+ offset: 1005,
+ content: [],
+ section: 'mock-closed-section',
+ section_footer: true,
+ section_duration: '00:09',
},
];
-export const headerTrace = [
+export const mockContentSectionHiddenDuration = [
{
- offset: 1,
+ offset: 0,
+ content: [{ text: 'Line 1' }],
+ section: 'mock-hidden-duration-section',
section_header: true,
- content: [
- {
- text: 'log line',
- },
- ],
- section: 'section',
+ section_options: { hide_duration: 'true' },
+ },
+ {
+ offset: 1001,
+ content: [{ text: 'Line 2' }],
+ section: 'mock-hidden-duration-section',
+ },
+ {
+ offset: 1002,
+ content: [],
+ section: 'mock-hidden-duration-section',
+ section_footer: true,
+ section_duration: '00:09',
},
];
-export const headerTraceIncremental = [
+export const mockContentSubsection = [
{
- offset: 1,
+ offset: 0,
+ content: [{ text: 'Line 1' }],
+ section: 'mock-section',
section_header: true,
- content: [
- {
- text: 'updated log line',
- },
- ],
- section: 'section',
},
-];
-
-export const collapsibleTrace = [
{
- offset: 1,
+ offset: 1002,
+ content: [{ text: 'Line 2 - section content' }],
+ section: 'mock-section',
+ },
+ {
+ offset: 1003,
+ content: [{ text: 'Line 3 - sub section header' }],
+ section: 'sub-section',
section_header: true,
- content: [
- {
- text: 'log line',
- },
- ],
- section: 'section',
},
{
- offset: 2,
- content: [
- {
- text: 'log line',
- },
- ],
- section: 'section',
+ offset: 1004,
+ content: [{ text: 'Line 4 - sub section content' }],
+ section: 'sub-section',
+ },
+ {
+ offset: 1005,
+ content: [{ text: 'Line 5 - sub sub section header with no content' }],
+ section: 'sub-sub-section',
+ section_header: true,
+ },
+ {
+ offset: 1006,
+ content: [],
+ section: 'sub-sub-section',
+ section_footer: true,
+ section_duration: '00:00',
+ },
+
+ {
+ offset: 1007,
+ content: [{ text: 'Line 6 - sub section content 2' }],
+ section: 'sub-section',
+ },
+ {
+ offset: 1008,
+ content: [],
+ section: 'sub-section',
+ section_footer: true,
+ section_duration: '00:29',
+ },
+ {
+ offset: 1009,
+ content: [{ text: 'Line 7 - section content' }],
+ section: 'mock-section',
+ },
+ {
+ offset: 1010,
+ content: [],
+ section: 'mock-section',
+ section_footer: true,
+ section_duration: '00:59',
+ },
+ {
+ offset: 1011,
+ content: [{ text: 'Job succeeded' }],
},
];
-export const collapsibleTraceIncremental = [
+export const mockTruncatedBottomSection = [
+ // only the top of a section is obtained, such as when a job gets cancelled
{
- offset: 2,
+ offset: 1004,
content: [
{
- text: 'updated log line',
+ text: 'Starting job',
},
],
- section: 'section',
+ section: 'mock-section',
+ section_header: true,
+ },
+ {
+ offset: 1005,
+ content: [{ text: 'Job interrupted' }],
+ section: 'mock-section',
},
];
-export const collapsibleSectionClosed = {
- offset: 5,
- section_header: true,
- isHeader: true,
- isClosed: true,
- line: {
- content: [{ text: 'foo' }],
- section: 'prepare-script',
- lineNumber: 1,
- },
- section_duration: '00:03',
- lines: [
- {
- offset: 80,
- content: [{ text: 'this is a collapsible nested section' }],
- section: 'prepare-script',
- lineNumber: 2,
- },
- ],
-};
-
-export const collapsibleSectionOpened = {
- offset: 5,
- section_header: true,
- isHeader: true,
- isClosed: false,
- line: {
- content: [{ text: 'foo' }],
- section: 'prepare-script',
- lineNumber: 1,
- },
- section_duration: '00:03',
- lines: [
- {
- offset: 80,
- content: [{ text: 'this is a collapsible nested section' }],
- section: 'prepare-script',
- lineNumber: 2,
- },
- ],
-};
+export const mockTruncatedTopSection = [
+ // only the bottom half of a section is obtained, such as when jobs are cut off due to large sizes
+ {
+ offset: 1008,
+ content: [{ text: 'Line N - incomplete section content' }],
+ section: 'mock-section',
+ },
+ {
+ offset: 1009,
+ content: [{ text: 'Line N+1 - incomplete section content' }],
+ section: 'mock-section',
+ },
+ {
+ offset: 1010,
+ content: [],
+ section: 'mock-section',
+ section_footer: true,
+ section_duration: '00:59',
+ },
+ {
+ offset: 1011,
+ content: [{ text: 'Job succeeded' }],
+ },
+];
diff --git a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js b/spec/frontend/ci/job_details/components/manual_variables_form_spec.js
index 3391cafb4fc..4961b605ee3 100644
--- a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js
+++ b/spec/frontend/ci/job_details/components/manual_variables_form_spec.js
@@ -1,7 +1,6 @@
import { GlSprintf, GlLink } from '@gitlab/ui';
-import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
import { createAlert } from '~/alert';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -24,9 +23,8 @@ import {
mockJobRetryMutationData,
} from '../mock_data';
-const localVue = createLocalVue();
jest.mock('~/alert');
-localVue.use(VueApollo);
+Vue.use(VueApollo);
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
@@ -62,7 +60,6 @@ describe('Manual Variables Form', () => {
]);
const options = {
- localVue,
apolloProvider: mockApollo,
};
@@ -180,6 +177,9 @@ describe('Manual Variables Form', () => {
beforeEach(async () => {
await createComponent({
handlers: {
+ getJobQueryResponseHandlerWithVariables: jest
+ .fn()
+ .mockResolvedValue(mockJobWithVariablesResponse),
playJobMutationHandler: jest.fn().mockResolvedValue(mockJobPlayMutationData),
},
});
@@ -211,6 +211,15 @@ describe('Manual Variables Form', () => {
expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(mockJobPlayMutationData.data.jobPlay.job.webPath); // eslint-disable-line import/no-deprecated
});
+
+ it('does not refetch variables after job is run', async () => {
+ expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1);
+
+ findRunBtn().vm.$emit('click');
+ await waitForPromises();
+
+ expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1);
+ });
});
describe('when play mutation is unsuccessful', () => {
@@ -237,6 +246,9 @@ describe('Manual Variables Form', () => {
await createComponent({
props: { isRetryable: true },
handlers: {
+ getJobQueryResponseHandlerWithVariables: jest
+ .fn()
+ .mockResolvedValue(mockJobWithVariablesResponse),
retryJobMutationHandler: jest.fn().mockResolvedValue(mockJobRetryMutationData),
},
});
@@ -253,6 +265,15 @@ describe('Manual Variables Form', () => {
expect(requestHandlers.retryJobMutationHandler).toHaveBeenCalledTimes(1);
expect(redirectTo).toHaveBeenCalledWith(mockJobRetryMutationData.data.jobRetry.job.webPath); // eslint-disable-line import/no-deprecated
});
+
+ it('does not refetch variables after job is rerun', async () => {
+ expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1);
+
+ findRunBtn().vm.$emit('click');
+ await waitForPromises();
+
+ expect(requestHandlers.getJobQueryResponseHandlerWithVariables).toHaveBeenCalledTimes(1);
+ });
});
describe('when retry mutation is unsuccessful', () => {
diff --git a/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js b/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js
index 0eabaefd5de..697235dbe54 100644
--- a/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js
+++ b/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import delayedJobFixture from 'test_fixtures/jobs/delayed.json';
import JobContainerItem from '~/ci/job_details/components/sidebar/job_container_item.vue';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import job from 'jest/ci/jobs_mock_data';
describe('JobContainerItem', () => {
diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js
index 37a2ca75df0..3b6cc85472b 100644
--- a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js
+++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js
@@ -3,7 +3,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DetailRow from '~/ci/job_details/components/sidebar/sidebar_detail_row.vue';
import SidebarJobDetailsContainer from '~/ci/job_details/components/sidebar/sidebar_job_details_container.vue';
import createStore from '~/ci/job_details/store';
-import job from 'jest/ci/jobs_mock_data';
+import job, { testSummaryData, testSummaryDataWithFailures } from 'jest/ci/jobs_mock_data';
describe('Job Sidebar Details Container', () => {
let store;
@@ -12,6 +12,7 @@ describe('Job Sidebar Details Container', () => {
const findJobTimeout = () => wrapper.findByTestId('job-timeout');
const findJobTags = () => wrapper.findByTestId('job-tags');
const findAllDetailsRow = () => wrapper.findAllComponents(DetailRow);
+ const findTestSummary = () => wrapper.findByTestId('test-summary');
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
@@ -22,6 +23,9 @@ describe('Job Sidebar Details Container', () => {
stubs: {
DetailRow,
},
+ provide: {
+ pipelineTestReportUrl: '/root/test-unit-test-reports/-/pipelines/512/test_report',
+ },
}),
);
};
@@ -90,6 +94,37 @@ describe('Job Sidebar Details Container', () => {
});
});
+ describe('Test summary details', () => {
+ it('displays the test summary section', async () => {
+ createWrapper();
+
+ await store.dispatch('receiveJobSuccess', job);
+ await store.dispatch('receiveTestSummarySuccess', testSummaryData);
+
+ expect(findTestSummary().exists()).toBe(true);
+ expect(findTestSummary().text()).toContain('Test summary');
+ expect(findTestSummary().text()).toContain('1');
+ });
+
+ it('does not display the test summary section', async () => {
+ createWrapper();
+
+ await store.dispatch('receiveJobSuccess', job);
+
+ expect(findTestSummary().exists()).toBe(false);
+ });
+
+ it('displays the failure count message', async () => {
+ createWrapper();
+
+ await store.dispatch('receiveJobSuccess', job);
+ await store.dispatch('receiveTestSummarySuccess', testSummaryDataWithFailures);
+
+ expect(findTestSummary().text()).toContain('Test summary');
+ expect(findTestSummary().text()).toContain('1 of 2 failed');
+ });
+ });
+
describe('timeout', () => {
const {
metadata: { timeout_human_readable, timeout_source },
diff --git a/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js b/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js
index 54c5a73f757..a629c1c185a 100644
--- a/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js
+++ b/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { Mousetrap } from '~/lib/mousetrap';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import StagesDropdown from '~/ci/job_details/components/sidebar/stages_dropdown.vue';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import * as copyToClipboard from '~/behaviors/copy_to_clipboard';
import {
mockPipelineWithoutRef,
diff --git a/spec/frontend/ci/job_details/job_app_spec.js b/spec/frontend/ci/job_details/job_app_spec.js
index 2bd0429ef56..8601850a403 100644
--- a/spec/frontend/ci/job_details/job_app_spec.js
+++ b/spec/frontend/ci/job_details/job_app_spec.js
@@ -4,7 +4,6 @@ import Vuex from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { TEST_HOST } from 'helpers/test_constants';
import EmptyState from '~/ci/job_details/components/empty_state.vue';
import EnvironmentsBlock from '~/ci/job_details/components/environments_block.vue';
import ErasedBlock from '~/ci/job_details/components/erased_block.vue';
@@ -29,8 +28,9 @@ describe('Job App', () => {
let mock;
const initSettings = {
- endpoint: `${TEST_HOST}jobs/123.json`,
- pagePath: `${TEST_HOST}jobs/123`,
+ jobEndpoint: '/group1/project1/-/jobs/99.json',
+ logEndpoint: '/group1/project1/-/jobs/99/trace',
+ testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json',
};
const props = {
@@ -50,8 +50,8 @@ describe('Job App', () => {
};
const setupAndMount = async ({ jobData = {}, jobLogData = {} } = {}) => {
- mock.onGet(initSettings.endpoint).replyOnce(HTTP_STATUS_OK, { ...job, ...jobData });
- mock.onGet(`${initSettings.pagePath}/trace.json`).reply(HTTP_STATUS_OK, jobLogData);
+ mock.onGet(initSettings.jobEndpoint).replyOnce(HTTP_STATUS_OK, { ...job, ...jobData });
+ mock.onGet(initSettings.logEndpoint).reply(HTTP_STATUS_OK, jobLogData);
const asyncInit = store.dispatch('init', initSettings);
diff --git a/spec/frontend/ci/job_details/store/actions_spec.js b/spec/frontend/ci/job_details/store/actions_spec.js
index 849f55ac444..9c4b241b6eb 100644
--- a/spec/frontend/ci/job_details/store/actions_spec.js
+++ b/spec/frontend/ci/job_details/store/actions_spec.js
@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import {
- setJobLogOptions,
+ init,
clearEtagPoll,
stopPolling,
requestJob,
@@ -15,7 +15,6 @@ import {
fetchJobLog,
startPollingJobLog,
stopPollingJobLog,
- receiveJobLogSuccess,
receiveJobLogError,
toggleCollapsibleLine,
requestJobsForStage,
@@ -25,11 +24,24 @@ import {
hideSidebar,
showSidebar,
toggleSidebar,
+ receiveTestSummarySuccess,
+ requestTestSummary,
+ enterFullscreenSuccess,
+ exitFullscreenSuccess,
+ fullScreenContainerSetUpResult,
} from '~/ci/job_details/store/actions';
+import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
+
import * as types from '~/ci/job_details/store/mutation_types';
import state from '~/ci/job_details/store/state';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { testSummaryData } from 'jest/ci/jobs_mock_data';
+
+jest.mock('~/lib/utils/scroll_utils');
+
+const mockJobEndpoint = '/group1/project1/-/jobs/99.json';
+const mockLogEndpoint = '/group1/project1/-/jobs/99/trace';
describe('Job State actions', () => {
let mockedState;
@@ -38,22 +50,28 @@ describe('Job State actions', () => {
mockedState = state();
});
- describe('setJobLogOptions', () => {
+ describe('init', () => {
it('should commit SET_JOB_LOG_OPTIONS mutation', () => {
return testAction(
- setJobLogOptions,
- { endpoint: '/group1/project1/-/jobs/99.json', pagePath: '/group1/project1/-/jobs/99' },
+ init,
+ {
+ jobEndpoint: mockJobEndpoint,
+ logEndpoint: mockLogEndpoint,
+ testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json',
+ },
mockedState,
[
{
type: types.SET_JOB_LOG_OPTIONS,
payload: {
- endpoint: '/group1/project1/-/jobs/99.json',
- pagePath: '/group1/project1/-/jobs/99',
+ fullScreenAPIAvailable: false,
+ jobEndpoint: mockJobEndpoint,
+ logEndpoint: mockLogEndpoint,
+ testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json',
},
},
],
- [],
+ [{ type: 'fetchJob' }],
);
});
});
@@ -96,7 +114,7 @@ describe('Job State actions', () => {
let mock;
beforeEach(() => {
- mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`;
+ mockedState.jobEndpoint = mockJobEndpoint;
mock = new MockAdapter(axios);
});
@@ -108,9 +126,7 @@ describe('Job State actions', () => {
describe('success', () => {
it('dispatches requestJob and receiveJobSuccess', () => {
- mock
- .onGet(`${TEST_HOST}/endpoint.json`)
- .replyOnce(HTTP_STATUS_OK, { id: 121212, name: 'karma' });
+ mock.onGet(mockJobEndpoint).replyOnce(HTTP_STATUS_OK, { id: 121212, name: 'karma' });
return testAction(
fetchJob,
@@ -200,7 +216,7 @@ describe('Job State actions', () => {
let mock;
beforeEach(() => {
- mockedState.jobLogEndpoint = `${TEST_HOST}/endpoint`;
+ mockedState.logEndpoint = mockLogEndpoint;
mock = new MockAdapter(axios);
});
@@ -211,46 +227,46 @@ describe('Job State actions', () => {
});
describe('success', () => {
- it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', () => {
- mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, {
- html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
- complete: true,
+ let jobLogPayload;
+
+ beforeEach(() => {
+ isScrolledToBottom.mockReturnValue(false);
+ });
+
+ describe('when job is complete', () => {
+ beforeEach(() => {
+ jobLogPayload = {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: true,
+ };
+
+ mock.onGet(mockLogEndpoint).replyOnce(HTTP_STATUS_OK, jobLogPayload);
});
- return testAction(
- fetchJobLog,
- null,
- mockedState,
- [],
- [
- {
- type: 'toggleScrollisInBottom',
- payload: true,
- },
- {
- payload: {
- html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
- complete: true,
+ it('commits RECEIVE_JOB_LOG_SUCCESS, dispatches stopPollingJobLog and requestTestSummary', () => {
+ return testAction(
+ fetchJobLog,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_JOB_LOG_SUCCESS,
+ payload: jobLogPayload,
},
- type: 'receiveJobLogSuccess',
- },
- {
- type: 'stopPollingJobLog',
- },
- ],
- );
+ ],
+ [{ type: 'stopPollingJobLog' }, { type: 'requestTestSummary' }],
+ );
+ });
});
describe('when job is incomplete', () => {
- let jobLogPayload;
-
beforeEach(() => {
jobLogPayload = {
html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
complete: false,
};
- mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, jobLogPayload);
+ mock.onGet(mockLogEndpoint).replyOnce(HTTP_STATUS_OK, jobLogPayload);
});
it('dispatches startPollingJobLog', () => {
@@ -258,12 +274,13 @@ describe('Job State actions', () => {
fetchJobLog,
null,
mockedState,
- [],
[
- { type: 'toggleScrollisInBottom', payload: true },
- { type: 'receiveJobLogSuccess', payload: jobLogPayload },
- { type: 'startPollingJobLog' },
+ {
+ type: types.RECEIVE_JOB_LOG_SUCCESS,
+ payload: jobLogPayload,
+ },
],
+ [{ type: 'startPollingJobLog' }],
);
});
@@ -274,10 +291,44 @@ describe('Job State actions', () => {
fetchJobLog,
null,
mockedState,
+ [
+ {
+ type: types.RECEIVE_JOB_LOG_SUCCESS,
+ payload: jobLogPayload,
+ },
+ ],
[],
+ );
+ });
+ });
+
+ describe('when user scrolled to the bottom', () => {
+ beforeEach(() => {
+ isScrolledToBottom.mockReturnValue(true);
+
+ jobLogPayload = {
+ html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
+ complete: true,
+ };
+
+ mock.onGet(mockLogEndpoint).replyOnce(HTTP_STATUS_OK, jobLogPayload);
+ });
+
+ it('should auto scroll to bottom by dispatching scrollBottom', () => {
+ return testAction(
+ fetchJobLog,
+ null,
+ mockedState,
+ [
+ {
+ type: types.RECEIVE_JOB_LOG_SUCCESS,
+ payload: jobLogPayload,
+ },
+ ],
[
- { type: 'toggleScrollisInBottom', payload: true },
- { type: 'receiveJobLogSuccess', payload: jobLogPayload },
+ { type: 'stopPollingJobLog' },
+ { type: 'requestTestSummary' },
+ { type: 'scrollBottom' },
],
);
});
@@ -286,7 +337,7 @@ describe('Job State actions', () => {
describe('server error', () => {
beforeEach(() => {
- mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ mock.onGet(mockLogEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
});
it('dispatches requestJobLog and receiveJobLogError', () => {
@@ -306,7 +357,7 @@ describe('Job State actions', () => {
describe('unexpected error', () => {
beforeEach(() => {
- mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(() => {
+ mock.onGet(mockLogEndpoint).reply(() => {
throw new Error('an error');
});
});
@@ -389,18 +440,6 @@ describe('Job State actions', () => {
});
});
- describe('receiveJobLogSuccess', () => {
- it('should commit RECEIVE_JOB_LOG_SUCCESS mutation', () => {
- return testAction(
- receiveJobLogSuccess,
- 'hello world',
- mockedState,
- [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }],
- [],
- );
- });
- });
-
describe('receiveJobLogError', () => {
it('should commit stop polling job log', () => {
return testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }]);
@@ -516,4 +555,95 @@ describe('Job State actions', () => {
);
});
});
+
+ describe('requestTestSummarySuccess', () => {
+ it('should commit RECEIVE_TEST_SUMMARY_SUCCESS mutation', () => {
+ return testAction(
+ receiveTestSummarySuccess,
+ { total: {}, test_suites: [] },
+ mockedState,
+ [{ type: types.RECEIVE_TEST_SUMMARY_SUCCESS, payload: { total: {}, test_suites: [] } }],
+ [],
+ );
+ });
+ });
+
+ describe('requestTestSummary', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ stopPolling();
+ clearEtagPoll();
+ });
+
+ describe('success', () => {
+ it('dispatches receiveTestSummarySuccess', () => {
+ mockedState.testReportSummaryUrl = `${TEST_HOST}/test_report_summary.json`;
+
+ mock
+ .onGet(`${TEST_HOST}/test_report_summary.json`)
+ .replyOnce(HTTP_STATUS_OK, testSummaryData);
+
+ return testAction(
+ requestTestSummary,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_TEST_SUMMARY_COMPLETE }],
+ [
+ {
+ payload: testSummaryData,
+ type: 'receiveTestSummarySuccess',
+ },
+ ],
+ );
+ });
+ });
+
+ describe('without testReportSummaryUrl', () => {
+ it('does not dispatch any actions or mutations', () => {
+ return testAction(requestTestSummary, null, mockedState, [], []);
+ });
+ });
+ });
+
+ describe('enterFullscreenSuccess', () => {
+ it('should commit ENTER_FULLSCREEN_SUCCESS mutation', () => {
+ return testAction(
+ enterFullscreenSuccess,
+ {},
+ mockedState,
+ [{ type: types.ENTER_FULLSCREEN_SUCCESS }],
+ [],
+ );
+ });
+ });
+
+ describe('exitFullscreenSuccess', () => {
+ it('should commit EXIT_FULLSCREEN_SUCCESS mutation', () => {
+ return testAction(
+ exitFullscreenSuccess,
+ {},
+ mockedState,
+ [{ type: types.EXIT_FULLSCREEN_SUCCESS }],
+ [],
+ );
+ });
+ });
+
+ describe('fullScreenContainerSetUpResult', () => {
+ it('should commit FULL_SCREEN_CONTAINER_SET_UP mutation', () => {
+ return testAction(
+ fullScreenContainerSetUpResult,
+ {},
+ mockedState,
+ [{ type: types.FULL_SCREEN_CONTAINER_SET_UP, payload: {} }],
+ [],
+ );
+ });
+ });
});
diff --git a/spec/frontend/ci/job_details/store/mutations_spec.js b/spec/frontend/ci/job_details/store/mutations_spec.js
index 601dff47584..d42e4c40107 100644
--- a/spec/frontend/ci/job_details/store/mutations_spec.js
+++ b/spec/frontend/ci/job_details/store/mutations_spec.js
@@ -16,13 +16,15 @@ describe('Jobs Store Mutations', () => {
describe('SET_JOB_LOG_OPTIONS', () => {
it('should set jobEndpoint', () => {
mutations[types.SET_JOB_LOG_OPTIONS](stateCopy, {
- endpoint: '/group1/project1/-/jobs/99.json',
- pagePath: '/group1/project1/-/jobs/99',
+ jobEndpoint: '/group1/project1/-/jobs/99.json',
+ logEndpoint: '/group1/project1/-/jobs/99/trace',
+ testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json',
});
expect(stateCopy).toMatchObject({
- jobLogEndpoint: '/group1/project1/-/jobs/99',
jobEndpoint: '/group1/project1/-/jobs/99.json',
+ logEndpoint: '/group1/project1/-/jobs/99/trace',
+ testReportSummaryUrl: '/group1/project1/-/jobs/99/test_report_summary.json',
});
});
});
@@ -113,7 +115,7 @@ describe('Jobs Store Mutations', () => {
it('sets the parsed log', () => {
mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, mockLog);
- expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, [], '');
+ expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, {}, '');
expect(stateCopy.jobLog).toEqual([
{
@@ -133,7 +135,7 @@ describe('Jobs Store Mutations', () => {
it('sets the parsed log', () => {
mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, mockLog);
- expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, [], '#L1');
+ expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, {}, '#L1');
expect(stateCopy.jobLog).toEqual([
{
@@ -214,9 +216,17 @@ describe('Jobs Store Mutations', () => {
describe('TOGGLE_COLLAPSIBLE_LINE', () => {
it('toggles the `isClosed` property of the provided object', () => {
- const section = { isClosed: true };
- mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, section);
- expect(section.isClosed).toEqual(false);
+ stateCopy.jobLogSections = {
+ 'step-script': { isClosed: true },
+ };
+
+ mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, 'step-script');
+
+ expect(stateCopy.jobLogSections['step-script'].isClosed).toEqual(false);
+
+ mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, 'step-script');
+
+ expect(stateCopy.jobLogSections['step-script'].isClosed).toEqual(true);
});
});
@@ -314,4 +324,34 @@ describe('Jobs Store Mutations', () => {
expect(stateCopy.jobs).toEqual([]);
});
});
+
+ describe('ENTER_FULLSCREEN_SUCCESS', () => {
+ beforeEach(() => {
+ mutations[types.ENTER_FULLSCREEN_SUCCESS](stateCopy);
+ });
+
+ it('sets fullScreenEnabled to true', () => {
+ expect(stateCopy.fullScreenEnabled).toEqual(true);
+ });
+ });
+
+ describe('EXIT_FULLSCREEN_SUCCESS', () => {
+ beforeEach(() => {
+ mutations[types.EXIT_FULLSCREEN_SUCCESS](stateCopy);
+ });
+
+ it('sets fullScreenEnabled to false', () => {
+ expect(stateCopy.fullScreenEnabled).toEqual(false);
+ });
+ });
+
+ describe('FULL_SCREEN_CONTAINER_SET_UP', () => {
+ beforeEach(() => {
+ mutations[types.FULL_SCREEN_CONTAINER_SET_UP](stateCopy, true);
+ });
+
+ it('sets fullScreenEnabled to true', () => {
+ expect(stateCopy.fullScreenContainerSetUp).toEqual(true);
+ });
+ });
});
diff --git a/spec/frontend/ci/job_details/store/utils_spec.js b/spec/frontend/ci/job_details/store/utils_spec.js
index 8fc4eeb0ca8..6105c53a306 100644
--- a/spec/frontend/ci/job_details/store/utils_spec.js
+++ b/spec/frontend/ci/job_details/store/utils_spec.js
@@ -1,524 +1,305 @@
+import { logLinesParser } from '~/ci/job_details/store/utils';
+
import {
- logLinesParser,
- updateIncrementalJobLog,
- parseHeaderLine,
- parseLine,
- addDurationToHeader,
- isCollapsibleSection,
- findOffsetAndRemove,
- getNextLineNumber,
-} from '~/ci/job_details/store/utils';
-import {
- mockJobLog,
- originalTrace,
- regularIncremental,
- regularIncrementalRepeated,
- headerTrace,
- headerTraceIncremental,
- collapsibleTrace,
- collapsibleTraceIncremental,
+ mockJobLines,
+ mockEmptySection,
+ mockContentSection,
+ mockContentSectionClosed,
+ mockContentSectionHiddenDuration,
+ mockContentSubsection,
+ mockTruncatedBottomSection,
+ mockTruncatedTopSection,
} from '../components/log/mock_data';
describe('Jobs Store Utils', () => {
- describe('parseHeaderLine', () => {
- it('returns a new object with the header keys and the provided line parsed', () => {
- const headerLine = { content: [{ text: 'foo' }] };
- const parsedHeaderLine = parseHeaderLine(headerLine, 2);
+ describe('logLinesParser', () => {
+ it('parses plain lines', () => {
+ const result = logLinesParser(mockJobLines);
- expect(parsedHeaderLine).toEqual({
- isClosed: false,
- isHeader: true,
- line: {
- ...headerLine,
- lineNumber: 2,
- },
- lines: [],
+ expect(result).toEqual({
+ lines: [
+ {
+ offset: 0,
+ content: [
+ {
+ text: 'Running with gitlab-runner 12.1.0 (de7731dd)',
+ style: 'term-fg-l-cyan term-bold',
+ },
+ ],
+ lineNumber: 1,
+ },
+ {
+ offset: 1001,
+ content: [{ text: ' on docker-auto-scale-com 8a6210b8' }],
+ lineNumber: 2,
+ },
+ ],
+ sections: {},
});
});
- it('pre-closes a section when specified in options', () => {
- const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } };
-
- const parsedHeaderLine = parseHeaderLine(headerLine, 2);
-
- expect(parsedHeaderLine.isClosed).toBe(true);
- });
-
- it('expands all pre-closed sections if hash is present', () => {
- const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } };
-
- const parsedHeaderLine = parseHeaderLine(headerLine, 2, '#L33');
-
- expect(parsedHeaderLine.isClosed).toBe(false);
- });
- });
-
- describe('parseLine', () => {
- it('returns a new object with the lineNumber key added to the provided line object', () => {
- const line = { content: [{ text: 'foo' }] };
- const parsed = parseLine(line, 1);
- expect(parsed.content).toEqual(line.content);
- expect(parsed.lineNumber).toEqual(1);
- });
- });
+ it('parses an empty section', () => {
+ const result = logLinesParser(mockEmptySection);
- describe('addDurationToHeader', () => {
- const duration = {
- offset: 106,
- content: [],
- section: 'prepare-script',
- section_duration: '00:03',
- };
-
- it('adds the section duration to the correct header', () => {
- const parsed = [
- {
- isClosed: false,
- isHeader: true,
- line: {
- section: 'prepare-script',
- content: [{ text: 'foo' }],
+ expect(result).toEqual({
+ lines: [
+ {
+ offset: 1002,
+ content: [
+ {
+ text: 'Resolving secrets',
+ style: 'term-fg-l-cyan term-bold',
+ },
+ ],
+ lineNumber: 1,
+ section: 'resolve-secrets',
+ isHeader: true,
},
- lines: [],
- },
- {
- isClosed: false,
- isHeader: true,
- line: {
- section: 'foo-bar',
- content: [{ text: 'foo' }],
+ ],
+ sections: {
+ 'resolve-secrets': {
+ startLineNumber: 1,
+ endLineNumber: 1,
+ duration: '00:00',
+ isClosed: false,
},
- lines: [],
},
- ];
-
- addDurationToHeader(parsed, duration);
-
- expect(parsed[0].line.section_duration).toEqual(duration.section_duration);
- expect(parsed[1].line.section_duration).toEqual(undefined);
+ });
});
- it('does not add the section duration when the headers do not match', () => {
- const parsed = [
- {
- isClosed: false,
- isHeader: true,
- line: {
- section: 'bar-foo',
- content: [{ text: 'foo' }],
+ it('parses a section with content', () => {
+ const result = logLinesParser(mockContentSection);
+
+ expect(result).toEqual({
+ lines: [
+ {
+ content: [{ text: 'Using Docker executor with image dev.gitlab.org3' }],
+ isHeader: true,
+ lineNumber: 1,
+ offset: 1004,
+ section: 'prepare-executor',
},
- lines: [],
- },
- {
- isClosed: false,
- isHeader: true,
- line: {
- section: 'foo-bar',
- content: [{ text: 'foo' }],
+ {
+ content: [{ text: 'Docker executor with image registry.gitlab.com ...' }],
+ lineNumber: 2,
+ offset: 1005,
+ section: 'prepare-executor',
+ },
+ {
+ content: [{ style: 'term-fg-l-green', text: 'Starting service ...' }],
+ lineNumber: 3,
+ offset: 1006,
+ section: 'prepare-executor',
+ },
+ ],
+ sections: {
+ 'prepare-executor': {
+ startLineNumber: 1,
+ endLineNumber: 3,
+ duration: '00:09',
+ isClosed: false,
},
- lines: [],
- },
- ];
- addDurationToHeader(parsed, duration);
-
- expect(parsed[0].line.section_duration).toEqual(undefined);
- expect(parsed[1].line.section_duration).toEqual(undefined);
- });
-
- it('does not add when content has no headers', () => {
- const parsed = [
- {
- section: 'bar-foo',
- content: [{ text: 'foo' }],
- lineNumber: 1,
- },
- {
- section: 'foo-bar',
- content: [{ text: 'foo' }],
- lineNumber: 2,
},
- ];
-
- addDurationToHeader(parsed, duration);
-
- expect(parsed[0].line).toEqual(undefined);
- expect(parsed[1].line).toEqual(undefined);
- });
- });
-
- describe('isCollapsibleSection', () => {
- const header = {
- isHeader: true,
- line: {
- section: 'foo',
- },
- };
- const line = {
- lineNumber: 1,
- section: 'foo',
- content: [],
- };
-
- it('returns true when line belongs to the last section', () => {
- expect(isCollapsibleSection([header], header, { section: 'foo', content: [] })).toEqual(true);
- });
-
- it('returns false when last line was not an header', () => {
- expect(isCollapsibleSection([line], line, { section: 'bar' })).toEqual(false);
- });
-
- it('returns false when accumulator is empty', () => {
- expect(isCollapsibleSection([], { isHeader: true }, { section: 'bar' })).toEqual(false);
- });
-
- it('returns false when section_duration is defined', () => {
- expect(isCollapsibleSection([header], header, { section_duration: '10:00' })).toEqual(false);
- });
-
- it('returns false when `section` is not a match', () => {
- expect(isCollapsibleSection([header], header, { section: 'bar' })).toEqual(false);
- });
-
- it('returns false when no parameters are provided', () => {
- expect(isCollapsibleSection()).toEqual(false);
- });
- });
- describe('logLinesParser', () => {
- let result;
-
- beforeEach(() => {
- result = logLinesParser(mockJobLog);
- });
-
- describe('regular line', () => {
- it('adds a lineNumber property with correct index', () => {
- expect(result[0].lineNumber).toEqual(1);
- expect(result[1].lineNumber).toEqual(2);
- expect(result[2].line.lineNumber).toEqual(3);
- expect(result[3].line.lineNumber).toEqual(4);
- expect(result[3].lines[0].lineNumber).toEqual(5);
- expect(result[3].lines[1].lineNumber).toEqual(6);
});
});
- describe('collapsible section', () => {
- it('adds a `isClosed` property', () => {
- expect(result[2].isClosed).toEqual(false);
- expect(result[3].isClosed).toEqual(false);
- });
-
- it('adds a `isHeader` property', () => {
- expect(result[2].isHeader).toEqual(true);
- expect(result[3].isHeader).toEqual(true);
- });
+ it('parses a closed section with content', () => {
+ const result = logLinesParser(mockContentSectionClosed);
- it('creates a lines array property with the content of the collapsible section', () => {
- expect(result[3].lines.length).toEqual(2);
- expect(result[3].lines[0].content).toEqual(mockJobLog[5].content);
- expect(result[3].lines[1].content).toEqual(mockJobLog[6].content);
+ expect(result.sections['mock-closed-section']).toMatchObject({
+ isClosed: true,
});
});
- describe('section duration', () => {
- it('adds the section information to the header section', () => {
- expect(result[2].line.section_duration).toEqual(mockJobLog[3].section_duration);
- expect(result[3].line.section_duration).toEqual(mockJobLog[7].section_duration);
- });
-
- it('does not add section duration as a line', () => {
- expect(result[2].lines.includes(mockJobLog[5])).toEqual(false);
- expect(result[3].lines.includes(mockJobLog[9])).toEqual(false);
- });
- });
- });
-
- describe('findOffsetAndRemove', () => {
- describe('when last item is header', () => {
- const existingLog = [
- {
- isHeader: true,
- isClosed: false,
- line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 },
- },
- ];
-
- describe('and matches the offset', () => {
- it('returns an array with the item removed', () => {
- const newData = [{ offset: 10, content: [{ text: 'foobar' }] }];
- const result = findOffsetAndRemove(newData, existingLog);
-
- expect(result).toEqual([]);
- });
- });
+ it('parses a closed section as open when hash is present', () => {
+ const result = logLinesParser(mockContentSectionClosed, {}, '#L1');
- describe('and does not match the offset', () => {
- it('returns the provided existing log', () => {
- const newData = [{ offset: 110, content: [{ text: 'foobar' }] }];
- const result = findOffsetAndRemove(newData, existingLog);
-
- expect(result).toEqual(existingLog);
- });
- });
- });
-
- describe('when last item is a regular line', () => {
- const existingLog = [{ content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }];
-
- describe('and matches the offset', () => {
- it('returns an array with the item removed', () => {
- const newData = [{ offset: 10, content: [{ text: 'foobar' }] }];
- const result = findOffsetAndRemove(newData, existingLog);
-
- expect(result).toEqual([]);
- });
- });
-
- describe('and does not match the fofset', () => {
- it('returns the provided old log', () => {
- const newData = [{ offset: 101, content: [{ text: 'foobar' }] }];
- const result = findOffsetAndRemove(newData, existingLog);
-
- expect(result).toEqual(existingLog);
- });
+ expect(result.sections['mock-closed-section']).toMatchObject({
+ isClosed: false,
});
});
- describe('when last item is nested', () => {
- const existingLog = [
- {
- isHeader: true,
- isClosed: false,
- lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }],
- line: {
- offset: 10,
- lineNumber: 1,
- section_duration: '10:00',
- },
- },
- ];
-
- describe('and matches the offset', () => {
- it('returns an array with the last nested line item removed', () => {
- const newData = [{ offset: 101, content: [{ text: 'foobar' }] }];
+ it('parses a section with a hidden duration', () => {
+ const result = logLinesParser(mockContentSectionHiddenDuration);
- const result = findOffsetAndRemove(newData, existingLog);
- expect(result[0].lines).toEqual([]);
- });
- });
-
- describe('and does not match the offset', () => {
- it('returns the provided old log', () => {
- const newData = [{ offset: 120, content: [{ text: 'foobar' }] }];
-
- const result = findOffsetAndRemove(newData, existingLog);
- expect(result).toEqual(existingLog);
- });
+ expect(result.sections['mock-hidden-duration-section']).toMatchObject({
+ hideDuration: true,
+ duration: '00:09',
});
});
- describe('when no data is provided', () => {
- it('returns an empty array', () => {
- const result = findOffsetAndRemove();
- expect(result).toEqual([]);
- });
- });
- });
-
- describe('getNextLineNumber', () => {
- describe('when there is no previous log', () => {
- it('returns 1', () => {
- expect(getNextLineNumber([])).toEqual(1);
- expect(getNextLineNumber(undefined)).toEqual(1);
- });
- });
+ it('parses a section with a sub section', () => {
+ const result = logLinesParser(mockContentSubsection);
- describe('when last line is 1', () => {
- it('returns 1', () => {
- const log = [
+ expect(result).toEqual({
+ lines: [
{
- content: [],
+ offset: 0,
+ content: [{ text: 'Line 1' }],
lineNumber: 1,
+ section: 'mock-section',
+ isHeader: true,
},
- ];
-
- expect(getNextLineNumber(log)).toEqual(2);
- });
- });
-
- describe('with unnested line', () => {
- it('returns the lineNumber of the last item in the array', () => {
- const log = [
{
- content: [],
- lineNumber: 10,
+ offset: 1002,
+ content: [{ text: 'Line 2 - section content' }],
+ lineNumber: 2,
+ section: 'mock-section',
},
{
- content: [],
- lineNumber: 101,
+ offset: 1003,
+ content: [{ text: 'Line 3 - sub section header' }],
+ lineNumber: 3,
+ section: 'sub-section',
+ isHeader: true,
},
- ];
-
- expect(getNextLineNumber(log)).toEqual(102);
- });
- });
-
- describe('when last line is the header section', () => {
- it('returns the lineNumber of the last item in the array', () => {
- const log = [
{
- content: [],
- lineNumber: 10,
+ offset: 1004,
+ content: [{ text: 'Line 4 - sub section content' }],
+ lineNumber: 4,
+ section: 'sub-section',
},
{
+ offset: 1005,
+ content: [{ text: 'Line 5 - sub sub section header with no content' }],
+ lineNumber: 5,
+ section: 'sub-sub-section',
isHeader: true,
- line: {
- lineNumber: 101,
- content: [],
- },
- lines: [],
},
- ];
-
- expect(getNextLineNumber(log)).toEqual(102);
- });
- });
-
- describe('when last line is a nested line', () => {
- it('returns the lineNumber of the last item in the nested array', () => {
- const log = [
{
- content: [],
- lineNumber: 10,
+ offset: 1007,
+ content: [{ text: 'Line 6 - sub section content 2' }],
+ lineNumber: 6,
+ section: 'sub-section',
},
{
- isHeader: true,
- line: {
- lineNumber: 101,
- content: [],
- },
- lines: [
- {
- lineNumber: 102,
- content: [],
- },
- { lineNumber: 103, content: [] },
- ],
+ offset: 1009,
+ content: [{ text: 'Line 7 - section content' }],
+ lineNumber: 7,
+ section: 'mock-section',
+ },
+ {
+ offset: 1011,
+ content: [{ text: 'Job succeeded' }],
+ lineNumber: 8,
+ },
+ ],
+ sections: {
+ 'mock-section': {
+ startLineNumber: 1,
+ endLineNumber: 7,
+ duration: '00:59',
+ isClosed: false,
},
- ];
+ 'sub-section': {
+ startLineNumber: 3,
+ endLineNumber: 6,
+ duration: '00:29',
+ isClosed: false,
+ },
+ 'sub-sub-section': {
+ startLineNumber: 5,
+ endLineNumber: 5,
+ duration: '00:00',
+ isClosed: false,
+ },
+ },
+ });
+ });
- expect(getNextLineNumber(log)).toEqual(104);
+ it('parsing repeated lines returns the same result', () => {
+ const result1 = logLinesParser(mockJobLines);
+ const result2 = logLinesParser(mockJobLines, {
+ currentLines: result1.lines,
+ currentSections: result1.sections,
});
+
+ // `toBe` is used to ensure objects do not change and trigger Vue reactivity
+ expect(result1.lines).toBe(result2.lines);
+ expect(result1.sections).toBe(result2.sections);
});
- });
- describe('updateIncrementalJobLog', () => {
- describe('without repeated section', () => {
- it('concats and parses both arrays', () => {
- const oldLog = logLinesParser(originalTrace);
- const result = updateIncrementalJobLog(regularIncremental, oldLog);
+ it('discards repeated lines and adds new ones', () => {
+ const result1 = logLinesParser(mockContentSection);
+ const result2 = logLinesParser(
+ [
+ ...mockContentSection,
+ {
+ content: [{ text: 'offset is too low, is ignored' }],
+ offset: 500,
+ },
+ {
+ content: [{ text: 'one new line' }],
+ offset: 1007,
+ },
+ ],
+ {
+ currentLines: result1.lines,
+ currentSections: result1.sections,
+ },
+ );
- expect(result).toEqual([
+ expect(result2).toEqual({
+ lines: [
{
- offset: 1,
- content: [
- {
- text: 'Downloading',
- },
- ],
+ content: [{ text: 'Using Docker executor with image dev.gitlab.org3' }],
+ isHeader: true,
lineNumber: 1,
+ offset: 1004,
+ section: 'prepare-executor',
},
{
- offset: 2,
- content: [
- {
- text: 'log line',
- },
- ],
+ content: [{ text: 'Docker executor with image registry.gitlab.com ...' }],
lineNumber: 2,
+ offset: 1005,
+ section: 'prepare-executor',
},
- ]);
- });
- });
-
- describe('with regular line repeated offset', () => {
- it('updates the last line and formats with the incremental part', () => {
- const oldLog = logLinesParser(originalTrace);
- const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog);
-
- expect(result).toEqual([
{
- offset: 1,
- content: [
- {
- text: 'log line',
- },
- ],
- lineNumber: 1,
+ content: [{ style: 'term-fg-l-green', text: 'Starting service ...' }],
+ lineNumber: 3,
+ offset: 1006,
+ section: 'prepare-executor',
+ },
+ {
+ content: [{ text: 'one new line' }],
+ lineNumber: 4,
+ offset: 1007,
},
- ]);
+ ],
+ sections: {
+ 'prepare-executor': {
+ startLineNumber: 1,
+ endLineNumber: 3,
+ duration: '00:09',
+ isClosed: false,
+ },
+ },
});
});
- describe('with header line repeated', () => {
- it('updates the header line and formats with the incremental part', () => {
- const oldLog = logLinesParser(headerTrace);
- const result = updateIncrementalJobLog(headerTraceIncremental, oldLog);
+ it('parses an interrupted job', () => {
+ const result = logLinesParser(mockTruncatedBottomSection);
- expect(result).toEqual([
- {
- isClosed: false,
- isHeader: true,
- line: {
- offset: 1,
- section_header: true,
- content: [
- {
- text: 'updated log line',
- },
- ],
- section: 'section',
- lineNumber: 1,
- },
- lines: [],
- },
- ]);
+ expect(result.sections).toEqual({
+ 'mock-section': {
+ startLineNumber: 1,
+ endLineNumber: Infinity,
+ duration: null,
+ isClosed: false,
+ },
});
});
- describe('with collapsible line repeated', () => {
- it('updates the collapsible line and formats with the incremental part', () => {
- const oldLog = logLinesParser(collapsibleTrace);
- const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog);
+ it('parses the ending of an incomplete section', () => {
+ const result = logLinesParser(mockTruncatedTopSection);
- expect(result).toEqual([
- {
- isClosed: false,
- isHeader: true,
- line: {
- offset: 1,
- section_header: true,
- content: [
- {
- text: 'log line',
- },
- ],
- section: 'section',
- lineNumber: 1,
- },
- lines: [
- {
- offset: 2,
- content: [
- {
- text: 'updated log line',
- },
- ],
- section: 'section',
- lineNumber: 2,
- },
- ],
- },
- ]);
+ expect(result.sections).toEqual({
+ 'mock-section': {
+ startLineNumber: 0,
+ endLineNumber: 2,
+ duration: '00:59',
+ isClosed: false,
+ },
});
});
});
diff --git a/spec/frontend/ci/jobs_mock_data.js b/spec/frontend/ci/jobs_mock_data.js
index c428de3b9d8..12833524fd9 100644
--- a/spec/frontend/ci/jobs_mock_data.js
+++ b/spec/frontend/ci/jobs_mock_data.js
@@ -1627,3 +1627,53 @@ export const mockJobLog = [
lineNumber: 23,
},
];
+
+export const testSummaryData = {
+ total: {
+ time: 0.001,
+ count: 1,
+ success: 1,
+ failed: 0,
+ skipped: 0,
+ error: 0,
+ suite_error: null,
+ },
+ test_suites: [
+ {
+ name: 'javascript',
+ total_time: 0.001,
+ total_count: 1,
+ success_count: 1,
+ failed_count: 0,
+ skipped_count: 0,
+ error_count: 0,
+ build_ids: [3633],
+ suite_error: null,
+ },
+ ],
+};
+
+export const testSummaryDataWithFailures = {
+ total: {
+ time: 0.001,
+ count: 2,
+ success: 1,
+ failed: 1,
+ skipped: 0,
+ error: 0,
+ suite_error: null,
+ },
+ test_suites: [
+ {
+ name: 'javascript',
+ total_time: 0.001,
+ total_count: 2,
+ success_count: 1,
+ failed_count: 1,
+ skipped_count: 0,
+ error_count: 0,
+ build_ids: [3633],
+ suite_error: null,
+ },
+ ],
+};
diff --git a/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js
index 1ffd680118e..7af333543b8 100644
--- a/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js
+++ b/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js
@@ -43,6 +43,7 @@ describe('Job actions cell', () => {
const cannotPlayJob = findMockJob('playable', mockJobsNodesAsGuest);
const cannotRetryJob = findMockJob('retryable', mockJobsNodesAsGuest);
const cannotPlayScheduledJob = findMockJob('scheduled', mockJobsNodesAsGuest);
+ const cannotCancelJob = findMockJob('cancelable', mockJobsNodesAsGuest);
const findRetryButton = () => wrapper.findByTestId('retry');
const findPlayButton = () => wrapper.findByTestId('play');
@@ -99,6 +100,7 @@ describe('Job actions cell', () => {
${findPlayButton} | ${'play'} | ${cannotPlayJob}
${findRetryButton} | ${'retry'} | ${cannotRetryJob}
${findPlayScheduledJobButton} | ${'play scheduled'} | ${cannotPlayScheduledJob}
+ ${findCancelButton} | ${'cancel'} | ${cannotCancelJob}
`('does not display the $action button if user cannot update build', ({ button, jobType }) => {
createComponent(jobType);
diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js
index d14afe7dd3e..a865b7a0c0c 100644
--- a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js
+++ b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js
@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JobsTable from '~/ci/jobs_page/components/jobs_table.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import { DEFAULT_FIELDS_ADMIN } from '~/ci/admin/jobs_table/constants';
import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue';
import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue';
diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js
index 10db7f398fe..432775d469c 100644
--- a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js
+++ b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js
@@ -5,7 +5,7 @@ import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue';
import axios from '~/lib/utils/axios_utils';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import ActionComponent from '~/ci/common/private/job_action_component.vue';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js
index 1da85ad9f78..b84ca77081a 100644
--- a/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js
+++ b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import jobNameComponent from '~/ci/common/private/job_name_component.vue';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
describe('job name component', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js
index 72be51575d7..e6f89910a97 100644
--- a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js
+++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js
@@ -10,7 +10,7 @@ import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/ci/pipeline_details/grap
import LinkedPipelineComponent from '~/ci/pipeline_details/graph/components/linked_pipeline.vue';
import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql';
import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import mockPipeline from './linked_pipelines_mock_data';
describe('Linked pipeline', () => {
diff --git a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js
index e8e178ed148..86b8c416a07 100644
--- a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js
+++ b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js
@@ -7,7 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import PipelineDetailsHeader from '~/ci/pipeline_details/header/pipeline_details_header.vue';
import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import cancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '~/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql';
@@ -15,6 +15,7 @@ import getPipelineDetailsQuery from '~/ci/pipeline_details/header/graphql/querie
import {
pipelineHeaderSuccess,
pipelineHeaderRunning,
+ pipelineHeaderRunningNoPermissions,
pipelineHeaderRunningWithDuration,
pipelineHeaderFailed,
pipelineRetryMutationResponseSuccess,
@@ -33,6 +34,9 @@ describe('Pipeline details header', () => {
const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess);
const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning);
+ const runningHandlerNoPermissions = jest
+ .fn()
+ .mockResolvedValue(pipelineHeaderRunningNoPermissions);
const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration);
const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed);
@@ -65,7 +69,6 @@ describe('Pipeline details header', () => {
const findPipelineName = () => wrapper.findByTestId('pipeline-name');
const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title');
const findTotalJobs = () => wrapper.findByTestId('total-jobs');
- const findComputeMinutes = () => wrapper.findByTestId('compute-minutes');
const findCommitLink = () => wrapper.findByTestId('commit-link');
const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text();
const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text();
@@ -82,31 +85,12 @@ describe('Pipeline details header', () => {
paths: {
pipelinesPath: '/namespace/my-project/-/pipelines',
fullProject: '/namespace/my-project',
- triggeredByPath: '',
},
};
const defaultProps = {
- name: 'Ruby 3.0 master branch pipeline',
- totalJobs: '50',
- computeMinutes: '0.65',
- yamlErrors: 'errors',
- failureReason: 'pipeline failed',
- badges: {
- schedule: true,
- trigger: false,
- child: false,
- latest: true,
- mergeTrainPipeline: false,
- mergedResultsPipeline: 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>',
+ yamlErrors: '',
+ trigger: false,
};
const createMockApolloProvider = (handlers) => {
@@ -159,11 +143,11 @@ describe('Pipeline details header', () => {
});
it('displays pipeline name', () => {
- expect(findPipelineName().text()).toBe(defaultProps.name);
+ expect(findPipelineName().text()).toBe('Build pipeline');
});
it('displays total jobs', () => {
- expect(findTotalJobs().text()).toBe('50 Jobs');
+ expect(findTotalJobs().text()).toBe('3 Jobs');
});
it('has link to commit', () => {
@@ -178,13 +162,13 @@ describe('Pipeline details header', () => {
it('displays correct badges', () => {
expect(findAllBadges()).toHaveLength(2);
- expect(wrapper.findByText('latest').exists()).toBe(true);
+ expect(wrapper.findByText('merged results').exists()).toBe(true);
expect(wrapper.findByText('Scheduled').exists()).toBe(true);
expect(wrapper.findByText('trigger token').exists()).toBe(false);
});
it('displays ref text', () => {
- expect(findPipelineRefText()).toBe('Related merge request !1 to merge test');
+ expect(findPipelineRefText()).toBe('Related merge request !1 to merge master into feature');
});
it('displays pipeline user link with required user popover attributes', () => {
@@ -209,7 +193,7 @@ describe('Pipeline details header', () => {
beforeEach(async () => {
createComponent(defaultHandlers, {
...defaultProps,
- badges: { ...defaultProps.badges, trigger: true },
+ trigger: true,
});
await waitForPromises();
@@ -222,7 +206,7 @@ describe('Pipeline details header', () => {
describe('without pipeline name', () => {
it('displays commit title', async () => {
- createComponent(defaultHandlers, { ...defaultProps, name: '' });
+ createComponent([[getPipelineDetailsQuery, runningHandler]]);
await waitForPromises();
@@ -234,22 +218,6 @@ describe('Pipeline details header', () => {
});
describe('finished pipeline', () => {
- it('displays compute minutes when not zero', async () => {
- createComponent();
-
- await waitForPromises();
-
- expect(findComputeMinutes().text()).toBe('0.65');
- });
-
- it('does not display compute minutes when zero', async () => {
- createComponent(defaultHandlers, { ...defaultProps, computeMinutes: '0.0' });
-
- await waitForPromises();
-
- expect(findComputeMinutes().exists()).toBe(false);
- });
-
it('does not display created time ago', async () => {
createComponent();
@@ -284,10 +252,6 @@ describe('Pipeline details header', () => {
await waitForPromises();
});
- it('does not display compute minutes', () => {
- expect(findComputeMinutes().exists()).toBe(false);
- });
-
it('does not display finished time ago', () => {
expect(findFinishedTimeAgo().exists()).toBe(false);
});
@@ -374,46 +338,58 @@ describe('Pipeline details header', () => {
});
describe('cancel action', () => {
- it('should call cancelPipeline Mutation with pipeline id', async () => {
- createComponent([
- [getPipelineDetailsQuery, runningHandler],
- [cancelPipelineMutation, cancelMutationHandlerSuccess],
- ]);
+ describe('with permissions', () => {
+ it('should call cancelPipeline Mutation with pipeline id', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, runningHandler],
+ [cancelPipelineMutation, cancelMutationHandlerSuccess],
+ ]);
- await waitForPromises();
+ await waitForPromises();
- findCancelButton().vm.$emit('click');
+ findCancelButton().vm.$emit('click');
- expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({
- id: pipelineHeaderRunning.data.project.pipeline.id,
+ expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({
+ id: pipelineHeaderRunning.data.project.pipeline.id,
+ });
+ expect(findAlert().exists()).toBe(false);
});
- expect(findAlert().exists()).toBe(false);
- });
- it('should render cancel action tooltip', async () => {
- createComponent([
- [getPipelineDetailsQuery, runningHandler],
- [cancelPipelineMutation, cancelMutationHandlerSuccess],
- ]);
+ it('should render cancel action tooltip', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, runningHandler],
+ [cancelPipelineMutation, cancelMutationHandlerSuccess],
+ ]);
- await waitForPromises();
+ await waitForPromises();
- expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL);
- });
+ expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL);
+ });
- it('should display error message on failure', async () => {
- createComponent([
- [getPipelineDetailsQuery, runningHandler],
- [cancelPipelineMutation, cancelMutationHandlerFailed],
- ]);
+ it('should display error message on failure', async () => {
+ createComponent([
+ [getPipelineDetailsQuery, runningHandler],
+ [cancelPipelineMutation, cancelMutationHandlerFailed],
+ ]);
- await waitForPromises();
+ await waitForPromises();
- findCancelButton().vm.$emit('click');
+ findCancelButton().vm.$emit('click');
- await waitForPromises();
+ await waitForPromises();
- expect(findAlert().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('without permissions', () => {
+ it('should not display cancel pipeline button', async () => {
+ createComponent([[getPipelineDetailsQuery, runningHandlerNoPermissions]]);
+
+ await waitForPromises();
+
+ expect(findCancelButton().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/ci/pipeline_details/mock_data.js b/spec/frontend/ci/pipeline_details/mock_data.js
index 56365622544..48570b2515f 100644
--- a/spec/frontend/ci/pipeline_details/mock_data.js
+++ b/spec/frontend/ci/pipeline_details/mock_data.js
@@ -1,5 +1,7 @@
+// pipeline header fixtures located in spec/frontend/fixtures/pipeline_header.rb
import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json';
import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json';
+import pipelineHeaderRunningNoPermissions from 'test_fixtures/graphql/pipelines/pipeline_header_running_no_permissions.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';
@@ -13,6 +15,7 @@ threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
export {
pipelineHeaderSuccess,
pipelineHeaderRunning,
+ pipelineHeaderRunningNoPermissions,
pipelineHeaderRunningWithDuration,
pipelineHeaderFailed,
};
diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js
index c0ffc2b34fb..ecc61ab43c0 100644
--- a/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js
+++ b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js
@@ -36,5 +36,33 @@ describe('Test reports utils', () => {
expect(result).toBe('4.82s');
});
});
+
+ describe('when time is greater than a minute', () => {
+ it('should return time in minutes', () => {
+ const result = formattedTime(99);
+ expect(result).toBe('1m 39s');
+ });
+ });
+
+ describe('when time is greater than a hour', () => {
+ it('should return time in hours', () => {
+ const result = formattedTime(3606);
+ expect(result).toBe('1h 6s');
+ });
+ });
+
+ describe('when time is exact a hour', () => {
+ it('should return time as one hour', () => {
+ const result = formattedTime(3600);
+ expect(result).toBe('1h');
+ });
+ });
+
+ describe('when time is greater than a hour with some minutes', () => {
+ it('should return time in hours', () => {
+ const result = formattedTime(3662);
+ expect(result).toBe('1h 1m 2s');
+ });
+ });
});
});
diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js
index 8ff060026da..d318aa36bcf 100644
--- a/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js
+++ b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js
@@ -5,6 +5,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import testReports from 'test_fixtures/pipelines/test_report.json';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { getParameterValues } from '~/lib/utils/url_utility';
import EmptyState from '~/ci/pipeline_details/test_reports/empty_state.vue';
import TestReports from '~/ci/pipeline_details/test_reports/test_reports.vue';
import TestSummary from '~/ci/pipeline_details/test_reports/test_summary.vue';
@@ -13,6 +14,11 @@ import * as getters from '~/ci/pipeline_details/stores/test_reports/getters';
Vue.use(Vuex);
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ getParameterValues: jest.fn().mockReturnValue([]),
+}));
+
describe('Test reports app', () => {
let wrapper;
let store;
@@ -100,6 +106,22 @@ describe('Test reports app', () => {
});
});
+ describe('when a job name is provided as a query parameter', () => {
+ beforeEach(() => {
+ getParameterValues.mockReturnValue(['javascript']);
+ createComponent();
+ });
+
+ it('shows tests details', () => {
+ expect(testsDetail().exists()).toBe(true);
+ });
+
+ it('should call setSelectedSuiteIndex and fetchTestSuite', () => {
+ expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled();
+ expect(actionSpies.fetchTestSuite).toHaveBeenCalled();
+ });
+ });
+
describe('when a suite is clicked', () => {
beforeEach(() => {
createComponent({ state: { hasFullReport: true } });
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 f6247fb4a19..46ef8a0d771 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
@@ -16,14 +16,15 @@ describe('CI Editor Header', () => {
const createComponent = ({
showHelpDrawer = false,
showJobAssistantDrawer = false,
- showAiAssistantDrawer = false,
aiChatAvailable = false,
aiCiConfigGenerator = false,
+ ciCatalogPath = '/explore/catalog',
} = {}) => {
wrapper = extendedWrapper(
shallowMount(CiEditorHeader, {
provide: {
aiChatAvailable,
+ ciCatalogPath,
glFeatures: {
aiCiConfigGenerator,
},
@@ -31,7 +32,6 @@ describe('CI Editor Header', () => {
propsData: {
showHelpDrawer,
showJobAssistantDrawer,
- showAiAssistantDrawer,
},
}),
);
@@ -39,7 +39,7 @@ describe('CI Editor Header', () => {
const findLinkBtn = () => wrapper.findByTestId('template-repo-link');
const findHelpBtn = () => wrapper.findByTestId('drawer-toggle');
- const findAiAssistnantBtn = () => wrapper.findByTestId('ai-assistant-drawer-toggle');
+ const findCatalogRepoLinkButton = () => wrapper.findByTestId('catalog-repo-link');
afterEach(() => {
unmockTracking();
@@ -55,29 +55,32 @@ describe('CI Editor Header', () => {
label,
});
};
- describe('Ai Assistant toggle button', () => {
- describe('when feature is unavailable', () => {
- it('should not show ai button when feature toggle is off', () => {
- createComponent({ aiChatAvailable: true });
- mockTracking(undefined, wrapper.element, jest.spyOn);
- expect(findAiAssistnantBtn().exists()).toBe(false);
- });
- it('should not show ai button when feature is unavailable', () => {
- createComponent({ aiCiConfigGenerator: true });
- mockTracking(undefined, wrapper.element, jest.spyOn);
- expect(findAiAssistnantBtn().exists()).toBe(false);
- });
+ describe('component repo link button', () => {
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
- describe('when feature is available', () => {
- it('should show ai button', () => {
- createComponent({ aiCiConfigGenerator: true, aiChatAvailable: true });
- mockTracking(undefined, wrapper.element, jest.spyOn);
- expect(findAiAssistnantBtn().exists()).toBe(true);
- });
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('finds the CI/CD Catalog button', () => {
+ expect(findCatalogRepoLinkButton().exists()).toBe(true);
+ });
+
+ it('has the external-link icon', () => {
+ expect(findCatalogRepoLinkButton().props('icon')).toBe('external-link');
+ });
+
+ it('tracks the click on the Catalog button', () => {
+ const { browseCatalog } = pipelineEditorTrackingOptions.actions;
+
+ testTracker(findCatalogRepoLinkButton(), browseCatalog);
});
});
+
describe('link button', () => {
beforeEach(() => {
createComponent();
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 69e91f11309..43620a58572 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
@@ -65,6 +65,7 @@ describe('Pipeline editor tabs component', () => {
},
provide: {
aiChatAvailable: false,
+ ciCatalogPath: '/explore/catalog',
ciConfigPath: '/path/to/ci-config',
ciLintPath: mockCiLintPath,
currentBranch: 'main',
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 f2818277c59..b66b44e5f06 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,5 +1,12 @@
import Vue from 'vue';
-import { GlAlert, GlDisclosureDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlDisclosureDropdown,
+ GlEmptyState,
+ GlIcon,
+ GlLoadingIcon,
+ GlPopover,
+} from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import MockAdapter from 'axios-mock-adapter';
@@ -70,7 +77,7 @@ describe('Pipeline Editor Validate Tab', () => {
const findCta = () => wrapper.findByTestId('simulate-pipeline-button');
const findDisabledCtaTooltip = () => wrapper.findByTestId('cta-tooltip');
const findHelpIcon = () => wrapper.findComponent(GlIcon);
- const findIllustration = () => wrapper.findByRole('img');
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPipelineSource = () => wrapper.findComponent(GlDisclosureDropdown);
const findPopover = () => wrapper.findComponent(GlPopover);
@@ -283,7 +290,7 @@ describe('Pipeline Editor Validate Tab', () => {
it('returns to init state', async () => {
// init state
- expect(findIllustration().exists()).toBe(true);
+ expect(findEmptyState().exists()).toBe(true);
expect(findCiLintResults().exists()).toBe(false);
// mutations should have successful results
@@ -294,7 +301,7 @@ describe('Pipeline Editor Validate Tab', () => {
await findCancelBtn().vm.$emit('click');
// should still render init state
- expect(findIllustration().exists()).toBe(true);
+ expect(findEmptyState().exists()).toBe(true);
expect(findCiLintResults().exists()).toBe(false);
});
});
diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js
index e08c35f1555..e700411ec57 100644
--- a/spec/frontend/ci/pipeline_editor/mock_data.js
+++ b/spec/frontend/ci/pipeline_editor/mock_data.js
@@ -22,18 +22,17 @@ export const commonOptions = {
usesExternalConfig: 'false',
validateTabIllustrationPath: 'illustrations/tab',
ymlHelpPagePath: 'help/ci/yml',
- aiChatAvailable: 'true',
};
export const editorDatasetOptions = {
initialBranchName: 'production',
pipelineEtag: 'pipelineEtag',
+ ciCatalogPath: '/explore/catalog',
...commonOptions,
};
export const expectedInjectValues = {
...commonOptions,
- aiChatAvailable: true,
usesExternalConfig: false,
totalBranches: 10,
};
diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
index ca5f80f331c..fd0d17ee05b 100644
--- a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
+++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
@@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import { GlButton, GlModal } from '@gitlab/ui';
+import { GlModal } from '@gitlab/ui';
+import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue';
@@ -60,7 +61,7 @@ describe('Pipeline editor home wrapper', () => {
const findPipelineEditorFileTree = () => wrapper.findComponent(PipelineEditorFileTree);
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
- const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle');
+ const findPipelineEditorFileNav = () => wrapper.findComponent(PipelineEditorFileNav);
const clickHelpBtn = async () => {
await findPipelineEditorDrawer().vm.$emit('switch-drawer', EDITOR_APP_DRAWER_HELP);
@@ -279,24 +280,16 @@ describe('Pipeline editor home wrapper', () => {
describe('file tree', () => {
const toggleFileTree = async () => {
- await findFileTreeBtn().vm.$emit('click');
+ findPipelineEditorFileNav().vm.$emit('toggle-file-tree');
+ await nextTick();
};
- describe('button toggle', () => {
+ describe('file navigation', () => {
beforeEach(() => {
- createComponent({
- stubs: {
- GlButton,
- PipelineEditorFileNav,
- },
- });
- });
-
- it('shows button toggle', () => {
- expect(findFileTreeBtn().exists()).toBe(true);
+ createComponent({});
});
- it('toggles the drawer on button click', async () => {
+ it('toggles the drawer on `toggle-file-tree` event', async () => {
await toggleFileTree();
expect(findPipelineEditorFileTree().exists()).toBe(true);
diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js
index 87df7676bf1..95fa82adc9e 100644
--- a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js
+++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js
@@ -2,7 +2,7 @@ import { GlDropdown } from '@gitlab/ui';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue';
diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js
index 55ce3c79039..4f0bf3767cd 100644
--- a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js
+++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import LinkedPipelinesMiniList from '~/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue';
import mockData from './linked_pipelines_mock_data';
diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js
index b79e7c6e251..b79662e7a89 100644
--- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js
+++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline_spec.js
@@ -1,5 +1,5 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import PipelineScheduleLastPipeline from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue';
import { mockPipelineScheduleNodes } from '../../../mock_data';
diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js
index fbef4aa08eb..f824dab9ae1 100644
--- a/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js
@@ -1,4 +1,5 @@
import '~/commons';
+import { GlButton } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
@@ -14,12 +15,15 @@ describe('Pipelines CI Templates', () => {
return shallowMountExtended(PipelinesCiTemplates, {
provide: {
pipelineEditorPath,
+ showJenkinsCiPrompt: false,
...propsData,
},
stubs,
});
};
+ const findMigrateFromJenkinsPrompt = () => wrapper.findByTestId('migrate-from-jenkins-prompt');
+ const findMigrationPlanBtn = () => findMigrateFromJenkinsPrompt().findComponent(GlButton);
const findTestTemplateLink = () => wrapper.findByTestId('test-template-link');
const findCiTemplates = () => wrapper.findComponent(CiTemplates);
@@ -34,6 +38,27 @@ describe('Pipelines CI Templates', () => {
);
expect(findCiTemplates().exists()).toBe(true);
});
+
+ it('does not show migrate from jenkins prompt', () => {
+ expect(findMigrateFromJenkinsPrompt().exists()).toBe(false);
+ });
+
+ describe('when Jenkinsfile is detected', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ showJenkinsCiPrompt: true });
+ });
+
+ it('shows migrate from jenkins prompt', () => {
+ expect(findMigrateFromJenkinsPrompt().exists()).toBe(true);
+ });
+
+ it('opens correct link in new tab after clicking migration plan CTA', () => {
+ expect(findMigrationPlanBtn().attributes('href')).toBe(
+ '/help/ci/migration/plan_a_migration',
+ );
+ expect(findMigrationPlanBtn().attributes('target')).toBe('_blank');
+ });
+ });
});
describe('tracking', () => {
@@ -54,5 +79,27 @@ describe('Pipelines CI Templates', () => {
label: 'Getting-Started',
});
});
+
+ describe('when Jenkinsfile detected', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ showJenkinsCiPrompt: true });
+ });
+
+ it('creates render event on page load', () => {
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'render', {
+ label: 'migrate_from_jenkins_prompt',
+ });
+ });
+
+ it('sends an event when migration plan is clicked', () => {
+ findMigrationPlanBtn().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledTimes(2);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
+ label: 'migrate_from_jenkins_prompt',
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/ci/pipelines_page/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js
index 97192058ff6..f3c28b17339 100644
--- a/spec/frontend/ci/pipelines_page/pipelines_spec.js
+++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js
@@ -110,6 +110,7 @@ describe('Pipelines', () => {
suggestedCiTemplates: [],
ciRunnerSettingsPath: defaultProps.ciRunnerSettingsPath,
anyRunnersAvailable: true,
+ showJenkinsCiPrompt: false,
},
propsData: {
...defaultProps,
diff --git a/spec/frontend/ci/reports/codequality_report/store/actions_spec.js b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js
deleted file mode 100644
index a606bce3d78..00000000000
--- a/spec/frontend/ci/reports/codequality_report/store/actions_spec.js
+++ /dev/null
@@ -1,190 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'helpers/vuex_action_helper';
-import { TEST_HOST } from 'spec/test_constants';
-import axios from '~/lib/utils/axios_utils';
-import {
- HTTP_STATUS_INTERNAL_SERVER_ERROR,
- HTTP_STATUS_NO_CONTENT,
- HTTP_STATUS_OK,
-} from '~/lib/utils/http_status';
-import createStore from '~/ci/reports/codequality_report/store';
-import * as actions from '~/ci/reports/codequality_report/store/actions';
-import * as types from '~/ci/reports/codequality_report/store/mutation_types';
-import { STATUS_NOT_FOUND } from '~/ci/reports/constants';
-import { reportIssues, parsedReportIssues } from '../mock_data';
-
-const pollInterval = 123;
-const pollIntervalHeader = {
- 'Poll-Interval': pollInterval,
-};
-
-describe('Codequality Reports actions', () => {
- let localState;
- let localStore;
-
- beforeEach(() => {
- localStore = createStore();
- localState = localStore.state;
- });
-
- describe('setPaths', () => {
- it('should commit SET_PATHS mutation', () => {
- const paths = {
- baseBlobPath: 'baseBlobPath',
- headBlobPath: 'headBlobPath',
- reportsPath: 'reportsPath',
- };
-
- return testAction(
- actions.setPaths,
- paths,
- localState,
- [{ type: types.SET_PATHS, payload: paths }],
- [],
- );
- });
- });
-
- describe('fetchReports', () => {
- const endpoint = `${TEST_HOST}/codequality_reports.json`;
- let mock;
-
- beforeEach(() => {
- localState.reportsPath = endpoint;
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('on success', () => {
- it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', () => {
- mock.onGet(endpoint).reply(HTTP_STATUS_OK, reportIssues);
-
- return testAction(
- actions.fetchReports,
- null,
- localState,
- [{ type: types.REQUEST_REPORTS }],
- [
- {
- payload: parsedReportIssues,
- type: 'receiveReportsSuccess',
- },
- ],
- );
- });
- });
-
- describe('on error', () => {
- it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => {
- mock.onGet(endpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- return testAction(
- actions.fetchReports,
- null,
- localState,
- [{ type: types.REQUEST_REPORTS }],
- [{ type: 'receiveReportsError', payload: expect.any(Error) }],
- );
- });
- });
-
- describe('when base report is not found', () => {
- it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => {
- const data = { status: STATUS_NOT_FOUND };
- mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(HTTP_STATUS_OK, data);
-
- return testAction(
- actions.fetchReports,
- null,
- localState,
- [{ type: types.REQUEST_REPORTS }],
- [{ type: 'receiveReportsError', payload: data }],
- );
- });
- });
-
- describe('while waiting for report results', () => {
- it('continues polling until it receives data', () => {
- mock
- .onGet(endpoint)
- .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader)
- .onGet(endpoint)
- .reply(HTTP_STATUS_OK, reportIssues);
-
- return Promise.all([
- testAction(
- actions.fetchReports,
- null,
- localState,
- [{ type: types.REQUEST_REPORTS }],
- [
- {
- payload: parsedReportIssues,
- type: 'receiveReportsSuccess',
- },
- ],
- ),
- axios
- // wait for initial NO_CONTENT response to be fulfilled
- .waitForAll()
- .then(() => {
- jest.advanceTimersByTime(pollInterval);
- }),
- ]);
- });
-
- it('continues polling until it receives an error', () => {
- mock
- .onGet(endpoint)
- .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader)
- .onGet(endpoint)
- .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- return Promise.all([
- testAction(
- actions.fetchReports,
- null,
- localState,
- [{ type: types.REQUEST_REPORTS }],
- [{ type: 'receiveReportsError', payload: expect.any(Error) }],
- ),
- axios
- // wait for initial NO_CONTENT response to be fulfilled
- .waitForAll()
- .then(() => {
- jest.advanceTimersByTime(pollInterval);
- }),
- ]);
- });
- });
- });
-
- describe('receiveReportsSuccess', () => {
- it('commits RECEIVE_REPORTS_SUCCESS', () => {
- const data = { issues: [] };
-
- return testAction(
- actions.receiveReportsSuccess,
- data,
- localState,
- [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }],
- [],
- );
- });
- });
-
- describe('receiveReportsError', () => {
- it('commits RECEIVE_REPORTS_ERROR', () => {
- return testAction(
- actions.receiveReportsError,
- null,
- localState,
- [{ type: types.RECEIVE_REPORTS_ERROR, payload: null }],
- [],
- );
- });
- });
-});
diff --git a/spec/frontend/ci/reports/codequality_report/store/getters_spec.js b/spec/frontend/ci/reports/codequality_report/store/getters_spec.js
deleted file mode 100644
index f4505204f67..00000000000
--- a/spec/frontend/ci/reports/codequality_report/store/getters_spec.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import createStore from '~/ci/reports/codequality_report/store';
-import * as getters from '~/ci/reports/codequality_report/store/getters';
-import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '~/ci/reports/constants';
-
-describe('Codequality reports store getters', () => {
- let localState;
- let localStore;
-
- beforeEach(() => {
- localStore = createStore();
- localState = localStore.state;
- });
-
- describe('hasCodequalityIssues', () => {
- describe('when there are issues', () => {
- it('returns true', () => {
- localState.newIssues = [{ reason: 'repetitive code' }];
- localState.resolvedIssues = [];
-
- expect(getters.hasCodequalityIssues(localState)).toEqual(true);
-
- localState.newIssues = [];
- localState.resolvedIssues = [{ reason: 'repetitive code' }];
-
- expect(getters.hasCodequalityIssues(localState)).toEqual(true);
- });
- });
-
- describe('when there are no issues', () => {
- it('returns false when there are no issues', () => {
- expect(getters.hasCodequalityIssues(localState)).toEqual(false);
- });
- });
- });
-
- describe('codequalityStatus', () => {
- describe('when loading', () => {
- it('returns loading status', () => {
- localState.isLoading = true;
-
- expect(getters.codequalityStatus(localState)).toEqual(LOADING);
- });
- });
-
- describe('on error', () => {
- it('returns error status', () => {
- localState.hasError = true;
-
- expect(getters.codequalityStatus(localState)).toEqual(ERROR);
- });
- });
-
- describe('when successfully loaded', () => {
- it('returns error status', () => {
- expect(getters.codequalityStatus(localState)).toEqual(SUCCESS);
- });
- });
- });
-
- describe('codequalityText', () => {
- it.each`
- resolvedIssues | newIssues | expectedText
- ${0} | ${0} | ${'No changes to code quality'}
- ${0} | ${1} | ${'Code quality degraded due to 1 new issue'}
- ${2} | ${0} | ${'Code quality improved due to 2 resolved issues'}
- ${1} | ${2} | ${'Code quality scanning detected 3 changes in merged results'}
- `(
- 'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues',
- ({ newIssues, resolvedIssues, expectedText }) => {
- localState.newIssues = new Array(newIssues).fill({ reason: 'Repetitive code' });
- localState.resolvedIssues = new Array(resolvedIssues).fill({ reason: 'Repetitive code' });
-
- expect(getters.codequalityText(localState)).toEqual(expectedText);
- },
- );
- });
-
- describe('codequalityPopover', () => {
- describe('when base report is not available', () => {
- it('returns a popover with a documentation link', () => {
- localState.status = STATUS_NOT_FOUND;
- localState.helpPath = 'codequality_help.html';
-
- expect(getters.codequalityPopover(localState).title).toEqual(
- 'Base pipeline codequality artifact not found',
- );
- expect(getters.codequalityPopover(localState).content).toContain(
- 'Learn more about codequality reports',
- 'href="codequality_help.html"',
- );
- });
- });
- });
-});
diff --git a/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js b/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js
deleted file mode 100644
index 22ff86b1040..00000000000
--- a/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import createStore from '~/ci/reports/codequality_report/store';
-import mutations from '~/ci/reports/codequality_report/store/mutations';
-import { STATUS_NOT_FOUND } from '~/ci/reports/constants';
-
-describe('Codequality Reports mutations', () => {
- let localState;
- let localStore;
-
- beforeEach(() => {
- localStore = createStore();
- localState = localStore.state;
- });
-
- describe('SET_PATHS', () => {
- it('sets paths to given values', () => {
- const baseBlobPath = 'base/blob/path/';
- const headBlobPath = 'head/blob/path/';
- const reportsPath = 'reports.json';
- const helpPath = 'help.html';
-
- mutations.SET_PATHS(localState, {
- baseBlobPath,
- headBlobPath,
- reportsPath,
- helpPath,
- });
-
- expect(localState.baseBlobPath).toEqual(baseBlobPath);
- expect(localState.headBlobPath).toEqual(headBlobPath);
- expect(localState.reportsPath).toEqual(reportsPath);
- expect(localState.helpPath).toEqual(helpPath);
- });
- });
-
- describe('REQUEST_REPORTS', () => {
- it('sets isLoading to true', () => {
- mutations.REQUEST_REPORTS(localState);
-
- expect(localState.isLoading).toEqual(true);
- });
- });
-
- describe('RECEIVE_REPORTS_SUCCESS', () => {
- it('sets isLoading to false', () => {
- mutations.RECEIVE_REPORTS_SUCCESS(localState, {});
-
- expect(localState.isLoading).toEqual(false);
- });
-
- it('sets hasError to false', () => {
- mutations.RECEIVE_REPORTS_SUCCESS(localState, {});
-
- expect(localState.hasError).toEqual(false);
- });
-
- it('clears status and statusReason', () => {
- mutations.RECEIVE_REPORTS_SUCCESS(localState, {});
-
- expect(localState.status).toEqual('');
- expect(localState.statusReason).toEqual('');
- });
-
- it('sets newIssues and resolvedIssues from response data', () => {
- const data = { newIssues: [{ id: 1 }], resolvedIssues: [{ id: 2 }] };
- mutations.RECEIVE_REPORTS_SUCCESS(localState, data);
-
- expect(localState.newIssues).toEqual(data.newIssues);
- expect(localState.resolvedIssues).toEqual(data.resolvedIssues);
- });
- });
-
- describe('RECEIVE_REPORTS_ERROR', () => {
- it('sets isLoading to false', () => {
- mutations.RECEIVE_REPORTS_ERROR(localState);
-
- expect(localState.isLoading).toEqual(false);
- });
-
- it('sets hasError to true', () => {
- mutations.RECEIVE_REPORTS_ERROR(localState);
-
- expect(localState.hasError).toEqual(true);
- });
-
- it('sets status based on error object', () => {
- const error = { status: STATUS_NOT_FOUND };
- mutations.RECEIVE_REPORTS_ERROR(localState, error);
-
- expect(localState.status).toEqual(error.status);
- });
-
- it('sets statusReason to string from error response data', () => {
- const data = { status_reason: 'This merge request does not have codequality reports' };
- const error = { response: { data } };
- mutations.RECEIVE_REPORTS_ERROR(localState, error);
-
- expect(localState.statusReason).toEqual(data.status_reason);
- });
- });
-});
diff --git a/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/ci/reports/codequality_report/utils/codequality_parser_spec.js
index f7d82d2b662..953e6173662 100644
--- a/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js
+++ b/spec/frontend/ci/reports/codequality_report/utils/codequality_parser_spec.js
@@ -1,5 +1,5 @@
import { reportIssues, parsedReportIssues } from 'jest/ci/reports/codequality_report/mock_data';
-import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser';
+import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/utils/codequality_parser';
describe('Codequality report store utils', () => {
let result;
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 4f5f9c43cb4..798cef252c9 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
@@ -50,11 +50,13 @@ import {
} from '~/ci/runner/constants';
import allRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners.query.graphql';
import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql';
+import runnerJobCountQuery from '~/ci/runner/graphql/list/runner_job_count.query.graphql';
import { captureException } from '~/ci/runner/sentry_utils';
import {
allRunnersData,
runnersCountData,
+ runnerJobCountData,
allRunnersDataPaginated,
onlineContactTimeoutSecs,
staleTimeoutSecs,
@@ -68,6 +70,7 @@ const mockRunnersCount = runnersCountData.data.runners.count;
const mockRunnersHandler = jest.fn();
const mockRunnersCountHandler = jest.fn();
+const mockRunnerJobCountHandler = jest.fn();
jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
@@ -108,6 +111,7 @@ describe('AdminRunnersApp', () => {
const handlers = [
[allRunnersQuery, mockRunnersHandler],
[allRunnersCountQuery, mockRunnersCountHandler],
+ [runnerJobCountQuery, mockRunnerJobCountHandler],
];
wrapper = mountFn(AdminRunnersApp, {
@@ -137,11 +141,13 @@ describe('AdminRunnersApp', () => {
beforeEach(() => {
mockRunnersHandler.mockResolvedValue(allRunnersData);
mockRunnersCountHandler.mockResolvedValue(runnersCountData);
+ mockRunnerJobCountHandler.mockResolvedValue(runnerJobCountData);
});
afterEach(() => {
mockRunnersHandler.mockReset();
mockRunnersCountHandler.mockReset();
+ mockRunnerJobCountHandler.mockReset();
showToast.mockReset();
});
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 27fb288c462..2504458efff 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
@@ -4,6 +4,7 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help
import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerCreatedAt from '~/ci/runner/components/runner_created_at.vue';
+import RunnerJobCount from '~/ci/runner/components/runner_job_count.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';
@@ -157,23 +158,9 @@ describe('RunnerTypeCell', () => {
});
it('Displays job count', () => {
- expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`);
- });
-
- it('Formats large job counts', () => {
- createComponent({
- runner: { jobCount: 1000 },
- });
-
- expect(findRunnerSummaryField('pipeline').text()).toContain('1,000');
- });
-
- it('Formats large job counts with a plus symbol', () => {
- createComponent({
- runner: { jobCount: 1001 },
- });
-
- expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+');
+ expect(
+ findRunnerSummaryField('pipeline').findComponent(RunnerJobCount).props('runner'),
+ ).toEqual(mockRunner);
});
it('Displays creation info', () => {
diff --git a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js
index ffc19d66cac..62ab40b2ebb 100644
--- a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js
+++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js
@@ -1,4 +1,4 @@
-import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlFilteredSearch, GlSorting } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { assertProps } from 'helpers/assert_props';
@@ -32,7 +32,12 @@ describe('RunnerList', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
- const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
+ const findGlSorting = () => wrapper.findComponent(GlSorting);
+ const getSortOptions = () => findGlSorting().props('sortOptions');
+ const getSelectedSortOption = () => {
+ const sortBy = findGlSorting().props('sortBy');
+ return getSortOptions().find(({ value }) => sortBy === value)?.text;
+ };
const mockOtherSort = CONTACTED_DESC;
const mockFilters = [
@@ -56,8 +61,6 @@ describe('RunnerList', () => {
stubs: {
FilteredSearch,
GlFilteredSearch,
- GlDropdown,
- GlDropdownItem,
},
...options,
});
@@ -74,9 +77,10 @@ describe('RunnerList', () => {
it('sets sorting options', () => {
const SORT_OPTIONS_COUNT = 2;
- expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT);
- expect(findSortOptions().at(0).text()).toBe('Created date');
- expect(findSortOptions().at(1).text()).toBe('Last contact');
+ const sortOptionsProp = getSortOptions();
+ expect(sortOptionsProp).toHaveLength(SORT_OPTIONS_COUNT);
+ expect(sortOptionsProp[0].text).toBe('Created date');
+ expect(sortOptionsProp[1].text).toBe('Last contact');
});
it('sets tokens to the filtered search', () => {
@@ -141,12 +145,7 @@ describe('RunnerList', () => {
});
it('sort option is selected', () => {
- expect(
- findSortOptions()
- .filter((w) => w.props('isChecked'))
- .at(0)
- .text(),
- ).toEqual('Last contact');
+ expect(getSelectedSortOption()).toBe('Last contact');
});
it('when the user sets a filter, the "search" preserves the other filters', async () => {
@@ -181,7 +180,7 @@ describe('RunnerList', () => {
});
it('when the user sets a sorting method, the "search" is emitted with the sort', () => {
- findSortOptions().at(1).vm.$emit('click');
+ findGlSorting().vm.$emit('sortByChange', 2);
expectToHaveLastEmittedInput({
runnerType: null,
diff --git a/spec/frontend/ci/runner/components/runner_job_count_spec.js b/spec/frontend/ci/runner/components/runner_job_count_spec.js
new file mode 100644
index 00000000000..01b5ca5332e
--- /dev/null
+++ b/spec/frontend/ci/runner/components/runner_job_count_spec.js
@@ -0,0 +1,74 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import runnerJobCountQuery from '~/ci/runner/graphql/list/runner_job_count.query.graphql';
+
+import RunnerJobCount from '~/ci/runner/components/runner_job_count.vue';
+
+import { runnerJobCountData } from '../mock_data';
+
+const mockRunner = runnerJobCountData.data.runner;
+
+Vue.use(VueApollo);
+
+describe('RunnerJobCount', () => {
+ let wrapper;
+ let runnerJobCountHandler;
+
+ const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
+ wrapper = mountFn(RunnerJobCount, {
+ apolloProvider: createMockApollo([[runnerJobCountQuery, runnerJobCountHandler]]),
+ propsData: {
+ runner: mockRunner,
+ ...props,
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ runnerJobCountHandler = jest.fn().mockReturnValue(new Promise(() => {}));
+ });
+
+ it('Loads data while it displays empty content', () => {
+ createComponent();
+
+ expect(runnerJobCountHandler).toHaveBeenCalledWith({ id: mockRunner.id });
+ expect(wrapper.text()).toBe('-');
+ });
+
+ it('Sets a batch key for the "jobCount" query', () => {
+ createComponent();
+
+ expect(wrapper.vm.$apollo.queries.jobCount.options.context.batchKey).toBe('RunnerJobCount');
+ });
+
+ it('Displays job count', async () => {
+ runnerJobCountHandler.mockResolvedValue(runnerJobCountData);
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toBe('999');
+ });
+
+ it('Displays formatted job count', async () => {
+ runnerJobCountHandler.mockResolvedValue({
+ data: {
+ runner: {
+ ...mockRunner,
+ jobCount: 1001,
+ },
+ },
+ });
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toBe('1,000+');
+ });
+});
diff --git a/spec/frontend/ci/runner/components/runner_managers_detail_spec.js b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js
index 3435292394f..6db9bb1d091 100644
--- a/spec/frontend/ci/runner/components/runner_managers_detail_spec.js
+++ b/spec/frontend/ci/runner/components/runner_managers_detail_spec.js
@@ -85,7 +85,7 @@ describe('RunnerJobs', () => {
});
it('is collapsed', () => {
- expect(findCollapse().attributes('visible')).toBeUndefined();
+ expect(findCollapse().props('visible')).toBe(false);
});
describe('when expanded', () => {
@@ -99,7 +99,7 @@ describe('RunnerJobs', () => {
});
it('shows loading state', () => {
- expect(findCollapse().attributes('visible')).toBe('true');
+ expect(findCollapse().props('visible')).toBe(true);
expect(findSkeletonLoader().exists()).toBe(true);
});
@@ -156,14 +156,14 @@ describe('RunnerJobs', () => {
});
it('shows rows', () => {
- expect(findCollapse().attributes('visible')).toBe('true');
+ expect(findCollapse().props('visible')).toBe(true);
expect(findRunnerManagersTable().props('items')).toEqual(mockRunnerManagers);
});
it('collapses when clicked', async () => {
await findHideDetails().trigger('click');
- expect(findCollapse().attributes('visible')).toBeUndefined();
+ expect(findCollapse().props('visible')).toBe(false);
});
});
});
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 f3d7ae85e0d..3e4cdecb07b 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
@@ -50,12 +50,14 @@ import {
} from '~/ci/runner/constants';
import groupRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners.query.graphql';
import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql';
+import runnerJobCountQuery from '~/ci/runner/graphql/list/runner_job_count.query.graphql';
import GroupRunnersApp from '~/ci/runner/group_runners/group_runners_app.vue';
import { captureException } from '~/ci/runner/sentry_utils';
import {
groupRunnersData,
groupRunnersDataPaginated,
groupRunnersCountData,
+ runnerJobCountData,
onlineContactTimeoutSecs,
staleTimeoutSecs,
mockRegistrationToken,
@@ -72,6 +74,7 @@ const mockGroupRunnersCount = mockGroupRunnersEdges.length;
const mockGroupRunnersHandler = jest.fn();
const mockGroupRunnersCountHandler = jest.fn();
+const mockRunnerJobCountHandler = jest.fn();
jest.mock('~/alert');
jest.mock('~/ci/runner/sentry_utils');
@@ -108,6 +111,7 @@ describe('GroupRunnersApp', () => {
const handlers = [
[groupRunnersQuery, mockGroupRunnersHandler],
[groupRunnersCountQuery, mockGroupRunnersCountHandler],
+ [runnerJobCountQuery, mockRunnerJobCountHandler],
];
wrapper = mountFn(GroupRunnersApp, {
@@ -138,11 +142,13 @@ describe('GroupRunnersApp', () => {
beforeEach(() => {
mockGroupRunnersHandler.mockResolvedValue(groupRunnersData);
mockGroupRunnersCountHandler.mockResolvedValue(groupRunnersCountData);
+ mockRunnerJobCountHandler.mockResolvedValue(runnerJobCountData);
});
afterEach(() => {
mockGroupRunnersHandler.mockReset();
mockGroupRunnersCountHandler.mockReset();
+ mockRunnerJobCountHandler.mockReset();
});
it('shows the runner tabs with a runner count for each type', async () => {
diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js
index 51556650c32..58d8e0ee74a 100644
--- a/spec/frontend/ci/runner/mock_data.js
+++ b/spec/frontend/ci/runner/mock_data.js
@@ -43,6 +43,15 @@ const emptyPageInfo = {
endCursor: '',
};
+const runnerJobCountData = {
+ data: {
+ runner: {
+ id: 'gid://gitlab/Ci::Runner/99',
+ jobCount: 999,
+ },
+ },
+};
+
// Other mock data
// Mock searches and their corresponding urls
@@ -348,6 +357,7 @@ export {
groupRunnersCountData,
emptyPageInfo,
runnerData,
+ runnerJobCountData,
runnerWithGroupData,
runnerProjectsData,
runnerJobsData,
diff --git a/spec/frontend/clusters/agents/components/integration_status_spec.js b/spec/frontend/clusters/agents/components/integration_status_spec.js
index 28a59391578..0f3da3e02be 100644
--- a/spec/frontend/clusters/agents/components/integration_status_spec.js
+++ b/spec/frontend/clusters/agents/components/integration_status_spec.js
@@ -58,7 +58,7 @@ describe('IntegrationStatus', () => {
});
it('sets collapse component as invisible by default', () => {
- expect(findCollapse().props('visible')).toBeUndefined();
+ expect(findCollapse().props('visible')).toBe(false);
});
});
@@ -73,7 +73,7 @@ describe('IntegrationStatus', () => {
});
it('sets collapse component as visible', () => {
- expect(findCollapse().attributes('visible')).toBe('true');
+ expect(findCollapse().props('visible')).toBe(true);
});
});
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 24b2677f497..97b8e1f7fc8 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
@@ -22,7 +22,7 @@ exports[`Comment templates list item component renders list item 1`] = `
<button
aria-controls="reference-1"
aria-labelledby="reference-0"
- class="btn btn-default btn-default-tertiary btn-md gl-button gl-new-dropdown-icon-only gl-new-dropdown-toggle gl-new-dropdown-toggle-no-caret"
+ class="btn btn-default btn-default-tertiary btn-icon btn-md gl-button gl-new-dropdown-icon-only gl-new-dropdown-toggle gl-new-dropdown-toggle-no-caret"
data-testid="base-dropdown-toggle"
id="reference-0"
type="button"
diff --git a/spec/frontend/commit/commit_pipeline_status_spec.js b/spec/frontend/commit/commit_pipeline_status_spec.js
index 08a7ec17785..6d407ed886a 100644
--- a/spec/frontend/commit/commit_pipeline_status_spec.js
+++ b/spec/frontend/commit/commit_pipeline_status_spec.js
@@ -6,7 +6,7 @@ import fixture from 'test_fixtures/pipelines/pipelines.json';
import { createAlert } from '~/alert';
import Poll from '~/lib/utils/poll';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status.vue';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
jest.mock('~/lib/utils/poll');
jest.mock('visibilityjs');
diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
index 008a1b2c068..37ce234c61c 100644
--- a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
+++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
@@ -5,7 +5,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue';
import {
COMMIT_BOX_POLL_INTERVAL,
diff --git a/spec/frontend/commons/nav/user_merge_requests_spec.js b/spec/frontend/commons/nav/user_merge_requests_spec.js
deleted file mode 100644
index 114cbbf812c..00000000000
--- a/spec/frontend/commons/nav/user_merge_requests_spec.js
+++ /dev/null
@@ -1,154 +0,0 @@
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import * as UserApi from '~/api/user_api';
-import {
- openUserCountsBroadcast,
- closeUserCountsBroadcast,
- refreshUserMergeRequestCounts,
-} from '~/commons/nav/user_merge_requests';
-
-jest.mock('~/api');
-
-const TEST_COUNT = 1000;
-const MR_COUNT_CLASS = 'js-merge-requests-count';
-
-describe('User Merge Requests', () => {
- let channelMock;
- let newBroadcastChannelMock;
-
- beforeEach(() => {
- jest.spyOn(document, 'dispatchEvent').mockReturnValue(false);
-
- global.gon.current_user_id = 123;
- global.gon.use_new_navigation = false;
-
- channelMock = {
- postMessage: jest.fn(),
- close: jest.fn(),
- };
- newBroadcastChannelMock = jest.fn().mockImplementation(() => channelMock);
-
- global.BroadcastChannel = newBroadcastChannelMock;
- setHTMLFixture(
- `<div><div class="${MR_COUNT_CLASS}">0</div><div class="js-assigned-mr-count"></div><div class="js-reviewer-mr-count"></div></div>`,
- );
- });
-
- afterEach(() => {
- resetHTMLFixture();
- });
-
- const findMRCountText = () => document.body.querySelector(`.${MR_COUNT_CLASS}`).textContent;
-
- describe('refreshUserMergeRequestCounts', () => {
- beforeEach(() => {
- jest.spyOn(UserApi, 'getUserCounts').mockResolvedValue({
- data: {
- assigned_merge_requests: TEST_COUNT,
- review_requested_merge_requests: TEST_COUNT,
- },
- });
- });
-
- describe('with open broadcast channel', () => {
- beforeEach(() => {
- openUserCountsBroadcast();
-
- return refreshUserMergeRequestCounts();
- });
-
- it('updates the top count of merge requests', () => {
- expect(findMRCountText()).toEqual(Number(TEST_COUNT + TEST_COUNT).toLocaleString());
- });
-
- it('calls the API', () => {
- expect(UserApi.getUserCounts).toHaveBeenCalled();
- });
-
- it('posts count to BroadcastChannel', () => {
- expect(channelMock.postMessage).toHaveBeenCalledWith(TEST_COUNT + TEST_COUNT);
- });
- });
-
- describe('without open broadcast channel', () => {
- beforeEach(() => refreshUserMergeRequestCounts());
-
- it('does not post anything', () => {
- expect(channelMock.postMessage).not.toHaveBeenCalled();
- });
- });
-
- it('does not emit event to refetch counts', () => {
- expect(document.dispatchEvent).not.toHaveBeenCalled();
- });
- });
-
- describe('openUserCountsBroadcast', () => {
- beforeEach(() => {
- openUserCountsBroadcast();
- });
-
- it('creates BroadcastChannel that updates DOM on message received', () => {
- expect(findMRCountText()).toEqual('0');
-
- channelMock.onmessage({ data: TEST_COUNT });
-
- expect(newBroadcastChannelMock).toHaveBeenCalled();
- expect(findMRCountText()).toEqual(TEST_COUNT.toLocaleString());
- });
-
- it('closes if called while already open', () => {
- expect(channelMock.close).not.toHaveBeenCalled();
-
- openUserCountsBroadcast();
-
- expect(newBroadcastChannelMock).toHaveBeenCalled();
- expect(channelMock.close).toHaveBeenCalled();
- });
- });
-
- describe('closeUserCountsBroadcast', () => {
- describe('when not opened', () => {
- it('does nothing', () => {
- expect(channelMock.close).not.toHaveBeenCalled();
- });
- });
-
- describe('when opened', () => {
- beforeEach(() => {
- openUserCountsBroadcast();
- });
-
- it('closes', () => {
- expect(channelMock.close).not.toHaveBeenCalled();
-
- closeUserCountsBroadcast();
-
- expect(channelMock.close).toHaveBeenCalled();
- });
- });
- });
-
- describe('if new navigation is enabled', () => {
- beforeEach(() => {
- global.gon.use_new_navigation = true;
- jest.spyOn(UserApi, 'getUserCounts');
- });
-
- it('openUserCountsBroadcast is a noop', () => {
- openUserCountsBroadcast();
- expect(newBroadcastChannelMock).not.toHaveBeenCalled();
- });
-
- describe('refreshUserMergeRequestCounts', () => {
- it('does not call api', async () => {
- await refreshUserMergeRequestCounts();
- expect(UserApi.getUserCounts).not.toHaveBeenCalled();
- });
-
- it('emits event to refetch counts', async () => {
- await refreshUserMergeRequestCounts();
- expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('todo:toggle'));
- });
- });
- });
-});
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
index a708f7d5f47..0fafd42095b 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_button_spec.js.snap
@@ -3,7 +3,7 @@
exports[`content_editor/components/toolbar_button displays tertiary, medium button with a provided label and icon 1`] = `
<b-button-stub
aria-label="Bold"
- class="btn-default-tertiary btn-icon gl-button gl-mr-3"
+ class="btn-default-tertiary btn-icon gl-button gl-mr-2"
size="sm"
tag="button"
title="Bold"
diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
index 2a6ab75227c..6e8a6092667 100644
--- a/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/code_block_bubble_menu_spec.js
@@ -80,6 +80,7 @@ describe('content_editor/components/bubble_menus/code_block_bubble_menu', () =>
await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text');
+ expect(wrapper.findComponent(GlDropdown).attributes('contenteditable')).toBe(String(false));
});
it('selects appropriate language based on the code block', async () => {
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 816c9458201..bbc0203344c 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -1,6 +1,8 @@
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { EditorContent, Editor } from '@tiptap/vue-2';
import { nextTick } from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from 'axios';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
@@ -16,11 +18,10 @@ import waitForPromises from 'helpers/wait_for_promises';
import { KEYDOWN_EVENT } from '~/content_editor/constants';
import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
-jest.mock('~/emoji');
-
describe('ContentEditor', () => {
let wrapper;
let renderMarkdown;
+ let mock;
const uploadsPath = '/uploads';
const findEditorElement = () => wrapper.findByTestId('content-editor');
@@ -32,6 +33,7 @@ describe('ContentEditor', () => {
wrapper = shallowMountExtended(ContentEditor, {
propsData: {
renderMarkdown,
+ markdownDocsPath: '/docs/markdown',
uploadsPath,
markdown,
autofocus,
@@ -49,9 +51,17 @@ describe('ContentEditor', () => {
};
beforeEach(() => {
+ mock = new MockAdapter(axios);
+ // ignore /-/emojis requests
+ mock.onGet().reply(200, []);
+
renderMarkdown = jest.fn();
});
+ afterEach(() => {
+ mock.restore();
+ });
+
it('triggers initialized event', () => {
createWrapper();
diff --git a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js
index ee3ad59bf9a..b17a1b5fc11 100644
--- a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js
@@ -1,5 +1,6 @@
-import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
+import { GlAvatar, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue';
@@ -14,11 +15,17 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
command: jest.fn(),
...propsData,
},
+ stubs: ['gl-emoji'],
}),
);
};
- const exampleUser = { username: 'root', avatar_url: 'root_avatar.png', type: 'User' };
+ const exampleUser = {
+ username: 'root',
+ avatar_url: 'root_avatar.png',
+ type: 'User',
+ name: 'Administrator',
+ };
const exampleIssue = { iid: 123, title: 'Test Issue' };
const exampleMergeRequest = { iid: 224, title: 'Test MR' };
const exampleMilestone1 = { iid: 21, title: '13' };
@@ -61,11 +68,14 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
title: 'Project creation QueryRecorder logs',
};
const exampleEmoji = {
- c: 'people',
- e: '😃',
- d: 'smiling face with open mouth',
- u: '6.0',
- name: 'smiley',
+ emoji: {
+ c: 'people',
+ e: '😃',
+ d: 'smiling face with open mouth',
+ u: '6.0',
+ name: 'smiley',
+ },
+ fieldValue: 'smiley',
};
const insertedEmojiProps = {
@@ -95,6 +105,68 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(loading);
});
+ it('selects first item if query is not empty and items are available', async () => {
+ buildWrapper({
+ propsData: {
+ char: '@',
+ nodeType: 'reference',
+ nodeProps: {
+ referenceType: 'member',
+ },
+ items: [exampleUser],
+ query: 'ro',
+ },
+ });
+
+ await nextTick();
+
+ expect(
+ wrapper.findByTestId('content-editor-suggestions-dropdown').find('li').classes(),
+ ).toContain('focused');
+ });
+
+ describe('when query is defined', () => {
+ it.each`
+ nodeType | referenceType | reference | query | expectedHTML
+ ${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'<strong class="gl-text-body!">r</strong>oot'}
+ ${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'Administ<strong class="gl-text-body!">r</strong>ator'}
+ ${'reference'} | ${'issue'} | ${exampleIssue} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> Issue'}
+ ${'reference'} | ${'issue'} | ${exampleIssue} | ${'12'} | ${'<strong class="gl-text-body!">12</strong>3'}
+ ${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> MR'}
+ ${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'22'} | ${'<strong class="gl-text-body!">22</strong>4'}
+ ${'reference'} | ${'epic'} | ${exampleEpic} | ${'rem'} | ${'❓ <strong class="gl-text-body!">Rem</strong>ote Development | Solution validation'}
+ ${'reference'} | ${'epic'} | ${exampleEpic} | ${'88'} | ${'gitlab-org&amp;<strong class="gl-text-body!">88</strong>84'}
+ ${'reference'} | ${'milestone'} | ${exampleMilestone1} | ${'1'} | ${'<strong class="gl-text-body!">1</strong>3'}
+ ${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'<strong class="gl-text-body!">due</strong>'}
+ ${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'Set <strong class="gl-text-body!">due</strong> date'}
+ ${'reference'} | ${'label'} | ${exampleLabel1} | ${'c'} | ${'<strong class="gl-text-body!">C</strong>reate'}
+ ${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'network'} | ${'System procs <strong class="gl-text-body!">network</strong> activity'}
+ ${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'85'} | ${'60<strong class="gl-text-body!">85</strong>0147'}
+ ${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'project'} | ${'<strong class="gl-text-body!">Project</strong> creation QueryRecorder logs'}
+ ${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'242'} | ${'<strong class="gl-text-body!">242</strong>0859'}
+ ${'emoji'} | ${'emoji'} | ${exampleEmoji} | ${'sm'} | ${'<strong class="gl-text-body!">sm</strong>iley'}
+ `(
+ 'highlights query as bolded in $referenceType text',
+ ({ nodeType, referenceType, reference, query, expectedHTML }) => {
+ buildWrapper({
+ propsData: {
+ char: '@',
+ nodeType,
+ nodeProps: {
+ referenceType,
+ },
+ items: [reference],
+ query,
+ },
+ });
+
+ expect(wrapper.findByTestId('content-editor-suggestions-dropdown').html()).toContain(
+ expectedHTML,
+ );
+ },
+ );
+ });
+
describe('on item select', () => {
it.each`
nodeType | referenceType | char | reference | insertedText | insertedProps
@@ -146,7 +218,7 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
});
describe('rendering user references', () => {
- it('displays avatar labeled component', () => {
+ it('displays avatar component', () => {
buildWrapper({
propsData: {
char: '@',
@@ -157,13 +229,11 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
},
});
- expect(wrapper.findComponent(GlAvatarLabeled).attributes()).toEqual(
- expect.objectContaining({
- label: exampleUser.username,
- shape: 'circle',
- src: exampleUser.avatar_url,
- }),
- );
+ expect(wrapper.findComponent(GlAvatar).attributes()).toMatchObject({
+ entityname: exampleUser.username,
+ shape: 'circle',
+ src: exampleUser.avatar_url,
+ });
});
describe.each`
@@ -273,20 +343,46 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
it('displays emoji', () => {
const testEmojis = [
{
- c: 'people',
- e: '😄',
- d: 'smiling face with open mouth and smiling eyes',
- u: '6.0',
- name: 'smile',
+ emoji: {
+ c: 'people',
+ e: '😄',
+ d: 'smiling face with open mouth and smiling eyes',
+ u: '6.0',
+ name: 'smile',
+ },
+ fieldValue: 'smile',
+ },
+ {
+ emoji: {
+ c: 'people',
+ e: '😸',
+ d: 'grinning cat face with smiling eyes',
+ u: '6.0',
+ name: 'smile_cat',
+ },
+ fieldValue: 'smile_cat',
+ },
+ {
+ emoji: {
+ c: 'people',
+ e: '😃',
+ d: 'smiling face with open mouth',
+ u: '6.0',
+ name: 'smiley',
+ },
+ fieldValue: 'smiley',
},
{
- c: 'people',
- e: '😸',
- d: 'grinning cat face with smiling eyes',
- u: '6.0',
- name: 'smile_cat',
+ emoji: {
+ c: 'custom',
+ e: null,
+ d: 'party-parrot',
+ u: 'custom',
+ name: 'party-parrot',
+ src: 'https://cultofthepartyparrot.com/parrots/hd/parrot.gif',
+ },
+ fieldValue: 'party-parrot',
},
- { c: 'people', e: '😃', d: 'smiling face with open mouth', u: '6.0', name: 'smiley' },
];
buildWrapper({
@@ -298,11 +394,41 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
},
});
- testEmojis.forEach((testEmoji) => {
- expect(wrapper.text()).toContain(testEmoji.e);
- expect(wrapper.text()).toContain(testEmoji.d);
- expect(wrapper.text()).toContain(testEmoji.name);
- });
+ expect(wrapper.findAllComponents('gl-emoji-stub').at(0).html()).toMatchInlineSnapshot(`
+ <gl-emoji-stub
+ data-name="smile"
+ data-unicode-version="6.0"
+ title="smiling face with open mouth and smiling eyes"
+ >
+ 😄
+ </gl-emoji-stub>
+ `);
+ expect(wrapper.findAllComponents('gl-emoji-stub').at(1).html()).toMatchInlineSnapshot(`
+ <gl-emoji-stub
+ data-name="smile_cat"
+ data-unicode-version="6.0"
+ title="grinning cat face with smiling eyes"
+ >
+ 😸
+ </gl-emoji-stub>
+ `);
+ expect(wrapper.findAllComponents('gl-emoji-stub').at(2).html()).toMatchInlineSnapshot(`
+ <gl-emoji-stub
+ data-name="smiley"
+ data-unicode-version="6.0"
+ title="smiling face with open mouth"
+ >
+ 😃
+ </gl-emoji-stub>
+ `);
+ expect(wrapper.findAllComponents('gl-emoji-stub').at(3).html()).toMatchInlineSnapshot(`
+ <gl-emoji-stub
+ data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif"
+ data-name="party-parrot"
+ data-unicode-version="custom"
+ title="party-parrot"
+ />
+ `);
});
});
});
diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
index 0093393eceb..1f15dc17f7f 100644
--- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
@@ -97,6 +97,7 @@ describe('content/components/wrappers/code_block', () => {
const label = wrapper.findByTestId('frontmatter-label');
expect(label.text()).toEqual('frontmatter:yaml');
+ expect(label.attributes('contenteditable')).toBe('false');
expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']);
});
@@ -128,6 +129,9 @@ describe('content/components/wrappers/code_block', () => {
await nextTick();
expect(wrapper.find('img').attributes('src')).toBe('url/to/some/diagram');
+ expect(wrapper.findByTestId('sandbox-preview').attributes('contenteditable')).toBe(
+ String(false),
+ );
jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(false);
@@ -214,6 +218,9 @@ describe('content/components/wrappers/code_block', () => {
});
it('shows a code suggestion block', () => {
+ expect(wrapper.findByTestId('code-suggestion-box').attributes('contenteditable')).toBe(
+ 'false',
+ );
expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 5');
expect(findCodeDeleted()).toMatchInlineSnapshot(`
<code
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 275f48ea857..94628f2b2c5 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
@@ -165,6 +165,9 @@ describe('content/components/wrappers/table_cell_base', () => {
it('does not allow adding a row before the header', () => {
expect(findDropdown().text()).not.toContain('Insert row before');
+ expect(wrapper.findByTestId('actions-dropdown').attributes('contenteditable')).toBe(
+ 'false',
+ );
});
it('does not allow removing the header row', async () => {
diff --git a/spec/frontend/content_editor/extensions/copy_paste_spec.js b/spec/frontend/content_editor/extensions/copy_paste_spec.js
index e290b4e5137..6969f4985a1 100644
--- a/spec/frontend/content_editor/extensions/copy_paste_spec.js
+++ b/spec/frontend/content_editor/extensions/copy_paste_spec.js
@@ -20,12 +20,6 @@ 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 CODE_SUGGESTION_HTML =
- '<pre data-lang-params="-0+0" class="js-syntax-highlight language-suggestion" lang="suggestion">Suggested code</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 dir="auto">Some text with <strong>bold</strong> and <em>italic</em> text.</p>';
@@ -123,19 +117,6 @@ describe('content_editor/extensions/copy_paste', () => {
expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(true);
});
- it.each`
- nodeType | html | handled | desc
- ${'codeBlock'} | ${CODE_BLOCK_HTML} | ${false} | ${'does not handle'}
- ${'codeSuggestion'} | ${CODE_SUGGESTION_HTML} | ${false} | ${'does not handle'}
- ${'diagram'} | ${DIAGRAM_HTML} | ${false} | ${'does not handle'}
- ${'frontmatter'} | ${FRONTMATTER_HTML} | ${false} | ${'does not handle'}
- ${'paragraph'} | ${PARAGRAPH_HTML} | ${true} | ${'handles'}
- `('$desc paste if currently a `$nodeType` is in focus', async ({ html, handled }) => {
- tiptapEditor.commands.insertContent(html);
-
- expect(await triggerPasteEventHandler(buildClipboardEvent())).toBe(handled);
- });
-
describe.each`
eventName | expectedDoc
${'cut'} | ${() => doc(p())}
diff --git a/spec/frontend/content_editor/extensions/reference_spec.js b/spec/frontend/content_editor/extensions/reference_spec.js
index c25c7c41d75..d4b07d5127e 100644
--- a/spec/frontend/content_editor/extensions/reference_spec.js
+++ b/spec/frontend/content_editor/extensions/reference_spec.js
@@ -1,9 +1,15 @@
import Reference from '~/content_editor/extensions/reference';
+import ReferenceLabel from '~/content_editor/extensions/reference_label';
import AssetResolver from '~/content_editor/services/asset_resolver';
import {
RESOLVED_ISSUE_HTML,
RESOLVED_MERGE_REQUEST_HTML,
RESOLVED_EPIC_HTML,
+ RESOLVED_LABEL_HTML,
+ RESOLVED_SNIPPET_HTML,
+ RESOLVED_MILESTONE_HTML,
+ RESOLVED_USER_HTML,
+ RESOLVED_VULNERABILITY_HTML,
} from '../test_constants';
import {
createTestEditor,
@@ -17,6 +23,7 @@ describe('content_editor/extensions/reference', () => {
let doc;
let p;
let reference;
+ let referenceLabel;
let renderMarkdown;
let assetResolver;
@@ -25,33 +32,54 @@ describe('content_editor/extensions/reference', () => {
assetResolver = new AssetResolver({ renderMarkdown });
tiptapEditor = createTestEditor({
- extensions: [Reference.configure({ assetResolver })],
+ extensions: [Reference.configure({ assetResolver }), ReferenceLabel],
});
({
- builders: { doc, p, reference },
+ builders: { doc, p, reference, referenceLabel },
} = createDocBuilder({
tiptapEditor,
names: {
reference: { nodeType: Reference.name },
+ referenceLabel: { nodeType: ReferenceLabel.name },
},
}));
});
describe('when typing a valid reference input rule', () => {
- const buildExpectedDoc = (href, originalText, referenceType, text) =>
+ const buildExpectedDoc = (href, originalText, referenceType, text = originalText) =>
doc(p(reference({ className: null, href, originalText, referenceType, text }), ' '));
+ const buildExpectedDocForLabel = (href, originalText, text, color) =>
+ doc(
+ p(
+ referenceLabel({
+ className: null,
+ referenceType: 'label',
+ href,
+ originalText,
+ text,
+ color,
+ }),
+ ' ',
+ ),
+ );
+
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)')}
+ 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)')}
+ ${'@root'} | ${RESOLVED_USER_HTML} | ${() => buildExpectedDoc('/root', '@root', 'user')}
+ ${'~Aquanix'} | ${RESOLVED_LABEL_HTML} | ${() => buildExpectedDocForLabel('/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix', '~Aquanix', 'Aquanix', 'rgb(230, 84, 49)')}
+ ${'%v4.0'} | ${RESOLVED_MILESTONE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab-shell/-/milestones/5', '%v4.0', 'milestone')}
+ ${'$25'} | ${RESOLVED_SNIPPET_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab-shell/-/snippets/25', '$25', 'snippet')}
+ ${'[vulnerability:1]'} | ${RESOLVED_VULNERABILITY_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab-shell/-/security/vulnerabilities/1', '[vulnerability:1]', 'vulnerability')}
`(
'replaces the input rule ($inputRuleText) with a reference node',
async ({ inputRuleText, mockReferenceHtml, expectedDoc }) => {
@@ -61,8 +89,8 @@ describe('content_editor/extensions/reference', () => {
action() {
renderMarkdown.mockResolvedValueOnce(mockReferenceHtml);
- tiptapEditor.commands.insertContent({ type: 'text', text: inputRuleText });
- triggerNodeInputRule({ tiptapEditor, inputRuleText });
+ tiptapEditor.commands.insertContent({ type: 'text', text: `${inputRuleText} ` });
+ triggerNodeInputRule({ tiptapEditor, inputRuleText: `${inputRuleText} ` });
},
});
diff --git a/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap b/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap
new file mode 100644
index 00000000000..2d16c6b1a2f
--- /dev/null
+++ b/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap
@@ -0,0 +1,256 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DataSourceFactory filters items based on command "/assign" for reference type "user" and command 1`] = `
+Array [
+ "florida.schoen",
+ "root",
+ "all",
+ "lakeesha.batz",
+ "laurene_blick",
+ "myrtis",
+ "patty",
+ "Commit451",
+ "flightjs",
+ "gitlab-instance-ade037f9",
+ "gitlab-org",
+ "gnuwget",
+ "h5bp",
+ "jashkenas",
+ "twitter",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/assign_reviewer" for reference type "user" and command 1`] = `
+Array [
+ "florida.schoen",
+ "root",
+ "all",
+ "errol",
+ "evelynn_olson",
+ "Commit451",
+ "flightjs",
+ "gitlab-instance-ade037f9",
+ "gitlab-org",
+ "gnuwget",
+ "h5bp",
+ "jashkenas",
+ "twitter",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/label" for reference type "label" and command 1`] = `
+Array [
+ "Bronce",
+ "Contour",
+ "Corolla",
+ "Cygsync",
+ "Frontier",
+ "Grand Am",
+ "Onesync",
+ "Phone",
+ "Pynefunc",
+ "Trinix",
+ "Trounswood",
+ "group::knowledge",
+ "scoped label",
+ "type::one",
+ "type::two",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/reassign" for reference type "user" and command 1`] = `
+Array [
+ "florida.schoen",
+ "root",
+ "all",
+ "errol",
+ "evelynn_olson",
+ "lakeesha.batz",
+ "laurene_blick",
+ "myrtis",
+ "patty",
+ "Commit451",
+ "flightjs",
+ "gitlab-instance-ade037f9",
+ "gitlab-org",
+ "gnuwget",
+ "h5bp",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/reassign_reviewer" for reference type "user" and command 1`] = `
+Array [
+ "florida.schoen",
+ "root",
+ "all",
+ "errol",
+ "evelynn_olson",
+ "lakeesha.batz",
+ "laurene_blick",
+ "myrtis",
+ "patty",
+ "Commit451",
+ "flightjs",
+ "gitlab-instance-ade037f9",
+ "gitlab-org",
+ "gnuwget",
+ "h5bp",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/relabel" for reference type "label" and command 1`] = `
+Array [
+ "Amsche",
+ "Brioffe",
+ "Bronce",
+ "Bryncefunc",
+ "Contour",
+ "Corolla",
+ "Cygsync",
+ "Frontier",
+ "Ghost",
+ "Grand Am",
+ "Onesync",
+ "Phone",
+ "Pynefunc",
+ "Trinix",
+ "Trounswood",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/unassign" for reference type "user" and command 1`] = `
+Array [
+ "errol",
+ "evelynn_olson",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/unassign_reviewer" for reference type "user" and command 1`] = `
+Array [
+ "lakeesha.batz",
+ "laurene_blick",
+ "myrtis",
+ "patty",
+]
+`;
+
+exports[`DataSourceFactory filters items based on command "/unlabel" for reference type "label" and command 1`] = `
+Array [
+ "Amsche",
+ "Brioffe",
+ "Bryncefunc",
+ "Ghost",
+]
+`;
+
+exports[`DataSourceFactory for reference type "command", searches for "re" correctly 1`] = `
+Array [
+ "relabel",
+ "remove_milestone",
+ "remove_estimate",
+ "remove_time_spent",
+ "relate",
+ "remove_epic",
+ "reassign",
+ "create_merge_request",
+]
+`;
+
+exports[`DataSourceFactory for reference type "epic", searches for "n" correctly 1`] = `
+Array [
+ "Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.",
+ "Minus eius ut omnis quos sunt dicta ex ipsum.",
+ "Quae nostrum possimus rerum aliquam pariatur a eos aut id.",
+ "Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.",
+ "Doloremque a quisquam qui culpa numquam doloribus similique iure enim.",
+]
+`;
+
+exports[`DataSourceFactory for reference type "issue", searches for "q" correctly 1`] = `
+Array [
+ "Quasi id et et nihil sint autem.",
+ "Eaque omnis eius quas necessitatibus hic ut et corrupti.",
+ "Aut quisquam magnam eos distinctio incidunt perferendis fugit.",
+ "Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.",
+ "Nesciunt quia molestiae in aliquam amet et dolorem.",
+ "Porro tempore qui qui culpa saepe et nam quos.",
+ "Sed sint a est consequatur quae quasi autem debitis alias.",
+ "Molestiae minima maxime optio nihil quam eveniet dolor.",
+ "Et laboriosam aut ratione voluptatem quasi recusandae.",
+ "Et molestiae delectus voluptates velit vero illo aut rerum quo et.",
+]
+`;
+
+exports[`DataSourceFactory for reference type "label", searches for "c" correctly 1`] = `
+Array [
+ "Contour",
+ "Corolla",
+ "Cygsync",
+ "scoped label",
+ "Amsche",
+ "Bronce",
+ "Bryncefunc",
+ "Onesync",
+ "Pynefunc",
+]
+`;
+
+exports[`DataSourceFactory for reference type "merge_request", searches for "n" correctly 1`] = `
+Array [
+ "Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.",
+ "Optio nemo qui dolorem sit ipsum qui saepe.",
+ "Draft: Alunny/publish lib",
+ "Draft: Fix event current target",
+ "Draft: Resolve \\"hgvbbvnnb\\"",
+ "Autem eaque et sed provident enim corrupti molestiae.",
+ "Always call registry's trigger method from withRegistration",
+]
+`;
+
+exports[`DataSourceFactory for reference type "milestone", searches for "16" correctly 1`] = `
+Array [
+ "16.7",
+ "16.8",
+ "16.9",
+ "16.10",
+ "16.11",
+ "16.0 (expired)",
+ "16.1 (expired)",
+ "16.2 (expired)",
+ "16.3 (expired)",
+ "16.4 (expired)",
+ "16.5 (expired)",
+ "16.6 (expired)",
+]
+`;
+
+exports[`DataSourceFactory for reference type "snippet", searches for "s" correctly 1`] = `
+Array [
+ "ss",
+ "test snippet",
+ "another test snippet",
+]
+`;
+
+exports[`DataSourceFactory for reference type "user", searches for "r" correctly 1`] = `
+Array [
+ "root",
+ "errol",
+ "lakeesha.batz",
+ "myrtis",
+ "florida.schoen",
+ "laurene_blick",
+ "all",
+ "twitter",
+ "gitlab-org",
+ "evelynn_olson",
+]
+`;
+
+exports[`DataSourceFactory for reference type "vulnerability", searches for "cross" correctly 1`] = `
+Array [
+ "Cross Site Scripting (Persistent)",
+ "Cross Site Scripting (Persistent)",
+ "Cross Site Scripting (Persistent)",
+]
+`;
diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js
index 292eec6db77..b0135a6bc9f 100644
--- a/spec/frontend/content_editor/services/asset_resolver_spec.js
+++ b/spec/frontend/content_editor/services/asset_resolver_spec.js
@@ -3,6 +3,11 @@ import {
RESOLVED_ISSUE_HTML,
RESOLVED_MERGE_REQUEST_HTML,
RESOLVED_EPIC_HTML,
+ RESOLVED_LABEL_HTML,
+ RESOLVED_SNIPPET_HTML,
+ RESOLVED_MILESTONE_HTML,
+ RESOLVED_USER_HTML,
+ RESOLVED_VULNERABILITY_HTML,
} from '../test_constants';
describe('content_editor/services/asset_resolver', () => {
@@ -48,6 +53,32 @@ describe('content_editor/services/asset_resolver', () => {
text: '!1 (merged)',
};
+ const resolvedLabel = {
+ backgroundColor: 'rgb(230, 84, 49)',
+ href: '/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix',
+ text: 'Aquanix',
+ };
+
+ const resolvedSnippet = {
+ href: '/gitlab-org/gitlab-shell/-/snippets/25',
+ text: '$25',
+ };
+
+ const resolvedMilestone = {
+ href: '/gitlab-org/gitlab-shell/-/milestones/5',
+ text: '%v4.0',
+ };
+
+ const resolvedUser = {
+ href: '/root',
+ text: '@root',
+ };
+
+ const resolvedVulnerability = {
+ href: '/gitlab-org/gitlab-shell/-/security/vulnerabilities/1',
+ text: '[vulnerability:1]',
+ };
+
describe.each`
referenceType | referenceId | sentMarkdown | returnedHtml | resolvedReference
${'issue'} | ${'#1'} | ${'#1 #1+ #1+s'} | ${RESOLVED_ISSUE_HTML} | ${resolvedIssue}
@@ -59,7 +90,9 @@ describe('content_editor/services/asset_resolver', () => {
it(`resolves ${referenceType} reference to href, text, title and summary`, async () => {
renderMarkdown.mockResolvedValue(returnedHtml);
- expect(await assetResolver.resolveReference(referenceId)).toEqual(resolvedReference);
+ expect(await assetResolver.resolveReference(referenceId)).toMatchObject(
+ resolvedReference,
+ );
});
it.each`
@@ -74,6 +107,26 @@ describe('content_editor/services/asset_resolver', () => {
},
);
+ describe.each`
+ referenceType | referenceId | returnedHtml | resolvedReference
+ ${'label'} | ${'~Aquanix'} | ${RESOLVED_LABEL_HTML} | ${resolvedLabel}
+ ${'snippet'} | ${'$25'} | ${RESOLVED_SNIPPET_HTML} | ${resolvedSnippet}
+ ${'milestone'} | ${'%v4.0'} | ${RESOLVED_MILESTONE_HTML} | ${resolvedMilestone}
+ ${'user'} | ${'@root'} | ${RESOLVED_USER_HTML} | ${resolvedUser}
+ ${'vulnerability'} | ${'[vulnerability:1]'} | ${RESOLVED_VULNERABILITY_HTML} | ${resolvedVulnerability}
+ `(
+ 'for reference type $referenceType',
+ ({ referenceType, referenceId, returnedHtml, resolvedReference }) => {
+ it(`resolves ${referenceType} reference to href, text and additional props (if any)`, async () => {
+ renderMarkdown.mockResolvedValue(returnedHtml);
+
+ expect(await assetResolver.resolveReference(referenceId)).toMatchObject(
+ resolvedReference,
+ );
+ });
+ },
+ );
+
it.each`
case | sentMarkdown | returnedHtml
${'no html is returned'} | ${''} | ${''}
diff --git a/spec/frontend/content_editor/services/autocomplete_mock_data.js b/spec/frontend/content_editor/services/autocomplete_mock_data.js
new file mode 100644
index 00000000000..c1bf2a6ae5b
--- /dev/null
+++ b/spec/frontend/content_editor/services/autocomplete_mock_data.js
@@ -0,0 +1,967 @@
+export const MOCK_MEMBERS = [
+ {
+ type: 'User',
+ username: 'florida.schoen',
+ name: 'Anglea Durgan',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/ac82b5615d3308ecbcacedad361af8e7?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ type: 'User',
+ username: 'root',
+ name: 'Administrator',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ username: 'all',
+ name: 'All Project and Group Members',
+ count: 8,
+ },
+ {
+ type: 'User',
+ username: 'errol',
+ name: "Linnie O'Connell",
+ avatar_url:
+ 'https://www.gravatar.com/avatar/d3d9a468a9884eb217fad5ca5b2b9bd7?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ type: 'User',
+ username: 'evelynn_olson',
+ name: 'Dimple Dare',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/bc1e51ee3512c2b4442f51732d655107?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ type: 'User',
+ username: 'lakeesha.batz',
+ name: 'Larae Veum',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e5605cb9bbb1a28640d65f25f256e541?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ type: 'User',
+ username: 'laurene_blick',
+ name: 'Evelina Murray',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/389768eef61b7b2d125c64ee01c240fb?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ type: 'User',
+ username: 'myrtis',
+ name: 'Fernanda Adams',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/719d5569bd31d4a70e350b4205fa2cb5?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ type: 'User',
+ username: 'patty',
+ name: 'Emily Toy',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/dca2077b662338808459dc11e70d6688?s=80\u0026d=identicon',
+ availability: null,
+ },
+ {
+ type: 'Group',
+ username: 'Commit451',
+ name: 'Commit451',
+ avatar_url: null,
+ count: 5,
+ mentionsDisabled: null,
+ },
+ {
+ type: 'Group',
+ username: 'flightjs',
+ name: 'Flightjs',
+ avatar_url: null,
+ count: 5,
+ mentionsDisabled: null,
+ },
+ {
+ type: 'Group',
+ username: 'gitlab-instance-ade037f9',
+ name: 'GitLab Instance',
+ avatar_url: null,
+ count: 1,
+ mentionsDisabled: null,
+ },
+ {
+ type: 'Group',
+ username: 'gitlab-org',
+ name: 'Gitlab Org',
+ avatar_url: null,
+ count: 5,
+ mentionsDisabled: null,
+ },
+ {
+ type: 'Group',
+ username: 'gnuwget',
+ name: 'Gnuwget',
+ avatar_url: null,
+ count: 5,
+ mentionsDisabled: null,
+ },
+ {
+ type: 'Group',
+ username: 'h5bp',
+ name: 'H5bp',
+ avatar_url: null,
+ count: 4,
+ mentionsDisabled: null,
+ },
+ {
+ type: 'Group',
+ username: 'jashkenas',
+ name: 'Jashkenas',
+ avatar_url: null,
+ count: 5,
+ mentionsDisabled: null,
+ },
+ {
+ type: 'Group',
+ username: 'twitter',
+ name: 'Twitter',
+ avatar_url: null,
+ count: 5,
+ mentionsDisabled: null,
+ },
+];
+
+export const MOCK_ASSIGNEES = MOCK_MEMBERS.filter(
+ ({ username }) => username === 'errol' || username === 'evelynn_olson',
+);
+
+export const MOCK_REVIEWERS = MOCK_MEMBERS.filter(
+ ({ username }) =>
+ username === 'lakeesha.batz' ||
+ username === 'laurene_blick' ||
+ username === 'myrtis' ||
+ username === 'patty',
+);
+
+export const MOCK_ISSUES = [
+ {
+ iid: 31,
+ title: 'rdfhdfj',
+ id: null,
+ },
+ {
+ iid: 30,
+ title: 'incident1',
+ id: null,
+ },
+ {
+ iid: 29,
+ title: 'example feature rollout',
+ id: null,
+ },
+ {
+ iid: 28,
+ title: 'sagasg',
+ id: null,
+ },
+ {
+ iid: 26,
+ title: 'Quasi id et et nihil sint autem.',
+ id: null,
+ },
+ {
+ iid: 25,
+ title: 'Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.',
+ id: null,
+ },
+ {
+ iid: 24,
+ title: 'Et molestiae delectus voluptates velit vero illo aut rerum quo et.',
+ id: null,
+ },
+ {
+ iid: 23,
+ title: 'Nesciunt quia molestiae in aliquam amet et dolorem.',
+ id: null,
+ },
+ {
+ iid: 22,
+ title: 'Sint asperiores unde vel autem delectus ullam dolor nihil et.',
+ id: null,
+ },
+ {
+ iid: 21,
+ title: 'Eaque omnis eius quas necessitatibus hic ut et corrupti.',
+ id: null,
+ },
+ {
+ iid: 20,
+ title: 'Porro tempore qui qui culpa saepe et nam quos.',
+ id: null,
+ },
+ {
+ iid: 19,
+ title: 'Molestiae minima maxime optio nihil quam eveniet dolor.',
+ id: null,
+ },
+ {
+ iid: 18,
+ title: 'Sed sint a est consequatur quae quasi autem debitis alias.',
+ id: null,
+ },
+ {
+ iid: 6,
+ title: 'Et laboriosam aut ratione voluptatem quasi recusandae.',
+ id: null,
+ },
+ {
+ iid: 2,
+ title: 'Aut quisquam magnam eos distinctio incidunt perferendis fugit.',
+ id: null,
+ },
+];
+
+export const MOCK_EPICS = [
+ {
+ iid: 6,
+ title: 'sgs',
+ reference: 'flightjs\u00266',
+ },
+ {
+ iid: 5,
+ title: 'Doloremque a quisquam qui culpa numquam doloribus similique iure enim.',
+ reference: 'flightjs\u00265',
+ },
+ {
+ iid: 4,
+ title: 'Minus eius ut omnis quos sunt dicta ex ipsum.',
+ reference: 'flightjs\u00264',
+ },
+ {
+ iid: 3,
+ title: 'Quae nostrum possimus rerum aliquam pariatur a eos aut id.',
+ reference: 'flightjs\u00263',
+ },
+ {
+ iid: 2,
+ title: 'Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.',
+ reference: 'flightjs\u00262',
+ },
+ {
+ iid: 1,
+ title: 'Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.',
+ reference: 'flightjs\u00261',
+ },
+];
+
+export const MOCK_MERGE_REQUESTS = [
+ {
+ iid: 12,
+ title: "Always call registry's trigger method from withRegistration",
+ id: null,
+ },
+ {
+ iid: 11,
+ title: 'Draft: Alunny/publish lib',
+ id: null,
+ },
+ {
+ iid: 10,
+ title: 'Draft: Resolve "hgvbbvnnb"',
+ id: null,
+ },
+ {
+ iid: 9,
+ title: 'Draft: Fix event current target',
+ id: null,
+ },
+ {
+ iid: 3,
+ title: 'Autem eaque et sed provident enim corrupti molestiae.',
+ id: null,
+ },
+ {
+ iid: 2,
+ title: 'Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.',
+ id: null,
+ },
+ {
+ iid: 1,
+ title: 'Optio nemo qui dolorem sit ipsum qui saepe.',
+ id: null,
+ },
+];
+
+export const MOCK_SNIPPETS = [
+ {
+ id: 24,
+ title: 'ss',
+ },
+ {
+ id: 22,
+ title: 'another test snippet',
+ },
+ {
+ id: 21,
+ title: 'test snippet',
+ },
+];
+
+export const MOCK_LABELS = [
+ {
+ title: 'Amsche',
+ color: '#9964cf',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ set: true,
+ },
+ {
+ title: 'Brioffe',
+ color: '#203e13',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ set: true,
+ },
+ {
+ title: 'Bronce',
+ color: '#c0b7f2',
+ type: 'GroupLabel',
+ textColor: '#1F1E24',
+ },
+ {
+ title: 'Bryncefunc',
+ color: '#8baa5e',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ set: true,
+ },
+ {
+ title: 'Contour',
+ color: '#8cf3a3',
+ type: 'ProjectLabel',
+ textColor: '#1F1E24',
+ },
+ {
+ title: 'Corolla',
+ color: '#0384f3',
+ type: 'ProjectLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'Cygsync',
+ color: '#1308c3',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'Frontier',
+ color: '#85db43',
+ type: 'ProjectLabel',
+ textColor: '#1F1E24',
+ },
+ {
+ title: 'Ghost',
+ color: '#df1bc4',
+ type: 'ProjectLabel',
+ textColor: '#FFFFFF',
+ set: true,
+ },
+ {
+ title: 'Grand Am',
+ color: '#a1d7ee',
+ type: 'ProjectLabel',
+ textColor: '#1F1E24',
+ },
+ {
+ title: 'Onesync',
+ color: '#a73ba0',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'Phone',
+ color: '#63dceb',
+ type: 'GroupLabel',
+ textColor: '#1F1E24',
+ },
+ {
+ title: 'Pynefunc',
+ color: '#974b19',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'Trinix',
+ color: '#2c894f',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'Trounswood',
+ color: '#ad0370',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'group::knowledge',
+ color: '#8fbc8f',
+ type: 'ProjectLabel',
+ textColor: '#1F1E24',
+ },
+ {
+ title: 'scoped label',
+ color: '#6699cc',
+ type: 'GroupLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'type::one',
+ color: '#9400d3',
+ type: 'ProjectLabel',
+ textColor: '#FFFFFF',
+ },
+ {
+ title: 'type::two',
+ color: '#013220',
+ type: 'ProjectLabel',
+ textColor: '#FFFFFF',
+ },
+];
+
+export const MOCK_MILESTONES = [
+ {
+ iid: 65,
+ title: '15.0',
+ due_date: '2022-05-17',
+ id: null,
+ },
+ {
+ iid: 73,
+ title: '15.1',
+ due_date: '2022-06-17',
+ id: null,
+ },
+ {
+ iid: 74,
+ title: '15.2',
+ due_date: '2022-07-17',
+ id: null,
+ },
+ {
+ iid: 75,
+ title: '15.3',
+ due_date: '2022-08-17',
+ id: null,
+ },
+ {
+ iid: 76,
+ title: '15.4',
+ due_date: '2022-09-17',
+ id: null,
+ },
+ {
+ iid: 77,
+ title: '15.5',
+ due_date: '2022-10-17',
+ id: null,
+ },
+ {
+ iid: 81,
+ title: '15.6',
+ due_date: '2022-11-17',
+ id: null,
+ },
+ {
+ iid: 82,
+ title: '15.7',
+ due_date: '2022-12-17',
+ id: null,
+ },
+ {
+ iid: 83,
+ title: '15.8',
+ due_date: '2023-01-17',
+ id: null,
+ },
+ {
+ iid: 84,
+ title: '15.9',
+ due_date: '2023-02-17',
+ id: null,
+ },
+ {
+ iid: 85,
+ title: '15.10',
+ due_date: '2023-03-17',
+ id: null,
+ },
+ {
+ iid: 86,
+ title: '15.11',
+ due_date: '2023-04-17',
+ id: null,
+ },
+ {
+ iid: 80,
+ title: '16.0',
+ due_date: '2023-05-17',
+ id: null,
+ },
+ {
+ iid: 88,
+ title: '16.1',
+ due_date: '2023-06-17',
+ id: null,
+ },
+ {
+ iid: 89,
+ title: '16.2',
+ due_date: '2023-07-17',
+ id: null,
+ },
+ {
+ iid: 90,
+ title: '16.3',
+ due_date: '2023-08-17',
+ id: null,
+ },
+ {
+ iid: 91,
+ title: '16.4',
+ due_date: '2023-09-17',
+ id: null,
+ },
+ {
+ iid: 92,
+ title: '16.5',
+ due_date: '2023-10-17',
+ id: null,
+ },
+ {
+ iid: 93,
+ title: '16.6',
+ due_date: '2023-11-10',
+ id: null,
+ },
+ {
+ iid: 95,
+ title: '16.7',
+ due_date: '2023-12-15',
+ id: null,
+ },
+ {
+ iid: 94,
+ title: '16.8',
+ due_date: '2024-01-12',
+ id: null,
+ },
+ {
+ iid: 96,
+ title: '16.9',
+ due_date: '2024-02-09',
+ id: null,
+ },
+ {
+ iid: 97,
+ title: '16.10',
+ due_date: '2024-03-15',
+ id: null,
+ },
+ {
+ iid: 98,
+ title: '16.11',
+ due_date: '2024-04-12',
+ id: null,
+ },
+ {
+ iid: 87,
+ title: '17.0',
+ due_date: '2024-05-10',
+ id: null,
+ },
+ {
+ iid: 48,
+ title: 'Next 1-3 releases',
+ due_date: null,
+ id: null,
+ },
+ {
+ iid: 24,
+ title: 'Awaiting further demand',
+ due_date: null,
+ id: null,
+ },
+ {
+ iid: 14,
+ title: 'Backlog',
+ due_date: null,
+ id: null,
+ },
+ {
+ iid: 11,
+ title: 'Next 4-7 releases',
+ due_date: null,
+ id: null,
+ },
+ {
+ iid: 10,
+ title: 'Next 3-4 releases',
+ due_date: null,
+ id: null,
+ },
+ {
+ iid: 6,
+ title: 'Next 7-13 releases',
+ due_date: null,
+ id: null,
+ },
+];
+
+export const MOCK_VULNERABILITIES = [
+ {
+ id: 99499903,
+ title: 'Cross Site Scripting (Persistent)',
+ },
+ {
+ id: 99495085,
+ title: 'Possible SQL injection',
+ },
+ {
+ id: 99490610,
+ title: 'GitLab Runner Authentication Token',
+ },
+ {
+ id: 99288920,
+ title: 'Cross Site Scripting (Persistent)',
+ },
+ {
+ id: 99258720,
+ title: 'Cross Site Scripting (Persistent)',
+ },
+];
+
+export const MOCK_COMMANDS = [
+ {
+ name: 'due',
+ aliases: [],
+ description: 'Set due date',
+ warning: '',
+ icon: '',
+ params: ['\u003cin 2 days | this Friday | December 31st\u003e'],
+ },
+ {
+ name: 'duplicate',
+ aliases: [],
+ description: 'Mark this issue as a duplicate of another issue',
+ warning: '',
+ icon: '',
+ params: ['#issue'],
+ },
+ {
+ name: 'clone',
+ aliases: [],
+ description: 'Clone this issue',
+ warning: '',
+ icon: '',
+ params: ['path/to/project [--with_notes]'],
+ },
+ {
+ name: 'move',
+ aliases: [],
+ description: 'Move this issue to another project.',
+ warning: '',
+ icon: '',
+ params: ['path/to/project'],
+ },
+ {
+ name: 'create_merge_request',
+ aliases: [],
+ description: 'Create a merge request',
+ warning: '',
+ icon: '',
+ params: ['\u003cbranch name\u003e'],
+ },
+ {
+ name: 'zoom',
+ aliases: [],
+ description: 'Add Zoom meeting',
+ warning: '',
+ icon: '',
+ params: ['\u003cZoom URL\u003e'],
+ },
+ {
+ name: 'promote_to_incident',
+ aliases: [],
+ description: 'Promote issue to incident',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'close',
+ aliases: [],
+ description: 'Close this issue',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'title',
+ aliases: [],
+ description: 'Change title',
+ warning: '',
+ icon: '',
+ params: ['\u003cNew title\u003e'],
+ },
+ {
+ name: 'label',
+ aliases: ['labels'],
+ description: 'Add labels',
+ warning: '',
+ icon: '',
+ params: ['~label1 ~"label 2"'],
+ },
+ {
+ name: 'unlabel',
+ aliases: ['remove_label'],
+ description: 'Remove all or specific labels',
+ warning: '',
+ icon: '',
+ params: ['~label1 ~"label 2"'],
+ },
+ {
+ name: 'relabel',
+ aliases: [],
+ description: 'Replace all labels',
+ warning: '',
+ icon: '',
+ params: ['~label1 ~"label 2"'],
+ },
+ {
+ name: 'todo',
+ aliases: [],
+ description: 'Add a to do',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'unsubscribe',
+ aliases: [],
+ description: 'Unsubscribe',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'award',
+ aliases: [],
+ description: 'Toggle emoji award',
+ warning: '',
+ icon: '',
+ params: [':emoji:'],
+ },
+ {
+ name: 'shrug',
+ aliases: [],
+ description: 'Append the comment with ¯\\_(ツ)_/¯',
+ warning: '',
+ icon: '',
+ params: ['\u003cComment\u003e'],
+ },
+ {
+ name: 'tableflip',
+ aliases: [],
+ description: 'Append the comment with (╯°□°)╯︵ ┻━┻',
+ warning: '',
+ icon: '',
+ params: ['\u003cComment\u003e'],
+ },
+ {
+ name: 'confidential',
+ aliases: [],
+ description: 'Make issue confidential',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'assign',
+ aliases: [],
+ description: 'Assign',
+ warning: '',
+ icon: '',
+ params: ['@user1 @user2'],
+ },
+ {
+ name: 'unassign',
+ aliases: [],
+ description: 'Remove all or specific assignees',
+ warning: '',
+ icon: '',
+ params: ['@user1 @user2'],
+ },
+ {
+ name: 'milestone',
+ aliases: [],
+ description: 'Set milestone',
+ warning: '',
+ icon: '',
+ params: ['%"milestone"'],
+ },
+ {
+ name: 'remove_milestone',
+ aliases: [],
+ description: 'Remove milestone',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'copy_metadata',
+ aliases: [],
+ description: 'Copy labels and milestone from other issue or merge request in this project',
+ warning: '',
+ icon: '',
+ params: ['#issue | !merge_request'],
+ },
+ {
+ name: 'estimate',
+ aliases: ['estimate_time'],
+ description: 'Set time estimate',
+ warning: '',
+ icon: '',
+ params: ['\u003c1w 3d 2h 14m\u003e'],
+ },
+ {
+ name: 'spend',
+ aliases: ['spent', 'spend_time'],
+ description: 'Add or subtract spent time',
+ warning: '',
+ icon: '',
+ params: ['\u003ctime(1h30m | -1h30m)\u003e \u003cdate(YYYY-MM-DD)\u003e'],
+ },
+ {
+ name: 'remove_estimate',
+ aliases: ['remove_time_estimate'],
+ description: 'Remove time estimate',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'remove_time_spent',
+ aliases: [],
+ description: 'Remove spent time',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'lock',
+ aliases: [],
+ description: 'Lock the discussion',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'cc',
+ aliases: [],
+ description: 'CC',
+ warning: '',
+ icon: '',
+ params: ['@user'],
+ },
+ {
+ name: 'relate',
+ aliases: [],
+ description: 'Mark this issue as related to another issue',
+ warning: '',
+ icon: '',
+ params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
+ },
+ {
+ name: 'unlink',
+ aliases: [],
+ description: 'Remove link with another issue',
+ warning: '',
+ icon: '',
+ params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
+ },
+ {
+ name: 'epic',
+ aliases: [],
+ description: 'Add to epic',
+ warning: '',
+ icon: '',
+ params: ['\u003c\u0026epic | group\u0026epic | Epic URL\u003e'],
+ },
+ {
+ name: 'remove_epic',
+ aliases: [],
+ description: 'Remove from epic',
+ warning: '',
+ icon: '',
+ params: [],
+ },
+ {
+ name: 'promote',
+ aliases: [],
+ description: 'Promote issue to an epic',
+ warning: '',
+ icon: 'confidential',
+ params: [],
+ },
+ {
+ name: 'iteration',
+ aliases: [],
+ description: 'Set iteration',
+ warning: '',
+ icon: '',
+ params: ['*iteration:"iteration name" | *iteration:\u003cID\u003e'],
+ },
+ {
+ name: 'health_status',
+ aliases: [],
+ description: 'Set health status',
+ warning: '',
+ icon: '',
+ params: ['\u003con_track|needs_attention|at_risk\u003e'],
+ },
+ {
+ name: 'reassign',
+ aliases: [],
+ description: 'Change assignees',
+ warning: '',
+ icon: '',
+ params: ['@user1 @user2'],
+ },
+ {
+ name: 'weight',
+ aliases: [],
+ description: 'Set weight',
+ warning: '',
+ icon: '',
+ params: ['0, 1, 2, …'],
+ },
+ {
+ name: 'blocks',
+ aliases: [],
+ description: 'Specifies that this issue blocks other issues',
+ warning: '',
+ icon: '',
+ params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
+ },
+ {
+ name: 'blocked_by',
+ aliases: [],
+ description: 'Mark this issue as blocked by other issues',
+ warning: '',
+ icon: '',
+ params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
+ },
+];
diff --git a/spec/frontend/content_editor/services/data_source_factory_spec.js b/spec/frontend/content_editor/services/data_source_factory_spec.js
new file mode 100644
index 00000000000..d540f11711d
--- /dev/null
+++ b/spec/frontend/content_editor/services/data_source_factory_spec.js
@@ -0,0 +1,202 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import DataSourceFactory, {
+ defaultSorter,
+ customSorter,
+ createDataSource,
+} from '~/content_editor/services/data_source_factory';
+import {
+ MOCK_MEMBERS,
+ MOCK_COMMANDS,
+ MOCK_EPICS,
+ MOCK_ISSUES,
+ MOCK_LABELS,
+ MOCK_MILESTONES,
+ MOCK_SNIPPETS,
+ MOCK_VULNERABILITIES,
+ MOCK_MERGE_REQUESTS,
+ MOCK_ASSIGNEES,
+ MOCK_REVIEWERS,
+} from './autocomplete_mock_data';
+
+jest.mock('~/emoji');
+
+describe('defaultSorter', () => {
+ it('returns items as is if query is empty', () => {
+ const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }];
+ const sorter = defaultSorter(['name']);
+ expect(sorter(items, '')).toEqual(items);
+ });
+
+ it('sorts items based on query match', () => {
+ const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }];
+ const sorter = defaultSorter(['name']);
+ expect(sorter(items, 'b')).toEqual([{ name: 'bcd' }, { name: 'abc' }, { name: 'cde' }]);
+ });
+
+ it('sorts items based on query match in multiple fields', () => {
+ const items = [
+ { name: 'wabc', description: 'xyz' },
+ { name: 'bcd', description: 'wxy' },
+ { name: 'cde', description: 'vwx' },
+ ];
+ const sorter = defaultSorter(['name', 'description']);
+ expect(sorter(items, 'w')).toEqual([
+ { name: 'wabc', description: 'xyz' },
+ { name: 'bcd', description: 'wxy' },
+ { name: 'cde', description: 'vwx' },
+ ]);
+ });
+});
+
+describe('customSorter', () => {
+ it('sorts items based on custom sorter function', () => {
+ const items = [3, 1, 2];
+ const sorter = customSorter((a, b) => a - b);
+ expect(sorter(items)).toEqual([1, 2, 3]);
+ });
+});
+
+describe('createDataSource', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('fetches data from source and filters based on query', async () => {
+ const data = [
+ { name: 'abc', description: 'xyz' },
+ { name: 'bcd', description: 'wxy' },
+ { name: 'cde', description: 'vwx' },
+ ];
+ mock.onGet('/source').reply(200, data);
+
+ const dataSource = createDataSource({
+ source: '/source',
+ searchFields: ['name', 'description'],
+ });
+
+ const results = await dataSource.search('b');
+ expect(results).toEqual([
+ { name: 'bcd', description: 'wxy' },
+ { name: 'abc', description: 'xyz' },
+ ]);
+ });
+
+ it('handles source fetch errors', async () => {
+ mock.onGet('/source').reply(500);
+
+ const dataSource = createDataSource({
+ source: '/source',
+ searchFields: ['name', 'description'],
+ sorter: (items) => items,
+ });
+
+ const results = await dataSource.search('b');
+ expect(results).toEqual([]);
+ });
+});
+
+describe('DataSourceFactory', () => {
+ let mock;
+ let autocompleteHelper;
+ let dateNowOld;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ const dataSourceUrls = {
+ members: '/members',
+ issues: '/issues',
+ snippets: '/snippets',
+ labels: '/labels',
+ epics: '/epics',
+ milestones: '/milestones',
+ mergeRequests: '/mergeRequests',
+ vulnerabilities: '/vulnerabilities',
+ commands: '/commands',
+ };
+
+ mock.onGet('/members').reply(200, MOCK_MEMBERS);
+ mock.onGet('/issues').reply(200, MOCK_ISSUES);
+ mock.onGet('/snippets').reply(200, MOCK_SNIPPETS);
+ mock.onGet('/labels').reply(200, MOCK_LABELS);
+ mock.onGet('/epics').reply(200, MOCK_EPICS);
+ mock.onGet('/milestones').reply(200, MOCK_MILESTONES);
+ mock.onGet('/mergeRequests').reply(200, MOCK_MERGE_REQUESTS);
+ mock.onGet('/vulnerabilities').reply(200, MOCK_VULNERABILITIES);
+ mock.onGet('/commands').reply(200, MOCK_COMMANDS);
+
+ const sidebarMediator = {
+ store: {
+ assignees: MOCK_ASSIGNEES,
+ reviewers: MOCK_REVIEWERS,
+ },
+ };
+
+ autocompleteHelper = new DataSourceFactory({
+ dataSourceUrls,
+ sidebarMediator,
+ });
+
+ dateNowOld = Date.now();
+
+ jest.spyOn(Date, 'now').mockImplementation(() => new Date('2023-11-14').getTime());
+ });
+
+ afterEach(() => {
+ mock.restore();
+
+ jest.spyOn(Date, 'now').mockImplementation(() => dateNowOld);
+ });
+
+ it.each`
+ referenceType | query
+ ${'user'} | ${'r'}
+ ${'issue'} | ${'q'}
+ ${'snippet'} | ${'s'}
+ ${'label'} | ${'c'}
+ ${'epic'} | ${'n'}
+ ${'milestone'} | ${'16'}
+ ${'merge_request'} | ${'n'}
+ ${'vulnerability'} | ${'cross'}
+ ${'command'} | ${'re'}
+ `(
+ 'for reference type "$referenceType", searches for "$query" correctly',
+ async ({ referenceType, query }) => {
+ const dataSource = autocompleteHelper.getDataSource(referenceType);
+ const results = await dataSource.search(query);
+
+ expect(
+ results.map(({ title, name, username }) => username || name || title),
+ ).toMatchSnapshot();
+ },
+ );
+
+ it.each`
+ referenceType | command
+ ${'label'} | ${'/label'}
+ ${'label'} | ${'/unlabel'}
+ ${'label'} | ${'/relabel'}
+ ${'user'} | ${'/assign'}
+ ${'user'} | ${'/reassign'}
+ ${'user'} | ${'/unassign'}
+ ${'user'} | ${'/assign_reviewer'}
+ ${'user'} | ${'/unassign_reviewer'}
+ ${'user'} | ${'/reassign_reviewer'}
+ `(
+ 'filters items based on command "$command" for reference type "$referenceType" and command',
+ async ({ referenceType, command }) => {
+ const dataSource = autocompleteHelper.getDataSource(referenceType, { command });
+ const results = await dataSource.search();
+
+ expect(
+ results.map(({ username, name, title }) => username || name || title),
+ ).toMatchSnapshot();
+ },
+ );
+});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index 548c6030ed7..c329a12bcc4 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -152,19 +152,26 @@ describe('markdownSerializer', () => {
expect(serialize(paragraph(italic('italics')))).toBe('_italics_');
});
- it('correctly serializes code blocks wrapped by italics and bold marks', () => {
- 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.each`
+ input | output
+ ${'code'} | ${'`code`'}
+ ${'code `with` backticks'} | ${'``code `with` backticks``'}
+ ${'this is `inline-code`'} | ${'`` this is `inline-code` ``'}
+ ${'`inline-code` in markdown'} | ${'`` `inline-code` in markdown ``'}
+ ${'```js'} | ${'`` ```js ``'}
+ `('correctly serializes inline code ("$input")', ({ input, output }) => {
+ expect(serialize(paragraph(code(input)))).toBe(output);
+ });
+
+ it('correctly serializes inline code wrapped by italics and bold marks', () => {
+ const content = 'code';
+
+ expect(serialize(paragraph(italic(code(content))))).toBe(`_\`${content}\`_`);
+ expect(serialize(paragraph(code(italic(content))))).toBe(`_\`${content}\`_`);
+ expect(serialize(paragraph(bold(code(content))))).toBe(`**\`${content}\`**`);
+ expect(serialize(paragraph(code(bold(content))))).toBe(`**\`${content}\`**`);
+ expect(serialize(paragraph(strike(code(content))))).toBe(`~~\`${content}\`~~`);
+ expect(serialize(paragraph(code(strike(content))))).toBe(`~~\`${content}\`~~`);
});
it('correctly serializes inline diff', () => {
@@ -461,6 +468,52 @@ this is not really json:table but just trying out whether this case works or not
);
});
+ it('correctly serializes a markdown code block containing a nested code block', () => {
+ expect(
+ serialize(
+ codeBlock(
+ { language: 'markdown' },
+ 'markdown code block **bold** _italic_ `code`\n\n```js\nvar a = 0;\n```\n\nend markdown code block',
+ ),
+ ),
+ ).toBe(
+ `
+\`\`\`\`markdown
+markdown code block **bold** _italic_ \`code\`
+
+\`\`\`js
+var a = 0;
+\`\`\`
+
+end markdown code block
+\`\`\`\`
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a markdown code block containing a markdown code block containing another code block', () => {
+ expect(
+ serialize(
+ codeBlock(
+ { language: 'markdown' },
+ '````md\na nested code block\n\n```js\nvar a = 0;\n```\n````',
+ ),
+ ),
+ ).toBe(
+ `
+\`\`\`\`\`markdown
+\`\`\`\`md
+a nested code block
+
+\`\`\`js
+var a = 0;
+\`\`\`
+\`\`\`\`
+\`\`\`\`\`
+ `.trim(),
+ );
+ });
+
it('correctly serializes emoji', () => {
expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:');
});
@@ -607,6 +660,34 @@ this is not really json:table but just trying out whether this case works or not
);
});
+ it('correctly serializes bullet task list with different bullet styles', () => {
+ expect(
+ serialize(
+ taskList(
+ { bullet: '+' },
+ taskItem({ checked: true }, paragraph('list item 1')),
+ taskItem(paragraph('list item 2')),
+ taskItem(
+ paragraph('list item 3'),
+ taskList(
+ { bullet: '-' },
+ taskItem({ checked: true }, paragraph('sub-list item 1')),
+ taskItem(paragraph('sub-list item 2')),
+ ),
+ ),
+ ),
+ ),
+ ).toBe(
+ `
++ [x] list item 1
++ [ ] list item 2
++ [ ] list item 3
+ - [x] sub-list item 1
+ - [ ] sub-list item 2
+ `.trim(),
+ );
+ });
+
it('correctly serializes a numeric list', () => {
expect(
serialize(
diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
index 2efc73ddef8..4428fa682e7 100644
--- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
+++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
@@ -1,6 +1,8 @@
import { Extension } from '@tiptap/core';
import BulletList from '~/content_editor/extensions/bullet_list';
import ListItem from '~/content_editor/extensions/list_item';
+import TaskList from '~/content_editor/extensions/task_list';
+import TaskItem from '~/content_editor/extensions/task_item';
import Paragraph from '~/content_editor/extensions/paragraph';
import markdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer';
import { getMarkdownSource, getFullSource } from '~/content_editor/services/markdown_sourcemap';
@@ -18,6 +20,20 @@ const BULLET_LIST_HTML = `<ul data-sourcepos="1:1-3:24" dir="auto">
</li>
</ul>`;
+const BULLET_TASK_LIST_MARKDOWN = `- [ ] list item 1
++ [x] checked list item 2
+ + [ ] embedded list item 1
+ - [x] checked embedded list item 2`;
+const BULLET_TASK_LIST_HTML = `<ul data-sourcepos="1:1-4:36" class="task-list" dir="auto">
+ <li data-sourcepos="1:1-1:17" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox"> list item 1</li>
+ <li data-sourcepos="2:1-4:36" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" checked> checked list item 2
+ <ul data-sourcepos="3:3-4:36" class="task-list">
+ <li data-sourcepos="3:3-3:28" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox"> embedded list item 1</li>
+ <li data-sourcepos="4:3-4:36" class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" checked> checked embedded list item 2</li>
+ </ul>
+ </li>
+</ul>`;
+
const SourcemapExtension = Extension.create({
// lets add `source` attribute to every element using `getMarkdownSource`
addGlobalAttributes() {
@@ -38,19 +54,68 @@ const SourcemapExtension = Extension.create({
});
const tiptapEditor = createTestEditor({
- extensions: [BulletList, ListItem, SourcemapExtension],
+ extensions: [BulletList, ListItem, TaskList, TaskItem, SourcemapExtension],
});
const {
- builders: { doc, bulletList, listItem, paragraph },
+ builders: { doc, bulletList, listItem, taskList, taskItem, paragraph },
} = createDocBuilder({
tiptapEditor,
names: {
bulletList: { nodeType: BulletList.name },
listItem: { nodeType: ListItem.name },
+ taskList: { nodeType: TaskList.name },
+ taskItem: { nodeType: TaskItem.name },
},
});
+const bulletListDoc = () =>
+ doc(
+ bulletList(
+ { bullet: '+', source: '+ list item 1\n+ list item 2\n - embedded list item 3' },
+ listItem({ source: '+ list item 1' }, paragraph('list item 1')),
+ listItem(
+ { source: '+ list item 2\n - embedded list item 3' },
+ paragraph('list item 2'),
+ bulletList(
+ { bullet: '-', source: '- embedded list item 3' },
+ listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')),
+ ),
+ ),
+ ),
+ );
+
+const bulletTaskListDoc = () =>
+ doc(
+ taskList(
+ {
+ bullet: '-',
+ source:
+ '- [ ] list item 1\n+ [x] checked list item 2\n + [ ] embedded list item 1\n - [x] checked embedded list item 2',
+ },
+ taskItem({ source: '- [ ] list item 1' }, paragraph('list item 1')),
+ taskItem(
+ {
+ source:
+ '+ [x] checked list item 2\n + [ ] embedded list item 1\n - [x] checked embedded list item 2',
+ checked: true,
+ },
+ paragraph('checked list item 2'),
+ taskList(
+ {
+ bullet: '+',
+ source: '+ [ ] embedded list item 1\n - [x] checked embedded list item 2',
+ },
+ taskItem({ source: '+ [ ] embedded list item 1' }, paragraph('embedded list item 1')),
+ taskItem(
+ { source: '- [x] checked embedded list item 2', checked: true },
+ paragraph('checked embedded list item 2'),
+ ),
+ ),
+ ),
+ ),
+ );
+
describe('content_editor/services/markdown_sourcemap', () => {
describe('getFullSource', () => {
it.each`
@@ -72,29 +137,21 @@ describe('content_editor/services/markdown_sourcemap', () => {
});
});
- it('gets markdown source for a rendered HTML element', async () => {
- const { document } = await markdownDeserializer({
- render: () => BULLET_LIST_HTML,
- }).deserialize({
- schema: tiptapEditor.schema,
- markdown: BULLET_LIST_MARKDOWN,
- });
-
- const expected = doc(
- bulletList(
- { bullet: '+', source: '+ list item 1\n+ list item 2' },
- listItem({ source: '+ list item 1' }, paragraph('list item 1')),
- listItem(
- { source: '+ list item 2' },
- paragraph('list item 2'),
- bulletList(
- { bullet: '-', source: '- embedded list item 3' },
- listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')),
- ),
- ),
- ),
- );
+ it.each`
+ description | sourceMarkdown | sourceHTML | expectedDoc
+ ${'bullet list'} | ${BULLET_LIST_MARKDOWN} | ${BULLET_LIST_HTML} | ${bulletListDoc}
+ ${'bullet task list'} | ${BULLET_TASK_LIST_MARKDOWN} | ${BULLET_TASK_LIST_HTML} | ${bulletTaskListDoc}
+ `(
+ 'gets markdown source for a rendered $description',
+ async ({ sourceMarkdown, sourceHTML, expectedDoc }) => {
+ const { document } = await markdownDeserializer({
+ render: () => sourceHTML,
+ }).deserialize({
+ schema: tiptapEditor.schema,
+ markdown: sourceMarkdown,
+ });
- expect(document.toJSON()).toEqual(expected.toJSON());
- });
+ expect(document.toJSON()).toEqual(expectedDoc().toJSON());
+ },
+ );
});
diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js
index cbd4f555e97..255a7104eaf 100644
--- a/spec/frontend/content_editor/test_constants.js
+++ b/spec/frontend/content_editor/test_constants.js
@@ -44,3 +44,18 @@ export const RESOLVED_MERGE_REQUEST_HTML =
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>';
+
+export const RESOLVED_LABEL_HTML =
+ '<p data-sourcepos="1:1-1:29" dir="auto"><span class="gl-label gl-label-sm"><a href="/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix" data-reference-type="label" data-original="~Aquanix" data-link="false" data-link-reference="false" data-project="2" data-label="5" data-container="body" data-placement="top" title="" class="gfm gfm-label has-tooltip gl-link gl-label-link"><span class="gl-label-text gl-label-text-light" data-container="body" data-html="true" style="background-color: #e65431">Aquanix</span></a></span> <span class="gl-label gl-label-sm"><a href="/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix" data-reference-type="label" data-original="~Aquanix" data-link="false" data-link-reference="false" data-project="2" data-label="5" data-container="body" data-placement="top" title="" class="gfm gfm-label has-tooltip gl-link gl-label-link"><span class="gl-label-text gl-label-text-light" data-container="body" data-html="true" style="background-color: #e65431">Aquanix</span></a></span>+ <span class="gl-label gl-label-sm"><a href="/gitlab-org/gitlab-shell/-/issues?label_name=Aquanix" data-reference-type="label" data-original="~Aquanix" data-link="false" data-link-reference="false" data-project="2" data-label="5" data-container="body" data-placement="top" title="" class="gfm gfm-label has-tooltip gl-link gl-label-link"><span class="gl-label-text gl-label-text-light" data-container="body" data-html="true" style="background-color: #e65431">Aquanix</span></a></span>+s</p>';
+
+export const RESOLVED_SNIPPET_HTML =
+ '<p data-sourcepos="1:1-1:14" dir="auto"><a href="/gitlab-org/gitlab-shell/-/snippets/25" data-reference-type="snippet" data-original="$25" data-link="false" data-link-reference="false" data-project="2" data-snippet="25" data-container="body" data-placement="top" title="test" class="gfm gfm-snippet has-tooltip">$25</a> <a href="/gitlab-org/gitlab-shell/-/snippets/25" data-reference-type="snippet" data-original="$25" data-link="false" data-link-reference="false" data-project="2" data-snippet="25" data-container="body" data-placement="top" title="test" class="gfm gfm-snippet has-tooltip">$25</a>+ <a href="/gitlab-org/gitlab-shell/-/snippets/25" data-reference-type="snippet" data-original="$25" data-link="false" data-link-reference="false" data-project="2" data-snippet="25" data-container="body" data-placement="top" title="test" class="gfm gfm-snippet has-tooltip">$25</a>+s</p>';
+
+export const RESOLVED_MILESTONE_HTML =
+ '<p data-sourcepos="1:1-1:20" dir="auto"><a href="/gitlab-org/gitlab-shell/-/milestones/5" data-reference-type="milestone" data-original="%v4.0" data-link="false" data-link-reference="false" data-project="2" data-milestone="10" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a> <a href="/gitlab-org/gitlab-shell/-/milestones/5" data-reference-type="milestone" data-original="%v4.0" data-link="false" data-link-reference="false" data-project="2" data-milestone="10" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a>+ %v4.0+s</p>';
+
+export const RESOLVED_USER_HTML =
+ '<p data-sourcepos="1:1-1:20" dir="auto"><a href="/root" data-reference-type="user" data-user="1" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Administrator">@root</a> <a href="/root" data-reference-type="user" data-user="1" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Administrator">@root</a>+ <a href="/root" data-reference-type="user" data-user="1" data-container="body" data-placement="top" class="gfm gfm-project_member js-user-link" title="Administrator">@root</a>+s</p>';
+
+export const RESOLVED_VULNERABILITY_HTML =
+ '<p data-sourcepos="1:1-1:56" dir="auto"><a href="/gitlab-org/gitlab-shell/-/security/vulnerabilities/1" data-reference-type="vulnerability" data-original="[vulnerability:1]" data-link="false" data-link-reference="false" data-project="2" data-vulnerability="1" data-container="body" data-placement="top" title="oh no!" class="gfm gfm-vulnerability has-tooltip">[vulnerability:1]</a> <a href="/gitlab-org/gitlab-shell/-/security/vulnerabilities/1" data-reference-type="vulnerability" data-original="[vulnerability:1]" data-link="false" data-link-reference="false" data-project="2" data-vulnerability="1" data-container="body" data-placement="top" title="oh no!" class="gfm gfm-vulnerability has-tooltip">[vulnerability:1]</a>+ <a href="/gitlab-org/gitlab-shell/-/security/vulnerabilities/1" data-reference-type="vulnerability" data-original="[vulnerability:1]" data-link="false" data-link-reference="false" data-project="2" data-vulnerability="1" data-container="body" data-placement="top" title="oh no!" class="gfm gfm-vulnerability has-tooltip">[vulnerability:1]</a>+s</p>';
diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
index 8b76a627c1e..50a4a21ef1f 100644
--- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
+++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap
@@ -45,22 +45,32 @@ exports[`Contributors charts should render charts and a RefSelector when loading
Excluding merge commits. Limited to 6,000 commits.
</span>
<glareachart-stub
- annotations=""
class="gl-mb-5"
data="[object Object]"
+ format-tooltip-text="function () { [native code] }"
height="264"
- includelegendavgmax="true"
- legendaveragetext="Avg"
- legendcurrenttext="Current"
- legendlayout="inline"
- legendmaxtext="Max"
- legendmintext="Min"
- legendseriesinfo=""
option="[object Object]"
responsive=""
- thresholds=""
width="auto"
- />
+ >
+ <div
+ data-testid="tooltip-title"
+ />
+ <div
+ class="gl-display-flex gl-gap-6 gl-justify-content-space-between"
+ >
+ <span
+ data-testid="tooltip-label"
+ >
+ Number of commits
+ </span>
+ <span
+ data-testid="tooltip-value"
+ >
+ []
+ </span>
+ </div>
+ </glareachart-stub>
<div
class="row"
>
@@ -78,21 +88,31 @@ exports[`Contributors charts should render charts and a RefSelector when loading
2 commits (jawnnypoo@gmail.com)
</p>
<glareachart-stub
- annotations=""
data="[object Object]"
+ format-tooltip-text="function () { [native code] }"
height="216"
- includelegendavgmax="true"
- legendaveragetext="Avg"
- legendcurrenttext="Current"
- legendlayout="inline"
- legendmaxtext="Max"
- legendmintext="Min"
- legendseriesinfo=""
option="[object Object]"
responsive=""
- thresholds=""
width="auto"
- />
+ >
+ <div
+ data-testid="tooltip-title"
+ />
+ <div
+ class="gl-display-flex gl-gap-6 gl-justify-content-space-between"
+ >
+ <span
+ data-testid="tooltip-label"
+ >
+ Commits
+ </span>
+ <span
+ data-testid="tooltip-value"
+ >
+ []
+ </span>
+ </div>
+ </glareachart-stub>
</div>
</div>
</div>
diff --git a/spec/frontend/contributors/component/contributor_area_chart_spec.js b/spec/frontend/contributors/component/contributor_area_chart_spec.js
new file mode 100644
index 00000000000..262c3e8afee
--- /dev/null
+++ b/spec/frontend/contributors/component/contributor_area_chart_spec.js
@@ -0,0 +1,92 @@
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ContributorAreaChart from '~/contributors/components/contributor_area_chart.vue';
+
+describe('Contributor area chart', () => {
+ let wrapper;
+
+ const defaultProps = {
+ data: [
+ {
+ name: 'Commits',
+ data: [
+ ['2015-01-01', 1],
+ ['2015-01-02', 2],
+ ['2015-01-03', 3],
+ ],
+ },
+ ],
+ height: 100,
+ option: {
+ xAxis: { name: '', type: 'time' },
+ yAxis: { name: 'Number of commits' },
+ grid: {
+ top: 10,
+ bottom: 10,
+ left: 10,
+ right: 10,
+ },
+ },
+ };
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMountExtended(ContributorAreaChart, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ const findAreaChart = () => wrapper.findComponent(GlAreaChart);
+ const findTooltipTitle = () => wrapper.findByTestId('tooltip-title').text();
+ const findTooltipLabel = () => wrapper.findByTestId('tooltip-label').text();
+ const findTooltipValue = () => wrapper.findByTestId('tooltip-value').text();
+
+ const setTooltipData = async (title, value) => {
+ findAreaChart().vm.formatTooltipText({ seriesData: [{ data: [title, value] }] });
+ await nextTick();
+ };
+
+ describe('default inputs', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders the area chart', () => {
+ expect(findAreaChart().exists()).toBe(true);
+ expect(findAreaChart().props()).toMatchObject(defaultProps);
+ });
+
+ it('emits the area chart created event', () => {
+ const payload = 'test';
+ findAreaChart().vm.$emit('created', payload);
+
+ expect(wrapper.emitted('created')).toHaveLength(1);
+ expect(wrapper.emitted('created')[0]).toEqual([payload]);
+ });
+
+ it('shows the tooltip with the formatted chart data', async () => {
+ await setTooltipData('01-01-2000', 10);
+
+ expect(findTooltipTitle()).toEqual('Jan 01, 2000');
+ expect(findTooltipLabel()).toEqual(defaultProps.option.yAxis.name);
+ expect(findTooltipValue()).toEqual('10');
+ });
+ });
+
+ describe('Y axis has no name', () => {
+ beforeEach(() => {
+ createWrapper({
+ option: {
+ ...defaultProps.option,
+ yAxis: {},
+ },
+ });
+ });
+
+ it('shows a default tooltip label if the Y axis name is missing', async () => {
+ await setTooltipData('01-01-2000', 10);
+
+ expect(findTooltipLabel()).toEqual('Value');
+ });
+ });
+});
diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js
index 7d863a8eb78..6235d2610a9 100644
--- a/spec/frontend/contributors/component/contributors_spec.js
+++ b/spec/frontend/contributors/component/contributors_spec.js
@@ -12,6 +12,8 @@ import { SET_CHART_DATA, SET_LOADING_STATE } from '~/contributors/stores/mutatio
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
+ joinPaths: jest.fn(),
+ setUrlFragment: jest.fn(),
}));
let wrapper;
diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js
index d39577baa59..86b72c673bc 100644
--- a/spec/frontend/deploy_freeze/store/actions_spec.js
+++ b/spec/frontend/deploy_freeze/store/actions_spec.js
@@ -36,7 +36,7 @@ describe('deploy freeze store actions', () => {
describe('setSelectedFreezePeriod', () => {
it('commits SET_SELECTED_TIMEZONE mutation', () => {
- testAction(
+ return testAction(
actions.setFreezePeriod,
{
id: 3,
@@ -69,7 +69,7 @@ describe('deploy freeze store actions', () => {
describe('setSelectedTimezone', () => {
it('commits SET_SELECTED_TIMEZONE mutation', () => {
- testAction(actions.setSelectedTimezone, {}, {}, [
+ return testAction(actions.setSelectedTimezone, {}, {}, [
{
payload: {},
type: types.SET_SELECTED_TIMEZONE,
@@ -80,7 +80,7 @@ describe('deploy freeze store actions', () => {
describe('setFreezeStartCron', () => {
it('commits SET_FREEZE_START_CRON mutation', () => {
- testAction(actions.setFreezeStartCron, {}, {}, [
+ return testAction(actions.setFreezeStartCron, {}, {}, [
{
type: types.SET_FREEZE_START_CRON,
},
@@ -90,7 +90,7 @@ describe('deploy freeze store actions', () => {
describe('setFreezeEndCron', () => {
it('commits SET_FREEZE_END_CRON mutation', () => {
- testAction(actions.setFreezeEndCron, {}, {}, [
+ return testAction(actions.setFreezeEndCron, {}, {}, [
{
type: types.SET_FREEZE_END_CRON,
},
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
index 3c4fa2a6de6..e57da4df150 100644
--- a/spec/frontend/deploy_keys/components/key_spec.js
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -4,7 +4,7 @@ import data from 'test_fixtures/deploy_keys/keys.json';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import key from '~/deploy_keys/components/key.vue';
import DeployKeysStore from '~/deploy_keys/store';
-import { getTimeago, formatDate } from '~/lib/utils/datetime_utility';
+import { getTimeago, localeDateFormat } from '~/lib/utils/datetime_utility';
describe('Deploy keys key', () => {
let wrapper;
@@ -64,7 +64,9 @@ describe('Deploy keys key', () => {
const expiryComponent = wrapper.find('[data-testid="expires-at-tooltip"]');
const tooltip = getBinding(expiryComponent.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
- expect(expiryComponent.attributes('title')).toBe(`${formatDate(expiresAt)}`);
+ expect(expiryComponent.attributes('title')).toBe(
+ `${localeDateFormat.asDateTimeFull.format(expiresAt)}`,
+ );
});
it('renders never when no expiration date', () => {
createComponent({
diff --git a/spec/frontend/deploy_keys/graphql/resolvers_spec.js b/spec/frontend/deploy_keys/graphql/resolvers_spec.js
new file mode 100644
index 00000000000..458232697cb
--- /dev/null
+++ b/spec/frontend/deploy_keys/graphql/resolvers_spec.js
@@ -0,0 +1,249 @@
+import MockAdapter from 'axios-mock-adapter';
+import { HTTP_STATUS_OK, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
+import axios from '~/lib/utils/axios_utils';
+import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql';
+import currentPageQuery from '~/deploy_keys/graphql/queries/current_page.query.graphql';
+import currentScopeQuery from '~/deploy_keys/graphql/queries/current_scope.query.graphql';
+import confirmRemoveKeyQuery from '~/deploy_keys/graphql/queries/confirm_remove_key.query.graphql';
+import { resolvers } from '~/deploy_keys/graphql/resolvers';
+
+const ENDPOINTS = {
+ enabledKeysEndpoint: '/enabled_keys',
+ availableProjectKeysEndpoint: '/available_project_keys',
+ availablePublicKeysEndpoint: '/available_public_keys',
+};
+
+describe('~/deploy_keys/graphql/resolvers', () => {
+ let mockResolvers;
+ let mock;
+ let client;
+
+ beforeEach(() => {
+ mockResolvers = resolvers(ENDPOINTS);
+ mock = new MockAdapter(axios);
+ client = {
+ writeQuery: jest.fn(),
+ readQuery: jest.fn(),
+ readFragment: jest.fn(),
+ cache: { evict: jest.fn(), gc: jest.fn() },
+ };
+ });
+
+ afterEach(() => {
+ mock.reset();
+ });
+
+ describe('deployKeys', () => {
+ const key = { id: 1, title: 'hello', edit_path: '/edit' };
+
+ it.each(['enabledKeys', 'availableProjectKeys', 'availablePublicKeys'])(
+ 'should request the endpoint for the %s scope',
+ async (scope) => {
+ mock.onGet(ENDPOINTS[`${scope}Endpoint`]).reply(HTTP_STATUS_OK, { keys: [key] });
+
+ const keys = await mockResolvers.Project.deployKeys(null, { scope, page: 1 }, { client });
+
+ expect(keys).toEqual([
+ { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' },
+ ]);
+ },
+ );
+
+ it('should default to enabled keys if a bad scope is given', async () => {
+ const scope = 'bad';
+ mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply(HTTP_STATUS_OK, { keys: [key] });
+
+ const keys = await mockResolvers.Project.deployKeys(null, { scope, page: 1 }, { client });
+
+ expect(keys).toEqual([
+ { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' },
+ ]);
+ });
+
+ it('should request the given page', async () => {
+ const scope = 'enabledKeys';
+ const page = 2;
+ mock
+ .onGet(ENDPOINTS.enabledKeysEndpoint, { params: { page } })
+ .reply(HTTP_STATUS_OK, { keys: [key] });
+
+ const keys = await mockResolvers.Project.deployKeys(null, { scope, page }, { client });
+
+ expect(keys).toEqual([
+ { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' },
+ ]);
+ });
+
+ it('should write pagination info to the cache', async () => {
+ const scope = 'enabledKeys';
+ const page = 1;
+
+ mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply(
+ HTTP_STATUS_OK,
+ { keys: [key] },
+ {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ },
+ );
+
+ await mockResolvers.Project.deployKeys(null, { scope, page }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: pageInfoQuery,
+ variables: { input: { scope, page } },
+ data: {
+ pageInfo: {
+ total: 37,
+ perPage: 2,
+ previousPage: NaN,
+ totalPages: 5,
+ nextPage: 2,
+ page: 1,
+ __typename: 'LocalPageInfo',
+ },
+ },
+ });
+ });
+
+ it('should not write page info if the request fails', async () => {
+ const scope = 'enabledKeys';
+ const page = 1;
+
+ mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply(HTTP_STATUS_NOT_FOUND);
+
+ try {
+ await mockResolvers.Project.deployKeys(null, { scope, page }, { client });
+ } catch {
+ expect(client.writeQuery).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('currentPage', () => {
+ it('sets the current page', () => {
+ const page = 5;
+ mockResolvers.Mutation.currentPage(null, { page }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: currentPageQuery,
+ data: { currentPage: page },
+ });
+ });
+ });
+
+ describe('currentScope', () => {
+ let scope;
+
+ beforeEach(() => {
+ scope = 'enabledKeys';
+ mockResolvers.Mutation.currentScope(null, { scope }, { client });
+ });
+
+ it('sets the current scope', () => {
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: currentScopeQuery,
+ data: { currentScope: scope },
+ });
+ });
+
+ it('resets the page to 1', () => {
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: currentPageQuery,
+ data: { currentPage: 1 },
+ });
+ });
+ });
+
+ describe('disableKey', () => {
+ it('disables the key that is pending confirmation', async () => {
+ const key = { id: 1, title: 'hello', disablePath: '/disable', __typename: 'LocalDeployKey' };
+ client.readQuery.mockReturnValue({ deployKeyToRemove: key });
+ client.readFragment.mockReturnValue(key);
+ mock.onPut(key.disablePath).reply(HTTP_STATUS_OK);
+ await mockResolvers.Mutation.disableKey(null, null, { client });
+
+ expect(client.readQuery).toHaveBeenCalledWith({ query: confirmRemoveKeyQuery });
+ expect(client.readFragment).toHaveBeenCalledWith(
+ expect.objectContaining({ id: `LocalDeployKey:${key.id}` }),
+ );
+ expect(client.cache.evict).toHaveBeenCalledWith({ fieldName: 'deployKeyToRemove' });
+ expect(client.cache.evict).toHaveBeenCalledWith({ id: `LocalDeployKey:${key.id}` });
+ expect(client.cache.gc).toHaveBeenCalled();
+ });
+
+ it('does not remove the key from the cache on fail', async () => {
+ const key = { id: 1, title: 'hello', disablePath: '/disable', __typename: 'LocalDeployKey' };
+ client.readQuery.mockReturnValue({ deployKeyToRemove: key });
+ client.readFragment.mockReturnValue(key);
+ mock.onPut(key.disablePath).reply(HTTP_STATUS_NOT_FOUND);
+
+ try {
+ await mockResolvers.Mutation.disableKey(null, null, { client });
+ } catch {
+ expect(client.readQuery).toHaveBeenCalledWith({ query: confirmRemoveKeyQuery });
+ expect(client.readFragment).toHaveBeenCalledWith(
+ expect.objectContaining({ id: `LocalDeployKey:${key.id}` }),
+ );
+ expect(client.cache.evict).not.toHaveBeenCalled();
+ expect(client.cache.gc).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('enableKey', () => {
+ it("calls the key's enable path", async () => {
+ const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' };
+ client.readQuery.mockReturnValue({ deployKeyToRemove: key });
+ client.readFragment.mockReturnValue(key);
+ mock.onPut(key.enablePath).reply(HTTP_STATUS_OK);
+ await mockResolvers.Mutation.enableKey(null, key, { client });
+
+ expect(client.readFragment).toHaveBeenCalledWith(
+ expect.objectContaining({ id: `LocalDeployKey:${key.id}` }),
+ );
+ expect(client.cache.evict).toHaveBeenCalledWith({ id: `LocalDeployKey:${key.id}` });
+ expect(client.cache.gc).toHaveBeenCalled();
+ });
+
+ it('does not remove the key from the cache on failure', async () => {
+ const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' };
+ client.readQuery.mockReturnValue({ deployKeyToRemove: key });
+ client.readFragment.mockReturnValue(key);
+ mock.onPut(key.enablePath).reply(HTTP_STATUS_NOT_FOUND);
+ try {
+ await mockResolvers.Mutation.enableKey(null, key, { client });
+ } catch {
+ expect(client.readFragment).toHaveBeenCalledWith(
+ expect.objectContaining({ id: `LocalDeployKey:${key.id}` }),
+ );
+ expect(client.cache.evict).not.toHaveBeenCalled();
+ expect(client.cache.gc).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('confirmDisable', () => {
+ it('sets the key to disable', () => {
+ const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' };
+ mockResolvers.Mutation.confirmDisable(null, key, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: confirmRemoveKeyQuery,
+ data: { deployKeyToRemove: { id: key.id, __type: 'LocalDeployKey' } },
+ });
+ });
+ it('clears the value when null id is passed', () => {
+ mockResolvers.Mutation.confirmDisable(null, { id: null }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: confirmRemoveKeyQuery,
+ data: { deployKeyToRemove: null },
+ });
+ });
+ });
+});
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 a05b3baecd3..6624c90a146 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
@@ -48,7 +48,7 @@ exports[`Design management list item component with notes renders item with mult
Updated
<timeago-stub
cssclass=""
- datetimeformat="DATE_WITH_TIME_FORMAT"
+ datetimeformat="asDateTime"
time="01-01-2019"
tooltipplacement="bottom"
/>
@@ -113,7 +113,7 @@ exports[`Design management list item component with notes renders item with sing
Updated
<timeago-stub
cssclass=""
- datetimeformat="DATE_WITH_TIME_FORMAT"
+ datetimeformat="asDateTime"
time="01-01-2019"
tooltipplacement="bottom"
/>
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 34af3d72b04..a9fbf4632ac 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -23,7 +23,7 @@ import eventHub from '~/diffs/event_hub';
import { diffViewerModes, diffViewerErrors } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
-import { scrollToElement } from '~/lib/utils/common_utils';
+import { scrollToElement, isElementStuck } from '~/lib/utils/common_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import createNotesStore from '~/notes/stores/modules';
import diffsModule from '~/diffs/store/modules';
@@ -399,6 +399,27 @@ describe('DiffFile', () => {
});
});
+ describe('automatically collapsed generated file', () => {
+ beforeEach(() => {
+ makeFileAutomaticallyCollapsed(store);
+ const file = store.state.diffs.diffFiles[0];
+ Object.assign(store.state.diffs.diffFiles[0], {
+ ...file,
+ viewer: {
+ ...file.viewer,
+ generated: true,
+ },
+ });
+ });
+
+ it('should show the generated file warning with expansion button', () => {
+ expect(findDiffContentArea(wrapper).html()).toContain(
+ 'Generated files are collapsed by default. This behavior can be overriden via .gitattributes file if required.',
+ );
+ expect(findToggleButton(wrapper).exists()).toBe(true);
+ });
+ });
+
describe('not collapsed', () => {
beforeEach(() => {
makeFileOpenByDefault(store);
@@ -429,6 +450,7 @@ describe('DiffFile', () => {
describe('scoll-to-top of file after collapse', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {});
+ isElementStuck.mockReturnValueOnce(true);
});
it("scrolls to the top when the file is open, the users initiates the collapse, and there's a content block to scroll to", async () => {
diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js
index 30510958704..e9fbde11211 100644
--- a/spec/frontend/diffs/components/diff_row_spec.js
+++ b/spec/frontend/diffs/components/diff_row_spec.js
@@ -112,6 +112,8 @@ describe('DiffRow', () => {
});
const getCommentButton = (side) => wrapper.find(`[data-testid="${side}-comment-button"]`);
+ const findRightCommentButton = () => wrapper.find('[data-testid="right-comment-button"]');
+ const findLeftCommentButton = () => wrapper.find('[data-testid="left-comment-button"]');
describe.each`
side
@@ -135,6 +137,10 @@ describe('DiffRow', () => {
it('renders', () => {
wrapper = createWrapper({ props: { line, inline: false } });
+ expect(findRightCommentButton().attributes('draggable')).toBe('true');
+ expect(findLeftCommentButton().attributes('draggable')).toBe(
+ side === 'left' ? 'true' : 'false',
+ );
expect(getCommentButton(side).exists()).toBe(true);
});
diff --git a/spec/frontend/diffs/components/merge_conflict_warning_spec.js b/spec/frontend/diffs/components/merge_conflict_warning_spec.js
deleted file mode 100644
index 715912b361f..00000000000
--- a/spec/frontend/diffs/components/merge_conflict_warning_spec.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import { shallowMount, mount } from '@vue/test-utils';
-import MergeConflictWarning from '~/diffs/components/merge_conflict_warning.vue';
-
-const propsData = {
- limited: true,
- mergeable: true,
- resolutionPath: 'a-path',
-};
-
-function findResolveButton(wrapper) {
- return wrapper.find('.gl-alert-actions a.gl-button:first-child');
-}
-function findLocalMergeButton(wrapper) {
- return wrapper.find('.gl-alert-actions button.gl-button:last-child');
-}
-
-describe('MergeConflictWarning', () => {
- let wrapper;
-
- const createComponent = (props = {}, { full } = { full: false }) => {
- const mounter = full ? mount : shallowMount;
-
- wrapper = mounter(MergeConflictWarning, {
- propsData: { ...propsData, ...props },
- });
- };
-
- it.each`
- present | resolutionPath
- ${false} | ${''}
- ${true} | ${'some-path'}
- `(
- 'toggles the resolve conflicts button based on the provided resolutionPath "$resolutionPath"',
- ({ present, resolutionPath }) => {
- createComponent({ resolutionPath }, { full: true });
- const resolveButton = findResolveButton(wrapper);
-
- expect(resolveButton.exists()).toBe(present);
- if (present) {
- expect(resolveButton.attributes('href')).toBe(resolutionPath);
- }
- },
- );
-
- it.each`
- present | mergeable
- ${false} | ${false}
- ${true} | ${true}
- `(
- 'toggles the local merge button based on the provided mergeable property "$mergable"',
- ({ present, mergeable }) => {
- createComponent({ mergeable }, { full: true });
- const localMerge = findLocalMergeButton(wrapper);
-
- expect(localMerge.exists()).toBe(present);
- },
- );
-});
diff --git a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap
index cfc34bd2f25..33a268c06cc 100644
--- a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap
+++ b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`FindingsDrawer matches the snapshot 1`] = `
+exports[`FindingsDrawer General Rendering matches the snapshot with detected badge 1`] = `
<transition-stub
class="findings-drawer"
name="gl-drawer"
@@ -16,7 +16,7 @@ exports[`FindingsDrawer matches the snapshot 1`] = `
class="gl-drawer-title"
>
<h2
- class="drawer-heading gl-font-base gl-mb-0 gl-mt-0"
+ class="drawer-heading gl-font-base gl-mb-0 gl-mt-0 gl-w-28"
>
<svg
aria-hidden="true"
@@ -61,6 +61,227 @@ exports[`FindingsDrawer matches the snapshot 1`] = `
>
<li
class="gl-mb-4"
+ data-testid="findings-drawer-title"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ Name
+ </span>
+ <span
+ data-testid="findings-drawer-item-value-prop"
+ >
+ mockedtitle
+ </span>
+ </p>
+ </li>
+ <li
+ class="gl-mb-4"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ Status
+ </span>
+ <span
+ class="badge badge-pill badge-warning gl-badge md text-capitalize"
+ >
+ detected
+ </span>
+ </p>
+ </li>
+ <li
+ class="gl-mb-4"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ Description
+ </span>
+ <span
+ data-testid="findings-drawer-item-value-prop"
+ >
+ fakedesc
+ </span>
+ </p>
+ </li>
+ <li
+ class="gl-mb-4"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ Project
+ </span>
+ <a
+ class="gl-link"
+ href="/testpath"
+ >
+ testname
+ </a>
+ </p>
+ </li>
+ <li
+ class="gl-mb-4"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ File
+ </span>
+ <span
+ data-testid="findings-drawer-item-value-prop"
+ />
+ </p>
+ </li>
+ <li
+ class="gl-mb-4"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ Identifiers
+ </span>
+ <span>
+ <a
+ class="gl-link"
+ href="https://semgrep.dev/r/gitlab.eslint.detect-disable-mustache-escape"
+ >
+ eslint.detect-disable-mustache-escape
+ </a>
+ </span>
+ </p>
+ </li>
+ <li
+ class="gl-mb-4"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ Tool
+ </span>
+ <span
+ data-testid="findings-drawer-item-value-prop"
+ >
+ SAST
+ </span>
+ </p>
+ </li>
+ <li
+ class="gl-mb-4"
+ >
+ <p
+ class="gl-line-height-20"
+ >
+ <span
+ class="gl-display-block gl-font-weight-bold gl-mb-1"
+ data-testid="findings-drawer-item-description"
+ >
+ Engine
+ </span>
+ <span
+ data-testid="findings-drawer-item-value-prop"
+ >
+ testengine name
+ </span>
+ </p>
+ </li>
+ </ul>
+ </div>
+ </aside>
+</transition-stub>
+`;
+
+exports[`FindingsDrawer General Rendering matches the snapshot with dismissed badge 1`] = `
+<transition-stub
+ class="findings-drawer"
+ name="gl-drawer"
+>
+ <aside
+ class="gl-drawer gl-drawer-default"
+ style="top: 0px; z-index: 252;"
+ >
+ <div
+ class="gl-drawer-header"
+ >
+ <div
+ class="gl-drawer-title"
+ >
+ <h2
+ class="drawer-heading gl-font-base gl-mb-0 gl-mt-0 gl-w-28"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-icon gl-text-orange-300 gl-vertical-align-baseline! inline-findings-severity-icon s12"
+ data-testid="severity-low-icon"
+ role="img"
+ >
+ <use
+ href="file-mock#severity-low"
+ />
+ </svg>
+ <span
+ class="drawer-heading-severity"
+ >
+ low
+ </span>
+ SAST Finding
+ </h2>
+ <button
+ aria-label="Close drawer"
+ class="btn btn-default btn-default-tertiary btn-icon btn-sm gl-button gl-drawer-close-button"
+ type="button"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-button-icon gl-icon s16"
+ data-testid="close-icon"
+ role="img"
+ >
+ <use
+ href="file-mock#close"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+ <div
+ class="gl-drawer-body gl-drawer-body-scrim"
+ >
+ <ul
+ class="gl-border-b-initial gl-list-style-none gl-mb-0 gl-pb-0!"
+ >
+ <li
+ class="gl-mb-4"
+ data-testid="findings-drawer-title"
>
<p
class="gl-line-height-20"
diff --git a/spec/frontend/diffs/components/shared/findings_drawer_spec.js b/spec/frontend/diffs/components/shared/findings_drawer_spec.js
index 62d875ed9b7..00b4ca262be 100644
--- a/spec/frontend/diffs/components/shared/findings_drawer_spec.js
+++ b/spec/frontend/diffs/components/shared/findings_drawer_spec.js
@@ -1,36 +1,106 @@
+import { nextTick } from 'vue';
import { GlDrawer } from '@gitlab/ui';
import FindingsDrawer from '~/diffs/components/shared/findings_drawer.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { mockFinding, mockProject } from '../../mock_data/findings_drawer';
-
-let wrapper;
-const getDrawer = () => wrapper.findComponent(GlDrawer);
-const closeEvent = 'close';
-
-const createWrapper = () => {
- return mountExtended(FindingsDrawer, {
- propsData: {
- drawer: mockFinding,
- project: mockProject,
- },
- });
-};
+import {
+ mockFindingDismissed,
+ mockFindingDetected,
+ mockProject,
+ mockFindingsMultiple,
+} from '../../mock_data/findings_drawer';
describe('FindingsDrawer', () => {
- it('renders without errors', () => {
- wrapper = createWrapper();
- expect(wrapper.exists()).toBe(true);
+ let wrapper;
+
+ const findPreviousButton = () => wrapper.findByTestId('findings-drawer-prev-button');
+ const findNextButton = () => wrapper.findByTestId('findings-drawer-next-button');
+ const findTitle = () => wrapper.findByTestId('findings-drawer-title');
+ const createWrapper = (
+ drawer = { findings: [mockFindingDetected], index: 0 },
+ project = mockProject,
+ ) => {
+ return mountExtended(FindingsDrawer, {
+ propsData: {
+ drawer,
+ project,
+ },
+ });
+ };
+
+ describe('General Rendering', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+ it('renders without errors', () => {
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ it('emits close event when gl-drawer emits close event', () => {
+ wrapper.findComponent(GlDrawer).vm.$emit('close');
+ expect(wrapper.emitted('close')).toHaveLength(1);
+ });
+
+ it('matches the snapshot with dismissed badge', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('matches the snapshot with detected badge', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
});
- it('emits close event when gl-drawer emits close event', () => {
- wrapper = createWrapper();
+ describe('Prev/Next Buttons with Multiple Items', () => {
+ it('renders prev/next buttons when there are multiple items', () => {
+ wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 });
+ expect(findPreviousButton().exists()).toBe(true);
+ expect(findNextButton().exists()).toBe(true);
+ });
+
+ it('does not render prev/next buttons when there is only one item', () => {
+ wrapper = createWrapper({ findings: [mockFindingDismissed], index: 0 });
+ expect(findPreviousButton().exists()).toBe(false);
+ expect(findNextButton().exists()).toBe(false);
+ });
- getDrawer().vm.$emit(closeEvent);
- expect(wrapper.emitted(closeEvent)).toHaveLength(1);
+ it('calls prev method on prev button click and loops correct activeIndex', async () => {
+ wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 });
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`);
+
+ await findPreviousButton().trigger('click');
+ await nextTick();
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`);
+
+ await findPreviousButton().trigger('click');
+ await nextTick();
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[1].title}`);
+ });
+
+ it('calls next method on next button click', async () => {
+ wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 });
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`);
+
+ await findNextButton().trigger('click');
+ await nextTick();
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[1].title}`);
+
+ await findNextButton().trigger('click');
+ await nextTick();
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`);
+
+ await findNextButton().trigger('click');
+ await nextTick();
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`);
+ });
});
- it('matches the snapshot', () => {
- wrapper = createWrapper();
- expect(wrapper.element).toMatchSnapshot();
+ describe('Active Index Handling', () => {
+ it('watcher sets active index on drawer prop change', async () => {
+ wrapper = createWrapper();
+ const newFinding = { findings: mockFindingsMultiple, index: 2 };
+
+ await wrapper.setProps({ drawer: newFinding });
+ await nextTick();
+ expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`);
+ });
});
});
diff --git a/spec/frontend/diffs/mock_data/findings_drawer.js b/spec/frontend/diffs/mock_data/findings_drawer.js
index 4823a18b267..257a3b3e499 100644
--- a/spec/frontend/diffs/mock_data/findings_drawer.js
+++ b/spec/frontend/diffs/mock_data/findings_drawer.js
@@ -1,6 +1,6 @@
-export const mockFinding = {
+export const mockFindingDismissed = {
title: 'mockedtitle',
- state: 'detected',
+ state: 'dismissed',
scale: 'sast',
line: 7,
description: 'fakedesc',
@@ -22,7 +22,54 @@ export const mockFinding = {
],
};
+export const mockFindingDetected = {
+ ...mockFindingDismissed,
+ state: 'detected',
+};
+
export const mockProject = {
nameWithNamespace: 'testname',
fullPath: 'testpath',
};
+
+export const mockFindingsMultiple = [
+ {
+ ...mockFindingDismissed,
+ title: 'Finding 1',
+ severity: 'critical',
+ engineName: 'Engine 1',
+ identifiers: [
+ {
+ ...mockFindingDismissed.identifiers[0],
+ name: 'identifier 1',
+ url: 'https://example.com/identifier1',
+ },
+ ],
+ },
+ {
+ ...mockFindingDetected,
+ title: 'Finding 2',
+ severity: 'medium',
+ engineName: 'Engine 2',
+ identifiers: [
+ {
+ ...mockFindingDetected.identifiers[0],
+ name: 'identifier 2',
+ url: 'https://example.com/identifier2',
+ },
+ ],
+ },
+ {
+ ...mockFindingDetected,
+ title: 'Finding 3',
+ severity: 'medium',
+ engineName: 'Engine 3',
+ identifiers: [
+ {
+ ...mockFindingDetected.identifiers[0],
+ name: 'identifier 3',
+ url: 'https://example.com/identifier3',
+ },
+ ],
+ },
+];
diff --git a/spec/frontend/diffs/mock_data/inline_findings.js b/spec/frontend/diffs/mock_data/inline_findings.js
index ae1ae909238..6307c2c7343 100644
--- a/spec/frontend/diffs/mock_data/inline_findings.js
+++ b/spec/frontend/diffs/mock_data/inline_findings.js
@@ -45,36 +45,43 @@ export const multipleFindingsArrSastScale = [
line: 2,
scale: 'sast',
text: 'mocked low Issue',
+ state: 'detected',
},
{
severity: 'medium',
description: 'mocked medium Issue',
line: 3,
scale: 'sast',
+ text: 'mocked medium Issue',
+ state: 'dismissed',
},
{
severity: 'info',
description: 'mocked info Issue',
line: 3,
scale: 'sast',
+ state: 'detected',
},
{
severity: 'high',
description: 'mocked high Issue',
line: 3,
scale: 'sast',
+ state: 'dismissed',
},
{
severity: 'critical',
description: 'mocked critical Issue',
line: 3,
scale: 'sast',
+ state: 'detected',
},
{
severity: 'unknown',
description: 'mocked unknown Issue',
line: 3,
scale: 'sast',
+ state: 'dismissed',
},
];
@@ -114,6 +121,9 @@ export const diffCodeQuality = {
export const singularCodeQualityFinding = [multipleFindingsArrCodeQualityScale[0]];
export const singularSastFinding = [multipleFindingsArrSastScale[0]];
+export const singularSastFindingDetected = [multipleFindingsArrSastScale[0]];
+export const singularSastFindingDismissed = [multipleFindingsArrSastScale[1]];
+
export const twoSastFindings = multipleFindingsArrSastScale.slice(0, 2);
export const fiveCodeQualityFindings = multipleFindingsArrCodeQualityScale.slice(0, 5);
export const threeCodeQualityFindings = multipleFindingsArrCodeQualityScale.slice(0, 3);
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 8cf376b13e3..be3b30e8e7a 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -631,7 +631,7 @@ describe('DiffsStoreActions', () => {
describe('prefetchFileNeighbors', () => {
it('dispatches two requests to prefetch the next/previous files', () => {
- testAction(
+ return testAction(
diffActions.prefetchFileNeighbors,
{},
{
@@ -1327,8 +1327,13 @@ describe('DiffsStoreActions', () => {
await waitForPromises();
expect(dispatch).toHaveBeenCalledWith('fetchFileByFile');
- expect(dispatch).toHaveBeenCalledWith('scrollToFile', file);
- expect(dispatch).toHaveBeenCalledTimes(2);
+ expect(commonUtils.historyPushState).toHaveBeenCalledWith(new URL(`${TEST_HOST}/#test`), {
+ skipScrolling: true,
+ });
+ expect(commonUtils.scrollToElement).toHaveBeenCalledWith('.diff-files-holder', {
+ duration: 0,
+ });
+ expect(dispatch).toHaveBeenCalledTimes(1);
});
it('shows an alert when there was an error fetching the file', async () => {
@@ -2057,11 +2062,48 @@ describe('DiffsStoreActions', () => {
describe('toggleFileCommentForm', () => {
it('commits TOGGLE_FILE_COMMENT_FORM', () => {
+ const file = getDiffFileMock();
return testAction(
diffActions.toggleFileCommentForm,
- 'path',
- {},
- [{ type: types.TOGGLE_FILE_COMMENT_FORM, payload: 'path' }],
+ file.file_path,
+ {
+ diffFiles: [file],
+ },
+ [
+ { type: types.TOGGLE_FILE_COMMENT_FORM, payload: file.file_path },
+ {
+ type: types.SET_FILE_COLLAPSED,
+ payload: { filePath: file.file_path, collapsed: false },
+ },
+ ],
+ [],
+ );
+ });
+
+ it('always opens if file is collapsed', () => {
+ const file = {
+ ...getDiffFileMock(),
+ viewer: {
+ ...getDiffFileMock().viewer,
+ manuallyCollapsed: true,
+ },
+ };
+ return testAction(
+ diffActions.toggleFileCommentForm,
+ file.file_path,
+ {
+ diffFiles: [file],
+ },
+ [
+ {
+ type: types.SET_FILE_COMMENT_FORM,
+ payload: { filePath: file.file_path, expanded: true },
+ },
+ {
+ type: types.SET_FILE_COLLAPSED,
+ payload: { filePath: file.file_path, collapsed: false },
+ },
+ ],
[],
);
});
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index fdcf7c3eeab..a5be41aa69f 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -1045,6 +1045,17 @@ describe('DiffsStoreMutations', () => {
});
});
+ describe('SET_FILE_COMMENT_FORM', () => {
+ it('toggles diff files hasCommentForm', () => {
+ const state = { diffFiles: [{ file_path: 'path', hasCommentForm: false }] };
+ const expanded = true;
+
+ mutations[types.SET_FILE_COMMENT_FORM](state, { filePath: 'path', expanded });
+
+ expect(state.diffFiles[0].hasCommentForm).toEqual(expanded);
+ });
+ });
+
describe('ADD_DRAFT_TO_FILE', () => {
it('adds draft to diff file', () => {
const state = { diffFiles: [{ file_path: 'path', drafts: [] }] };
diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js
index ba4d838e44b..bde84d3b603 100644
--- a/spec/frontend/dropzone_input_spec.js
+++ b/spec/frontend/dropzone_input_spec.js
@@ -21,6 +21,10 @@ const TEMPLATE = `<form class="gfm-form" data-uploads-path="${TEST_UPLOAD_PATH}"
</form>`;
describe('dropzone_input', () => {
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
it('returns null when failed to initialize', () => {
const dropzone = dropzoneInput($('<form class="gfm-form"></form>'));
@@ -58,8 +62,6 @@ describe('dropzone_input', () => {
afterEach(() => {
form = null;
-
- resetHTMLFixture();
});
it('pastes Markdown tables', () => {
@@ -154,8 +156,6 @@ describe('dropzone_input', () => {
mock.teardown();
});
- beforeEach(() => {});
-
it.each`
responseType | responseBody
${'application/json'} | ${JSON.stringify({ message: TEST_ERROR_MESSAGE })}
@@ -174,4 +174,36 @@ describe('dropzone_input', () => {
});
});
});
+
+ describe('clickable element', () => {
+ let form;
+
+ beforeEach(() => {
+ jest.spyOn($.fn, 'dropzone');
+ setHTMLFixture(TEMPLATE);
+ form = $('form');
+ });
+
+ describe('if attach file button exists', () => {
+ let attachFileButton;
+
+ beforeEach(() => {
+ attachFileButton = document.createElement('button');
+ attachFileButton.dataset.buttonType = 'attach-file';
+ document.body.querySelector('form').appendChild(attachFileButton);
+ });
+
+ it('passes attach file button as `clickable` to dropzone', () => {
+ dropzoneInput(form);
+ expect($.fn.dropzone.mock.calls[0][0]).toMatchObject({ clickable: attachFileButton });
+ });
+ });
+
+ describe('if attach file button does not exist', () => {
+ it('passes attach file button as `clickable`, if it exists', () => {
+ dropzoneInput(form);
+ expect($.fn.dropzone.mock.calls[0][0]).toMatchObject({ clickable: true });
+ });
+ });
+ });
});
diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js
index 0f380f13679..7986509074e 100644
--- a/spec/frontend/editor/schema/ci/ci_schema_spec.js
+++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js
@@ -23,6 +23,7 @@ import RetryUnknownWhenJson from './json_tests/negative_tests/retry_unknown_when
// YAML POSITIVE TEST
import ArtifactsYaml from './yaml_tests/positive_tests/artifacts.yml';
+import ImageYaml from './yaml_tests/positive_tests/image.yml';
import CacheYaml from './yaml_tests/positive_tests/cache.yml';
import FilterYaml from './yaml_tests/positive_tests/filter.yml';
import IncludeYaml from './yaml_tests/positive_tests/include.yml';
@@ -37,9 +38,12 @@ import SecretsYaml from './yaml_tests/positive_tests/secrets.yml';
import ServicesYaml from './yaml_tests/positive_tests/services.yml';
import NeedsParallelMatrixYaml from './yaml_tests/positive_tests/needs_parallel_matrix.yml';
import ScriptYaml from './yaml_tests/positive_tests/script.yml';
+import AutoCancelPipelineOnJobFailureAllYaml from './yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml';
+import AutoCancelPipelineOnJobFailureNoneYaml from './yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml';
// YAML NEGATIVE TEST
import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml';
+import ImageNegativeYaml from './yaml_tests/negative_tests/image.yml';
import CacheKeyNeative from './yaml_tests/negative_tests/cache.yml';
import IncludeNegativeYaml from './yaml_tests/negative_tests/include.yml';
import JobWhenNegativeYaml from './yaml_tests/negative_tests/job_when.yml';
@@ -62,6 +66,7 @@ import NeedsParallelMatrixNumericYaml from './yaml_tests/negative_tests/needs/pa
import NeedsParallelMatrixWrongParallelValueYaml from './yaml_tests/negative_tests/needs/parallel_matrix/wrong_parallel_value.yml';
import NeedsParallelMatrixWrongMatrixValueYaml from './yaml_tests/negative_tests/needs/parallel_matrix/wrong_matrix_value.yml';
import ScriptNegativeYaml from './yaml_tests/negative_tests/script.yml';
+import AutoCancelPipelineNegativeYaml from './yaml_tests/negative_tests/auto_cancel_pipeline.yml';
const ajv = new Ajv({
strictTypes: false,
@@ -90,6 +95,7 @@ describe('positive tests', () => {
// YAML
ArtifactsYaml,
+ ImageYaml,
CacheYaml,
FilterYaml,
IncludeYaml,
@@ -104,6 +110,8 @@ describe('positive tests', () => {
SecretsYaml,
NeedsParallelMatrixYaml,
ScriptYaml,
+ AutoCancelPipelineOnJobFailureAllYaml,
+ AutoCancelPipelineOnJobFailureNoneYaml,
}),
)('schema validates %s', (_, input) => {
// We construct a new "JSON" from each main key that is inside a
@@ -126,6 +134,7 @@ describe('negative tests', () => {
// YAML
ArtifactsNegativeYaml,
+ ImageNegativeYaml,
CacheKeyNeative,
HooksNegative,
IdTokensNegativeYaml,
@@ -148,6 +157,7 @@ describe('negative tests', () => {
NeedsParallelMatrixWrongParallelValueYaml,
NeedsParallelMatrixWrongMatrixValueYaml,
ScriptNegativeYaml,
+ AutoCancelPipelineNegativeYaml,
}),
)('schema validates %s', (_, input) => {
// We construct a new "JSON" from each main key that is inside a
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml
new file mode 100644
index 00000000000..0ba3e5632e3
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml
@@ -0,0 +1,4 @@
+# invalid workflow:auto-cancel:on-job-failure
+workflow:
+ auto_cancel:
+ on_job_failure: unexpected_value
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml
new file mode 100644
index 00000000000..ad37cd6c3ba
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml
@@ -0,0 +1,38 @@
+empty_image:
+ image:
+
+multi_image_array:
+ image:
+ - alpine:latest
+ - ubuntu:latest
+
+image_without_name:
+ image:
+ entrypoint: ["/bin/sh", "-c"]
+
+image_with_invalid_entrypoint:
+ image:
+ name: my-postgres:11.7
+ entrypoint: "/usr/local/bin/db-postgres" # must be array
+
+image_with_empty_pull_policy:
+ image:
+ name: postgres:11.6
+ pull_policy: []
+
+invalid_image_platform:
+ image:
+ name: alpine:latest
+ docker:
+ platform: ["arm64"] # The expected value is a string, not an array
+
+invalid_image_executor_opts:
+ image:
+ name: alpine:latest
+ docker:
+ unknown_key: test
+
+image_with_empty_executor_opts:
+ image:
+ name: alpine:latest
+ docker:
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml
index 6761a603a0a..e14ac9ca86e 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml
@@ -36,3 +36,17 @@ empty_pull_policy:
services:
- name: postgres:11.6
pull_policy: []
+
+invalid_service_executor_opts:
+ script: echo "Specifying platform."
+ services:
+ - name: mysql:5.7
+ docker:
+ unknown_key: test
+
+invalid_service_platform:
+ script: echo "Specifying platform."
+ services:
+ - name: mysql:5.7
+ docker:
+ platform: ["arm64"] # The expected value is a string, not an array
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml
new file mode 100644
index 00000000000..bf84ff16f42
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml
@@ -0,0 +1,4 @@
+# valid workflow:auto-cancel:on-job-failure
+workflow:
+ auto_cancel:
+ on_job_failure: all
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml
new file mode 100644
index 00000000000..b99eb50e962
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml
@@ -0,0 +1,4 @@
+# valid workflow:auto-cancel:on-job-failure
+workflow:
+ auto_cancel:
+ on_job_failure: none
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml
new file mode 100644
index 00000000000..4c2559d0800
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml
@@ -0,0 +1,41 @@
+valid_image:
+ image: alpine:latest
+
+valid_image_basic:
+ image:
+ name: alpine:latest
+
+valid_image_with_entrypoint:
+ image:
+ name: alpine:latest
+ entrypoint:
+ - /bin/sh
+ - -c
+
+valid_image_with_pull_policy:
+ image:
+ name: alpine:latest
+ pull_policy: always
+
+valid_image_with_pull_policies:
+ image:
+ name: alpine:latest
+ pull_policy:
+ - always
+ - if-not-present
+
+valid_image_with_docker:
+ image:
+ name: alpine:latest
+ docker:
+ platform: linux/amd64
+
+valid_image_full:
+ image:
+ name: alpine:latest
+ entrypoint:
+ - /bin/sh
+ - -c
+ docker:
+ platform: linux/amd64
+ pull_policy: if-not-present
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml
index 8a0f59d1dfd..1d19ee52cc3 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml
@@ -29,3 +29,10 @@ pull_policy_array:
services:
- name: postgres:11.6
pull_policy: [always, if-not-present]
+
+services_platform_string:
+ script: echo "Specifying platform."
+ services:
+ - name: mysql:5.7
+ docker:
+ platform: arm64
diff --git a/spec/frontend/editor/source_editor_security_policy_schema_ext_spec.js b/spec/frontend/editor/source_editor_security_policy_schema_ext_spec.js
new file mode 100644
index 00000000000..96c876b27c9
--- /dev/null
+++ b/spec/frontend/editor/source_editor_security_policy_schema_ext_spec.js
@@ -0,0 +1,181 @@
+import MockAdapter from 'axios-mock-adapter';
+import { registerSchema } from '~/ide/utils';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { TEST_HOST } from 'helpers/test_constants';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import {
+ getSecurityPolicyListUrl,
+ getSecurityPolicySchemaUrl,
+ getSinglePolicySchema,
+ SecurityPolicySchemaExtension,
+} from '~/editor/extensions/source_editor_security_policy_schema_ext';
+import SourceEditor from '~/editor/source_editor';
+
+jest.mock('~/ide/utils');
+
+const mockNamespacePath = 'mock-namespace';
+
+const mockSchema = {
+ $id: 1,
+ title: 'mockSchema',
+ description: 'mockDescriptions',
+ type: 'Object',
+ properties: {
+ scan_execution_policy: { items: { properties: { foo: 'bar' } } },
+ scan_result_policy: { items: { properties: { fizz: 'buzz' } } },
+ },
+};
+
+const createMockOutput = (policyType) => ({
+ $id: mockSchema.$id,
+ title: mockSchema.title,
+ description: mockSchema.description,
+ type: mockSchema.type,
+ properties: {
+ type: {
+ type: 'string',
+ description: 'Specifies the type of policy to be enforced.',
+ enum: policyType,
+ },
+ ...mockSchema.properties[policyType].items.properties,
+ },
+});
+
+describe('getSecurityPolicyListUrl', () => {
+ it.each`
+ input | output
+ ${{ namespacePath: '' }} | ${`${TEST_HOST}/groups/-/security/policies`}
+ ${{ namespacePath: 'test', namespaceType: 'group' }} | ${`${TEST_HOST}/groups/test/-/security/policies`}
+ ${{ namespacePath: '', namespaceType: 'project' }} | ${`${TEST_HOST}/-/security/policies`}
+ ${{ namespacePath: 'test', namespaceType: 'project' }} | ${`${TEST_HOST}/test/-/security/policies`}
+ ${{ namespacePath: undefined, namespaceType: 'project' }} | ${`${TEST_HOST}/-/security/policies`}
+ ${{ namespacePath: undefined, namespaceType: 'group' }} | ${`${TEST_HOST}/groups/-/security/policies`}
+ ${{ namespacePath: null, namespaceType: 'project' }} | ${`${TEST_HOST}/-/security/policies`}
+ ${{ namespacePath: null, namespaceType: 'group' }} | ${`${TEST_HOST}/groups/-/security/policies`}
+ `('returns `$output` when passed `$input`', ({ input, output }) => {
+ expect(getSecurityPolicyListUrl(input)).toBe(output);
+ });
+});
+
+describe('getSecurityPolicySchemaUrl', () => {
+ it.each`
+ namespacePath | namespaceType | output
+ ${'test'} | ${'project'} | ${`${TEST_HOST}/test/-/security/policies/schema`}
+ ${'test'} | ${'group'} | ${`${TEST_HOST}/groups/test/-/security/policies/schema`}
+ `(
+ 'returns $output when passed $namespacePath and $namespaceType',
+ ({ namespacePath, namespaceType, output }) => {
+ expect(getSecurityPolicySchemaUrl({ namespacePath, namespaceType })).toBe(output);
+ },
+ );
+});
+
+describe('getSinglePolicySchema', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it.each`
+ policyType
+ ${'scan_execution_policy'}
+ ${'scan_result_policy'}
+ `('returns the appropriate schema on request success for $policyType', async ({ policyType }) => {
+ mock.onGet().reply(HTTP_STATUS_OK, mockSchema);
+
+ await expect(
+ getSinglePolicySchema({
+ namespacePath: mockNamespacePath,
+ namespaceType: 'project',
+ policyType,
+ }),
+ ).resolves.toStrictEqual(createMockOutput(policyType));
+ });
+
+ it('returns an empty schema on request failure', async () => {
+ await expect(
+ getSinglePolicySchema({
+ namespacePath: mockNamespacePath,
+ namespaceType: 'project',
+ policyType: 'scan_execution_policy',
+ }),
+ ).resolves.toStrictEqual({});
+ });
+
+ it('returns an empty schema on non-existing policy type', async () => {
+ await expect(
+ getSinglePolicySchema({
+ namespacePath: mockNamespacePath,
+ namespaceType: 'project',
+ policyType: 'non_existent_policy',
+ }),
+ ).resolves.toStrictEqual({});
+ });
+});
+
+describe('SecurityPolicySchemaExtension', () => {
+ let mock;
+ let editor;
+ let instance;
+ let editorEl;
+
+ const createMockEditor = ({ blobPath = '.gitlab/security-policies/policy.yml' } = {}) => {
+ setHTMLFixture('<div id="editor"></div>');
+ editorEl = document.getElementById('editor');
+ editor = new SourceEditor();
+ instance = editor.createInstance({ el: editorEl, blobPath, blobContent: '' });
+ instance.use({ definition: SecurityPolicySchemaExtension });
+ };
+
+ beforeEach(() => {
+ createMockEditor();
+ mock = new MockAdapter(axios);
+ mock.onGet().reply(HTTP_STATUS_OK, mockSchema);
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ editorEl.remove();
+ resetHTMLFixture();
+ mock.restore();
+ });
+
+ describe('registerSecurityPolicyEditorSchema', () => {
+ describe('register validations options with monaco for yaml language', () => {
+ it('registers the schema', async () => {
+ const policyType = 'scan_execution_policy';
+ await instance.registerSecurityPolicyEditorSchema({
+ namespacePath: mockNamespacePath,
+ namespaceType: 'project',
+ policyType,
+ });
+
+ expect(registerSchema).toHaveBeenCalledTimes(1);
+ expect(registerSchema).toHaveBeenCalledWith({
+ uri: `${TEST_HOST}/${mockNamespacePath}/-/security/policies/schema`,
+ schema: createMockOutput(policyType),
+ fileMatch: ['policy.yml'],
+ });
+ });
+ });
+ });
+
+ describe('registerSecurityPolicySchema', () => {
+ describe('register validations options with monaco for yaml language', () => {
+ it('registers the schema', async () => {
+ await instance.registerSecurityPolicySchema(mockNamespacePath);
+ expect(registerSchema).toHaveBeenCalledTimes(1);
+ expect(registerSchema).toHaveBeenCalledWith({
+ uri: `${TEST_HOST}/${mockNamespacePath}/-/security/policies/schema`,
+ fileMatch: ['policy.yml'],
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/emoji/components/emoji_group_spec.js b/spec/frontend/emoji/components/emoji_group_spec.js
index 75397ce25ff..a2a46bedd7b 100644
--- a/spec/frontend/emoji/components/emoji_group_spec.js
+++ b/spec/frontend/emoji/components/emoji_group_spec.js
@@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
+import { GlButton } from '@gitlab/ui';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import EmojiGroup from '~/emoji/components/emoji_group.vue';
@@ -10,6 +11,9 @@ function factory(propsData = {}) {
wrapper = extendedWrapper(
shallowMount(EmojiGroup, {
propsData,
+ stubs: {
+ GlButton,
+ },
}),
);
}
@@ -19,7 +23,6 @@ describe('Emoji group component', () => {
factory({
emojis: [],
renderGroup: false,
- clickEmoji: jest.fn(),
});
expect(wrapper.findByTestId('emoji-button').exists()).toBe(false);
@@ -29,24 +32,20 @@ describe('Emoji group component', () => {
factory({
emojis: ['thumbsup', 'thumbsdown'],
renderGroup: true,
- clickEmoji: jest.fn(),
});
expect(wrapper.findAllByTestId('emoji-button').exists()).toBe(true);
expect(wrapper.findAllByTestId('emoji-button').length).toBe(2);
});
- it('calls clickEmoji', () => {
- const clickEmoji = jest.fn();
-
+ it('emits emoji-click', () => {
factory({
emojis: ['thumbsup', 'thumbsdown'],
renderGroup: true,
- clickEmoji,
});
- wrapper.findByTestId('emoji-button').trigger('click');
+ wrapper.findComponent(GlButton).vm.$emit('click');
- expect(clickEmoji).toHaveBeenCalledWith('thumbsup');
+ expect(wrapper.emitted('emoji-click')).toStrictEqual([['thumbsup']]);
});
});
diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js
index 7d6a45fbf30..577b7bc726e 100644
--- a/spec/frontend/emoji/index_spec.js
+++ b/spec/frontend/emoji/index_spec.js
@@ -925,7 +925,7 @@ describe('emoji', () => {
window.gon = {};
});
- it('returns empty object', async () => {
+ it('returns empty emoji data', async () => {
const result = await loadCustomEmojiWithNames();
expect(result).toEqual({ emojis: {}, names: [] });
@@ -937,7 +937,28 @@ describe('emoji', () => {
delete document.body.dataset.groupFullPath;
});
- it('returns empty object', async () => {
+ it('returns empty emoji data', async () => {
+ const result = await loadCustomEmojiWithNames();
+
+ expect(result).toEqual({ emojis: {}, names: [] });
+ });
+ });
+
+ describe('when GraphQL request returns null data', () => {
+ beforeEach(() => {
+ mockClient = createMockClient([
+ [
+ customEmojiQuery,
+ jest.fn().mockResolvedValue({
+ data: {
+ group: null,
+ },
+ }),
+ ],
+ ]);
+ });
+
+ it('returns empty emoji data', async () => {
const result = await loadCustomEmojiWithNames();
expect(result).toEqual({ emojis: {}, names: [] });
@@ -945,7 +966,7 @@ describe('emoji', () => {
});
describe('when in a group with flag enabled', () => {
- it('returns empty object', async () => {
+ it('returns emoji data', async () => {
const result = await loadCustomEmojiWithNames();
expect(result).toEqual({
diff --git a/spec/frontend/environments/deploy_board_wrapper_spec.js b/spec/frontend/environments/deploy_board_wrapper_spec.js
index 49eed68fa11..fec5032e31b 100644
--- a/spec/frontend/environments/deploy_board_wrapper_spec.js
+++ b/spec/frontend/environments/deploy_board_wrapper_spec.js
@@ -56,7 +56,7 @@ describe('~/environments/components/deploy_board_wrapper.vue', () => {
});
it('is collapsed by default', () => {
- expect(collapse.attributes('visible')).toBeUndefined();
+ expect(collapse.props('visible')).toBe(false);
expect(icon.props('name')).toBe('chevron-lg-right');
});
@@ -64,7 +64,7 @@ describe('~/environments/components/deploy_board_wrapper.vue', () => {
const button = await expandCollapsedSection();
expect(button.attributes('aria-label')).toBe(__('Collapse'));
- expect(collapse.attributes('visible')).toBe('visible');
+ expect(collapse.props('visible')).toBe(true);
expect(icon.props('name')).toBe('chevron-lg-down');
const deployBoard = findDeployBoard();
diff --git a/spec/frontend/environments/deployment_spec.js b/spec/frontend/environments/deployment_spec.js
index 4cbbb60b74c..bc0f1c58e7d 100644
--- a/spec/frontend/environments/deployment_spec.js
+++ b/spec/frontend/environments/deployment_spec.js
@@ -4,7 +4,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { useFakeDate } from 'helpers/fake_date';
import { stubTransition } from 'helpers/stub_transition';
-import { formatDate } from '~/lib/utils/datetime_utility';
+import { localeDateFormat } from '~/lib/utils/datetime_utility';
import { __, s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Deployment from '~/environments/components/deployment.vue';
@@ -158,7 +158,9 @@ describe('~/environments/components/deployment.vue', () => {
describe('is present', () => {
it('shows the timestamp the deployment was deployed at', () => {
wrapper = createWrapper();
- const date = wrapper.findByTitle(formatDate(deployment.createdAt));
+ const date = wrapper.findByTitle(
+ localeDateFormat.asDateTimeFull.format(deployment.createdAt),
+ );
expect(date.text()).toBe('1 day ago');
});
@@ -166,7 +168,9 @@ describe('~/environments/components/deployment.vue', () => {
describe('is not present', () => {
it('does not show the timestamp', () => {
wrapper = createWrapper({ propsData: { deployment: { ...deployment, createdAt: null } } });
- const date = wrapper.findByTitle(formatDate(deployment.createdAt));
+ const date = wrapper.findByTitle(
+ localeDateFormat.asDateTimeFull.format(deployment.createdAt),
+ );
expect(date.exists()).toBe(false);
});
diff --git a/spec/frontend/environments/environment_flux_resource_selector_spec.js b/spec/frontend/environments/environment_flux_resource_selector_spec.js
index ba3375c731f..8dab8fdd96a 100644
--- a/spec/frontend/environments/environment_flux_resource_selector_spec.js
+++ b/spec/frontend/environments/environment_flux_resource_selector_spec.js
@@ -25,7 +25,7 @@ const DEFAULT_PROPS = {
fluxResourcePath: '',
};
-describe('~/environments/components/form.vue', () => {
+describe('~/environments/components/flux_resource_selector.vue', () => {
let wrapper;
const kustomizationItem = {
diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js
index 1973613897d..e21e0f280ec 100644
--- a/spec/frontend/environments/environment_folder_spec.js
+++ b/spec/frontend/environments/environment_folder_spec.js
@@ -79,7 +79,7 @@ describe('~/environments/components/environments_folder.vue', () => {
it('is collapsed by default', () => {
const link = findLink();
- expect(collapse.attributes('visible')).toBeUndefined();
+ expect(collapse.props('visible')).toBe(false);
const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2);
expect(iconNames).toEqual(['chevron-lg-right', 'folder-o']);
expect(folderName.classes('gl-font-weight-bold')).toBe(false);
@@ -96,7 +96,7 @@ describe('~/environments/components/environments_folder.vue', () => {
const link = findLink();
expect(button.attributes('aria-label')).toBe(__('Collapse'));
- expect(collapse.attributes('visible')).toBe('visible');
+ expect(collapse.props('visible')).toBe(true);
const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2);
expect(iconNames).toEqual(['chevron-lg-down', 'folder-open']);
expect(folderName.classes('gl-font-weight-bold')).toBe(true);
diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js
index 478ac8d6e0e..f3dfc7a72f2 100644
--- a/spec/frontend/environments/environment_form_spec.js
+++ b/spec/frontend/environments/environment_form_spec.js
@@ -1,11 +1,12 @@
-import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
-import Vue from 'vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import Vue, { nextTick } 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 EnvironmentFluxResourceSelector from '~/environments/components/environment_flux_resource_selector.vue';
+import EnvironmentNamespaceSelector from '~/environments/components/environment_namespace_selector.vue';
import createMockApollo from '../__helpers__/mock_apollo_helper';
import { mockKasTunnelUrl } from './mock_data';
@@ -36,13 +37,16 @@ const configuration = {
credentials: 'include',
};
+const environmentWithAgentAndNamespace = {
+ ...DEFAULT_PROPS.environment,
+ clusterAgent: { id: '12', name: 'agent-2' },
+ clusterAgentId: '2',
+ kubernetesNamespace: 'agent',
+};
+
describe('~/environments/components/form.vue', () => {
let wrapper;
- const getNamespacesQueryResult = jest
- .fn()
- .mockReturnValue([{ metadata: { name: 'default' } }, { metadata: { name: 'agent' } }]);
-
const createWrapper = (propsData = {}, options = {}) =>
mountExtended(EnvironmentForm, {
provide: PROVIDE,
@@ -53,7 +57,7 @@ describe('~/environments/components/form.vue', () => {
},
});
- const createWrapperWithApollo = ({ propsData = {}, queryResult = null } = {}) => {
+ const createWrapperWithApollo = (propsData = {}) => {
Vue.use(VueApollo);
const requestHandlers = [
@@ -70,12 +74,6 @@ describe('~/environments/components/form.vue', () => {
],
];
- const mockResolvers = {
- Query: {
- k8sNamespaces: queryResult || getNamespacesQueryResult,
- },
- };
-
return mountExtended(EnvironmentForm, {
provide: {
...PROVIDE,
@@ -84,13 +82,12 @@ describe('~/environments/components/form.vue', () => {
...DEFAULT_PROPS,
...propsData,
},
- apolloProvider: createMockApollo(requestHandlers, mockResolvers),
+ apolloProvider: createMockApollo(requestHandlers, []),
});
};
const findAgentSelector = () => wrapper.findByTestId('agent-selector');
- const findNamespaceSelector = () => wrapper.findByTestId('namespace-selector');
- const findAlert = () => wrapper.findComponent(GlAlert);
+ const findNamespaceSelector = () => wrapper.findComponent(EnvironmentNamespaceSelector);
const findFluxResourceSelector = () => wrapper.findComponent(EnvironmentFluxResourceSelector);
const selectAgent = async () => {
@@ -326,91 +323,15 @@ describe('~/environments/components/form.vue', () => {
expect(findNamespaceSelector().exists()).toBe(true);
});
- it('requests the kubernetes namespaces with the correct configuration', async () => {
- await waitForPromises();
-
- expect(getNamespacesQueryResult).toHaveBeenCalledWith(
- {},
- { configuration },
- expect.anything(),
- expect.anything(),
- );
- });
-
- it('sets the loading prop while fetching the list', async () => {
- expect(findNamespaceSelector().props('loading')).toBe(true);
-
- await waitForPromises();
-
- expect(findNamespaceSelector().props('loading')).toBe(false);
- });
-
- it('renders a list of available namespaces', async () => {
- await waitForPromises();
-
- expect(findNamespaceSelector().props('items')).toEqual([
- { text: 'default', value: 'default' },
- { text: 'agent', value: 'agent' },
- ]);
- });
-
- it('filters the namespaces list on user search', async () => {
- await waitForPromises();
- await findNamespaceSelector().vm.$emit('search', 'default');
-
- expect(findNamespaceSelector().props('items')).toEqual([
- { value: 'default', text: 'default' },
- ]);
- });
-
- it('updates namespace selector field with the name of selected namespace', async () => {
- await waitForPromises();
- await findNamespaceSelector().vm.$emit('select', 'agent');
-
- expect(findNamespaceSelector().props('toggleText')).toBe('agent');
- });
-
it('emits changes to the kubernetesNamespace', async () => {
await waitForPromises();
- await findNamespaceSelector().vm.$emit('select', 'agent');
+ findNamespaceSelector().vm.$emit('change', 'agent');
+ await nextTick();
expect(wrapper.emitted('change')[1]).toEqual([
{ name: '', externalUrl: '', kubernetesNamespace: 'agent', fluxResourcePath: null },
]);
});
-
- it('clears namespace selector when another agent was selected', async () => {
- await waitForPromises();
- await findNamespaceSelector().vm.$emit('select', 'agent');
-
- expect(findNamespaceSelector().props('toggleText')).toBe('agent');
-
- await findAgentSelector().vm.$emit('select', '1');
- expect(findNamespaceSelector().props('toggleText')).toBe(
- EnvironmentForm.i18n.namespaceHelpText,
- );
- });
- });
-
- describe('when cannot connect to the cluster', () => {
- const error = new Error('Error from the cluster_client API');
-
- beforeEach(async () => {
- wrapper = createWrapperWithApollo({
- queryResult: jest.fn().mockRejectedValueOnce(error),
- });
-
- await selectAgent();
- await waitForPromises();
- });
-
- it("doesn't render the namespace selector", () => {
- expect(findNamespaceSelector().exists()).toBe(false);
- });
-
- it('renders an alert', () => {
- expect(findAlert().text()).toBe('Error from the cluster_client API');
- });
});
});
@@ -431,16 +352,6 @@ describe('~/environments/components/form.vue', () => {
it("doesn't render flux resource selector", () => {
expect(findFluxResourceSelector().exists()).toBe(false);
});
-
- it('renders the flux resource selector when the namespace is selected', async () => {
- await findNamespaceSelector().vm.$emit('select', 'agent');
-
- expect(findFluxResourceSelector().props()).toEqual({
- namespace: 'agent',
- fluxResourcePath: '',
- configuration,
- });
- });
});
});
@@ -451,9 +362,7 @@ describe('~/environments/components/form.vue', () => {
clusterAgentId: '1',
};
beforeEach(() => {
- wrapper = createWrapperWithApollo({
- propsData: { environment: environmentWithAgent },
- });
+ wrapper = createWrapperWithApollo({ environment: environmentWithAgent });
});
it('updates agent selector field with the name of the associated agent', () => {
@@ -468,45 +377,46 @@ describe('~/environments/components/form.vue', () => {
it('renders a list of available namespaces', async () => {
await waitForPromises();
- expect(findNamespaceSelector().props('items')).toEqual([
- { text: 'default', value: 'default' },
- { text: 'agent', value: 'agent' },
- ]);
+ expect(findNamespaceSelector().exists()).toBe(true);
});
});
describe('when environment has an associated kubernetes namespace', () => {
- const environmentWithAgentAndNamespace = {
- ...DEFAULT_PROPS.environment,
- clusterAgent: { id: '1', name: 'agent-1' },
- clusterAgentId: '1',
- kubernetesNamespace: 'default',
- };
beforeEach(() => {
- wrapper = createWrapperWithApollo({
- propsData: { environment: environmentWithAgentAndNamespace },
- });
+ wrapper = createWrapperWithApollo({ environment: environmentWithAgentAndNamespace });
});
it('updates namespace selector with the name of the associated namespace', async () => {
await waitForPromises();
- expect(findNamespaceSelector().props('toggleText')).toBe('default');
+ expect(findNamespaceSelector().props('namespace')).toBe('agent');
+ });
+
+ it('clears namespace selector when another agent was selected', async () => {
+ expect(findNamespaceSelector().props('namespace')).toBe('agent');
+
+ findAgentSelector().vm.$emit('select', '1');
+ await nextTick();
+
+ expect(findNamespaceSelector().props('namespace')).toBe(null);
+ });
+
+ it('renders the flux resource selector when the namespace is selected', () => {
+ expect(findFluxResourceSelector().props()).toEqual({
+ namespace: 'agent',
+ fluxResourcePath: '',
+ configuration,
+ });
});
});
describe('when environment has an associated flux resource', () => {
const fluxResourcePath = 'path/to/flux/resource';
- const environmentWithAgentAndNamespace = {
- ...DEFAULT_PROPS.environment,
- clusterAgent: { id: '1', name: 'agent-1' },
- clusterAgentId: '1',
- kubernetesNamespace: 'default',
+ const environmentWithFluxResource = {
+ ...environmentWithAgentAndNamespace,
fluxResourcePath,
};
beforeEach(() => {
- wrapper = createWrapperWithApollo({
- propsData: { environment: environmentWithAgentAndNamespace },
- });
+ wrapper = createWrapperWithApollo({ environment: environmentWithFluxResource });
});
it('provides flux resource path to the flux resource selector component', () => {
diff --git a/spec/frontend/environments/environment_namespace_selector_spec.js b/spec/frontend/environments/environment_namespace_selector_spec.js
new file mode 100644
index 00000000000..53e4f807751
--- /dev/null
+++ b/spec/frontend/environments/environment_namespace_selector_spec.js
@@ -0,0 +1,217 @@
+import { GlAlert, GlCollapsibleListbox, GlButton } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import EnvironmentNamespaceSelector from '~/environments/components/environment_namespace_selector.vue';
+import { stubComponent } from 'helpers/stub_component';
+import createMockApollo from '../__helpers__/mock_apollo_helper';
+import { mockKasTunnelUrl } from './mock_data';
+
+const configuration = {
+ basePath: mockKasTunnelUrl.replace(/\/$/, ''),
+ headers: {
+ 'GitLab-Agent-Id': 2,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ credentials: 'include',
+};
+
+const DEFAULT_PROPS = {
+ namespace: '',
+ configuration,
+};
+
+describe('~/environments/components/namespace_selector.vue', () => {
+ let wrapper;
+
+ const getNamespacesQueryResult = jest
+ .fn()
+ .mockReturnValue([
+ { metadata: { name: 'default' } },
+ { metadata: { name: 'agent' } },
+ { metadata: { name: 'test-agent' } },
+ ]);
+
+ const closeMock = jest.fn();
+
+ const createWrapper = ({ propsData = {}, queryResult = null } = {}) => {
+ Vue.use(VueApollo);
+
+ const mockResolvers = {
+ Query: {
+ k8sNamespaces: queryResult || getNamespacesQueryResult,
+ },
+ };
+
+ return shallowMount(EnvironmentNamespaceSelector, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...propsData,
+ },
+ stubs: {
+ GlCollapsibleListbox: stubComponent(GlCollapsibleListbox, {
+ template: `<div><slot name="footer"></slot></div>`,
+ methods: {
+ close: closeMock,
+ },
+ }),
+ },
+ apolloProvider: createMockApollo([], mockResolvers),
+ });
+ };
+
+ const findNamespaceSelector = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findSelectButton = () => wrapper.findComponent(GlButton);
+
+ const searchNamespace = async (searchTerm = 'test') => {
+ findNamespaceSelector().vm.$emit('search', searchTerm);
+ await nextTick();
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ it('renders namespace selector', () => {
+ expect(findNamespaceSelector().exists()).toBe(true);
+ });
+
+ it('requests the namespaces', async () => {
+ await waitForPromises();
+
+ expect(getNamespacesQueryResult).toHaveBeenCalled();
+ });
+
+ it('sets the loading prop while fetching the list', async () => {
+ expect(findNamespaceSelector().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findNamespaceSelector().props('loading')).toBe(false);
+ });
+
+ it('renders a list of available namespaces', async () => {
+ await waitForPromises();
+
+ expect(findNamespaceSelector().props('items')).toMatchObject([
+ {
+ text: 'default',
+ value: 'default',
+ },
+ {
+ text: 'agent',
+ value: 'agent',
+ },
+ {
+ text: 'test-agent',
+ value: 'test-agent',
+ },
+ ]);
+ });
+
+ it('filters the namespaces list on user search', async () => {
+ await waitForPromises();
+ await searchNamespace('agent');
+
+ expect(findNamespaceSelector().props('items')).toMatchObject([
+ {
+ text: 'agent',
+ value: 'agent',
+ },
+ {
+ text: 'test-agent',
+ value: 'test-agent',
+ },
+ ]);
+ });
+
+ it('emits changes to the namespace', () => {
+ findNamespaceSelector().vm.$emit('select', 'agent');
+
+ expect(wrapper.emitted('change')).toEqual([['agent']]);
+ });
+ });
+
+ describe('custom select button', () => {
+ beforeEach(async () => {
+ wrapper = createWrapper();
+ await waitForPromises();
+ });
+
+ it("doesn't render custom select button before searching", () => {
+ expect(findSelectButton().exists()).toBe(false);
+ });
+
+ it("doesn't render custom select button when the search is found in the namespaces list", async () => {
+ await searchNamespace('test-agent');
+ expect(findSelectButton().exists()).toBe(false);
+ });
+
+ it('renders custom select button when the namespace searched for is not found in the namespaces list', async () => {
+ await searchNamespace();
+ expect(findSelectButton().exists()).toBe(true);
+ });
+
+ it('emits custom filled namespace name to the `change` event', async () => {
+ await searchNamespace();
+ findSelectButton().vm.$emit('click');
+
+ expect(wrapper.emitted('change')).toEqual([['test']]);
+ });
+
+ it('closes the listbox after the custom value for the namespace was selected', async () => {
+ await searchNamespace();
+ findSelectButton().vm.$emit('click');
+
+ expect(closeMock).toHaveBeenCalled();
+ });
+ });
+
+ describe('when environment has an associated namespace', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ propsData: { namespace: 'existing-namespace' },
+ });
+ });
+
+ it('updates namespace selector with the name of the associated namespace', () => {
+ expect(findNamespaceSelector().props('toggleText')).toBe('existing-namespace');
+ });
+ });
+
+ describe('on error', () => {
+ const error = new Error('Error from the cluster_client API');
+
+ beforeEach(async () => {
+ wrapper = createWrapper({
+ queryResult: jest.fn().mockRejectedValueOnce(error),
+ });
+ await waitForPromises();
+ });
+
+ it('renders an alert with the error text', () => {
+ expect(findAlert().text()).toContain(error.message);
+ });
+
+ it('renders an empty namespace selector', () => {
+ expect(findNamespaceSelector().props('items')).toMatchObject([]);
+ });
+
+ it('renders custom select button when the user performs search', async () => {
+ await searchNamespace();
+
+ expect(findSelectButton().exists()).toBe(true);
+ });
+
+ it('emits custom filled namespace name to the `change` event', async () => {
+ await searchNamespace();
+ findSelectButton().vm.$emit('click');
+
+ expect(wrapper.emitted('change')).toEqual([['test']]);
+ });
+ });
+});
diff --git a/spec/frontend/environments/folder/environments_folder_app_spec.js b/spec/frontend/environments/folder/environments_folder_app_spec.js
new file mode 100644
index 00000000000..0b76a74e3a0
--- /dev/null
+++ b/spec/frontend/environments/folder/environments_folder_app_spec.js
@@ -0,0 +1,131 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlSkeletonLoader, GlTab, GlPagination } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import EnvironmentsFolderAppComponent from '~/environments/folder/environments_folder_app.vue';
+import EnvironmentItem from '~/environments/components/new_environment_item.vue';
+import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue';
+import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
+import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
+import CanaryUpdateModal from '~/environments/components/canary_update_modal.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ resolvedFolder,
+ resolvedEnvironment,
+ resolvedEnvironmentToDelete,
+ resolvedEnvironmentToRollback,
+} from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('EnvironmentsFolderAppComponent', () => {
+ let wrapper;
+ const mockFolderName = 'folders';
+
+ let environmentFolderMock;
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ folder: environmentFolderMock,
+ environmentToDelete: jest.fn().mockReturnValue(resolvedEnvironmentToDelete),
+ environmentToRollback: jest.fn().mockReturnValue(resolvedEnvironment),
+ environmentToChangeCanary: jest.fn().mockReturnValue(resolvedEnvironment),
+ environmentToStop: jest.fn().mockReturnValue(resolvedEnvironment),
+ weight: jest.fn().mockReturnValue(1),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ beforeEach(() => {
+ environmentFolderMock = jest.fn();
+ });
+
+ const emptyFolderData = {
+ environments: [],
+ activeCount: 0,
+ stoppedCount: 0,
+ __typename: 'LocalEnvironmentFolder',
+ };
+
+ const createWrapper = ({ folderData } = {}) => {
+ environmentFolderMock.mockReturnValue(folderData || emptyFolderData);
+
+ const apolloProvider = createApolloProvider();
+
+ wrapper = shallowMountExtended(EnvironmentsFolderAppComponent, {
+ apolloProvider,
+ propsData: {
+ folderName: mockFolderName,
+ folderPath: '/gitlab-org/test-project/-/environments/folder/dev',
+ scope: 'active',
+ page: 1,
+ },
+ });
+ };
+
+ const findHeader = () => wrapper.findByTestId('folder-name');
+ const findEnvironmentItems = () => wrapper.findAllComponents(EnvironmentItem);
+ const findSkeletonLoaders = () => wrapper.findAllComponents(GlSkeletonLoader);
+ const findTabs = () => wrapper.findAllComponents(GlTab);
+
+ it('should render a header with the folder name', () => {
+ createWrapper();
+
+ expect(findHeader().text()).toMatchInterpolatedText(`Environments / ${mockFolderName}`);
+ });
+
+ it('should show skeletons while loading', () => {
+ createWrapper();
+ expect(findSkeletonLoaders().length).toBe(3);
+ });
+
+ describe('when environments are loaded', () => {
+ beforeEach(async () => {
+ createWrapper({ folderData: resolvedFolder });
+ await waitForPromises();
+ });
+
+ it('should list environmnets in folder', () => {
+ const items = findEnvironmentItems();
+ expect(items.length).toBe(resolvedFolder.environments.length);
+ });
+
+ it('should render active and stopped tabs', () => {
+ const tabs = findTabs();
+ expect(tabs.length).toBe(2);
+ });
+
+ [
+ [StopEnvironmentModal, resolvedEnvironment],
+ [DeleteEnvironmentModal, resolvedEnvironmentToDelete],
+ [ConfirmRollbackModal, resolvedEnvironmentToRollback],
+ ].forEach(([Component, expectedEnvironment]) =>
+ it(`should render ${Component.name} component`, () => {
+ const modal = wrapper.findComponent(Component);
+
+ expect(modal.exists()).toBe(true);
+ expect(modal.props().environment).toEqual(expectedEnvironment);
+ expect(modal.props().graphql).toBe(true);
+ }),
+ );
+
+ it(`should render CanaryUpdateModal component`, () => {
+ const modal = wrapper.findComponent(CanaryUpdateModal);
+
+ expect(modal.exists()).toBe(true);
+ expect(modal.props().environment).toEqual(resolvedEnvironment);
+ expect(modal.props().weight).toBe(1);
+ });
+
+ it('should render pagination component', () => {
+ const pagination = wrapper.findComponent(GlPagination);
+
+ expect(pagination.props().perPage).toBe(20);
+ expect(pagination.props().totalItems).toBe(2);
+ });
+ });
+});
diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js
index 6a40c68397b..34eef1e89ab 100644
--- a/spec/frontend/environments/folder/environments_folder_view_spec.js
+++ b/spec/frontend/environments/folder/environments_folder_view_spec.js
@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { removeBreakLine, removeWhitespace } from 'helpers/text_helper';
import EnvironmentTable from '~/environments/components/environments_table.vue';
+import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
@@ -91,6 +92,10 @@ describe('Environments Folder View', () => {
).toContain('Environments / review');
});
+ it('should render the confirm rollback modal', () => {
+ expect(wrapper.findComponent(ConfirmRollbackModal).exists()).toBe(true);
+ });
+
describe('pagination', () => {
it('should render pagination', () => {
expect(wrapper.findComponent(GlPagination).exists()).toBe(true);
diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js
index 7d354566761..efc63a80e89 100644
--- a/spec/frontend/environments/graphql/mock_data.js
+++ b/spec/frontend/environments/graphql/mock_data.js
@@ -886,6 +886,11 @@ const failedDeployment = {
],
},
};
+const pendingDeployment = {
+ status: {
+ conditions: [],
+ },
+};
const readyDaemonSet = {
status: { numberReady: 1, desiredNumberScheduled: 1, numberMisscheduled: 0 },
};
@@ -904,7 +909,7 @@ const suspendedCronJob = { spec: { suspend: 1 }, status: { active: 0, lastSchedu
const failedCronJob = { spec: { suspend: 0 }, status: { active: 2, lastScheduleTime: '' } };
export const k8sWorkloadsMock = {
- DeploymentList: [readyDeployment, failedDeployment],
+ DeploymentList: [readyDeployment, failedDeployment, pendingDeployment],
DaemonSetList: [readyDaemonSet, failedDaemonSet, failedDaemonSet],
StatefulSetList: [readySet, readySet, failedSet],
ReplicaSetList: [readySet, failedSet],
@@ -925,3 +930,153 @@ export const fluxKustomizationsMock = [
];
export const fluxResourcePathMock = 'path/to/flux/resource';
+
+export const resolvedEnvironmentToDelete = {
+ __typename: 'LocalEnvironment',
+ id: 41,
+ name: 'review/hello',
+ deletePath: '/api/v4/projects/8/environments/41',
+};
+
+export const resolvedEnvironmentToRollback = {
+ __typename: 'LocalEnvironment',
+ id: 41,
+ name: 'review/hello',
+ lastDeployment: {
+ id: 78,
+ iid: 24,
+ sha: 'f3ba6dd84f8f891373e9b869135622b954852db1',
+ ref: { name: 'main', refPath: '/h5bp/html5-boilerplate/-/tree/main' },
+ status: 'success',
+ createdAt: '2022-01-07T15:47:27.415Z',
+ deployedAt: '2022-01-07T15:47:32.450Z',
+ tierInYaml: 'staging',
+ tag: false,
+ isLast: true,
+ user: {
+ id: 1,
+ username: 'root',
+ name: 'Administrator',
+ state: 'active',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ webUrl: 'http://gck.test:3000/root',
+ showStatus: false,
+ path: '/root',
+ },
+ deployable: {
+ id: 1014,
+ name: 'deploy-prod',
+ started: '2022-01-07T15:47:31.037Z',
+ complete: true,
+ archived: false,
+ buildPath: '/h5bp/html5-boilerplate/-/jobs/1014',
+ retryPath: '/h5bp/html5-boilerplate/-/jobs/1014/retry',
+ playable: false,
+ scheduled: false,
+ createdAt: '2022-01-07T15:47:27.404Z',
+ updatedAt: '2022-01-07T15:47:32.341Z',
+ status: {
+ action: {
+ buttonTitle: 'Retry this job',
+ icon: 'retry',
+ method: 'post',
+ path: '/h5bp/html5-boilerplate/-/jobs/1014/retry',
+ title: 'Retry',
+ },
+ detailsPath: '/h5bp/html5-boilerplate/-/jobs/1014',
+ favicon:
+ '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
+ group: 'success',
+ hasDetails: true,
+ icon: 'status_success',
+ illustration: {
+ image:
+ '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg',
+ size: 'svg-430',
+ title: 'This job does not have a trace.',
+ },
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ },
+ },
+ commit: {
+ id: 'f3ba6dd84f8f891373e9b869135622b954852db1',
+ shortId: 'f3ba6dd8',
+ createdAt: '2022-01-07T15:47:26.000+00:00',
+ parentIds: ['3213b6ac17afab99be37d5d38f38c6c8407387cc'],
+ title: 'Update .gitlab-ci.yml file',
+ message: 'Update .gitlab-ci.yml file',
+ authorName: 'Administrator',
+ authorEmail: 'admin@example.com',
+ authoredDate: '2022-01-07T15:47:26.000+00:00',
+ committerName: 'Administrator',
+ committerEmail: 'admin@example.com',
+ committedDate: '2022-01-07T15:47:26.000+00:00',
+ trailers: {},
+ webUrl:
+ 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1',
+ author: {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 1,
+ name: 'Administrator',
+ path: '/root',
+ showStatus: false,
+ state: 'active',
+ username: 'root',
+ webUrl: 'http://gck.test:3000/root',
+ },
+ authorGravatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ commitUrl:
+ 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1',
+ commitPath: '/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1',
+ },
+ manualActions: [
+ {
+ id: 1015,
+ name: 'deploy-staging',
+ started: null,
+ complete: false,
+ archived: false,
+ buildPath: '/h5bp/html5-boilerplate/-/jobs/1015',
+ playPath: '/h5bp/html5-boilerplate/-/jobs/1015/play',
+ playable: true,
+ scheduled: false,
+ createdAt: '2022-01-07T15:47:27.422Z',
+ updatedAt: '2022-01-07T15:47:28.557Z',
+ status: {
+ icon: 'status_manual',
+ text: 'manual',
+ label: 'manual play action',
+ group: 'manual',
+ tooltip: 'manual action',
+ hasDetails: true,
+ detailsPath: '/h5bp/html5-boilerplate/-/jobs/1015',
+ illustration: {
+ image:
+ '/assets/illustrations/manual_action-c55aee2c5f9ebe9f72751480af8bb307be1a6f35552f344cc6d1bf979d3422f6.svg',
+ size: 'svg-394',
+ title: 'This job requires a manual action',
+ content:
+ 'This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.',
+ },
+ favicon:
+ '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
+ action: {
+ icon: 'play',
+ title: 'Play',
+ path: '/h5bp/html5-boilerplate/-/jobs/1015/play',
+ method: 'post',
+ buttonTitle: 'Run job',
+ },
+ },
+ },
+ ],
+ scheduledActions: [],
+ cluster: null,
+ },
+ retryUrl: '/h5bp/html5-boilerplate/-/jobs/1014/retry',
+};
diff --git a/spec/frontend/environments/graphql/resolvers/base_spec.js b/spec/frontend/environments/graphql/resolvers/base_spec.js
index e01cf18c40d..939ccc0780c 100644
--- a/spec/frontend/environments/graphql/resolvers/base_spec.js
+++ b/spec/frontend/environments/graphql/resolvers/base_spec.js
@@ -9,7 +9,7 @@ import environmentToStopQuery from '~/environments/graphql/queries/environment_t
import createMockApollo from 'helpers/mock_apollo_helper';
import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql';
import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql';
-import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql';
+import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql';
import { TEST_HOST } from 'helpers/test_constants';
import {
environmentsApp,
@@ -131,13 +131,14 @@ describe('~/frontend/environments/graphql/resolvers', () => {
describe('folder', () => {
it('should fetch the folder url passed to it', async () => {
mock
- .onGet(ENDPOINT, { params: { per_page: 3, scope: 'available', search: '' } })
+ .onGet(ENDPOINT, { params: { per_page: 3, scope: 'available', search: '', page: 1 } })
.reply(HTTP_STATUS_OK, folder);
const environmentFolder = await mockResolvers.Query.folder(null, {
environment: { folderPath: ENDPOINT },
scope: 'available',
search: '',
+ page: 1,
});
expect(environmentFolder).toEqual(resolvedFolder);
@@ -147,10 +148,10 @@ describe('~/frontend/environments/graphql/resolvers', () => {
describe('stopEnvironmentREST', () => {
it('should post to the stop environment path', async () => {
mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK);
-
+ const cache = { evict: jest.fn() };
const client = { writeQuery: jest.fn() };
const environment = { stopPath: ENDPOINT };
- await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client });
+ await mockResolvers.Mutation.stopEnvironmentREST(null, { environment }, { client, cache });
expect(mock.history.post).toContainEqual(
expect.objectContaining({ url: ENDPOINT, method: 'post' }),
@@ -161,6 +162,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
variables: { environment },
data: { isEnvironmentStopping: true },
});
+ expect(cache.evict).toHaveBeenCalledWith({ fieldName: 'folder' });
});
it('should set is stopping to false if stop fails', async () => {
mock.onPost(ENDPOINT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
@@ -183,27 +185,39 @@ describe('~/frontend/environments/graphql/resolvers', () => {
describe('rollbackEnvironment', () => {
it('should post to the retry environment path', async () => {
mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK);
+ const cache = { evict: jest.fn() };
- await mockResolvers.Mutation.rollbackEnvironment(null, {
- environment: { retryUrl: ENDPOINT },
- });
+ await mockResolvers.Mutation.rollbackEnvironment(
+ null,
+ {
+ environment: { retryUrl: ENDPOINT },
+ },
+ { cache },
+ );
expect(mock.history.post).toContainEqual(
expect.objectContaining({ url: ENDPOINT, method: 'post' }),
);
+ expect(cache.evict).toHaveBeenCalledWith({ fieldName: 'folder' });
});
});
describe('deleteEnvironment', () => {
it('should DELETE to the delete environment path', async () => {
mock.onDelete(ENDPOINT).reply(HTTP_STATUS_OK);
+ const cache = { evict: jest.fn() };
- await mockResolvers.Mutation.deleteEnvironment(null, {
- environment: { deletePath: ENDPOINT },
- });
+ await mockResolvers.Mutation.deleteEnvironment(
+ null,
+ {
+ environment: { deletePath: ENDPOINT },
+ },
+ { cache },
+ );
expect(mock.history.delete).toContainEqual(
expect.objectContaining({ url: ENDPOINT, method: 'delete' }),
);
+ expect(cache.evict).toHaveBeenCalledWith({ fieldName: 'folder' });
});
});
describe('cancelAutoStop', () => {
diff --git a/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js
index f244ddb01b5..4f3295442b5 100644
--- a/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js
+++ b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js
@@ -4,6 +4,8 @@ import axios from '~/lib/utils/axios_utils';
import { resolvers } from '~/environments/graphql/resolvers';
import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants';
import k8sPodsQuery from '~/environments/graphql/queries/k8s_pods.query.graphql';
+import k8sWorkloadsQuery from '~/environments/graphql/queries/k8s_workloads.query.graphql';
+import k8sServicesQuery from '~/environments/graphql/queries/k8s_services.query.graphql';
import { k8sPodsMock, k8sServicesMock, k8sNamespacesMock } from '../mock_data';
describe('~/frontend/environments/graphql/resolvers', () => {
@@ -157,6 +159,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
});
});
describe('k8sServices', () => {
+ const client = { writeQuery: jest.fn() };
const mockServicesListFn = jest.fn().mockImplementation(() => {
return Promise.resolve({
items: k8sServicesMock,
@@ -166,49 +169,130 @@ describe('~/frontend/environments/graphql/resolvers', () => {
const mockNamespacedServicesListFn = jest.fn().mockImplementation(mockServicesListFn);
const mockAllServicesListFn = jest.fn().mockImplementation(mockServicesListFn);
- beforeEach(() => {
- jest
- .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
- .mockImplementation(mockServicesListFn);
+ describe('when k8sWatchApi feature is disabled', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService')
+ .mockImplementation(mockNamespacedServicesListFn);
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
+ .mockImplementation(mockAllServicesListFn);
+ });
- jest
- .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService')
- .mockImplementation(mockNamespacedServicesListFn);
- jest
- .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
- .mockImplementation(mockAllServicesListFn);
- });
+ it('should request namespaced services from the cluster_client library if namespace is specified', async () => {
+ const services = await mockResolvers.Query.k8sServices(
+ null,
+ { configuration, namespace },
+ { client },
+ );
- it('should request namespaced services from the cluster_client library if namespace is specified', async () => {
- const services = await mockResolvers.Query.k8sServices(null, { configuration, namespace });
+ expect(mockNamespacedServicesListFn).toHaveBeenCalledWith({ namespace });
+ expect(mockAllServicesListFn).not.toHaveBeenCalled();
- expect(mockNamespacedServicesListFn).toHaveBeenCalledWith({ namespace });
- expect(mockAllServicesListFn).not.toHaveBeenCalled();
+ expect(services).toEqual(k8sServicesMock);
+ });
+ it('should request all services from the cluster_client library if namespace is not specified', async () => {
+ const services = await mockResolvers.Query.k8sServices(
+ null,
+ {
+ configuration,
+ namespace: '',
+ },
+ { client },
+ );
+
+ expect(mockServicesListFn).toHaveBeenCalled();
+ expect(mockNamespacedServicesListFn).not.toHaveBeenCalled();
+
+ expect(services).toEqual(k8sServicesMock);
+ });
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
- expect(services).toEqual(k8sServicesMock);
+ await expect(
+ mockResolvers.Query.k8sServices(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
});
- it('should request all services from the cluster_client library if namespace is not specified', async () => {
- const services = await mockResolvers.Query.k8sServices(null, {
- configuration,
- namespace: '',
+
+ describe('when k8sWatchApi feature is enabled', () => {
+ const mockWatcher = WatchApi.prototype;
+ const mockServicesListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
});
- expect(mockServicesListFn).toHaveBeenCalled();
- expect(mockNamespacedServicesListFn).not.toHaveBeenCalled();
+ describe('when the services data is present', () => {
+ beforeEach(() => {
+ gon.features = { k8sWatchApi: true };
- expect(services).toEqual(k8sServicesMock);
- });
- it('should throw an error if the API call fails', async () => {
- jest
- .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
- .mockRejectedValue(new Error('API error'));
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService')
+ .mockImplementation(mockNamespacedServicesListFn);
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
+ .mockImplementation(mockAllServicesListFn);
+ jest
+ .spyOn(mockWatcher, 'subscribeToStream')
+ .mockImplementation(mockServicesListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request namespaced services from the cluster_client library if namespace is specified', async () => {
+ await mockResolvers.Query.k8sServices(null, { configuration, namespace }, { client });
+
+ expect(mockServicesListWatcherFn).toHaveBeenCalledWith(
+ `/api/v1/namespaces/${namespace}/services`,
+ {
+ watch: true,
+ },
+ );
+ });
+ it('should request all services from the cluster_client library if namespace is not specified', async () => {
+ await mockResolvers.Query.k8sServices(null, { configuration, namespace: '' }, { client });
+
+ expect(mockServicesListWatcherFn).toHaveBeenCalledWith(`/api/v1/services`, {
+ watch: true,
+ });
+ });
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sServices(null, { configuration, namespace: '' }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sServicesQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sServices: [] },
+ });
+ });
+ });
+
+ it('should not watch pods from the cluster_client library when the services data is not present', async () => {
+ jest.spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedService').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
- await expect(mockResolvers.Query.k8sServices(null, { configuration })).rejects.toThrow(
- 'API error',
- );
+ await mockResolvers.Query.k8sServices(null, { configuration, namespace }, { client });
+
+ expect(mockServicesListWatcherFn).not.toHaveBeenCalled();
+ });
});
});
describe('k8sWorkloads', () => {
+ const client = {
+ readQuery: jest.fn(() => ({ k8sWorkloads: {} })),
+ writeQuery: jest.fn(),
+ };
const emptyImplementation = jest.fn().mockImplementation(() => {
return Promise.resolve({
data: {
@@ -250,48 +334,137 @@ describe('~/frontend/environments/graphql/resolvers', () => {
{ method: 'listBatchV1CronJobForAllNamespaces', api: BatchV1Api, spy: mockAllCronJob },
];
- beforeEach(() => {
- [...namespacedMocks, ...allMocks].forEach((workloadMock) => {
- jest
- .spyOn(workloadMock.api.prototype, workloadMock.method)
- .mockImplementation(workloadMock.spy);
+ describe('when k8sWatchApi feature is disabled', () => {
+ beforeEach(() => {
+ [...namespacedMocks, ...allMocks].forEach((workloadMock) => {
+ jest
+ .spyOn(workloadMock.api.prototype, workloadMock.method)
+ .mockImplementation(workloadMock.spy);
+ });
});
- });
- it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => {
- await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace });
+ it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => {
+ await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client });
- namespacedMocks.forEach((workloadMock) => {
- expect(workloadMock.spy).toHaveBeenCalledWith({ namespace });
+ namespacedMocks.forEach((workloadMock) => {
+ expect(workloadMock.spy).toHaveBeenCalledWith({ namespace });
+ });
});
- });
- it('should request all workload types from the cluster_client library if namespace is not specified', async () => {
- await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' });
+ it('should request all workload types from the cluster_client library if namespace is not specified', async () => {
+ await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' }, { client });
- allMocks.forEach((workloadMock) => {
- expect(workloadMock.spy).toHaveBeenCalled();
+ allMocks.forEach((workloadMock) => {
+ expect(workloadMock.spy).toHaveBeenCalled();
+ });
});
- });
- it('should pass fulfilled calls data if one of the API calls fail', async () => {
- jest
- .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces')
- .mockRejectedValue(new Error('API error'));
-
- await expect(
- mockResolvers.Query.k8sWorkloads(null, { configuration }),
- ).resolves.toBeDefined();
- });
- it('should throw an error if all the API calls fail', async () => {
- [...allMocks].forEach((workloadMock) => {
+ it('should pass fulfilled calls data if one of the API calls fail', async () => {
jest
- .spyOn(workloadMock.api.prototype, workloadMock.method)
+ .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces')
.mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sWorkloads(null, { configuration }, { client }),
+ ).resolves.toBeDefined();
+ });
+ it('should throw an error if all the API calls fail', async () => {
+ [...allMocks].forEach((workloadMock) => {
+ jest
+ .spyOn(workloadMock.api.prototype, workloadMock.method)
+ .mockRejectedValue(new Error('API error'));
+ });
+
+ await expect(
+ mockResolvers.Query.k8sWorkloads(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+ describe('when k8sWatchApi feature is enabled', () => {
+ const mockDeployment = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ kind: 'DeploymentList',
+ apiVersion: 'apps/v1',
+ items: [
+ {
+ status: {
+ conditions: [],
+ },
+ },
+ ],
+ });
+ });
+ const mockWatcher = WatchApi.prototype;
+ const mockDeploymentsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
});
- await expect(mockResolvers.Query.k8sWorkloads(null, { configuration })).rejects.toThrow(
- 'API error',
- );
+ describe('when the deployments data is present', () => {
+ beforeEach(() => {
+ gon.features = { k8sWatchApi: true };
+
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1NamespacedDeployment')
+ .mockImplementation(mockDeployment);
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces')
+ .mockImplementation(mockDeployment);
+ jest
+ .spyOn(mockWatcher, 'subscribeToStream')
+ .mockImplementation(mockDeploymentsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request namespaced deployments from the cluster_client library if namespace is specified', async () => {
+ await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client });
+
+ expect(mockDeploymentsListWatcherFn).toHaveBeenCalledWith(
+ `/apis/apps/v1/namespaces/${namespace}/deployments`,
+ {
+ watch: true,
+ },
+ );
+ });
+ it('should request all deployments from the cluster_client library if namespace is not specified', async () => {
+ await mockResolvers.Query.k8sWorkloads(
+ null,
+ { configuration, namespace: '' },
+ { client },
+ );
+
+ expect(mockDeploymentsListWatcherFn).toHaveBeenCalledWith(`/apis/apps/v1/deployments`, {
+ watch: true,
+ });
+ });
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sWorkloadsQuery,
+ variables: { configuration, namespace },
+ data: { k8sWorkloads: { DeploymentList: [] } },
+ });
+ });
+ });
+
+ it('should not watch deployments from the cluster_client library when the deployments data is not present', async () => {
+ jest.spyOn(AppsV1Api.prototype, 'listAppsV1NamespacedDeployment').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }, { client });
+
+ expect(mockDeploymentsListWatcherFn).not.toHaveBeenCalled();
+ });
});
});
describe('k8sNamespaces', () => {
diff --git a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
new file mode 100644
index 00000000000..97100557ef3
--- /dev/null
+++ b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
@@ -0,0 +1,225 @@
+import {
+ generateServicePortsString,
+ getDeploymentsStatuses,
+ getDaemonSetStatuses,
+ getStatefulSetStatuses,
+ getReplicaSetStatuses,
+ getJobsStatuses,
+ getCronJobsStatuses,
+ humanizeClusterErrors,
+} from '~/environments/helpers/k8s_integration_helper';
+
+import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants';
+
+describe('k8s_integration_helper', () => {
+ describe('generateServicePortsString', () => {
+ const port = '8080';
+ const protocol = 'TCP';
+ const nodePort = '31732';
+
+ it('returns empty string if no ports provided', () => {
+ expect(generateServicePortsString([])).toBe('');
+ });
+
+ it('returns port and protocol when provided', () => {
+ expect(generateServicePortsString([{ port, protocol }])).toBe(`${port}/${protocol}`);
+ });
+
+ it('returns port, protocol and nodePort when provided', () => {
+ expect(generateServicePortsString([{ port, protocol, nodePort }])).toBe(
+ `${port}:${nodePort}/${protocol}`,
+ );
+ });
+
+ it('returns joined strings of ports if multiple are provided', () => {
+ expect(
+ generateServicePortsString([
+ { port, protocol },
+ { port, protocol, nodePort },
+ ]),
+ ).toBe(`${port}/${protocol}, ${port}:${nodePort}/${protocol}`);
+ });
+ });
+
+ describe('getDeploymentsStatuses', () => {
+ const pending = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'False' },
+ { type: 'Progressing', status: 'True' },
+ ],
+ },
+ };
+ const ready = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'True' },
+ { type: 'Progressing', status: 'False' },
+ ],
+ },
+ };
+ const failed = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'False' },
+ { type: 'Progressing', status: 'False' },
+ ],
+ },
+ };
+
+ it.each`
+ condition | items | expected
+ ${'there are only pending items'} | ${[pending]} | ${{ pending: [pending] }}
+ ${'there are pending and ready items'} | ${[pending, ready]} | ${{ pending: [pending], ready: [ready] }}
+ ${'there are all kind of items'} | ${[failed, ready, ready, pending]} | ${{ pending: [pending], failed: [failed], ready: [ready, ready] }}
+ `('returns correct object of statuses when $condition', ({ items, expected }) => {
+ expect(getDeploymentsStatuses(items)).toEqual(expected);
+ });
+ });
+
+ describe('getDaemonSetStatuses', () => {
+ const ready = {
+ status: {
+ numberMisscheduled: 0,
+ numberReady: 1,
+ desiredNumberScheduled: 1,
+ },
+ };
+ const failed = {
+ status: {
+ numberReady: 0,
+ desiredNumberScheduled: 1,
+ },
+ };
+ const anotherFailed = {
+ status: {
+ numberReady: 0,
+ desiredNumberScheduled: 0,
+ numberMisscheduled: 1,
+ },
+ };
+
+ it.each`
+ condition | items | expected
+ ${'there are only failed items'} | ${[failed, anotherFailed]} | ${{ failed: [failed, anotherFailed] }}
+ ${'there are only ready items'} | ${[ready]} | ${{ ready: [ready] }}
+ ${'there are all kind of items'} | ${[failed, ready, anotherFailed]} | ${{ failed: [failed, anotherFailed], ready: [ready] }}
+ `('returns correct object of statuses when $condition', ({ items, expected }) => {
+ expect(getDaemonSetStatuses(items)).toEqual(expected);
+ });
+ });
+
+ describe('getStatefulSetStatuses', () => {
+ const ready = {
+ status: {
+ readyReplicas: 1,
+ },
+ spec: { replicas: 1 },
+ };
+ const failed = {
+ status: {
+ readyReplicas: 1,
+ },
+ spec: { replicas: 3 },
+ };
+
+ it.each`
+ condition | items | expected
+ ${'there are only failed items'} | ${[failed, failed]} | ${{ failed: [failed, failed] }}
+ ${'there are only ready items'} | ${[ready]} | ${{ ready: [ready] }}
+ ${'there are all kind of items'} | ${[failed, failed, ready]} | ${{ failed: [failed, failed], ready: [ready] }}
+ `('returns correct object of statuses when $condition', ({ items, expected }) => {
+ expect(getStatefulSetStatuses(items)).toEqual(expected);
+ });
+ });
+
+ describe('getReplicaSetStatuses', () => {
+ const ready = {
+ status: {
+ readyReplicas: 1,
+ },
+ spec: { replicas: 1 },
+ };
+ const failed = {
+ status: {
+ readyReplicas: 1,
+ },
+ spec: { replicas: 3 },
+ };
+
+ it.each`
+ condition | items | expected
+ ${'there are only failed items'} | ${[failed, failed]} | ${{ failed: [failed, failed] }}
+ ${'there are only ready items'} | ${[ready]} | ${{ ready: [ready] }}
+ ${'there are all kind of items'} | ${[failed, failed, ready]} | ${{ failed: [failed, failed], ready: [ready] }}
+ `('returns correct object of statuses when $condition', ({ items, expected }) => {
+ expect(getReplicaSetStatuses(items)).toEqual(expected);
+ });
+ });
+
+ describe('getJobsStatuses', () => {
+ const completed = {
+ status: {
+ succeeded: 1,
+ },
+ spec: { completions: 1 },
+ };
+ const failed = {
+ status: {
+ failed: 1,
+ },
+ spec: { completions: 2 },
+ };
+
+ const anotherFailed = {
+ status: {
+ succeeded: 1,
+ },
+ spec: { completions: 2 },
+ };
+
+ it.each`
+ condition | items | expected
+ ${'there are only failed items'} | ${[failed, anotherFailed]} | ${{ failed: [failed, anotherFailed] }}
+ ${'there are only completed items'} | ${[completed]} | ${{ completed: [completed] }}
+ ${'there are all kind of items'} | ${[failed, completed, anotherFailed]} | ${{ failed: [failed, anotherFailed], completed: [completed] }}
+ `('returns correct object of statuses when $condition', ({ items, expected }) => {
+ expect(getJobsStatuses(items)).toEqual(expected);
+ });
+ });
+
+ describe('getCronJobsStatuses', () => {
+ const suspended = {
+ spec: { suspend: true },
+ };
+ const ready = {
+ status: {
+ active: 2,
+ lastScheduleTime: new Date(),
+ },
+ };
+ const failed = {
+ status: {
+ active: 2,
+ },
+ };
+
+ it.each`
+ condition | items | expected
+ ${'there are only suspended items'} | ${[suspended]} | ${{ suspended: [suspended] }}
+ ${'there are suspended and ready items'} | ${[suspended, ready]} | ${{ suspended: [suspended], ready: [ready] }}
+ ${'there are all kind of items'} | ${[failed, ready, ready, suspended]} | ${{ suspended: [suspended], failed: [failed], ready: [ready, ready] }}
+ `('returns correct object of statuses when $condition', ({ items, expected }) => {
+ expect(getCronJobsStatuses(items)).toEqual(expected);
+ });
+ });
+
+ describe('humanizeClusterErrors', () => {
+ it.each(['unauthorized', 'forbidden', 'not found', 'other'])(
+ 'returns correct object of statuses when error reason is %s',
+ (reason) => {
+ expect(humanizeClusterErrors(reason)).toEqual(CLUSTER_AGENT_ERROR_MESSAGES[reason]);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js
index 12689df586f..e00cabd1066 100644
--- a/spec/frontend/environments/kubernetes_overview_spec.js
+++ b/spec/frontend/environments/kubernetes_overview_spec.js
@@ -74,7 +74,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => {
});
it('is collapsed by default', () => {
- expect(findCollapse().props('visible')).toBeUndefined();
+ expect(findCollapse().props('visible')).toBe(false);
expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.expand);
expect(findCollapseButton().props('icon')).toBe('chevron-right');
});
@@ -88,7 +88,7 @@ describe('~/environments/components/kubernetes_overview.vue', () => {
findCollapseButton().vm.$emit('click');
await nextTick();
- expect(findCollapse().attributes('visible')).toBe('true');
+ expect(findCollapse().props('visible')).toBe(true);
expect(findCollapseButton().attributes('aria-label')).toBe(KubernetesOverview.i18n.collapse);
expect(findCollapseButton().props('icon')).toBe('chevron-down');
});
@@ -149,14 +149,14 @@ describe('~/environments/components/kubernetes_overview.vue', () => {
});
it('sets `clusterHealthStatus` as error when pods emitted a failure', async () => {
- findKubernetesPods().vm.$emit('failed');
+ findKubernetesPods().vm.$emit('update-failed-state', { pods: true });
await nextTick();
expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
});
it('sets `clusterHealthStatus` as error when workload types emitted a failure', async () => {
- findKubernetesTabs().vm.$emit('failed');
+ findKubernetesTabs().vm.$emit('update-failed-state', { summary: true });
await nextTick();
expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
@@ -165,6 +165,21 @@ describe('~/environments/components/kubernetes_overview.vue', () => {
it('sets `clusterHealthStatus` as success when data is loaded and no failures where emitted', () => {
expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('success');
});
+
+ it('sets `clusterHealthStatus` as success after state update if there are no failures', async () => {
+ findKubernetesTabs().vm.$emit('update-failed-state', { summary: true });
+ findKubernetesTabs().vm.$emit('update-failed-state', { pods: true });
+ await nextTick();
+ expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
+
+ findKubernetesTabs().vm.$emit('update-failed-state', { summary: false });
+ await nextTick();
+ expect(findKubernetesStatusBar().props('clusterHealthStatus')).toBe('error');
+
+ findKubernetesTabs().vm.$emit('update-failed-state', { pods: false });
+ await nextTick();
+ 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 a51c85468b4..6c3e49e4d8a 100644
--- a/spec/frontend/environments/kubernetes_pods_spec.js
+++ b/spec/frontend/environments/kubernetes_pods_spec.js
@@ -2,10 +2,10 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
-import { GlSingleStat } from '@gitlab/ui/dist/charts';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import KubernetesPods from '~/environments/components/kubernetes_pods.vue';
+import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue';
import { mockKasTunnelUrl } from './mock_data';
import { k8sPodsMock } from './graphql/mock_data';
@@ -23,8 +23,7 @@ describe('~/environments/components/kubernetes_pods.vue', () => {
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findAllStats = () => wrapper.findAllComponents(GlSingleStat);
- const findSingleStat = (at) => findAllStats().at(at);
+ const findWorkloadStats = () => wrapper.findComponent(WorkloadStats);
const createApolloProvider = () => {
const mockResolvers = {
@@ -67,37 +66,41 @@ describe('~/environments/components/kubernetes_pods.vue', () => {
});
describe('when gets pods data', () => {
- it('renders stats', async () => {
+ it('renders workload stats with the correct data', async () => {
createWrapper();
await waitForPromises();
- expect(findAllStats()).toHaveLength(4);
+ expect(findWorkloadStats().props('stats')).toEqual([
+ {
+ value: 2,
+ title: 'Running',
+ },
+ {
+ value: 1,
+ title: 'Pending',
+ },
+ {
+ value: 1,
+ title: 'Succeeded',
+ },
+ {
+ value: 2,
+ title: 'Failed',
+ },
+ ]);
});
- it.each`
- count | title | index
- ${2} | ${KubernetesPods.i18n.runningPods} | ${0}
- ${1} | ${KubernetesPods.i18n.pendingPods} | ${1}
- ${1} | ${KubernetesPods.i18n.succeededPods} | ${2}
- ${2} | ${KubernetesPods.i18n.failedPods} | ${3}
- `(
- 'renders stat with title "$title" and count "$count" at index $index',
- async ({ count, title, index }) => {
- createWrapper();
- await waitForPromises();
-
- expect(findSingleStat(index).props()).toMatchObject({
- value: count,
- title,
- });
- },
- );
-
- it('emits a failed event when there are failed pods', async () => {
+ it('emits a update-failed-state event for each pod', async () => {
createWrapper();
await waitForPromises();
- expect(wrapper.emitted('failed')).toHaveLength(1);
+ expect(wrapper.emitted('update-failed-state')).toHaveLength(4);
+ expect(wrapper.emitted('update-failed-state')).toEqual([
+ [{ pods: false }],
+ [{ pods: false }],
+ [{ pods: false }],
+ [{ pods: true }],
+ ]);
});
});
@@ -119,7 +122,7 @@ describe('~/environments/components/kubernetes_pods.vue', () => {
});
it("doesn't show pods stats", () => {
- expect(findAllStats()).toHaveLength(0);
+ expect(findWorkloadStats().exists()).toBe(false);
});
it('emits an error message', () => {
diff --git a/spec/frontend/environments/kubernetes_summary_spec.js b/spec/frontend/environments/kubernetes_summary_spec.js
index 457d1a37c1d..0d448d0b6af 100644
--- a/spec/frontend/environments/kubernetes_summary_spec.js
+++ b/spec/frontend/environments/kubernetes_summary_spec.js
@@ -80,16 +80,16 @@ describe('~/environments/components/kubernetes_summary.vue', () => {
});
it.each`
- type | successText | successCount | failedCount | suspendedCount | index
- ${'Deployments'} | ${'ready'} | ${1} | ${1} | ${0} | ${0}
- ${'DaemonSets'} | ${'ready'} | ${1} | ${2} | ${0} | ${1}
- ${'StatefulSets'} | ${'ready'} | ${2} | ${1} | ${0} | ${2}
- ${'ReplicaSets'} | ${'ready'} | ${1} | ${1} | ${0} | ${3}
- ${'Jobs'} | ${'completed'} | ${2} | ${1} | ${0} | ${4}
- ${'CronJobs'} | ${'ready'} | ${1} | ${1} | ${1} | ${5}
+ type | successText | successCount | failedCount | suspendedCount | pendingCount | index
+ ${'Deployments'} | ${'ready'} | ${1} | ${1} | ${0} | ${1} | ${0}
+ ${'DaemonSets'} | ${'ready'} | ${1} | ${2} | ${0} | ${0} | ${1}
+ ${'StatefulSets'} | ${'ready'} | ${2} | ${1} | ${0} | ${0} | ${2}
+ ${'ReplicaSets'} | ${'ready'} | ${1} | ${1} | ${0} | ${0} | ${3}
+ ${'Jobs'} | ${'completed'} | ${2} | ${1} | ${0} | ${0} | ${4}
+ ${'CronJobs'} | ${'ready'} | ${1} | ${1} | ${1} | ${0} | ${5}
`(
'populates view with the correct badges for workload type $type',
- ({ type, successText, successCount, failedCount, suspendedCount, index }) => {
+ ({ type, successText, successCount, failedCount, suspendedCount, pendingCount, index }) => {
const findAllBadges = () => findSummaryListItem(index).findAllComponents(GlBadge);
const findBadgeByVariant = (variant) =>
findAllBadges().wrappers.find((badge) => badge.props('variant') === variant);
@@ -100,12 +100,15 @@ describe('~/environments/components/kubernetes_summary.vue', () => {
if (suspendedCount > 0) {
expect(findBadgeByVariant('neutral').text()).toBe(`${suspendedCount} suspended`);
}
+ if (pendingCount > 0) {
+ expect(findBadgeByVariant('info').text()).toBe(`${pendingCount} pending`);
+ }
},
);
});
- it('emits a failed event when there are failed workload types', () => {
- expect(wrapper.emitted('failed')).toHaveLength(1);
+ it('emits a update-failed-state event when there are failed workload types', () => {
+ expect(wrapper.emitted('update-failed-state')).toEqual([[{ summary: true }]]);
});
it('emits an error message when gets an error from the cluster_client API', async () => {
diff --git a/spec/frontend/environments/kubernetes_tabs_spec.js b/spec/frontend/environments/kubernetes_tabs_spec.js
index fecd6d2a8ee..bf029ad6a81 100644
--- a/spec/frontend/environments/kubernetes_tabs_spec.js
+++ b/spec/frontend/environments/kubernetes_tabs_spec.js
@@ -179,9 +179,10 @@ describe('~/environments/components/kubernetes_tabs.vue', () => {
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);
+ it('emits a state update event when gets it from the component', () => {
+ const eventData = { summary: true };
+ findKubernetesSummary().vm.$emit('update-failed-state', eventData);
+ expect(wrapper.emitted('update-failed-state')).toEqual([[eventData]]);
});
});
});
diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js
index 7ee31bf2c62..552c44fe197 100644
--- a/spec/frontend/environments/new_environment_item_spec.js
+++ b/spec/frontend/environments/new_environment_item_spec.js
@@ -5,7 +5,7 @@ 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 { getTimeago, localeDateFormat } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import EnvironmentItem from '~/environments/components/new_environment_item.vue';
import EnvironmentActions from '~/environments/components/environment_actions.vue';
@@ -253,7 +253,9 @@ describe('~/environments/components/new_environment_item.vue', () => {
});
it('shows when the environment auto stops', () => {
- const autoStop = wrapper.findByTitle(formatDate(environment.autoStopAt));
+ const autoStop = wrapper.findByTitle(
+ localeDateFormat.asDateTimeFull.format(environment.autoStopAt),
+ );
expect(autoStop.text()).toBe('in 1 minute');
});
@@ -380,7 +382,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
});
it('is collapsed by default', () => {
- expect(collapse.attributes('visible')).toBeUndefined();
+ expect(collapse.props('visible')).toBe(false);
expect(icon.props('name')).toBe('chevron-lg-right');
expect(environmentName.classes('gl-font-weight-bold')).toBe(false);
});
@@ -392,7 +394,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
expect(button.attributes('aria-label')).toBe(__('Collapse'));
expect(button.props('category')).toBe('secondary');
- expect(collapse.attributes('visible')).toBe('visible');
+ expect(collapse.props('visible')).toBe(true);
expect(icon.props('name')).toBe('chevron-lg-down');
expect(environmentName.classes('gl-font-weight-bold')).toBe(true);
expect(findDeployment().isVisible()).toBe(true);
diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js
index 977e0a55a99..f43d6a2b025 100644
--- a/spec/frontend/error_tracking/components/error_details_spec.js
+++ b/spec/frontend/error_tracking/components/error_details_spec.js
@@ -463,7 +463,7 @@ describe('ErrorDetails', () => {
const gitlabIssuePath = 'https://gitlab.example.com/issues/1';
const findGitLabLink = () => wrapper.find(`[href="${gitlabIssuePath}"]`);
const findCreateIssueButton = () => wrapper.find('[data-testid="create-issue-button"]');
- const findViewIssueButton = () => wrapper.find('[data-qa-selector="view_issue_button"]');
+ const findViewIssueButton = () => wrapper.find('[data-testid="view-issue-button"]');
describe('is present', () => {
beforeEach(() => {
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 a9cd407f758..823f7132fdd 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -43,6 +43,8 @@ describe('ErrorTrackingList', () => {
userCanEnableErrorTracking = true,
showIntegratedTrackingDisabledAlert = false,
integratedErrorTrackingEnabled = false,
+ listPath = '/error_tracking',
+
stubs = {},
} = {}) {
wrapper = extendedWrapper(
@@ -50,7 +52,7 @@ describe('ErrorTrackingList', () => {
store,
propsData: {
indexPath: '/path',
- listPath: '/error_tracking',
+ listPath,
projectPath: 'project/test',
enableErrorTrackingLink: '/link',
userCanEnableErrorTracking,
@@ -144,13 +146,27 @@ describe('ErrorTrackingList', () => {
expect(findErrorListRows().length).toEqual(store.state.list.errors.length);
});
- it('each error in a list should have a link to the error page', () => {
- const errorTitle = wrapper.findAll('tbody tr a');
+ describe.each([
+ ['/test-project/-/error_tracking'],
+ ['/test-project/-/error_tracking/'], // handles leading '/' https://gitlab.com/gitlab-org/gitlab/-/issues/430211
+ ])('details link', (url) => {
+ beforeEach(() => {
+ mountComponent({
+ listPath: url,
+ stubs: {
+ GlTable: false,
+ GlLink: false,
+ },
+ });
+ });
+ it('each error in a list should have a link to the error page', () => {
+ const errorTitle = wrapper.findAll('tbody tr a');
- errorTitle.wrappers.forEach((_, index) => {
- expect(errorTitle.at(index).attributes('href')).toEqual(
- expect.stringMatching(/error_tracking\/\d+\/details$/),
- );
+ errorTitle.wrappers.forEach((_, index) => {
+ expect(errorTitle.at(index).attributes('href')).toEqual(
+ `/test-project/-/error_tracking/${errorsList[index].id}/details`,
+ );
+ });
});
});
diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js
index 24a26476455..622195defa1 100644
--- a/spec/frontend/error_tracking/store/list/actions_spec.js
+++ b/spec/frontend/error_tracking/store/list/actions_spec.js
@@ -57,7 +57,7 @@ describe('error tracking actions', () => {
describe('restartPolling', () => {
it('should restart polling', () => {
- testAction(
+ return testAction(
actions.restartPolling,
{},
{},
@@ -74,7 +74,7 @@ describe('error tracking actions', () => {
it('should search by query', () => {
const query = 'search';
- testAction(
+ return testAction(
actions.searchByQuery,
query,
{},
@@ -92,7 +92,7 @@ describe('error tracking actions', () => {
it('should search errors by status', () => {
const status = 'ignored';
- testAction(
+ return testAction(
actions.filterByStatus,
status,
{},
@@ -106,7 +106,7 @@ describe('error tracking actions', () => {
it('should search by query', () => {
const field = 'frequency';
- testAction(
+ return testAction(
actions.sortByField,
field,
{},
@@ -123,7 +123,7 @@ describe('error tracking actions', () => {
it('should set search endpoint', () => {
const endpoint = 'https://sentry.io';
- testAction(
+ return testAction(
actions.setEndpoint,
{ endpoint },
{},
@@ -136,7 +136,7 @@ describe('error tracking actions', () => {
describe('fetchPaginatedResults', () => {
it('should start polling the selected page cursor', () => {
const cursor = '1576637570000:1:1';
- testAction(
+ return testAction(
actions.fetchPaginatedResults,
cursor,
{},
diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js
index 4c40c2acf01..61e96057017 100644
--- a/spec/frontend/feature_flags/mock_data.js
+++ b/spec/frontend/feature_flags/mock_data.js
@@ -56,7 +56,7 @@ export const userList = {
iid: 2,
project_id: 1,
created_at: '2020-02-04T08:13:10.507Z',
- updated_at: '2020-02-04T08:13:10.507Z',
+ updated_at: '2020-02-05T08:14:10.507Z',
path: '/path/to/user/list',
edit_path: '/path/to/user/list/edit',
};
diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
deleted file mode 100644
index 4609bfc23d7..00000000000
--- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import { dismiss } from '~/feature_highlight/feature_highlight_helper';
-import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_CREATED, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
-
-jest.mock('~/alert');
-
-describe('feature highlight helper', () => {
- describe('dismiss', () => {
- let mockAxios;
- const endpoint = '/-/callouts/dismiss';
- const highlightId = '123';
-
- beforeEach(() => {
- mockAxios = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mockAxios.reset();
- });
-
- it('calls persistent dismissal endpoint with highlightId', async () => {
- mockAxios.onPost(endpoint, { feature_name: highlightId }).replyOnce(HTTP_STATUS_CREATED);
-
- await expect(dismiss(endpoint, highlightId)).resolves.toEqual(expect.anything());
- });
-
- it('triggers an alert when dismiss request fails', async () => {
- mockAxios
- .onPost(endpoint, { feature_name: highlightId })
- .replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- await dismiss(endpoint, highlightId);
-
- expect(createAlert).toHaveBeenCalledWith({
- message:
- 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.',
- });
- });
- });
-});
diff --git a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js b/spec/frontend/feature_highlight/feature_highlight_popover_spec.js
deleted file mode 100644
index 66ea22cece3..00000000000
--- a/spec/frontend/feature_highlight/feature_highlight_popover_spec.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import { GlPopover, GlLink, GlButton } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { POPOVER_TARGET_ID } from '~/feature_highlight/constants';
-import { dismiss } from '~/feature_highlight/feature_highlight_helper';
-import FeatureHighlightPopover from '~/feature_highlight/feature_highlight_popover.vue';
-
-jest.mock('~/feature_highlight/feature_highlight_helper');
-
-describe('feature_highlight/feature_highlight_popover', () => {
- let wrapper;
- const props = {
- autoDevopsHelpPath: '/help/autodevops',
- highlightId: '123',
- dismissEndpoint: '/api/dismiss',
- };
-
- const buildWrapper = (propsData = props) => {
- wrapper = mount(FeatureHighlightPopover, {
- propsData,
- });
- };
- const findPopoverTarget = () => wrapper.find(`#${POPOVER_TARGET_ID}`);
- const findPopover = () => wrapper.findComponent(GlPopover);
- const findAutoDevopsHelpLink = () => wrapper.findComponent(GlLink);
- const findDismissButton = () => wrapper.findComponent(GlButton);
-
- beforeEach(() => {
- buildWrapper();
- });
-
- it('renders popover target', () => {
- expect(findPopoverTarget().exists()).toBe(true);
- });
-
- it('renders popover', () => {
- expect(findPopover().props()).toMatchObject({
- target: POPOVER_TARGET_ID,
- cssClasses: ['feature-highlight-popover'],
- container: 'body',
- placement: 'right',
- boundary: 'viewport',
- });
- });
-
- it('renders link that points to the autodevops help page', () => {
- expect(findAutoDevopsHelpLink().attributes().href).toBe(props.autoDevopsHelpPath);
- expect(findAutoDevopsHelpLink().text()).toBe('Auto DevOps');
- });
-
- it('renders dismiss button', () => {
- expect(findDismissButton().props()).toMatchObject({
- size: 'small',
- icon: 'thumb-up',
- variant: 'confirm',
- });
- });
-
- it('dismisses popover when dismiss button is clicked', async () => {
- await findDismissButton().trigger('click');
-
- expect(findPopover().emitted('close')).toHaveLength(1);
- expect(dismiss).toHaveBeenCalledWith(props.dismissEndpoint, props.highlightId);
- });
-
- describe('when popover is dismissed and hidden', () => {
- it('hides the popover target', async () => {
- await findDismissButton().trigger('click');
- findPopover().vm.$emit('hidden');
- await nextTick();
-
- expect(findPopoverTarget().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/filtered_search/add_extra_tokens_for_merge_requests_spec.js b/spec/frontend/filtered_search/add_extra_tokens_for_merge_requests_spec.js
new file mode 100644
index 00000000000..6b3490122c3
--- /dev/null
+++ b/spec/frontend/filtered_search/add_extra_tokens_for_merge_requests_spec.js
@@ -0,0 +1,30 @@
+import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
+import { createFilteredSearchTokenKeys } from '~/filtered_search/issuable_filtered_search_token_keys';
+
+describe('app/assets/javascripts/pages/dashboard/merge_requests/index.js', () => {
+ let IssuableFilteredSearchTokenKeys;
+
+ beforeEach(() => {
+ IssuableFilteredSearchTokenKeys = createFilteredSearchTokenKeys();
+ window.gon = {
+ ...window.gon,
+ features: {
+ mrApprovedFilter: true,
+ },
+ };
+ });
+
+ describe.each(['Branch', 'Environment'])('when $filter is disabled', (filter) => {
+ beforeEach(() => {
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, {
+ [`disable${filter}Filter`]: true,
+ });
+ });
+
+ it('excludes the filter', () => {
+ expect(IssuableFilteredSearchTokenKeys.tokenKeys).not.toContainEqual(
+ expect.objectContaining({ tag: filter.toLowerCase() }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js
index 2041bc3d959..35fdb02e208 100644
--- a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js
+++ b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js
@@ -1,4 +1,6 @@
-import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import IssuableFilteredSearchTokenKeys, {
+ createFilteredSearchTokenKeys,
+} from '~/filtered_search/issuable_filtered_search_token_keys';
describe('Issues Filtered Search Token Keys', () => {
describe('get', () => {
@@ -167,3 +169,21 @@ describe('Issues Filtered Search Token Keys', () => {
});
});
});
+
+describe('createFilteredSearchTokenKeys', () => {
+ describe.each(['Release'])('when $filter is disabled', (filter) => {
+ let tokens;
+
+ beforeEach(() => {
+ tokens = createFilteredSearchTokenKeys({
+ [`disable${filter}Filter`]: true,
+ });
+ });
+
+ it('excludes the filter', () => {
+ expect(tokens.tokenKeys).not.toContainEqual(
+ expect.objectContaining({ tag: filter.toLowerCase() }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/fixtures/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb
index 05fca368fd5..8c371827594 100644
--- a/spec/frontend/fixtures/deploy_keys.rb
+++ b/spec/frontend/fixtures/deploy_keys.rb
@@ -12,12 +12,19 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c
let(:project2) { create(:project, :internal) }
let(:project3) { create(:project, :internal) }
let(:project4) { create(:project, :internal) }
+ let(:project_key) { create(:deploy_key) }
+ let(:internal_key) { create(:deploy_key) }
before do
# Using an admin for these fixtures because they are used for verifying a frontend
# component that would normally get its data from `Admin::DeployKeysController`
sign_in(admin)
enable_admin_mode!(admin)
+ create(:rsa_deploy_key_5120, public: true)
+ create(:deploy_keys_project, project: project, deploy_key: project_key)
+ create(:deploy_keys_project, project: project2, deploy_key: internal_key)
+ create(:deploy_keys_project, project: project3, deploy_key: project_key)
+ create(:deploy_keys_project, project: project4, deploy_key: project_key)
end
after do
@@ -27,14 +34,6 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c
render_views
it 'deploy_keys/keys.json' do
- create(:rsa_deploy_key_5120, public: true)
- project_key = create(:deploy_key)
- internal_key = create(:deploy_key)
- create(:deploy_keys_project, project: project, deploy_key: project_key)
- create(:deploy_keys_project, project: project2, deploy_key: internal_key)
- create(:deploy_keys_project, project: project3, deploy_key: project_key)
- create(:deploy_keys_project, project: project4, deploy_key: project_key)
-
get :index, params: {
namespace_id: project.namespace.to_param,
project_id: project
@@ -42,4 +41,31 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c
expect(response).to be_successful
end
+
+ it 'deploy_keys/enabled_keys.json' do
+ get :enabled_keys, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project
+ }, format: :json
+
+ expect(response).to be_successful
+ end
+
+ it 'deploy_keys/available_project_keys.json' do
+ get :available_project_keys, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project
+ }, format: :json
+
+ expect(response).to be_successful
+ end
+
+ it 'deploy_keys/available_public_keys.json' do
+ get :available_public_keys, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project
+ }, format: :json
+
+ expect(response).to be_successful
+ end
end
diff --git a/spec/frontend/fixtures/pipeline_header.rb b/spec/frontend/fixtures/pipeline_header.rb
index 744df18a403..77d626100ad 100644
--- a/spec/frontend/fixtures/pipeline_header.rb
+++ b/spec/frontend/fixtures/pipeline_header.rb
@@ -18,18 +18,23 @@ RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :reques
let_it_be(:pipeline) do
create(
:ci_pipeline,
+ :merged_result_pipeline,
project: project,
sha: commit.id,
ref: 'master',
user: user,
+ name: 'Build pipeline',
status: :success,
duration: 7210,
created_at: 2.hours.ago,
started_at: 1.hour.ago,
- finished_at: Time.current
+ finished_at: Time.current,
+ source: :schedule
)
end
+ let_it_be(:builds) { create_list(:ci_build, 3, :success, pipeline: pipeline, ref: 'master') }
+
it "graphql/pipelines/pipeline_header_success.json" do
query = get_graphql_query_as_string(query_path)
@@ -64,6 +69,34 @@ RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :reques
end
end
+ context 'with running pipeline and no permissions' 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_no_permissions.json" do
+ guest = create(:user)
+ project.add_guest(guest)
+
+ query = get_graphql_query_as_string(query_path)
+
+ post_graphql(query, current_user: guest, 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(
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index a73a0dcbdd1..3b03a03cb96 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :runner_fleet do
+RSpec.describe 'Runner (JavaScript fixtures)', feature_category: :fleet_visibility do
include AdminModeHelper
include ApiHelpers
include JavaScriptFixturesHelpers
diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html
deleted file mode 100644
index bc8a27c779f..00000000000
--- a/spec/frontend/fixtures/static/whats_new_notification.html
+++ /dev/null
@@ -1,7 +0,0 @@
-<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>
-</div>
diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js
deleted file mode 100644
index 122155a5d3f..00000000000
--- a/spec/frontend/frequent_items/components/app_spec.js
+++ /dev/null
@@ -1,286 +0,0 @@
-import { GlButton, GlIcon } from '@gitlab/ui';
-import MockAdapter from 'axios-mock-adapter';
-import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import App from '~/frequent_items/components/app.vue';
-import FrequentItemsList from '~/frequent_items/components/frequent_items_list.vue';
-import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants';
-import eventHub from '~/frequent_items/event_hub';
-import { createStore } from '~/frequent_items/store';
-import { getTopFrequentItems } from '~/frequent_items/utils';
-import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data';
-
-Vue.use(Vuex);
-
-useLocalStorageSpy();
-
-const TEST_NAMESPACE = 'projects';
-const TEST_VUEX_MODULE = 'frequentProjects';
-const TEST_PROJECT = currentSession[TEST_NAMESPACE].project;
-const TEST_STORAGE_KEY = currentSession[TEST_NAMESPACE].storageKey;
-const TEST_SEARCH_CLASS = 'test-search-class';
-
-describe('Frequent Items App Component', () => {
- let wrapper;
- let mock;
- let store;
-
- const createComponent = (props = {}) => {
- const session = currentSession[TEST_NAMESPACE];
- gon.api_version = session.apiVersion;
-
- wrapper = mountExtended(App, {
- store,
- propsData: {
- namespace: TEST_NAMESPACE,
- currentUserName: session.username,
- currentItem: session.project,
- ...props,
- },
- provide: {
- vuexModule: TEST_VUEX_MODULE,
- },
- });
- };
-
- const triggerDropdownOpen = () => eventHub.$emit(`${TEST_NAMESPACE}-dropdownOpen`);
- const getStoredProjects = () => JSON.parse(localStorage.getItem(TEST_STORAGE_KEY));
- const findSearchInput = () => wrapper.findByTestId('frequent-items-search-input');
- const findLoading = () => wrapper.findByTestId('loading');
- const findSectionHeader = () => wrapper.findByTestId('header');
- const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
- const findFrequentItems = () => findFrequentItemsList().findAll('li');
- const setSearch = (search) => {
- const searchInput = wrapper.find('input');
-
- searchInput.setValue(search);
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- store = createStore();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('default', () => {
- beforeEach(() => {
- jest.spyOn(store, 'dispatch');
-
- createComponent();
- });
-
- it('should fetch frequent items', () => {
- triggerDropdownOpen();
-
- expect(store.dispatch).toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`);
- });
-
- it('should not fetch frequent items if detroyed', () => {
- wrapper.destroy();
- triggerDropdownOpen();
-
- expect(store.dispatch).not.toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`);
- });
-
- it('should render search input', () => {
- expect(findSearchInput().classes()).toEqual(['search-input-container']);
- });
-
- it('should render loading animation', async () => {
- triggerDropdownOpen();
- store.state[TEST_VUEX_MODULE].isLoadingItems = true;
-
- await nextTick();
-
- const loading = findLoading();
-
- expect(loading.exists()).toBe(true);
- expect(loading.find('[aria-label="Loading projects"]').exists()).toBe(true);
- expect(findSectionHeader().exists()).toBe(false);
- });
-
- it('should render frequent projects list header', () => {
- const sectionHeader = findSectionHeader();
-
- expect(sectionHeader.exists()).toBe(true);
- expect(sectionHeader.text()).toBe('Frequently visited');
- });
-
- it('should render searched projects list', async () => {
- mock
- .onGet(/\/api\/v4\/projects.json(.*)$/)
- .replyOnce(HTTP_STATUS_OK, mockSearchedProjects.data);
-
- setSearch('gitlab');
- await nextTick();
-
- expect(findLoading().exists()).toBe(true);
-
- await waitForPromises();
-
- expect(findFrequentItems().length).toBe(mockSearchedProjects.data.length);
- expect(findFrequentItemsList().props()).toEqual(
- expect.objectContaining({
- items: mockSearchedProjects.data.map(
- ({
- avatar_url: avatarUrl,
- web_url: webUrl,
- name_with_namespace: namespace,
- ...item
- }) => ({
- ...item,
- avatarUrl,
- webUrl,
- namespace,
- }),
- ),
- namespace: TEST_NAMESPACE,
- hasSearchQuery: true,
- isFetchFailed: false,
- matcher: 'gitlab',
- }),
- );
- });
-
- describe('with frequent items list', () => {
- const expectedResult = getTopFrequentItems(mockFrequentProjects);
-
- beforeEach(async () => {
- localStorage.setItem(TEST_STORAGE_KEY, JSON.stringify(mockFrequentProjects));
- triggerDropdownOpen();
- await nextTick();
- });
-
- it('should render edit button within header', () => {
- const itemEditButton = findSectionHeader().findComponent(GlButton);
-
- expect(itemEditButton.exists()).toBe(true);
- expect(itemEditButton.attributes('title')).toBe('Toggle edit mode');
- expect(itemEditButton.findComponent(GlIcon).props('name')).toBe('pencil');
- });
-
- it('should render frequent projects list', () => {
- expect(findFrequentItems().length).toBe(expectedResult.length);
- expect(findFrequentItemsList().props()).toEqual({
- items: expectedResult,
- namespace: TEST_NAMESPACE,
- hasSearchQuery: false,
- isFetchFailed: false,
- isItemRemovalFailed: false,
- matcher: '',
- });
- });
-
- it('dispatches action `toggleItemsListEditablity` when edit button is clicked', async () => {
- const itemEditButton = findSectionHeader().findComponent(GlButton);
- itemEditButton.vm.$emit('click');
-
- await nextTick();
-
- expect(store.dispatch).toHaveBeenCalledWith(
- `${TEST_VUEX_MODULE}/toggleItemsListEditablity`,
- );
- });
- });
- });
-
- describe('with searchClass', () => {
- beforeEach(() => {
- createComponent({ searchClass: TEST_SEARCH_CLASS });
- });
-
- it('should render search input with searchClass', () => {
- expect(findSearchInput().classes()).toEqual(['search-input-container', TEST_SEARCH_CLASS]);
- });
- });
-
- describe('logging', () => {
- it('when created, it should create a project storage entry and adds a project', () => {
- createComponent();
-
- expect(getStoredProjects()).toEqual([
- expect.objectContaining({
- frequency: 1,
- lastAccessedOn: Date.now(),
- }),
- ]);
- });
-
- describe('when created multiple times', () => {
- beforeEach(() => {
- createComponent();
- wrapper.destroy();
- createComponent();
- wrapper.destroy();
- });
-
- it('should only log once', () => {
- expect(getStoredProjects()).toEqual([
- expect.objectContaining({
- lastAccessedOn: Date.now(),
- frequency: 1,
- }),
- ]);
- });
-
- it('should increase frequency, when created 15 minutes later', () => {
- const fifteenMinutesLater = Date.now() + FIFTEEN_MINUTES_IN_MS + 1;
-
- jest.spyOn(Date, 'now').mockReturnValue(fifteenMinutesLater);
- createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: fifteenMinutesLater } });
-
- expect(getStoredProjects()).toEqual([
- expect.objectContaining({
- lastAccessedOn: fifteenMinutesLater,
- frequency: 2,
- }),
- ]);
- });
- });
-
- it('should always update project metadata', () => {
- const oldProject = {
- ...TEST_PROJECT,
- };
-
- const newProject = {
- ...oldProject,
- name: 'New Name',
- avatarUrl: 'new/avatar.png',
- namespace: 'New / Namespace',
- webUrl: 'http://localhost/new/web/url',
- };
-
- createComponent({ currentItem: oldProject });
- wrapper.destroy();
- expect(getStoredProjects()).toEqual([expect.objectContaining(oldProject)]);
-
- createComponent({ currentItem: newProject });
- wrapper.destroy();
-
- expect(getStoredProjects()).toEqual([expect.objectContaining(newProject)]);
- });
-
- it('should not add more than 20 projects in store', () => {
- for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT + 10; id += 1) {
- const project = {
- ...TEST_PROJECT,
- id,
- };
- createComponent({ currentItem: project });
- wrapper.destroy();
- }
-
- expect(getStoredProjects().length).toBe(FREQUENT_ITEMS.MAX_COUNT);
- });
- });
-});
diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js
deleted file mode 100644
index 55d20ad603c..00000000000
--- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js
+++ /dev/null
@@ -1,161 +0,0 @@
-import { GlIcon } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { trimText } from 'helpers/text_helper';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
-import { createStore } from '~/frequent_items/store';
-import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
-import { mockProject } from '../mock_data';
-
-Vue.use(Vuex);
-
-describe('FrequentItemsListItemComponent', () => {
- const TEST_VUEX_MODULE = 'frequentProjects';
- let wrapper;
- let trackingSpy;
- let store;
-
- const findTitle = () => wrapper.findByTestId('frequent-items-item-title');
- const findAvatar = () => wrapper.findComponent(ProjectAvatar);
- const findAllTitles = () => wrapper.findAllByTestId('frequent-items-item-title');
- const findNamespace = () => wrapper.findByTestId('frequent-items-item-namespace');
- const findAllFrequentItems = () => wrapper.findAllByTestId('frequent-item-link');
- const findAllNamespace = () => wrapper.findAllByTestId('frequent-items-item-namespace');
- const findAllAvatars = () => wrapper.findAllComponents(ProjectAvatar);
- const findAllMetadataContainers = () =>
- wrapper.findAllByTestId('frequent-items-item-metadata-container');
- const findRemoveButton = () => wrapper.findByTestId('item-remove');
-
- const toggleItemsListEditablity = async () => {
- store.dispatch(`${TEST_VUEX_MODULE}/toggleItemsListEditablity`);
-
- await nextTick();
- };
-
- const createComponent = (props = {}) => {
- wrapper = shallowMountExtended(frequentItemsListItemComponent, {
- store,
- propsData: {
- itemId: mockProject.id,
- itemName: mockProject.name,
- namespace: mockProject.namespace,
- webUrl: mockProject.webUrl,
- avatarUrl: mockProject.avatarUrl,
- ...props,
- },
- provide: {
- vuexModule: TEST_VUEX_MODULE,
- },
- });
- };
-
- beforeEach(() => {
- store = createStore();
- trackingSpy = mockTracking('_category_', document, jest.spyOn);
- trackingSpy.mockImplementation(() => {});
- });
-
- afterEach(() => {
- unmockTracking();
- });
-
- describe('computed', () => {
- describe('highlightedItemName', () => {
- it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
- createComponent({ matcher: 'lab' });
-
- expect(findTitle().element.innerHTML).toContain('<b>L</b><b>a</b><b>b</b>');
- });
-
- it('should return project name as it is if `matcher` is not available', () => {
- createComponent({ matcher: null });
-
- expect(trimText(findTitle().text())).toBe(mockProject.name);
- });
- });
-
- describe('truncatedNamespace', () => {
- it('should truncate project name from namespace string', () => {
- createComponent({ namespace: 'platform / nokia-3310' });
-
- expect(trimText(findNamespace().text())).toBe('platform');
- });
-
- it('should truncate namespace string from the middle if it includes more than two groups in path', () => {
- createComponent({
- namespace: 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310',
- });
-
- expect(trimText(findNamespace().text())).toBe('platform / ... / Mobile Chipset');
- });
- });
- });
-
- describe('template', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders avatar', () => {
- expect(findAvatar().exists()).toBe(true);
- });
-
- it('renders root element with the right classes', () => {
- expect(wrapper.classes('frequent-items-list-item-container')).toBe(true);
- });
-
- it.each`
- name | selector | expected
- ${'list item'} | ${findAllFrequentItems} | ${1}
- ${'avatar container'} | ${findAllAvatars} | ${1}
- ${'metadata container'} | ${findAllMetadataContainers} | ${1}
- ${'title'} | ${findAllTitles} | ${1}
- ${'namespace'} | ${findAllNamespace} | ${1}
- `('should render $expected $name', ({ selector, expected }) => {
- expect(selector()).toHaveLength(expected);
- });
-
- it('renders remove button within item when `isItemsListEditable` is true', async () => {
- await toggleItemsListEditablity();
-
- const removeButton = findRemoveButton();
- expect(removeButton.exists()).toBe(true);
- expect(removeButton.attributes('title')).toBe('Remove');
- expect(removeButton.findComponent(GlIcon).props('name')).toBe('close');
- });
-
- it('dispatches action `removeFrequentItem` when remove button is clicked', async () => {
- await toggleItemsListEditablity();
-
- jest.spyOn(store, 'dispatch');
-
- const removeButton = findRemoveButton();
- removeButton.vm.$emit(
- 'click',
- { stopPropagation: jest.fn(), preventDefault: jest.fn() },
- mockProject.id,
- );
-
- await nextTick();
-
- expect(store.dispatch).toHaveBeenCalledWith(
- `${TEST_VUEX_MODULE}/removeFrequentItem`,
- mockProject.id,
- );
- });
-
- it('tracks when item link is clicked', () => {
- const link = wrapper.findByTestId('frequent-item-link');
-
- link.vm.$emit('click');
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', {
- label: 'projects_dropdown_frequent_items_list_item',
- property: 'navigation_top',
- });
- });
- });
-});
diff --git a/spec/frontend/frequent_items/components/frequent_items_list_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_spec.js
deleted file mode 100644
index 8055b7a9c13..00000000000
--- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue';
-import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
-import { createStore } from '~/frequent_items/store';
-import { mockFrequentProjects } from '../mock_data';
-
-Vue.use(Vuex);
-
-describe('FrequentItemsListComponent', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = mountExtended(frequentItemsListComponent, {
- store: createStore(),
- propsData: {
- namespace: 'projects',
- items: mockFrequentProjects,
- isFetchFailed: false,
- isItemRemovalFailed: false,
- hasSearchQuery: false,
- matcher: 'lab',
- ...props,
- },
- provide: {
- vuexModule: 'frequentProjects',
- },
- });
- };
-
- describe('computed', () => {
- describe('isListEmpty', () => {
- it('should return `true` or `false` representing whether if `items` is empty or not with projects', async () => {
- createComponent({
- items: [],
- });
-
- expect(wrapper.vm.isListEmpty).toBe(true);
-
- wrapper.setProps({
- items: mockFrequentProjects,
- });
- await nextTick();
-
- expect(wrapper.vm.isListEmpty).toBe(false);
- });
- });
-
- describe('fetched item messages', () => {
- it('should show default empty list message', () => {
- createComponent({
- items: [],
- });
-
- expect(wrapper.findByTestId('frequent-items-list-empty').text()).toContain(
- 'Projects you visit often will appear here',
- );
- });
-
- it.each`
- isFetchFailed | isItemRemovalFailed
- ${true} | ${false}
- ${false} | ${true}
- `(
- 'should show failure message when `isFetchFailed` is $isFetchFailed or `isItemRemovalFailed` is $isItemRemovalFailed',
- ({ isFetchFailed, isItemRemovalFailed }) => {
- createComponent({
- items: [],
- isFetchFailed,
- isItemRemovalFailed,
- });
-
- expect(wrapper.findByTestId('frequent-items-list-empty').text()).toContain(
- 'This feature requires browser localStorage support',
- );
- },
- );
- });
-
- describe('searched item messages', () => {
- it('should return appropriate empty list message based on value of `searchFailed` prop with projects', async () => {
- createComponent({
- hasSearchQuery: true,
- isFetchFailed: true,
- });
-
- expect(wrapper.vm.listEmptyMessage).toBe('Something went wrong on our end.');
-
- wrapper.setProps({
- isFetchFailed: false,
- });
- await nextTick();
-
- expect(wrapper.vm.listEmptyMessage).toBe('Sorry, no projects matched your search');
- });
- });
- });
-
- describe('template', () => {
- it('should render component element with list of projects', async () => {
- createComponent();
-
- await nextTick();
- expect(wrapper.classes('frequent-items-list-container')).toBe(true);
- expect(wrapper.findAllByTestId('frequent-items-list')).toHaveLength(1);
- expect(wrapper.findAllComponents(frequentItemsListItemComponent)).toHaveLength(5);
- });
-
- it('should render component element with empty message', async () => {
- createComponent({
- items: [],
- });
-
- await nextTick();
- expect(wrapper.vm.$el.querySelectorAll('li.section-empty')).toHaveLength(1);
- expect(wrapper.findAllComponents(frequentItemsListItemComponent)).toHaveLength(0);
- });
- });
-});
diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js
deleted file mode 100644
index d6aa0f4e221..00000000000
--- a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { GlSearchBoxByType } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
-import { createStore } from '~/frequent_items/store';
-
-Vue.use(Vuex);
-
-describe('FrequentItemsSearchInputComponent', () => {
- let wrapper;
- let trackingSpy;
- let vm;
- let store;
-
- const createComponent = (namespace = 'projects') =>
- shallowMount(searchComponent, {
- store,
- propsData: { namespace },
- provide: {
- vuexModule: 'frequentProjects',
- },
- });
-
- const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
-
- beforeEach(() => {
- store = createStore();
- jest.spyOn(store, 'dispatch').mockImplementation(() => {});
-
- trackingSpy = mockTracking('_category_', document, jest.spyOn);
- trackingSpy.mockImplementation(() => {});
-
- wrapper = createComponent();
-
- ({ vm } = wrapper);
- });
-
- afterEach(() => {
- unmockTracking();
- vm.$destroy();
- });
-
- describe('template', () => {
- it('should render component element', () => {
- expect(wrapper.classes()).toContain('search-input-container');
- expect(findSearchBoxByType().exists()).toBe(true);
- expect(findSearchBoxByType().attributes()).toMatchObject({
- placeholder: 'Search your projects',
- });
- });
- });
-
- describe('tracking', () => {
- it('tracks when search query is entered', async () => {
- expect(trackingSpy).not.toHaveBeenCalled();
- expect(store.dispatch).not.toHaveBeenCalled();
-
- const value = 'my project';
-
- findSearchBoxByType().vm.$emit('input', value);
-
- await nextTick();
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'type_search_query', {
- label: 'projects_dropdown_frequent_items_search_input',
- property: 'navigation_top',
- });
- expect(store.dispatch).toHaveBeenCalledWith('frequentProjects/setSearchQuery', value);
- });
- });
-});
diff --git a/spec/frontend/frequent_items/mock_data.js b/spec/frontend/frequent_items/mock_data.js
deleted file mode 100644
index 6563daee6c3..00000000000
--- a/spec/frontend/frequent_items/mock_data.js
+++ /dev/null
@@ -1,169 +0,0 @@
-import { TEST_HOST } from 'helpers/test_constants';
-
-export const currentSession = {
- groups: {
- username: 'root',
- storageKey: 'root/frequent-groups',
- apiVersion: 'v4',
- group: {
- id: 1,
- name: 'dummy-group',
- full_name: 'dummy-parent-group',
- webUrl: `${TEST_HOST}/dummy-group`,
- avatarUrl: null,
- lastAccessedOn: Date.now(),
- },
- },
- projects: {
- username: 'root',
- storageKey: 'root/frequent-projects',
- apiVersion: 'v4',
- project: {
- id: 1,
- name: 'dummy-project',
- namespace: 'SampleGroup / Dummy-Project',
- webUrl: `${TEST_HOST}/samplegroup/dummy-project`,
- avatarUrl: null,
- lastAccessedOn: Date.now(),
- },
- },
-};
-
-export const mockNamespace = 'projects';
-export const mockStorageKey = 'test-user/frequent-projects';
-
-export const mockGroup = {
- id: 1,
- name: 'Sub451',
- namespace: 'Commit451 / Sub451',
- webUrl: `${TEST_HOST}/Commit451/Sub451`,
- avatarUrl: null,
-};
-
-export const mockRawGroup = {
- id: 1,
- name: 'Sub451',
- full_name: 'Commit451 / Sub451',
- web_url: `${TEST_HOST}/Commit451/Sub451`,
- avatar_url: null,
-};
-
-export const mockFrequentGroups = [
- {
- id: 3,
- name: 'Subgroup451',
- full_name: 'Commit451 / Subgroup451',
- webUrl: '/Commit451/Subgroup451',
- avatarUrl: null,
- frequency: 7,
- lastAccessedOn: 1497979281815,
- },
- {
- id: 1,
- name: 'Commit451',
- full_name: 'Commit451',
- webUrl: '/Commit451',
- avatarUrl: null,
- frequency: 3,
- lastAccessedOn: 1497979281815,
- },
-];
-
-export const mockSearchedGroups = { data: [mockRawGroup] };
-export const mockProcessedSearchedGroups = [mockGroup];
-
-export const mockProject = {
- id: 1,
- name: 'GitLab Community Edition',
- namespace: 'gitlab-org / gitlab-ce',
- webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`,
- avatarUrl: null,
-};
-
-export const mockRawProject = {
- id: 1,
- name: 'GitLab Community Edition',
- name_with_namespace: 'gitlab-org / gitlab-ce',
- web_url: `${TEST_HOST}/gitlab-org/gitlab-foss`,
- avatar_url: null,
-};
-
-export const mockFrequentProjects = [
- {
- id: 1,
- name: 'GitLab Community Edition',
- namespace: 'gitlab-org / gitlab-ce',
- webUrl: `${TEST_HOST}/gitlab-org/gitlab-foss`,
- avatarUrl: null,
- frequency: 1,
- lastAccessedOn: Date.now(),
- },
- {
- id: 2,
- name: 'GitLab CI',
- namespace: 'gitlab-org / gitlab-ci',
- webUrl: `${TEST_HOST}/gitlab-org/gitlab-ci`,
- avatarUrl: null,
- frequency: 9,
- lastAccessedOn: Date.now(),
- },
- {
- id: 3,
- name: 'Typeahead.Js',
- namespace: 'twitter / typeahead-js',
- webUrl: `${TEST_HOST}/twitter/typeahead-js`,
- avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png',
- frequency: 2,
- lastAccessedOn: Date.now(),
- },
- {
- id: 4,
- name: 'Intel',
- namespace: 'platform / hardware / bsp / intel',
- webUrl: `${TEST_HOST}/platform/hardware/bsp/intel`,
- avatarUrl: null,
- frequency: 3,
- lastAccessedOn: Date.now(),
- },
- {
- id: 5,
- name: 'v4.4',
- namespace: 'platform / hardware / bsp / kernel / common / v4.4',
- webUrl: `${TEST_HOST}/platform/hardware/bsp/kernel/common/v4.4`,
- avatarUrl: null,
- frequency: 8,
- lastAccessedOn: Date.now(),
- },
-];
-
-export const mockSearchedProjects = { data: [mockRawProject] };
-export const mockProcessedSearchedProjects = [mockProject];
-
-export const unsortedFrequentItems = [
- { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
- { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
- { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
- { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
- { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
- { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
- { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
- { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
- { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
-];
-
-/**
- * This const has a specific order which tests authenticity
- * of `getTopFrequentItems` method so
- * DO NOT change order of items in this const.
- */
-export const sortedFrequentItems = [
- { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
- { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
- { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
- { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
- { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
- { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
- { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
- { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
- { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
-];
diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js
deleted file mode 100644
index 2feb488da2c..00000000000
--- a/spec/frontend/frequent_items/store/actions_spec.js
+++ /dev/null
@@ -1,304 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'helpers/vuex_action_helper';
-import * as actions from '~/frequent_items/store/actions';
-import * as types from '~/frequent_items/store/mutation_types';
-import state from '~/frequent_items/store/state';
-import AccessorUtilities from '~/lib/utils/accessor';
-import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import {
- mockNamespace,
- mockStorageKey,
- mockFrequentProjects,
- mockSearchedProjects,
-} from '../mock_data';
-
-describe('Frequent Items Dropdown Store Actions', () => {
- useLocalStorageSpy();
- let mockedState;
- let mock;
-
- beforeEach(() => {
- mockedState = state();
- mock = new MockAdapter(axios);
-
- mockedState.namespace = mockNamespace;
- mockedState.storageKey = mockStorageKey;
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('setNamespace', () => {
- it('should set namespace', () => {
- return testAction(
- actions.setNamespace,
- mockNamespace,
- mockedState,
- [{ type: types.SET_NAMESPACE, payload: mockNamespace }],
- [],
- );
- });
- });
-
- describe('setStorageKey', () => {
- it('should set storage key', () => {
- return testAction(
- actions.setStorageKey,
- mockStorageKey,
- mockedState,
- [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }],
- [],
- );
- });
- });
-
- describe('toggleItemsListEditablity', () => {
- it('should toggle items list editablity', () => {
- return testAction(
- actions.toggleItemsListEditablity,
- null,
- mockedState,
- [{ type: types.TOGGLE_ITEMS_LIST_EDITABILITY }],
- [],
- );
- });
- });
-
- describe('requestFrequentItems', () => {
- it('should request frequent items', () => {
- return testAction(
- actions.requestFrequentItems,
- null,
- mockedState,
- [{ type: types.REQUEST_FREQUENT_ITEMS }],
- [],
- );
- });
- });
-
- describe('receiveFrequentItemsSuccess', () => {
- it('should set frequent items', () => {
- return testAction(
- actions.receiveFrequentItemsSuccess,
- mockFrequentProjects,
- mockedState,
- [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }],
- [],
- );
- });
- });
-
- describe('receiveFrequentItemsError', () => {
- it('should set frequent items error state', () => {
- return testAction(
- actions.receiveFrequentItemsError,
- null,
- mockedState,
- [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }],
- [],
- );
- });
- });
-
- describe('fetchFrequentItems', () => {
- it('should dispatch `receiveFrequentItemsSuccess`', () => {
- mockedState.namespace = mockNamespace;
- mockedState.storageKey = mockStorageKey;
-
- return testAction(
- actions.fetchFrequentItems,
- null,
- mockedState,
- [],
- [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }],
- );
- });
-
- it('should dispatch `receiveFrequentItemsError`', () => {
- jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false);
- mockedState.namespace = mockNamespace;
- mockedState.storageKey = mockStorageKey;
-
- return testAction(
- actions.fetchFrequentItems,
- null,
- mockedState,
- [],
- [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }],
- );
- });
- });
-
- describe('requestSearchedItems', () => {
- it('should request searched items', () => {
- return testAction(
- actions.requestSearchedItems,
- null,
- mockedState,
- [{ type: types.REQUEST_SEARCHED_ITEMS }],
- [],
- );
- });
- });
-
- describe('receiveSearchedItemsSuccess', () => {
- it('should set searched items', () => {
- return testAction(
- actions.receiveSearchedItemsSuccess,
- mockSearchedProjects,
- mockedState,
- [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }],
- [],
- );
- });
- });
-
- describe('receiveSearchedItemsError', () => {
- it('should set searched items error state', () => {
- return testAction(
- actions.receiveSearchedItemsError,
- null,
- mockedState,
- [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }],
- [],
- );
- });
- });
-
- describe('fetchSearchedItems', () => {
- beforeEach(() => {
- gon.api_version = 'v4';
- });
-
- it('should dispatch `receiveSearchedItemsSuccess`', () => {
- mock
- .onGet(/\/api\/v4\/projects.json(.*)$/)
- .replyOnce(HTTP_STATUS_OK, mockSearchedProjects, {});
-
- return testAction(
- actions.fetchSearchedItems,
- null,
- mockedState,
- [],
- [
- { type: 'requestSearchedItems' },
- {
- type: 'receiveSearchedItemsSuccess',
- payload: { data: mockSearchedProjects, headers: {} },
- },
- ],
- );
- });
-
- it('should dispatch `receiveSearchedItemsError`', () => {
- gon.api_version = 'v4';
- mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- return testAction(
- actions.fetchSearchedItems,
- null,
- mockedState,
- [],
- [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }],
- );
- });
- });
-
- describe('setSearchQuery', () => {
- it('should commit query and dispatch `fetchSearchedItems` when query is present', () => {
- return testAction(
- actions.setSearchQuery,
- { query: 'test' },
- mockedState,
- [{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }],
- [{ type: 'fetchSearchedItems', payload: { query: 'test' } }],
- );
- });
-
- it('should commit query and dispatch `fetchFrequentItems` when query is empty', () => {
- return testAction(
- actions.setSearchQuery,
- null,
- mockedState,
- [{ type: types.SET_SEARCH_QUERY, payload: null }],
- [{ type: 'fetchFrequentItems' }],
- );
- });
- });
-
- describe('removeFrequentItemSuccess', () => {
- it('should remove frequent item on success', () => {
- return testAction(
- actions.removeFrequentItemSuccess,
- { itemId: 1 },
- mockedState,
- [
- {
- type: types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS,
- payload: { itemId: 1 },
- },
- ],
- [],
- );
- });
- });
-
- describe('removeFrequentItemError', () => {
- it('should should not remove frequent item on failure', () => {
- return testAction(
- actions.removeFrequentItemError,
- null,
- mockedState,
- [{ type: types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR }],
- [],
- );
- });
- });
-
- describe('removeFrequentItem', () => {
- beforeEach(() => {
- mockedState.items = [...mockFrequentProjects];
- window.localStorage.setItem(mockStorageKey, JSON.stringify(mockFrequentProjects));
- });
-
- it('should remove provided itemId from localStorage', () => {
- jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
-
- actions.removeFrequentItem(
- { commit: jest.fn(), dispatch: jest.fn(), state: mockedState },
- mockFrequentProjects[0].id,
- );
-
- expect(window.localStorage.getItem(mockStorageKey)).toBe(
- JSON.stringify(mockFrequentProjects.slice(1)), // First item was removed
- );
- });
-
- it('should dispatch `removeFrequentItemSuccess` on localStorage update success', () => {
- jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
-
- return testAction(
- actions.removeFrequentItem,
- mockFrequentProjects[0].id,
- mockedState,
- [],
- [{ type: 'removeFrequentItemSuccess', payload: mockFrequentProjects[0].id }],
- );
- });
-
- it('should dispatch `removeFrequentItemError` on localStorage update failure', () => {
- jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false);
-
- return testAction(
- actions.removeFrequentItem,
- mockFrequentProjects[0].id,
- mockedState,
- [],
- [{ type: 'removeFrequentItemError' }],
- );
- });
- });
-});
diff --git a/spec/frontend/frequent_items/store/getters_spec.js b/spec/frontend/frequent_items/store/getters_spec.js
deleted file mode 100644
index 97732cd95fc..00000000000
--- a/spec/frontend/frequent_items/store/getters_spec.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import * as getters from '~/frequent_items/store/getters';
-import state from '~/frequent_items/store/state';
-
-describe('Frequent Items Dropdown Store Getters', () => {
- let mockedState;
-
- beforeEach(() => {
- mockedState = state();
- });
-
- describe('hasSearchQuery', () => {
- it('should return `true` when search query is present', () => {
- mockedState.searchQuery = 'test';
-
- expect(getters.hasSearchQuery(mockedState)).toBe(true);
- });
-
- it('should return `false` when search query is empty', () => {
- mockedState.searchQuery = '';
-
- expect(getters.hasSearchQuery(mockedState)).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/frequent_items/store/mutations_spec.js b/spec/frontend/frequent_items/store/mutations_spec.js
deleted file mode 100644
index 1e1878c3377..00000000000
--- a/spec/frontend/frequent_items/store/mutations_spec.js
+++ /dev/null
@@ -1,152 +0,0 @@
-import * as types from '~/frequent_items/store/mutation_types';
-import mutations from '~/frequent_items/store/mutations';
-import state from '~/frequent_items/store/state';
-import {
- mockNamespace,
- mockStorageKey,
- mockFrequentProjects,
- mockSearchedProjects,
- mockProcessedSearchedProjects,
- mockSearchedGroups,
- mockProcessedSearchedGroups,
-} from '../mock_data';
-
-describe('Frequent Items dropdown mutations', () => {
- let stateCopy;
-
- beforeEach(() => {
- stateCopy = state();
- });
-
- describe('SET_NAMESPACE', () => {
- it('should set namespace', () => {
- mutations[types.SET_NAMESPACE](stateCopy, mockNamespace);
-
- expect(stateCopy.namespace).toEqual(mockNamespace);
- });
- });
-
- describe('SET_STORAGE_KEY', () => {
- it('should set storage key', () => {
- mutations[types.SET_STORAGE_KEY](stateCopy, mockStorageKey);
-
- expect(stateCopy.storageKey).toEqual(mockStorageKey);
- });
- });
-
- describe('SET_SEARCH_QUERY', () => {
- it('should set search query', () => {
- const searchQuery = 'gitlab-ce';
-
- mutations[types.SET_SEARCH_QUERY](stateCopy, searchQuery);
-
- expect(stateCopy.searchQuery).toEqual(searchQuery);
- });
- });
-
- describe('TOGGLE_ITEMS_LIST_EDITABILITY', () => {
- it('should toggle items list editablity', () => {
- mutations[types.TOGGLE_ITEMS_LIST_EDITABILITY](stateCopy);
-
- expect(stateCopy.isItemsListEditable).toEqual(true);
-
- mutations[types.TOGGLE_ITEMS_LIST_EDITABILITY](stateCopy);
-
- expect(stateCopy.isItemsListEditable).toEqual(false);
- });
- });
-
- describe('REQUEST_FREQUENT_ITEMS', () => {
- it('should set view states when requesting frequent items', () => {
- mutations[types.REQUEST_FREQUENT_ITEMS](stateCopy);
-
- expect(stateCopy.isLoadingItems).toEqual(true);
- expect(stateCopy.hasSearchQuery).toEqual(false);
- });
- });
-
- describe('RECEIVE_FREQUENT_ITEMS_SUCCESS', () => {
- it('should set view states when receiving frequent items', () => {
- mutations[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](stateCopy, mockFrequentProjects);
-
- expect(stateCopy.items).toEqual(mockFrequentProjects);
- expect(stateCopy.isLoadingItems).toEqual(false);
- expect(stateCopy.hasSearchQuery).toEqual(false);
- expect(stateCopy.isFetchFailed).toEqual(false);
- });
- });
-
- describe('RECEIVE_FREQUENT_ITEMS_ERROR', () => {
- it('should set items and view states when error occurs retrieving frequent items', () => {
- mutations[types.RECEIVE_FREQUENT_ITEMS_ERROR](stateCopy);
-
- expect(stateCopy.items).toEqual([]);
- expect(stateCopy.isLoadingItems).toEqual(false);
- expect(stateCopy.hasSearchQuery).toEqual(false);
- expect(stateCopy.isFetchFailed).toEqual(true);
- });
- });
-
- describe('REQUEST_SEARCHED_ITEMS', () => {
- it('should set view states when requesting searched items', () => {
- mutations[types.REQUEST_SEARCHED_ITEMS](stateCopy);
-
- expect(stateCopy.isLoadingItems).toEqual(true);
- expect(stateCopy.hasSearchQuery).toEqual(true);
- });
- });
-
- describe('RECEIVE_SEARCHED_ITEMS_SUCCESS', () => {
- it('should set items and view states when receiving searched items', () => {
- mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedProjects);
-
- expect(stateCopy.items).toEqual(mockProcessedSearchedProjects);
- expect(stateCopy.isLoadingItems).toEqual(false);
- expect(stateCopy.hasSearchQuery).toEqual(true);
- expect(stateCopy.isFetchFailed).toEqual(false);
- });
-
- it('should also handle the different `full_name` key for namespace in groups payload', () => {
- mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedGroups);
-
- expect(stateCopy.items).toEqual(mockProcessedSearchedGroups);
- expect(stateCopy.isLoadingItems).toEqual(false);
- expect(stateCopy.hasSearchQuery).toEqual(true);
- expect(stateCopy.isFetchFailed).toEqual(false);
- });
- });
-
- describe('RECEIVE_SEARCHED_ITEMS_ERROR', () => {
- it('should set view states when error occurs retrieving searched items', () => {
- mutations[types.RECEIVE_SEARCHED_ITEMS_ERROR](stateCopy);
-
- expect(stateCopy.items).toEqual([]);
- expect(stateCopy.isLoadingItems).toEqual(false);
- expect(stateCopy.hasSearchQuery).toEqual(true);
- expect(stateCopy.isFetchFailed).toEqual(true);
- });
- });
-
- describe('RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS', () => {
- it('should remove item with provided itemId from the items', () => {
- stateCopy.isItemRemovalFailed = true;
- stateCopy.items = mockFrequentProjects;
-
- mutations[types.RECEIVE_REMOVE_FREQUENT_ITEM_SUCCESS](stateCopy, mockFrequentProjects[0].id);
-
- expect(stateCopy.items).toHaveLength(mockFrequentProjects.length - 1);
- expect(stateCopy.items).toEqual([...mockFrequentProjects.slice(1)]);
- expect(stateCopy.isItemRemovalFailed).toBe(false);
- });
- });
-
- describe('RECEIVE_REMOVE_FREQUENT_ITEM_ERROR', () => {
- it('should remove item with provided itemId from the items', () => {
- stateCopy.isItemRemovalFailed = false;
-
- mutations[types.RECEIVE_REMOVE_FREQUENT_ITEM_ERROR](stateCopy);
-
- expect(stateCopy.isItemRemovalFailed).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/frequent_items/utils_spec.js b/spec/frontend/frequent_items/utils_spec.js
deleted file mode 100644
index 8d4c89bd48f..00000000000
--- a/spec/frontend/frequent_items/utils_spec.js
+++ /dev/null
@@ -1,131 +0,0 @@
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { FIFTEEN_MINUTES_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants';
-import {
- isMobile,
- getTopFrequentItems,
- updateExistingFrequentItem,
- sanitizeItem,
-} from '~/frequent_items/utils';
-import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data';
-
-describe('Frequent Items utils spec', () => {
- describe('isMobile', () => {
- it('returns true when the screen is medium', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
-
- expect(isMobile()).toBe(true);
- });
-
- it('returns true when the screen is small', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm');
-
- expect(isMobile()).toBe(true);
- });
-
- it('returns true when the screen is extra-small', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs');
-
- expect(isMobile()).toBe(true);
- });
-
- it('returns false when the screen is larger than medium', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg');
-
- expect(isMobile()).toBe(false);
- });
- });
-
- describe('getTopFrequentItems', () => {
- it('returns empty array if no items provided', () => {
- const result = getTopFrequentItems();
-
- expect(result.length).toBe(0);
- });
-
- it('returns correct amount of items for mobile', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md');
- const result = getTopFrequentItems(unsortedFrequentItems);
-
- expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE);
- });
-
- it('returns correct amount of items for desktop', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
- const result = getTopFrequentItems(unsortedFrequentItems);
-
- expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
- });
-
- it('sorts frequent items in order of frequency and lastAccessedOn', () => {
- jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl');
- const result = getTopFrequentItems(unsortedFrequentItems);
- const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP);
-
- expect(result).toEqual(expectedResult);
- });
- });
-
- describe('updateExistingFrequentItem', () => {
- const LAST_ACCESSED = 1497979281815;
- const WITHIN_FIFTEEN_MINUTES = LAST_ACCESSED + FIFTEEN_MINUTES_IN_MS;
- const OVER_FIFTEEN_MINUTES = WITHIN_FIFTEEN_MINUTES + 1;
- const EXISTING_ITEM = Object.freeze({
- ...mockProject,
- frequency: 1,
- lastAccessedOn: 1497979281815,
- });
-
- it.each`
- desc | existingProps | newProps | expected
- ${'updates item if accessed over 15 minutes ago'} | ${{}} | ${{ lastAccessedOn: OVER_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }}
- ${'does not update is accessed with 15 minutes'} | ${{}} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: EXISTING_ITEM.lastAccessedOn, frequency: 1 }}
- ${'updates if lastAccessedOn not found'} | ${{ lastAccessedOn: undefined }} | ${{ lastAccessedOn: WITHIN_FIFTEEN_MINUTES }} | ${{ lastAccessedOn: Date.now(), frequency: 2 }}
- `('$desc', ({ existingProps, newProps, expected }) => {
- const newItem = {
- ...EXISTING_ITEM,
- ...newProps,
- };
- const existingItem = {
- ...EXISTING_ITEM,
- ...existingProps,
- };
-
- const result = updateExistingFrequentItem(existingItem, newItem);
-
- expect(result).toEqual({
- ...newItem,
- ...expected,
- });
- });
- });
-
- describe('sanitizeItem', () => {
- it('strips HTML tags for name and namespace', () => {
- const input = {
- name: '<br><b>test</b>',
- namespace: '<br>test',
- id: 1,
- };
-
- expect(sanitizeItem(input)).toEqual({ name: 'test', namespace: 'test', id: 1 });
- });
-
- it("skips `name` key if it doesn't exist on the item", () => {
- const input = {
- namespace: '<br>test',
- id: 1,
- };
-
- expect(sanitizeItem(input)).toEqual({ namespace: 'test', id: 1 });
- });
-
- it("skips `namespace` key if it doesn't exist on the item", () => {
- const input = {
- name: '<br><b>test</b>',
- id: 1,
- };
-
- expect(sanitizeItem(input)).toEqual({ name: 'test', id: 1 });
- });
- });
-});
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index da465552db3..2d7841771a1 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -45,6 +45,16 @@ describe('GfmAutoComplete', () => {
let sorterValue;
let filterValue;
+ const triggerDropdown = ($textarea, text) => {
+ $textarea
+ .trigger('focus')
+ .val($textarea.val() + text)
+ .caret('pos', -1);
+ $textarea.trigger('keyup');
+
+ jest.runOnlyPendingTimers();
+ };
+
describe('DefaultOptions.filter', () => {
let items;
@@ -537,7 +547,7 @@ describe('GfmAutoComplete', () => {
expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([
{
username: 'my-group',
- avatarTag: '<div class="avatar rect-avatar center avatar-inline s26">M</div>',
+ avatarTag: '<div class="avatar rect-avatar avatar-inline s24 gl-mr-2">M</div>',
title: 'My Group (2)',
search: 'MyGroup my-group',
icon: '',
@@ -550,7 +560,7 @@ describe('GfmAutoComplete', () => {
{
username: 'my-group',
avatarTag:
- '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
+ '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>',
title: 'My Group (2)',
search: 'MyGroup my-group',
icon: '',
@@ -563,7 +573,7 @@ describe('GfmAutoComplete', () => {
{
username: 'my-group',
avatarTag:
- '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
+ '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>',
title: 'My Group',
search: 'MyGroup my-group',
icon:
@@ -581,7 +591,7 @@ describe('GfmAutoComplete', () => {
{
username: 'my-user',
avatarTag:
- '<img src="./users.jpg" alt="my-user" class="avatar avatar-inline center s26"/>',
+ '<img src="./users.jpg" alt="my-user" class="avatar avatar-inline s24 gl-mr-2"/>',
title: 'My User',
search: 'MyUser my-user',
icon: '',
@@ -786,13 +796,6 @@ describe('GfmAutoComplete', () => {
resetHTMLFixture();
});
- const triggerDropdown = (text) => {
- $textarea.trigger('focus').val(text).caret('pos', -1);
- $textarea.trigger('keyup');
-
- jest.runOnlyPendingTimers();
- };
-
const getDropdownItems = () => {
const dropdown = document.getElementById('at-view-labels');
const items = dropdown.getElementsByTagName('li');
@@ -800,7 +803,7 @@ describe('GfmAutoComplete', () => {
};
const expectLabels = ({ input, output }) => {
- triggerDropdown(input);
+ triggerDropdown($textarea, input);
expect(getDropdownItems()).toEqual(output.map((label) => label.title));
};
@@ -860,6 +863,50 @@ describe('GfmAutoComplete', () => {
});
});
+ describe('submit_review', () => {
+ let autocomplete;
+ let $textarea;
+
+ const getDropdownItems = () => {
+ const dropdown = document.getElementById('at-view-submit_review');
+
+ return dropdown.getElementsByTagName('li');
+ };
+
+ beforeEach(() => {
+ jest
+ .spyOn(AjaxCache, 'retrieve')
+ .mockReturnValue(Promise.resolve([{ name: 'submit_review' }]));
+
+ window.gon = { features: { mrRequestChanges: true } };
+
+ setHTMLFixture('<textarea data-supports-quick-actions="true"></textarea>');
+ autocomplete = new GfmAutoComplete({
+ commands: `${TEST_HOST}/autocomplete_sources/commands`,
+ });
+ $textarea = $('textarea');
+ autocomplete.setup($textarea, {});
+ });
+
+ afterEach(() => {
+ autocomplete.destroy();
+ resetHTMLFixture();
+ });
+
+ it('renders submit review options', async () => {
+ triggerDropdown($textarea, '/');
+
+ await waitForPromises();
+
+ triggerDropdown($textarea, 'submit_review ');
+
+ expect(getDropdownItems()).toHaveLength(3);
+ expect(getDropdownItems()[0].textContent).toContain('Comment');
+ expect(getDropdownItems()[1].textContent).toContain('Approve');
+ expect(getDropdownItems()[2].textContent).toContain('Request changes');
+ });
+ });
+
describe('emoji', () => {
const mockItem = {
'atwho-at': ':',
@@ -951,13 +998,6 @@ describe('GfmAutoComplete', () => {
resetHTMLFixture();
});
- const triggerDropdown = (text) => {
- $textarea.trigger('focus').val(text).caret('pos', -1);
- $textarea.trigger('keyup');
-
- jest.runOnlyPendingTimers();
- };
-
const getDropdownItems = () => {
const dropdown = document.getElementById('at-view-contacts');
const items = dropdown.getElementsByTagName('li');
@@ -965,7 +1005,7 @@ describe('GfmAutoComplete', () => {
};
const expectContacts = ({ input, output }) => {
- triggerDropdown(input);
+ triggerDropdown($textarea, input);
expect(getDropdownItems()).toEqual(
output.map((contact) => `${contact.first_name} ${contact.last_name} ${contact.email}`),
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
index e32c50db8bf..8ac410c87b1 100644
--- a/spec/frontend/groups/components/app_spec.js
+++ b/spec/frontend/groups/components/app_spec.js
@@ -1,12 +1,10 @@
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import AxiosMockAdapter from 'axios-mock-adapter';
-import Vue, { nextTick } from 'vue';
+import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
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 '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';
@@ -67,8 +65,6 @@ describe('AppComponent', () => {
beforeEach(async () => {
mock = new AxiosMockAdapter(axios);
mock.onGet('/dashboard/groups.json').reply(HTTP_STATUS_OK, mockGroups);
- Vue.component('GroupFolder', groupFolderComponent);
- Vue.component('GroupItem', groupItemComponent);
setWindowLocation('?filter=foobar');
document.body.innerHTML = `
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
index 3cdbd3e38be..33fd2681766 100644
--- a/spec/frontend/groups/components/groups_spec.js
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -1,9 +1,7 @@
import Vue from 'vue';
import { GlEmptyState } from '@gitlab/ui';
-
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMount } from '@vue/test-utils';
import GroupFolderComponent from '~/groups/components/group_folder.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';
@@ -19,7 +17,7 @@ describe('GroupsComponent', () => {
};
const createComponent = ({ propsData } = {}) => {
- wrapper = mountExtended(GroupsComponent, {
+ wrapper = shallowMount(GroupsComponent, {
propsData: {
...defaultPropsData,
...propsData,
@@ -32,11 +30,6 @@ describe('GroupsComponent', () => {
const findPaginationLinks = () => wrapper.findComponent(PaginationLinks);
- beforeEach(() => {
- Vue.component('GroupFolder', GroupFolderComponent);
- Vue.component('GroupItem', GroupItemComponent);
- });
-
describe('methods', () => {
describe('change', () => {
it('should emit `fetchPage` event when page is changed via pagination', () => {
@@ -57,6 +50,8 @@ describe('GroupsComponent', () => {
});
describe('template', () => {
+ Vue.component('GroupFolder', GroupFolderComponent);
+
it('should render component template correctly', () => {
createComponent();
diff --git a/spec/frontend/groups/service/archived_projects_service_spec.js b/spec/frontend/groups/service/archived_projects_service_spec.js
index 6bc46e4799c..988fb5553ba 100644
--- a/spec/frontend/groups/service/archived_projects_service_spec.js
+++ b/spec/frontend/groups/service/archived_projects_service_spec.js
@@ -30,7 +30,7 @@ describe('ArchivedProjectsService', () => {
markdown_description: project.description_html,
visibility: project.visibility,
avatar_url: project.avatar_url,
- relative_path: `/${project.path_with_namespace}`,
+ relative_path: `${gon.relative_url_root}/${project.path_with_namespace}`,
edit_path: null,
leave_path: null,
can_edit: false,
diff --git a/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js b/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js
new file mode 100644
index 00000000000..1bcff8a44be
--- /dev/null
+++ b/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js
@@ -0,0 +1,173 @@
+import { GlDisclosureDropdownItem, GlDisclosureDropdown } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import moreActionsDropdown from '~/groups_projects/components/more_actions_dropdown.vue';
+
+describe('moreActionsDropdown', () => {
+ let wrapper;
+
+ const createComponent = ({ provideData = {}, propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(moreActionsDropdown, {
+ provide: {
+ isGroup: false,
+ id: 1,
+ leavePath: '',
+ leaveConfirmMessage: '',
+ withdrawPath: '',
+ withdrawConfirmMessage: '',
+ requestAccessPath: '',
+ ...provideData,
+ },
+ propsData,
+ stubs: {
+ GlDisclosureDropdownItem,
+ },
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const showDropdown = () => {
+ findDropdown().vm.$emit('show');
+ };
+
+ describe('copy id', () => {
+ describe('project namespace type', () => {
+ beforeEach(async () => {
+ createComponent({
+ provideData: {
+ id: 22,
+ },
+ });
+ await showDropdown();
+ });
+
+ it('has correct test id `copy-project-id`', () => {
+ expect(wrapper.findByTestId('copy-project-id').exists()).toBe(true);
+ expect(wrapper.findByTestId('copy-group-id').exists()).toBe(false);
+ });
+
+ it('renders copy project id with correct id', () => {
+ expect(wrapper.findByTestId('copy-project-id').text()).toBe('Copy project ID: 22');
+ });
+ });
+
+ describe('group namespace type', () => {
+ beforeEach(async () => {
+ createComponent({
+ provideData: {
+ isGroup: true,
+ id: 11,
+ },
+ });
+ await showDropdown();
+ });
+
+ it('has correct test id `copy-group-id`', () => {
+ expect(wrapper.findByTestId('copy-project-id').exists()).toBe(false);
+ expect(wrapper.findByTestId('copy-group-id').exists()).toBe(true);
+ });
+
+ it('renders copy group id with correct id', () => {
+ expect(wrapper.findByTestId('copy-group-id').text()).toBe('Copy group ID: 11');
+ });
+ });
+ });
+
+ describe('request access', () => {
+ it('does not render request access link', async () => {
+ createComponent();
+ await showDropdown();
+
+ expect(wrapper.findByTestId('request-access-link').exists()).toBe(false);
+ });
+
+ it('renders request access link', async () => {
+ createComponent({
+ provideData: {
+ requestAccessPath: 'http://request.path/path',
+ },
+ });
+ await showDropdown();
+
+ expect(wrapper.findByTestId('request-access-link').text()).toBe('Request Access');
+ expect(wrapper.findByTestId('request-access-link').attributes('href')).toBe(
+ 'http://request.path/path',
+ );
+ });
+ });
+
+ describe('withdraw access', () => {
+ it('does not render withdraw access link', async () => {
+ createComponent();
+ await showDropdown();
+
+ expect(wrapper.findByTestId('withdraw-access-link').exists()).toBe(false);
+ });
+
+ it('renders withdraw access link', async () => {
+ createComponent({
+ provideData: {
+ withdrawPath: 'http://withdraw.path/path',
+ },
+ });
+ await showDropdown();
+
+ expect(wrapper.findByTestId('withdraw-access-link').text()).toBe('Withdraw Access Request');
+ expect(wrapper.findByTestId('withdraw-access-link').attributes('href')).toBe(
+ 'http://withdraw.path/path',
+ );
+ });
+ });
+
+ describe('leave access', () => {
+ it('does not render leave link', async () => {
+ createComponent();
+ await showDropdown();
+
+ expect(wrapper.findByTestId('leave-project-link').exists()).toBe(false);
+ });
+
+ it('renders leave link', async () => {
+ createComponent({
+ provideData: {
+ leavePath: 'http://leave.path/path',
+ },
+ });
+ await showDropdown();
+
+ expect(wrapper.findByTestId('leave-project-link').exists()).toBe(true);
+ expect(wrapper.findByTestId('leave-project-link').text()).toBe('Leave project');
+ expect(wrapper.findByTestId('leave-project-link').attributes('href')).toBe(
+ 'http://leave.path/path',
+ );
+ });
+
+ describe('when `isGroup` is set to `false`', () => {
+ it('use testid `leave-project-link`', async () => {
+ createComponent({
+ provideData: {
+ leavePath: 'http://leave.path/path',
+ },
+ });
+ await showDropdown();
+
+ expect(wrapper.findByTestId('leave-project-link').exists()).toBe(true);
+ expect(wrapper.findByTestId('leave-group-link').exists()).toBe(false);
+ });
+ });
+
+ describe('when `isGroup` is set to `true`', () => {
+ it('use testid `leave-group-link`', async () => {
+ createComponent({
+ provideData: {
+ isGroup: true,
+ leavePath: 'http://leave.path/path',
+ },
+ });
+ await showDropdown();
+
+ expect(wrapper.findByTestId('leave-project-link').exists()).toBe(false);
+ expect(wrapper.findByTestId('leave-group-link').exists()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
deleted file mode 100644
index 0d0b6628bdf..00000000000
--- a/spec/frontend/header_search/components/app_spec.js
+++ /dev/null
@@ -1,517 +0,0 @@
-import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { mockTracking } from 'helpers/tracking_helper';
-import { s__, sprintf } from '~/locale';
-import HeaderSearchApp from '~/header_search/components/app.vue';
-import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
-import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
-import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
-import {
- SEARCH_INPUT_DESCRIPTION,
- SEARCH_RESULTS_DESCRIPTION,
- SEARCH_BOX_INDEX,
- ICON_PROJECT,
- ICON_GROUP,
- ICON_SUBGROUP,
- SCOPE_TOKEN_MAX_LENGTH,
- IS_SEARCHING,
- IS_NOT_FOCUSED,
- IS_FOCUSED,
- SEARCH_SHORTCUTS_MIN_CHARACTERS,
- DROPDOWN_CLOSE_TIMEOUT,
-} from '~/header_search/constants';
-import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
-import { ENTER_KEY } from '~/lib/utils/keys';
-import { visitUrl } from '~/lib/utils/url_utility';
-import { truncate } from '~/lib/utils/text_utility';
-import {
- MOCK_SEARCH,
- MOCK_SEARCH_QUERY,
- MOCK_USERNAME,
- MOCK_DEFAULT_SEARCH_OPTIONS,
- MOCK_SCOPED_SEARCH_OPTIONS,
- MOCK_SEARCH_CONTEXT_FULL,
-} from '../mock_data';
-
-Vue.use(Vuex);
-
-jest.mock('~/lib/utils/url_utility', () => ({
- visitUrl: jest.fn(),
-}));
-
-describe('HeaderSearchApp', () => {
- let wrapper;
-
- jest.useFakeTimers();
- jest.spyOn(global, 'setTimeout');
-
- const actionSpies = {
- setSearch: jest.fn(),
- fetchAutocompleteOptions: jest.fn(),
- clearAutocomplete: jest.fn(),
- };
-
- const createComponent = (initialState, mockGetters) => {
- const store = new Vuex.Store({
- state: {
- ...initialState,
- },
- actions: actionSpies,
- getters: {
- searchQuery: () => MOCK_SEARCH_QUERY,
- searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
- ...mockGetters,
- },
- });
-
- wrapper = shallowMountExtended(HeaderSearchApp, {
- store,
- });
- };
-
- const formatScopeName = (scopeName) => {
- if (!scopeName) {
- return false;
- }
- const searchResultsScope = s__('GlobalSearch|in %{scope}');
- return truncate(
- sprintf(searchResultsScope, {
- scope: scopeName,
- }),
- SCOPE_TOKEN_MAX_LENGTH,
- );
- };
-
- const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form');
- const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
- const findScopeToken = () => wrapper.findComponent(GlToken);
- const findHeaderSearchInputKBD = () => wrapper.find('.keyboard-shortcut-helper');
- const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
- const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
- const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
- const findHeaderSearchAutocompleteItems = () =>
- wrapper.findComponent(HeaderSearchAutocompleteItems);
- const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation);
- const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`);
- const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION);
-
- describe('template', () => {
- describe('always renders', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('Header Search Input', () => {
- expect(findHeaderSearchInput().exists()).toBe(true);
- });
-
- it('Header Search Input KBD hint', () => {
- expect(findHeaderSearchInputKBD().exists()).toBe(true);
- expect(findHeaderSearchInputKBD().text()).toContain('/');
- expect(findHeaderSearchInputKBD().attributes('title')).toContain(
- 'Use the shortcut key <kbd>/</kbd> to start a search',
- );
- });
-
- it('Search Input Description', () => {
- expect(findSearchInputDescription().exists()).toBe(true);
- });
-
- it('Search Results Description', () => {
- expect(findSearchResultsDescription().exists()).toBe(true);
- });
- });
-
- describe.each`
- showDropdown | username | showSearchDropdown
- ${false} | ${null} | ${false}
- ${false} | ${MOCK_USERNAME} | ${false}
- ${true} | ${null} | ${false}
- ${true} | ${MOCK_USERNAME} | ${true}
- `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => {
- describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => {
- beforeEach(() => {
- window.gon.current_username = username;
- createComponent();
- findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : '');
- });
-
- it(`should${showSearchDropdown ? '' : ' not'} render`, () => {
- expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown);
- });
- });
- });
-
- describe.each`
- search | showDefault | showScoped | showAutocomplete
- ${null} | ${true} | ${false} | ${false}
- ${''} | ${true} | ${false} | ${false}
- ${'t'} | ${false} | ${false} | ${true}
- ${'te'} | ${false} | ${false} | ${true}
- ${'tes'} | ${false} | ${true} | ${true}
- ${MOCK_SEARCH} | ${false} | ${true} | ${true}
- `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
- describe(`when search is ${search}`, () => {
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent({ search }, {});
- findHeaderSearchInput().vm.$emit('focusin');
- });
-
- it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
- expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
- });
-
- it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
- expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
- });
-
- it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => {
- expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
- });
-
- it(`should render the Dropdown Navigation Component`, () => {
- expect(findDropdownKeyboardNavigation().exists()).toBe(true);
- });
-
- it(`should close the dropdown when press escape key`, async () => {
- findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 }));
- jest.runAllTimers();
- await nextTick();
- expect(findHeaderSearchDropdown().exists()).toBe(false);
- expect(wrapper.emitted().expandSearchBar.length).toBe(1);
- });
- });
- });
-
- describe.each`
- username | showDropdown | expectedDesc
- ${null} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN}
- ${null} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN}
- ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN}
- ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN}
- `('Search Input Description', ({ username, showDropdown, expectedDesc }) => {
- describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => {
- beforeEach(() => {
- window.gon.current_username = username;
- createComponent();
- findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : '');
- });
-
- it(`sets description to ${expectedDesc}`, () => {
- expect(findSearchInputDescription().text()).toBe(expectedDesc);
- });
- });
- });
-
- describe.each`
- username | showDropdown | search | loading | searchOptions | expectedDesc
- ${null} | ${true} | ${''} | ${false} | ${[]} | ${''}
- ${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''}
- ${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
- ${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
- ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`}
- ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.SEARCH_RESULTS_LOADING}
- `(
- 'Search Results Description',
- ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
- describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => {
- beforeEach(() => {
- window.gon.current_username = username;
- createComponent(
- {
- search,
- loading,
- },
- {
- searchOptions: () => searchOptions,
- },
- );
- findHeaderSearchInput().vm.$emit(showDropdown ? 'focusin' : '');
- });
-
- it(`sets description to ${expectedDesc}`, () => {
- expect(findSearchResultsDescription().text()).toBe(expectedDesc);
- });
- });
- },
- );
-
- describe('input box', () => {
- describe.each`
- search | searchOptions | hasToken
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true}
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true}
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true}
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true}
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true}
- ${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false}
- ${'x'} | ${[]} | ${false}
- `('token', ({ search, searchOptions, hasToken }) => {
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent(
- { search },
- {
- searchOptions: () => searchOptions,
- },
- );
- findHeaderSearchInput().vm.$emit('focusin');
- });
-
- it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${
- searchOptions[0]?.html_id
- }"`, () => {
- expect(findScopeToken().exists()).toBe(hasToken);
- });
-
- it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${
- searchOptions[0]?.scope || searchOptions[0]?.description
- }"`, () => {
- expect(findScopeToken().exists() && findScopeToken().text()).toBe(
- formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description),
- );
- });
- });
- });
-
- describe('form', () => {
- describe.each`
- searchContext | search | searchOptions | isFocused
- ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} | ${true}
- ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} | ${true}
- ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true}
- ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${false}
- ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true}
- ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true}
- ${null} | ${null} | ${[]} | ${true}
- `('wrapper', ({ searchContext, search, searchOptions, isFocused }) => {
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent({ search, searchContext }, { searchOptions: () => searchOptions });
- if (isFocused) {
- findHeaderSearchInput().vm.$emit('focusin');
- }
- });
-
- const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
-
- it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => {
- if (isSearching) {
- expect(findHeaderSearchForm().classes()).toContain(IS_SEARCHING);
- return;
- }
- if (!isSearching) {
- expect(findHeaderSearchForm().classes()).not.toContain(IS_SEARCHING);
- }
- });
-
- it(`classes ${isSearching ? 'contain' : 'do not contain'} "${
- isFocused ? IS_FOCUSED : IS_NOT_FOCUSED
- }"`, () => {
- expect(findHeaderSearchForm().classes()).toContain(
- isFocused ? IS_FOCUSED : IS_NOT_FOCUSED,
- );
- });
- });
- });
-
- describe.each`
- search | searchOptions | hasIcon | iconName
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT}
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP}
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP}
- ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false}
- `('token', ({ search, searchOptions, hasIcon, iconName }) => {
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent(
- { search },
- {
- searchOptions: () => searchOptions,
- },
- );
- findHeaderSearchInput().vm.$emit('focusin');
- });
-
- it(`icon for data set type "${searchOptions[0]?.html_id}" ${
- hasIcon ? 'is' : 'is NOT'
- } rendered`, () => {
- expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon);
- });
-
- it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${
- searchOptions[0]?.html_id
- }"`, () => {
- expect(
- findScopeToken().findComponent(GlIcon).exists() &&
- findScopeToken().findComponent(GlIcon).attributes('name'),
- ).toBe(iconName);
- });
- });
- });
-
- describe('events', () => {
- describe('Header Search Input', () => {
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent();
- });
-
- describe('when dropdown is closed', () => {
- let trackingSpy;
-
- beforeEach(() => {
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- });
-
- it('onFocusin opens dropdown and triggers snowplow event', async () => {
- expect(findHeaderSearchDropdown().exists()).toBe(false);
- findHeaderSearchInput().vm.$emit('focusin');
-
- await nextTick();
-
- expect(findHeaderSearchDropdown().exists()).toBe(true);
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- });
-
- it('onFocusout closes dropdown and triggers snowplow event', async () => {
- expect(findHeaderSearchDropdown().exists()).toBe(false);
-
- findHeaderSearchInput().vm.$emit('focusout');
- jest.runAllTimers();
- await nextTick();
-
- expect(findHeaderSearchDropdown().exists()).toBe(false);
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'blur_input', {
- label: 'global_search',
- property: 'navigation_top',
- });
- });
- });
-
- describe('onInput', () => {
- describe('when search has text', () => {
- beforeEach(() => {
- findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH);
- });
-
- it('calls setSearch with search term', () => {
- expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
- });
-
- it('calls fetchAutocompleteOptions', () => {
- expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled();
- });
-
- it('does not call clearAutocomplete', () => {
- expect(actionSpies.clearAutocomplete).not.toHaveBeenCalled();
- });
- });
-
- describe('when search is emptied', () => {
- beforeEach(() => {
- findHeaderSearchInput().vm.$emit('input', '');
- });
-
- it('calls setSearch with empty term', () => {
- expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), '');
- });
-
- it('does not call fetchAutocompleteOptions', () => {
- expect(actionSpies.fetchAutocompleteOptions).not.toHaveBeenCalled();
- });
-
- it('calls clearAutocomplete', () => {
- expect(actionSpies.clearAutocomplete).toHaveBeenCalled();
- });
- });
- });
- });
-
- describe('onFocusout dropdown', () => {
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent({ search: 'tes' }, {});
- findHeaderSearchInput().vm.$emit('focusin');
- });
-
- it('closes with timeout so click event gets emited', () => {
- findHeaderSearchInput().vm.$emit('focusout');
-
- expect(setTimeout).toHaveBeenCalledTimes(1);
- expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), DROPDOWN_CLOSE_TIMEOUT);
- });
- });
- });
-
- describe('computed', () => {
- describe.each`
- MOCK_INDEX | search
- ${1} | ${null}
- ${SEARCH_BOX_INDEX} | ${'test'}
- ${2} | ${'test1'}
- `('currentFocusedOption', ({ MOCK_INDEX, search }) => {
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent({ search });
- findHeaderSearchInput().vm.$emit('focusin');
- });
-
- it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => {
- findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
- expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]);
- });
- });
- });
-
- describe('Submitting a search', () => {
- describe('with no currentFocusedOption', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('onKey-enter submits a search', () => {
- findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
-
- expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
- });
- });
-
- describe('with less than min characters and no dropdown results', () => {
- beforeEach(() => {
- createComponent({ search: 'x' });
- });
-
- it('onKey-enter will NOT submit a search', () => {
- findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
-
- expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
- });
- });
-
- describe('with currentFocusedOption', () => {
- const MOCK_INDEX = 1;
-
- beforeEach(() => {
- window.gon.current_username = MOCK_USERNAME;
- createComponent();
- findHeaderSearchInput().vm.$emit('focusin');
- });
-
- it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => {
- await nextTick();
- findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
-
- findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
- expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url);
- });
- });
- });
-});
diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
deleted file mode 100644
index 868edb3e651..00000000000
--- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
+++ /dev/null
@@ -1,236 +0,0 @@
-import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
-import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '~/header_search/constants';
-import {
- PROJECTS_CATEGORY,
- GROUPS_CATEGORY,
- ISSUES_CATEGORY,
- MERGE_REQUEST_CATEGORY,
- RECENT_EPICS_CATEGORY,
-} from '~/vue_shared/global_search/constants';
-import {
- MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
- MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
- MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP,
- MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP,
- MOCK_SEARCH,
- MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2,
-} from '../mock_data';
-
-Vue.use(Vuex);
-
-describe('HeaderSearchAutocompleteItems', () => {
- let wrapper;
-
- const createComponent = (initialState, mockGetters, props) => {
- const store = new Vuex.Store({
- state: {
- loading: false,
- ...initialState,
- },
- getters: {
- autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
- ...mockGetters,
- },
- });
-
- wrapper = shallowMount(HeaderSearchAutocompleteItems, {
- store,
- propsData: {
- ...props,
- },
- });
- };
-
- const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider);
- const findFirstDropdownItem = () => findDropdownItems().at(0);
- const findDropdownItemTitles = () =>
- findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text());
- const findDropdownItemSubTitles = () =>
- findDropdownItems()
- .wrappers.filter((w) => w.findAll('span').length > 2)
- .map((w) => w.findAll('span').at(2).text());
- const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
- const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findGlAvatar = () => wrapper.findComponent(GlAvatar);
- const findGlAlert = () => wrapper.findComponent(GlAlert);
-
- describe('template', () => {
- describe('when loading is true', () => {
- beforeEach(() => {
- createComponent({ loading: true });
- });
-
- it('renders GlLoadingIcon', () => {
- expect(findGlLoadingIcon().exists()).toBe(true);
- });
-
- it('does not render autocomplete options', () => {
- expect(findDropdownItems()).toHaveLength(0);
- });
- });
-
- describe('when api returns error', () => {
- beforeEach(() => {
- createComponent({ autocompleteError: true });
- });
-
- it('renders Alert', () => {
- expect(findGlAlert().exists()).toBe(true);
- });
- });
- describe('when loading is false', () => {
- beforeEach(() => {
- createComponent({ loading: false });
- });
-
- it('does not render GlLoadingIcon', () => {
- expect(findGlLoadingIcon().exists()).toBe(false);
- });
-
- describe('Dropdown items', () => {
- it('renders item for each option in autocomplete option', () => {
- expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length);
- });
-
- it('renders titles correctly', () => {
- const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label);
- expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
- });
-
- it('renders sub-titles correctly', () => {
- const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map(
- (o) => o.label,
- );
- expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles);
- });
-
- it('renders links correctly', () => {
- const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url);
- expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
- });
- });
-
- describe.each`
- item | showAvatar | avatarSize | searchContext | entityId | entityName
- ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''}
- ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''}
- ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''}
- ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false}
- ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'}
- ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'}
- ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'}
- ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'}
- ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'}
- ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'}
- ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'}
- ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'}
- ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'}
- ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'}
- ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'}
- ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'}
- ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'}
- ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'}
- ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'}
- `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => {
- describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => {
- beforeEach(() => {
- createComponent({ searchContext }, { autocompleteGroupedSearchOptions: () => [item] });
- });
-
- it(`should${showAvatar ? '' : ' not'} render`, () => {
- expect(findGlAvatar().exists()).toBe(showAvatar);
- });
-
- it(`should set avatarSize to ${avatarSize}`, () => {
- expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize);
- });
-
- it(`should set avatar entityId to ${entityId}`, () => {
- expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId);
- });
-
- it(`should set avatar entityName to ${entityName}`, () => {
- expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe(
- entityName,
- );
- });
- });
- });
- });
-
- describe.each`
- currentFocusedOption | isFocused | ariaSelected
- ${null} | ${false} | ${undefined}
- ${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
- ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'}
- `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
- describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
- beforeEach(() => {
- createComponent({}, {}, { currentFocusedOption });
- });
-
- it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
- expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
- });
-
- it(`sets "aria-selected to ${ariaSelected}`, () => {
- expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
- });
- });
- });
-
- describe.each`
- search | items | dividerCount
- ${null} | ${[]} | ${0}
- ${''} | ${[]} | ${0}
- ${'1'} | ${[]} | ${0}
- ${')'} | ${[]} | ${0}
- ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1}
- ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0}
- ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1}
- ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1}
- `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => {
- describe(`when search is ${search}`, () => {
- beforeEach(() => {
- createComponent(
- { search },
- {
- autocompleteGroupedSearchOptions: () => items,
- },
- {},
- );
- });
-
- it(`component should have ${dividerCount} dividers`, () => {
- expect(findGlDropdownDividers()).toHaveLength(dividerCount);
- });
- });
- });
- });
-
- describe('watchers', () => {
- describe('currentFocusedOption', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('when focused changes to existing element calls scroll into view on the newly focused element', async () => {
- const focusedElement = findFirstDropdownItem().element;
- const scrollSpy = jest.spyOn(focusedElement, 'scrollIntoView');
-
- wrapper.setProps({ currentFocusedOption: MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0] });
-
- await nextTick();
-
- expect(scrollSpy).toHaveBeenCalledWith(false);
- scrollSpy.mockRestore();
- });
- });
- });
-});
diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js
deleted file mode 100644
index acaad251bec..00000000000
--- a/spec/frontend/header_search/components/header_search_default_items_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
-import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data';
-
-Vue.use(Vuex);
-
-describe('HeaderSearchDefaultItems', () => {
- let wrapper;
-
- const createComponent = (initialState, props) => {
- const store = new Vuex.Store({
- state: {
- searchContext: MOCK_SEARCH_CONTEXT,
- ...initialState,
- },
- getters: {
- defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
- },
- });
-
- wrapper = shallowMount(HeaderSearchDefaultItems, {
- store,
- propsData: {
- ...props,
- },
- });
- };
-
- const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
- const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findFirstDropdownItem = () => findDropdownItems().at(0);
- const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
- const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
-
- describe('template', () => {
- describe('Dropdown items', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders item for each option in defaultSearchOptions', () => {
- expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length);
- });
-
- it('renders titles correctly', () => {
- const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title);
- expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
- });
-
- it('renders links correctly', () => {
- const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url);
- expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
- });
- });
-
- describe.each`
- group | project | dropdownTitle
- ${null} | ${null} | ${'All GitLab'}
- ${{ name: 'Test Group' }} | ${null} | ${'Test Group'}
- ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'}
- `('Dropdown Header', ({ group, project, dropdownTitle }) => {
- describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
- beforeEach(() => {
- createComponent({
- searchContext: {
- group,
- project,
- },
- });
- });
-
- it(`should render as ${dropdownTitle}`, () => {
- expect(findDropdownHeader().text()).toBe(dropdownTitle);
- });
- });
- });
-
- describe.each`
- currentFocusedOption | isFocused | ariaSelected
- ${null} | ${false} | ${undefined}
- ${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
- ${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'}
- `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
- describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
- beforeEach(() => {
- createComponent({}, { currentFocusedOption });
- });
-
- it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
- expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
- });
-
- it(`sets "aria-selected to ${ariaSelected}`, () => {
- expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
deleted file mode 100644
index 78ea148caac..00000000000
--- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { trimText } from 'helpers/text_helper';
-import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
-import { truncate } from '~/lib/utils/text_utility';
-import { SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
-import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants';
-import {
- MOCK_SEARCH,
- MOCK_SCOPED_SEARCH_OPTIONS,
- MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
-} from '../mock_data';
-
-Vue.use(Vuex);
-
-describe('HeaderSearchScopedItems', () => {
- let wrapper;
-
- const createComponent = (initialState, mockGetters, props) => {
- const store = new Vuex.Store({
- state: {
- search: MOCK_SEARCH,
- ...initialState,
- },
- getters: {
- scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS,
- autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
- ...mockGetters,
- },
- });
-
- wrapper = shallowMount(HeaderSearchScopedItems, {
- store,
- propsData: {
- ...props,
- },
- });
- };
-
- const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findFirstDropdownItem = () => findDropdownItems().at(0);
- const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
- const findScopeTokens = () => wrapper.findAllComponents(GlToken);
- const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text()));
- const findScopeTokensIcons = () =>
- findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon));
- const findDropdownItemAriaLabels = () =>
- findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label')));
- const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
-
- describe('template', () => {
- describe('Dropdown items', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders item for each option in scopedSearchOptions', () => {
- expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length);
- });
-
- it('renders titles correctly', () => {
- findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH));
- });
-
- it('renders scope names correctly', () => {
- const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
- truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH),
- );
-
- expect(findScopeTokensText()).toStrictEqual(expectedTitles);
- });
-
- it('renders scope icons correctly', () => {
- findScopeTokensIcons().forEach((icon, i) => {
- const w = icon.wrappers[0];
- expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon);
- });
- });
-
- it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => {
- expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false);
- });
-
- it('renders aria-labels correctly', () => {
- const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
- trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`),
- );
- expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels);
- });
-
- it('renders links correctly', () => {
- const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url);
- expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
- });
- });
-
- describe.each`
- currentFocusedOption | isFocused | ariaSelected
- ${null} | ${false} | ${undefined}
- ${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
- ${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'}
- `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
- describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
- beforeEach(() => {
- createComponent({}, {}, { currentFocusedOption });
- });
-
- it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
- expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
- });
-
- it(`sets "aria-selected to ${ariaSelected}`, () => {
- expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js
deleted file mode 100644
index 459ca33ee66..00000000000
--- a/spec/frontend/header_search/init_spec.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-
-import initHeaderSearch, { eventHandler, cleanEventListeners } from '~/header_search/init';
-
-describe('Header Search EventListener', () => {
- beforeEach(() => {
- jest.resetModules();
- setHTMLFixture(`
- <div class="js-header-content">
- <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>`);
- });
-
- afterEach(() => {
- resetHTMLFixture();
- });
-
- it('attached event listener', () => {
- const searchInputBox = document?.querySelector('#search');
- const addEventListenerSpy = jest.spyOn(searchInputBox, 'addEventListener');
- initHeaderSearch();
-
- expect(addEventListenerSpy).toHaveBeenCalledTimes(2);
- });
-
- it('removes event listener', async () => {
- const searchInputBox = document?.querySelector('#search');
- const removeEventListenerSpy = jest.spyOn(searchInputBox, 'removeEventListener');
- jest.mock('~/header_search', () => ({ initHeaderSearchApp: jest.fn() }));
- await eventHandler.apply(
- {
- searchInputBox: document.querySelector('#search'),
- },
- [cleanEventListeners],
- );
-
- expect(removeEventListenerSpy).toHaveBeenCalledTimes(2);
- });
-
- it('attaches new vue dropdown when feature flag is enabled', async () => {
- const mockVueApp = jest.fn();
- jest.mock('~/header_search', () => ({ initHeaderSearchApp: mockVueApp }));
- await eventHandler.apply(
- {
- searchInputBox: document.querySelector('#search'),
- },
- () => {},
- );
-
- expect(mockVueApp).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
deleted file mode 100644
index 2218c81efc3..00000000000
--- a/spec/frontend/header_search/mock_data.js
+++ /dev/null
@@ -1,400 +0,0 @@
-import { ICON_PROJECT, ICON_GROUP, ICON_SUBGROUP } from '~/header_search/constants';
-import {
- PROJECTS_CATEGORY,
- GROUPS_CATEGORY,
- MSG_ISSUES_ASSIGNED_TO_ME,
- MSG_ISSUES_IVE_CREATED,
- MSG_MR_ASSIGNED_TO_ME,
- MSG_MR_IM_REVIEWER,
- MSG_MR_IVE_CREATED,
- MSG_IN_ALL_GITLAB,
-} from '~/vue_shared/global_search/constants';
-
-export const MOCK_USERNAME = 'anyone';
-
-export const MOCK_SEARCH_PATH = '/search';
-
-export const MOCK_ISSUE_PATH = '/dashboard/issues';
-
-export const MOCK_MR_PATH = '/dashboard/merge_requests';
-
-export const MOCK_ALL_PATH = '/';
-
-export const MOCK_AUTOCOMPLETE_PATH = '/autocomplete';
-
-export const MOCK_PROJECT = {
- id: 123,
- name: 'MockProject',
- path: '/mock-project',
-};
-
-export const MOCK_PROJECT_LONG = {
- id: 124,
- name: 'Mock Project Name That Is Ridiculously Long And It Goes Forever',
- path: '/mock-project-name-that-is-ridiculously-long-and-it-goes-forever',
-};
-
-export const MOCK_GROUP = {
- id: 321,
- name: 'MockGroup',
- path: '/mock-group',
-};
-
-export const MOCK_SUBGROUP = {
- id: 322,
- name: 'MockSubGroup',
- path: `${MOCK_GROUP}/mock-subgroup`,
-};
-
-export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test';
-
-export const MOCK_SEARCH = 'test';
-
-export const MOCK_SEARCH_CONTEXT = {
- project: null,
- project_metadata: {},
- group: null,
- group_metadata: {},
-};
-
-export const MOCK_SEARCH_CONTEXT_FULL = {
- group: {
- id: 31,
- name: 'testGroup',
- full_name: 'testGroup',
- },
- group_metadata: {
- group_path: 'testGroup',
- name: 'testGroup',
- issues_path: '/groups/testGroup/-/issues',
- mr_path: '/groups/testGroup/-/merge_requests',
- },
-};
-
-export const MOCK_DEFAULT_SEARCH_OPTIONS = [
- {
- html_id: 'default-issues-assigned',
- title: MSG_ISSUES_ASSIGNED_TO_ME,
- url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`,
- },
- {
- html_id: 'default-issues-created',
- title: MSG_ISSUES_IVE_CREATED,
- url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`,
- },
- {
- html_id: 'default-mrs-assigned',
- title: MSG_MR_ASSIGNED_TO_ME,
- url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`,
- },
- {
- html_id: 'default-mrs-reviewer',
- title: MSG_MR_IM_REVIEWER,
- url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`,
- },
- {
- html_id: 'default-mrs-created',
- title: MSG_MR_IVE_CREATED,
- url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
- },
-];
-
-export const MOCK_SCOPED_SEARCH_OPTIONS = [
- {
- html_id: 'scoped-in-project',
- scope: MOCK_PROJECT.name,
- scopeCategory: PROJECTS_CATEGORY,
- icon: ICON_PROJECT,
- url: MOCK_PROJECT.path,
- },
- {
- html_id: 'scoped-in-project-long',
- scope: MOCK_PROJECT_LONG.name,
- scopeCategory: PROJECTS_CATEGORY,
- icon: ICON_PROJECT,
- url: MOCK_PROJECT_LONG.path,
- },
- {
- html_id: 'scoped-in-group',
- scope: MOCK_GROUP.name,
- scopeCategory: GROUPS_CATEGORY,
- icon: ICON_GROUP,
- url: MOCK_GROUP.path,
- },
- {
- html_id: 'scoped-in-subgroup',
- scope: MOCK_SUBGROUP.name,
- scopeCategory: GROUPS_CATEGORY,
- icon: ICON_SUBGROUP,
- url: MOCK_SUBGROUP.path,
- },
- {
- html_id: 'scoped-in-all',
- description: MSG_IN_ALL_GITLAB,
- url: MOCK_ALL_PATH,
- },
-];
-
-export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
- {
- html_id: 'scoped-in-project',
- scope: MOCK_PROJECT.name,
- scopeCategory: PROJECTS_CATEGORY,
- icon: ICON_PROJECT,
- url: MOCK_PROJECT.path,
- },
- {
- html_id: 'scoped-in-group',
- scope: MOCK_GROUP.name,
- scopeCategory: GROUPS_CATEGORY,
- icon: ICON_GROUP,
- url: MOCK_GROUP.path,
- },
- {
- html_id: 'scoped-in-all',
- description: MSG_IN_ALL_GITLAB,
- url: MOCK_ALL_PATH,
- },
-];
-
-export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [
- {
- category: 'Projects',
- id: 1,
- label: 'Gitlab Org / MockProject1',
- value: 'MockProject1',
- url: 'project/1',
- },
- {
- category: 'Groups',
- id: 1,
- label: 'Gitlab Org / MockGroup1',
- value: 'MockGroup1',
- url: 'group/1',
- },
- {
- category: 'Projects',
- id: 2,
- label: 'Gitlab Org / MockProject2',
- value: 'MockProject2',
- url: 'project/2',
- },
- {
- category: 'Help',
- label: 'GitLab Help',
- url: 'help/gitlab',
- },
-];
-
-export const MOCK_AUTOCOMPLETE_OPTIONS = [
- {
- category: 'Projects',
- html_id: 'autocomplete-Projects-0',
- id: 1,
- label: 'Gitlab Org / MockProject1',
- value: 'MockProject1',
- url: 'project/1',
- },
- {
- category: 'Groups',
- html_id: 'autocomplete-Groups-1',
- id: 1,
- label: 'Gitlab Org / MockGroup1',
- value: 'MockGroup1',
- url: 'group/1',
- },
- {
- category: 'Projects',
- html_id: 'autocomplete-Projects-2',
- id: 2,
- label: 'Gitlab Org / MockProject2',
- value: 'MockProject2',
- url: 'project/2',
- },
- {
- category: 'Help',
- html_id: 'autocomplete-Help-3',
- label: 'GitLab Help',
- url: 'help/gitlab',
- },
-];
-
-export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
- {
- category: 'Groups',
- data: [
- {
- category: 'Groups',
- html_id: 'autocomplete-Groups-1',
-
- id: 1,
- label: 'Gitlab Org / MockGroup1',
- value: 'MockGroup1',
- url: 'group/1',
- },
- ],
- },
- {
- category: 'Projects',
- data: [
- {
- category: 'Projects',
- html_id: 'autocomplete-Projects-0',
-
- id: 1,
- label: 'Gitlab Org / MockProject1',
- value: 'MockProject1',
- url: 'project/1',
- },
- {
- category: 'Projects',
- html_id: 'autocomplete-Projects-2',
-
- id: 2,
- label: 'Gitlab Org / MockProject2',
- value: 'MockProject2',
- url: 'project/2',
- },
- ],
- },
- {
- category: 'Help',
- data: [
- {
- category: 'Help',
- html_id: 'autocomplete-Help-3',
-
- label: 'GitLab Help',
- url: 'help/gitlab',
- },
- ],
- },
-];
-
-export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [
- {
- category: 'Groups',
- html_id: 'autocomplete-Groups-1',
- id: 1,
- label: 'Gitlab Org / MockGroup1',
- value: 'MockGroup1',
- url: 'group/1',
- },
- {
- category: 'Projects',
- html_id: 'autocomplete-Projects-0',
- id: 1,
- label: 'Gitlab Org / MockProject1',
- value: 'MockProject1',
- url: 'project/1',
- },
- {
- category: 'Projects',
- html_id: 'autocomplete-Projects-2',
- id: 2,
- label: 'Gitlab Org / MockProject2',
- value: 'MockProject2',
- url: 'project/2',
- },
- {
- category: 'Help',
- html_id: 'autocomplete-Help-3',
- label: 'GitLab Help',
- url: 'help/gitlab',
- },
-];
-
-export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [
- {
- category: 'Help',
- data: [
- {
- html_id: 'autocomplete-Help-1',
- category: 'Help',
- label: 'Rake Tasks Help',
- url: '/help/raketasks/index',
- },
- {
- html_id: 'autocomplete-Help-2',
- category: 'Help',
- label: 'System Hooks Help',
- url: '/help/system_hooks/system_hooks',
- },
- ],
- },
-];
-
-export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP = [
- {
- category: 'Settings',
- data: [
- {
- html_id: 'autocomplete-Settings-0',
- category: 'Settings',
- label: 'User settings',
- url: '/-/profile',
- },
- {
- html_id: 'autocomplete-Settings-3',
- category: 'Settings',
- label: 'Admin Section',
- url: '/admin',
- },
- ],
- },
- {
- category: 'Help',
- data: [
- {
- html_id: 'autocomplete-Help-1',
- category: 'Help',
- label: 'Rake Tasks Help',
- url: '/help/raketasks/index',
- },
- {
- html_id: 'autocomplete-Help-2',
- category: 'Help',
- label: 'System Hooks Help',
- url: '/help/system_hooks/system_hooks',
- },
- ],
- },
-];
-
-export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2 = [
- {
- category: 'Groups',
- data: [
- {
- html_id: 'autocomplete-Groups-0',
- category: 'Groups',
- id: 148,
- label: 'Jashkenas / Test Subgroup / test-subgroup',
- url: '/jashkenas/test-subgroup/test-subgroup',
- avatar_url: '',
- },
- {
- html_id: 'autocomplete-Groups-1',
- category: 'Groups',
- id: 147,
- label: 'Jashkenas / Test Subgroup',
- url: '/jashkenas/test-subgroup',
- avatar_url: '',
- },
- ],
- },
- {
- category: 'Projects',
- data: [
- {
- html_id: 'autocomplete-Projects-2',
- category: 'Projects',
- id: 1,
- value: 'Gitlab Test',
- label: 'Gitlab Org / Gitlab Test',
- url: '/gitlab-org/gitlab-test',
- avatar_url: '/uploads/-/system/project/avatar/1/icons8-gitlab-512.png',
- },
- ],
- },
-];
diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js
deleted file mode 100644
index 95a619ebeca..00000000000
--- a/spec/frontend/header_search/store/actions_spec.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'helpers/vuex_action_helper';
-import * as actions from '~/header_search/store/actions';
-import * as types from '~/header_search/store/mutation_types';
-import initState from '~/header_search/store/state';
-import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import {
- MOCK_SEARCH,
- MOCK_AUTOCOMPLETE_OPTIONS_RES,
- MOCK_AUTOCOMPLETE_PATH,
- MOCK_PROJECT,
- MOCK_SEARCH_CONTEXT,
- MOCK_SEARCH_PATH,
- MOCK_MR_PATH,
- MOCK_ISSUE_PATH,
-} from '../mock_data';
-
-jest.mock('~/alert');
-
-describe('Header Search Store Actions', () => {
- let state;
- let mock;
-
- const createState = (initialState) =>
- initState({
- searchPath: MOCK_SEARCH_PATH,
- issuesPath: MOCK_ISSUE_PATH,
- mrPath: MOCK_MR_PATH,
- autocompletePath: MOCK_AUTOCOMPLETE_PATH,
- searchContext: MOCK_SEARCH_CONTEXT,
- ...initialState,
- });
-
- afterEach(() => {
- state = null;
- mock.restore();
- });
-
- describe.each`
- axiosMock | type | expectedMutations
- ${{ method: 'onGet', code: HTTP_STATUS_OK, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]}
- ${{ method: 'onGet', code: HTTP_STATUS_INTERNAL_SERVER_ERROR, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]}
- `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations }) => {
- describe(`on ${type}`, () => {
- beforeEach(() => {
- state = createState({});
- mock = new MockAdapter(axios);
- mock[axiosMock.method]().reply(axiosMock.code, axiosMock.res);
- });
- it(`should dispatch the correct mutations`, () => {
- return testAction({
- action: actions.fetchAutocompleteOptions,
- state,
- expectedMutations,
- });
- });
- });
- });
-
- describe.each`
- project | ref | fetchType | expectedPath
- ${null} | ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}`}
- ${MOCK_PROJECT} | ${null} | ${'generic'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&filter=generic`}
- ${null} | ${MOCK_PROJECT.id} | ${'generic'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_ref=${MOCK_PROJECT.id}&filter=generic`}
- ${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${'search'} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}&filter=search`}
- `('autocompleteQuery', ({ project, ref, fetchType, expectedPath }) => {
- describe(`when project is ${project?.name} and project ref is ${ref}`, () => {
- beforeEach(() => {
- state = createState({
- search: MOCK_SEARCH,
- searchContext: {
- project,
- ref,
- },
- });
- });
-
- it(`should return ${expectedPath}`, () => {
- expect(actions.autocompleteQuery({ state, fetchType })).toBe(expectedPath);
- });
- });
- });
-
- describe('clearAutocomplete', () => {
- beforeEach(() => {
- state = createState({});
- });
-
- it('calls the CLEAR_AUTOCOMPLETE mutation', () => {
- return testAction({
- action: actions.clearAutocomplete,
- state,
- expectedMutations: [{ type: types.CLEAR_AUTOCOMPLETE }],
- });
- });
- });
-
- describe('setSearch', () => {
- beforeEach(() => {
- state = createState({});
- });
-
- it('calls the SET_SEARCH mutation', () => {
- return testAction({
- action: actions.setSearch,
- payload: MOCK_SEARCH,
- state,
- expectedMutations: [{ type: types.SET_SEARCH, payload: MOCK_SEARCH }],
- });
- });
- });
-});
diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js
deleted file mode 100644
index 7a7a00178f1..00000000000
--- a/spec/frontend/header_search/store/getters_spec.js
+++ /dev/null
@@ -1,333 +0,0 @@
-import * as getters from '~/header_search/store/getters';
-import initState from '~/header_search/store/state';
-import {
- MOCK_USERNAME,
- MOCK_SEARCH_PATH,
- MOCK_ISSUE_PATH,
- MOCK_MR_PATH,
- MOCK_AUTOCOMPLETE_PATH,
- MOCK_SEARCH_CONTEXT,
- MOCK_DEFAULT_SEARCH_OPTIONS,
- MOCK_SCOPED_SEARCH_OPTIONS,
- MOCK_SCOPED_SEARCH_OPTIONS_DEF,
- MOCK_PROJECT,
- MOCK_GROUP,
- MOCK_ALL_PATH,
- MOCK_SEARCH,
- MOCK_AUTOCOMPLETE_OPTIONS,
- MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
- MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
-} from '../mock_data';
-
-describe('Header Search Store Getters', () => {
- let state;
-
- const createState = (initialState) => {
- state = initState({
- searchPath: MOCK_SEARCH_PATH,
- issuesPath: MOCK_ISSUE_PATH,
- mrPath: MOCK_MR_PATH,
- autocompletePath: MOCK_AUTOCOMPLETE_PATH,
- searchContext: MOCK_SEARCH_CONTEXT,
- ...initialState,
- });
- };
-
- afterEach(() => {
- state = null;
- });
-
- describe.each`
- group | project | scope | forSnippets | codeSearch | ref | expectedPath
- ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
- ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
- ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
- ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
- `('searchQuery', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
- beforeEach(() => {
- createState({
- searchContext: {
- group,
- project,
- scope,
- for_snippets: forSnippets,
- code_search: codeSearch,
- ref,
- },
- });
- state.search = MOCK_SEARCH;
- });
-
- it(`should return ${expectedPath}`, () => {
- expect(getters.searchQuery(state)).toBe(expectedPath);
- });
- });
- });
-
- describe.each`
- group | group_metadata | project | project_metadata | expectedPath
- ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH}
- ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
- ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ issues_path: 'project/path' }} | ${'project/path'}
- `('scopedIssuesPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
- describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
- beforeEach(() => {
- createState({
- searchContext: {
- group,
- group_metadata,
- project,
- project_metadata,
- },
- });
- });
-
- it(`should return ${expectedPath}`, () => {
- expect(getters.scopedIssuesPath(state)).toBe(expectedPath);
- });
- });
- });
-
- describe.each`
- group | group_metadata | project | project_metadata | expectedPath
- ${null} | ${null} | ${null} | ${null} | ${MOCK_MR_PATH}
- ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
- ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ mr_path: 'project/path' }} | ${'project/path'}
- `('scopedMRPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
- describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
- beforeEach(() => {
- createState({
- searchContext: {
- group,
- group_metadata,
- project,
- project_metadata,
- },
- });
- });
-
- it(`should return ${expectedPath}`, () => {
- expect(getters.scopedMRPath(state)).toBe(expectedPath);
- });
- });
- });
-
- describe.each`
- group | project | scope | forSnippets | codeSearch | ref | expectedPath
- ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
- ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
- ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
- ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
- `('projectUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
- beforeEach(() => {
- createState({
- searchContext: {
- group,
- project,
- scope,
- for_snippets: forSnippets,
- code_search: codeSearch,
- ref,
- },
- });
- state.search = MOCK_SEARCH;
- });
-
- it(`should return ${expectedPath}`, () => {
- expect(getters.projectUrl(state)).toBe(expectedPath);
- });
- });
- });
-
- describe.each`
- group | project | scope | forSnippets | codeSearch | ref | expectedPath
- ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
- ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
- ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
- ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
- `('groupUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
- beforeEach(() => {
- createState({
- searchContext: {
- group,
- project,
- scope,
- for_snippets: forSnippets,
- code_search: codeSearch,
- ref,
- },
- });
- state.search = MOCK_SEARCH;
- });
-
- it(`should return ${expectedPath}`, () => {
- expect(getters.groupUrl(state)).toBe(expectedPath);
- });
- });
- });
-
- describe.each`
- group | project | scope | forSnippets | codeSearch | ref | expectedPath
- ${null} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${null} | ${null} | ${null} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&snippets=true`}
- ${null} | ${null} | ${null} | ${false} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&search_code=true`}
- ${null} | ${null} | ${null} | ${false} | ${false} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&repository_ref=test-branch`}
- ${MOCK_GROUP} | ${null} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${null} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${false} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${false} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${true} | ${true} | ${'test-branch'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues&snippets=true&search_code=true&repository_ref=test-branch`}
- `('allUrl', ({ group, project, scope, forSnippets, codeSearch, ref, expectedPath }) => {
- describe(`when group is ${group?.name}, project is ${project?.name}, scope is ${scope}, for_snippets is ${forSnippets}, code_search is ${codeSearch}, and ref is ${ref}`, () => {
- beforeEach(() => {
- createState({
- searchContext: {
- group,
- project,
- scope,
- for_snippets: forSnippets,
- code_search: codeSearch,
- ref,
- },
- });
- state.search = MOCK_SEARCH;
- });
-
- it(`should return ${expectedPath}`, () => {
- expect(getters.allUrl(state)).toBe(expectedPath);
- });
- });
- });
-
- describe('defaultSearchOptions', () => {
- const mockGetters = {
- scopedIssuesPath: MOCK_ISSUE_PATH,
- scopedMRPath: MOCK_MR_PATH,
- };
-
- beforeEach(() => {
- createState();
- window.gon.current_username = MOCK_USERNAME;
- });
-
- it('returns the correct array', () => {
- expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual(
- MOCK_DEFAULT_SEARCH_OPTIONS,
- );
- });
-
- it('returns the correct array if issues path is false', () => {
- mockGetters.scopedIssuesPath = undefined;
- expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual(
- MOCK_DEFAULT_SEARCH_OPTIONS.slice(2, MOCK_DEFAULT_SEARCH_OPTIONS.length),
- );
- });
- });
-
- describe('scopedSearchOptions', () => {
- const mockGetters = {
- projectUrl: MOCK_PROJECT.path,
- groupUrl: MOCK_GROUP.path,
- allUrl: MOCK_ALL_PATH,
- };
-
- beforeEach(() => {
- createState({
- searchContext: {
- project: MOCK_PROJECT,
- group: MOCK_GROUP,
- },
- });
- });
-
- it('returns the correct array', () => {
- expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual(
- MOCK_SCOPED_SEARCH_OPTIONS_DEF,
- );
- });
- });
-
- describe('autocompleteGroupedSearchOptions', () => {
- beforeEach(() => {
- createState();
- state.autocompleteOptions = MOCK_AUTOCOMPLETE_OPTIONS;
- });
-
- it('returns the correct grouped array', () => {
- expect(getters.autocompleteGroupedSearchOptions(state)).toStrictEqual(
- MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
- );
- });
- });
-
- describe.each`
- search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray
- ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS}
- ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS}
- ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
- ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
- ${1} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
- ${'('} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
- ${'t'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
- ${'te'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
- ${'tes'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
- `(
- 'searchOptions',
- ({
- search,
- defaultSearchOptions,
- scopedSearchOptions,
- autocompleteGroupedSearchOptions,
- expectedArray,
- }) => {
- describe(`when search is ${search} and the defaultSearchOptions${
- defaultSearchOptions.length ? '' : ' do not'
- } exist, scopedSearchOptions${
- scopedSearchOptions.length ? '' : ' do not'
- } exist, and autocompleteGroupedSearchOptions${
- autocompleteGroupedSearchOptions.length ? '' : ' do not'
- } exist`, () => {
- const mockGetters = {
- defaultSearchOptions,
- scopedSearchOptions,
- autocompleteGroupedSearchOptions,
- };
-
- beforeEach(() => {
- createState();
- state.search = search;
- });
-
- it(`should return the correct combined array`, () => {
- expect(getters.searchOptions(state, mockGetters)).toStrictEqual(expectedArray);
- });
- });
- },
- );
-});
diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js
deleted file mode 100644
index e3c15ded948..00000000000
--- a/spec/frontend/header_search/store/mutations_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import * as types from '~/header_search/store/mutation_types';
-import mutations from '~/header_search/store/mutations';
-import createState from '~/header_search/store/state';
-import {
- MOCK_SEARCH,
- MOCK_AUTOCOMPLETE_OPTIONS_RES,
- MOCK_AUTOCOMPLETE_OPTIONS,
-} from '../mock_data';
-
-describe('Header Search Store Mutations', () => {
- let state;
-
- beforeEach(() => {
- state = createState({});
- });
-
- describe('REQUEST_AUTOCOMPLETE', () => {
- it('sets loading to true and empties autocompleteOptions array', () => {
- mutations[types.REQUEST_AUTOCOMPLETE](state);
-
- expect(state.loading).toBe(true);
- expect(state.autocompleteOptions).toStrictEqual([]);
- expect(state.autocompleteError).toBe(false);
- });
- });
-
- describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => {
- it('sets loading to false and then formats and sets the autocompleteOptions array', () => {
- mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES);
-
- expect(state.loading).toBe(false);
- expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS);
- expect(state.autocompleteError).toBe(false);
- });
- });
-
- describe('RECEIVE_AUTOCOMPLETE_ERROR', () => {
- it('sets loading to false and empties autocompleteOptions array', () => {
- mutations[types.RECEIVE_AUTOCOMPLETE_ERROR](state);
-
- expect(state.loading).toBe(false);
- expect(state.autocompleteOptions).toStrictEqual([]);
- expect(state.autocompleteError).toBe(true);
- });
- });
-
- describe('CLEAR_AUTOCOMPLETE', () => {
- it('empties autocompleteOptions array', () => {
- mutations[types.CLEAR_AUTOCOMPLETE](state);
-
- expect(state.autocompleteOptions).toStrictEqual([]);
- expect(state.autocompleteError).toBe(false);
- });
- });
-
- describe('SET_SEARCH', () => {
- it('sets search to value', () => {
- mutations[types.SET_SEARCH](state, MOCK_SEARCH);
-
- expect(state.search).toBe(MOCK_SEARCH);
- });
- });
-});
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
deleted file mode 100644
index 13c11863443..00000000000
--- a/spec/frontend/header_spec.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import htmlOpenIssue from 'test_fixtures/issues/open-issue.html';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import initTodoToggle, { initNavUserDropdownTracking } from '~/header';
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-
-// TODO: Remove this with the removal of the old navigation.
-// See https://gitlab.com/groups/gitlab-org/-/epics/11875.
-//
-// This and ~/header will be removed. These tests no longer work due to the
-// corresponding fixtures changing for
-// https://gitlab.com/gitlab-org/gitlab/-/issues/420121.
-// eslint-disable-next-line jest/no-disabled-tests
-describe.skip('Header', () => {
- describe('Todos notification', () => {
- const todosPendingCount = '.js-todos-count';
-
- function isTodosCountHidden() {
- return document.querySelector(todosPendingCount).classList.contains('hidden');
- }
-
- function triggerToggle(newCount) {
- const event = new CustomEvent('todo:toggle', {
- detail: {
- count: newCount,
- },
- });
-
- document.dispatchEvent(event);
- }
-
- beforeEach(() => {
- initTodoToggle();
- setHTMLFixture(htmlOpenIssue);
- });
-
- afterEach(() => {
- resetHTMLFixture();
- });
-
- it('should update todos-count after receiving the todo:toggle event', () => {
- triggerToggle(5);
-
- expect(document.querySelector(todosPendingCount).textContent).toEqual('5');
- });
-
- it('should hide todos-count when it is 0', () => {
- triggerToggle(0);
-
- expect(isTodosCountHidden()).toEqual(true);
- });
-
- it('should show todos-count when it is more than 0', () => {
- triggerToggle(10);
-
- expect(isTodosCountHidden()).toEqual(false);
- });
-
- describe('when todos-count is 1000', () => {
- beforeEach(() => {
- triggerToggle(1000);
- });
-
- it('should show todos-count', () => {
- expect(isTodosCountHidden()).toEqual(false);
- });
-
- it('should show 99+ for todos-count', () => {
- expect(document.querySelector(todosPendingCount).textContent).toEqual('99+');
- });
- });
- });
-
- describe('Track user dropdown open', () => {
- let trackingSpy;
-
- beforeEach(() => {
- setHTMLFixture(`
- <li class="js-nav-user-dropdown">
- <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a>
- </li>`);
-
- trackingSpy = mockTracking(
- '_category_',
- document.querySelector('.js-nav-user-dropdown').element,
- jest.spyOn,
- );
- document.body.dataset.page = 'some:page';
-
- initNavUserDropdownTracking();
- });
-
- afterEach(() => {
- unmockTracking();
- resetHTMLFixture();
- });
-
- it('sends a tracking event when the dropdown is opened and contains Buy Pipeline minutes link', () => {
- const event = new CustomEvent('shown.bs.dropdown');
- document.querySelector('.js-nav-user-dropdown').dispatchEvent(event);
-
- expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_buy_ci_minutes', {
- label: 'free',
- property: 'user_dropdown',
- });
- });
- });
-});
diff --git a/spec/frontend/ide/components/ide_sidebar_nav_spec.js b/spec/frontend/ide/components/ide_sidebar_nav_spec.js
index 4ee24f63f76..d89891bdd41 100644
--- a/spec/frontend/ide/components/ide_sidebar_nav_spec.js
+++ b/spec/frontend/ide/components/ide_sidebar_nav_spec.js
@@ -45,7 +45,6 @@ describe('ide/components/ide_sidebar_nav', () => {
title: button.attributes('title'),
ariaLabel: button.attributes('aria-label'),
classes: button.classes(),
- qaSelector: button.attributes('data-qa-selector'),
icon: button.findComponent(GlIcon).props('name'),
tooltip: getBinding(button.element, 'tooltip').value,
};
@@ -75,7 +74,6 @@ describe('ide/components/ide_sidebar_nav', () => {
title: tab.title,
ariaLabel: tab.title,
classes: ['ide-sidebar-link', ...classes, ...(classesObj[index] || [])],
- qaSelector: `${tab.title.toLowerCase()}_tab_button`,
icon: tab.icon,
tooltip: {
container: 'body',
diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js
index fe392a64013..eb51faaaa16 100644
--- a/spec/frontend/ide/components/ide_status_bar_spec.js
+++ b/spec/frontend/ide/components/ide_status_bar_spec.js
@@ -1,4 +1,4 @@
-import _ from 'lodash';
+import { clone } from 'lodash';
import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import IdeStatusBar from '~/ide/components/ide_status_bar.vue';
@@ -28,7 +28,7 @@ describe('IdeStatusBar component', () => {
currentProjectId: TEST_PROJECT_ID,
projects: {
...store.state.projects,
- [TEST_PROJECT_ID]: _.clone(projectData),
+ [TEST_PROJECT_ID]: clone(projectData),
},
...state,
});
@@ -100,7 +100,7 @@ describe('IdeStatusBar component', () => {
currentMergeRequestId: TEST_MERGE_REQUEST_ID,
projects: {
[TEST_PROJECT_ID]: {
- ..._.clone(projectData),
+ ...clone(projectData),
mergeRequests: {
[TEST_MERGE_REQUEST_ID]: {
web_url: TEST_MERGE_REQUEST_URL,
diff --git a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js
index 56e62829971..174e62550d5 100644
--- a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js
+++ b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue';
@@ -37,31 +37,26 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
});
describe('with a tab', () => {
- let fakeView;
- let extensionTabs;
-
- beforeEach(() => {
- const FakeComponent = Vue.component(fakeComponentName, {
- render: () => null,
- });
-
- fakeView = {
- name: fakeComponentName,
- keepAlive: true,
- component: FakeComponent,
- };
-
- extensionTabs = [
- {
- show: true,
- title: fakeComponentName,
- views: [fakeView],
- icon: 'text-description',
- buttonClasses: ['button-class-1', 'button-class-2'],
- },
- ];
+ const FakeComponent = Vue.component(fakeComponentName, {
+ render: () => null,
});
+ const fakeView = {
+ name: fakeComponentName,
+ keepAlive: true,
+ component: FakeComponent,
+ };
+
+ const extensionTabs = [
+ {
+ show: true,
+ title: fakeComponentName,
+ views: [fakeView],
+ icon: 'text-description',
+ buttonClasses: ['button-class-1', 'button-class-2'],
+ },
+ ];
+
describe.each`
side
${'left'}
@@ -79,10 +74,6 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
expect(findSidebarNav().props('side')).toBe(side);
});
- it('nothing is dispatched', () => {
- expect(store.dispatch).not.toHaveBeenCalled();
- });
-
it('when sidebar emits open, dispatch open', () => {
const view = 'lorem-view';
@@ -98,6 +89,13 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
});
});
+ describe('when side bar is rendered initially', () => {
+ it('nothing is dispatched', () => {
+ createComponent({ extensionTabs });
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+ });
+
describe.each`
isOpen
${true}
@@ -125,25 +123,15 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => {
});
describe('with initOpenView that does not exist', () => {
- beforeEach(async () => {
- createComponent({ extensionTabs, initOpenView: 'does-not-exist' });
-
- await nextTick();
- });
-
it('nothing is dispatched', () => {
+ createComponent({ extensionTabs, initOpenView: 'does-not-exist' });
expect(store.dispatch).not.toHaveBeenCalled();
});
});
describe('with initOpenView that does exist', () => {
- beforeEach(async () => {
- createComponent({ extensionTabs, initOpenView: fakeView.name });
-
- await nextTick();
- });
-
it('dispatches open with view on create', () => {
+ createComponent({ extensionTabs, initOpenView: fakeView.name });
expect(store.dispatch).toHaveBeenCalledWith('rightPane/open', fakeView);
});
});
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index 9c11ae9334b..9d8b3b1d32a 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -8,7 +8,7 @@ import JobsList from '~/ide/components/jobs/list.vue';
import List from '~/ide/components/pipelines/list.vue';
import EmptyState from '~/ide/components/pipelines/empty_state.vue';
import IDEServices from '~/ide/services';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
Vue.use(Vuex);
diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js
index ead609421b7..3dd9ae1285d 100644
--- a/spec/frontend/ide/components/repo_commit_section_spec.js
+++ b/spec/frontend/ide/components/repo_commit_section_spec.js
@@ -71,11 +71,8 @@ describe('RepoCommitSection', () => {
createComponent();
});
- it('renders no changes text', () => {
- expect(wrapper.findComponent(EmptyState).text().trim()).toContain('No changes');
- expect(wrapper.findComponent(EmptyState).find('img').attributes('src')).toBe(
- TEST_NO_CHANGES_SVG,
- );
+ it('renders empty state component', () => {
+ expect(wrapper.findComponent(EmptyState).exists()).toBe(true);
});
});
diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js
index 6a5bedb0bbb..d7a16bec1c3 100644
--- a/spec/frontend/ide/init_gitlab_web_ide_spec.js
+++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js
@@ -40,6 +40,9 @@ const TEST_EDITOR_FONT_SRC_URL = 'http://gitlab.test/assets/gitlab-mono/GitLabMo
const TEST_EDITOR_FONT_FORMAT = 'woff2';
const TEST_EDITOR_FONT_FAMILY = 'GitLab Mono';
+const TEST_OAUTH_CLIENT_ID = 'oauth-client-id-123abc';
+const TEST_OAUTH_CALLBACK_URL = 'https://example.com/oauth_callback';
+
describe('ide/init_gitlab_web_ide', () => {
let resolveConfirm;
@@ -231,4 +234,29 @@ describe('ide/init_gitlab_web_ide', () => {
);
});
});
+
+ describe('when oauth info is in dataset', () => {
+ beforeEach(() => {
+ findRootElement().dataset.clientId = TEST_OAUTH_CLIENT_ID;
+ findRootElement().dataset.callbackUrl = TEST_OAUTH_CALLBACK_URL;
+
+ createSubject();
+ });
+
+ it('calls start with element', () => {
+ expect(start).toHaveBeenCalledTimes(1);
+ expect(start).toHaveBeenCalledWith(
+ findRootElement(),
+ expect.objectContaining({
+ auth: {
+ type: 'oauth',
+ clientId: TEST_OAUTH_CLIENT_ID,
+ callbackUrl: TEST_OAUTH_CALLBACK_URL,
+ protectRefreshToken: true,
+ },
+ httpHeaders: undefined,
+ }),
+ );
+ });
+ });
});
diff --git a/spec/frontend/ide/lib/gitlab_web_ide/get_oauth_config_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/get_oauth_config_spec.js
new file mode 100644
index 00000000000..3431068937f
--- /dev/null
+++ b/spec/frontend/ide/lib/gitlab_web_ide/get_oauth_config_spec.js
@@ -0,0 +1,16 @@
+import { getOAuthConfig } from '~/ide/lib/gitlab_web_ide/get_oauth_config';
+
+describe('~/ide/lib/gitlab_web_ide/get_oauth_config', () => {
+ it('returns undefined if no clientId found', () => {
+ expect(getOAuthConfig({})).toBeUndefined();
+ });
+
+ it('returns auth config from dataset', () => {
+ expect(getOAuthConfig({ clientId: 'test-clientId', callbackUrl: 'test-callbackUrl' })).toEqual({
+ type: 'oauth',
+ clientId: 'test-clientId',
+ callbackUrl: 'test-callbackUrl',
+ protectRefreshToken: true,
+ });
+ });
+});
diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js
index b1f192e1d98..722f15db87d 100644
--- a/spec/frontend/ide/mock_data.js
+++ b/spec/frontend/ide/mock_data.js
@@ -14,6 +14,7 @@ export const projectData = {
commit: {
id: '123',
short_id: 'abc123de',
+ committed_date: '2019-09-13T15:37:30+0300',
},
},
},
diff --git a/spec/frontend/ide/mount_oauth_callback_spec.js b/spec/frontend/ide/mount_oauth_callback_spec.js
new file mode 100644
index 00000000000..6ac0b4e4615
--- /dev/null
+++ b/spec/frontend/ide/mount_oauth_callback_spec.js
@@ -0,0 +1,53 @@
+import { oauthCallback } from '@gitlab/web-ide';
+import { TEST_HOST } from 'helpers/test_constants';
+import { mountOAuthCallback } from '~/ide/mount_oauth_callback';
+
+jest.mock('@gitlab/web-ide');
+
+const TEST_USERNAME = 'gandalf.the.grey';
+const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/public/path';
+
+const TEST_OAUTH_CLIENT_ID = 'oauth-client-id-123abc';
+const TEST_OAUTH_CALLBACK_URL = 'https://example.com/oauth_callback';
+
+describe('~/ide/mount_oauth_callback', () => {
+ const createRootElement = () => {
+ const el = document.createElement('div');
+
+ el.id = 'ide';
+ el.dataset.clientId = TEST_OAUTH_CLIENT_ID;
+ el.dataset.callbackUrl = TEST_OAUTH_CALLBACK_URL;
+
+ document.body.append(el);
+ };
+
+ beforeEach(() => {
+ gon.current_username = TEST_USERNAME;
+ process.env.GITLAB_WEB_IDE_PUBLIC_PATH = TEST_GITLAB_WEB_IDE_PUBLIC_PATH;
+
+ createRootElement();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('calls oauthCallback', () => {
+ expect(oauthCallback).not.toHaveBeenCalled();
+
+ mountOAuthCallback();
+
+ expect(oauthCallback).toHaveBeenCalledTimes(1);
+ expect(oauthCallback).toHaveBeenCalledWith({
+ auth: {
+ type: 'oauth',
+ callbackUrl: TEST_OAUTH_CALLBACK_URL,
+ clientId: TEST_OAUTH_CLIENT_ID,
+ protectRefreshToken: true,
+ },
+ gitlabUrl: TEST_HOST,
+ baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
+ username: TEST_USERNAME,
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/editor/actions_spec.js b/spec/frontend/ide/stores/modules/editor/actions_spec.js
index f006018364b..e24d54ef6da 100644
--- a/spec/frontend/ide/stores/modules/editor/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/editor/actions_spec.js
@@ -8,7 +8,7 @@ describe('~/ide/stores/modules/editor/actions', () => {
it('commits with payload', () => {
const payload = {};
- testAction(actions.updateFileEditor, payload, {}, [
+ return testAction(actions.updateFileEditor, payload, {}, [
{ type: types.UPDATE_FILE_EDITOR, payload },
]);
});
@@ -18,7 +18,7 @@ describe('~/ide/stores/modules/editor/actions', () => {
it('commits with payload', () => {
const payload = 'path/to/file.txt';
- testAction(actions.removeFileEditor, payload, {}, [
+ return testAction(actions.removeFileEditor, payload, {}, [
{ type: types.REMOVE_FILE_EDITOR, payload },
]);
});
@@ -28,7 +28,7 @@ describe('~/ide/stores/modules/editor/actions', () => {
it('commits with payload', () => {
const payload = createTriggerRenamePayload('test', 'test123');
- testAction(actions.renameFileEditor, payload, {}, [
+ return testAction(actions.renameFileEditor, payload, {}, [
{ type: types.RENAME_FILE_EDITOR, payload },
]);
});
diff --git a/spec/frontend/import/details/components/bulk_import_details_app_spec.js b/spec/frontend/import/details/components/bulk_import_details_app_spec.js
index d32afb7ddcb..18b03ed9802 100644
--- a/spec/frontend/import/details/components/bulk_import_details_app_spec.js
+++ b/spec/frontend/import/details/components/bulk_import_details_app_spec.js
@@ -1,18 +1,30 @@
import { shallowMount } from '@vue/test-utils';
+import { getParameterValues } from '~/lib/utils/url_utility';
+
import BulkImportDetailsApp from '~/import/details/components/bulk_import_details_app.vue';
+jest.mock('~/lib/utils/url_utility');
+
describe('Bulk import details app', () => {
let wrapper;
+ const mockId = 151;
+
const createComponent = () => {
wrapper = shallowMount(BulkImportDetailsApp);
};
+ beforeEach(() => {
+ getParameterValues.mockReturnValueOnce([mockId]);
+ });
+
describe('template', () => {
it('renders heading', () => {
createComponent();
- expect(wrapper.find('h1').text()).toBe('GitLab Migration details');
+ const headingText = wrapper.find('h1').text();
+
+ expect(headingText).toBe(`Items that failed to be imported for ${mockId}`);
});
});
});
diff --git a/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js b/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js
new file mode 100644
index 00000000000..5f530f2c3be
--- /dev/null
+++ b/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink } from '@gitlab/ui';
+
+import ImportHistoryLink from '~/import_entities/import_groups/components/import_history_link.vue';
+
+describe('import history link', () => {
+ let wrapper;
+
+ const mockHistoryPath = '/import/history';
+
+ const createComponent = ({ props } = {}) => {
+ wrapper = shallowMount(ImportHistoryLink, {
+ propsData: {
+ historyPath: mockHistoryPath,
+ ...props,
+ },
+ });
+ };
+
+ const findGlLink = () => wrapper.findComponent(GlLink);
+
+ it('renders link with href', () => {
+ const mockId = 174;
+
+ createComponent({
+ props: {
+ id: mockId,
+ },
+ });
+
+ expect(findGlLink().text()).toBe('View details');
+ expect(findGlLink().attributes('href')).toBe('/import/history?bulk_import_id=174');
+ });
+});
diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
index 4fab22e316a..84f149b4dd5 100644
--- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
@@ -9,9 +9,12 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { createAlert } from '~/alert';
import { HTTP_STATUS_OK, HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
+
import { STATUSES } from '~/import_entities/constants';
-import { i18n, ROOT_NAMESPACE } from '~/import_entities/import_groups/constants';
+import { ROOT_NAMESPACE } from '~/import_entities/import_groups/constants';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
+import ImportStatus from '~/import_entities/import_groups/components/import_status.vue';
+import ImportHistoryLink from '~/import_entities/import_groups/components//import_history_link.vue';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
@@ -39,6 +42,7 @@ describe('import table', () => {
generateFakeEntry({ id: 1, status: STATUSES.NONE }),
generateFakeEntry({ id: 2, status: STATUSES.FINISHED }),
generateFakeEntry({ id: 3, status: STATUSES.NONE }),
+ generateFakeEntry({ id: 4, status: STATUSES.FINISHED, hasFailures: true }),
];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
@@ -64,6 +68,7 @@ describe('import table', () => {
const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]');
const findNewPathCol = () => wrapper.find('[data-test-id="new-path-col"]');
const findUnavailableFeaturesWarning = () => wrapper.findByTestId('unavailable-features-alert');
+ const findAllImportStatuses = () => wrapper.findAllComponents(ImportStatus);
const triggerSelectAllCheckbox = (checked = true) =>
wrapper.find('thead input[type=checkbox]').setChecked(checked);
@@ -144,7 +149,7 @@ describe('import table', () => {
});
await waitForPromises();
- expect(wrapper.findComponent(GlEmptyState).props().title).toBe(i18n.NO_GROUPS_FOUND);
+ expect(wrapper.findComponent(GlEmptyState).props().title).toBe('No groups found');
});
});
@@ -161,6 +166,38 @@ describe('import table', () => {
expect(wrapper.findAll('tbody tr')).toHaveLength(FAKE_GROUPS.length);
});
+ it('renders correct import status for each group', async () => {
+ const expectedStatuses = ['Not started', 'Complete', 'Not started', 'Partially completed'];
+
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: FAKE_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
+ });
+ await waitForPromises();
+
+ expect(findAllImportStatuses().wrappers.map((w) => w.text())).toEqual(expectedStatuses);
+ });
+
+ it('renders import history link for imports with id', async () => {
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: FAKE_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
+ });
+ await waitForPromises();
+
+ const importHistoryLinks = wrapper.findAllComponents(ImportHistoryLink);
+
+ expect(importHistoryLinks).toHaveLength(2);
+ expect(importHistoryLinks.at(0).props('id')).toBe(FAKE_GROUPS[1].id);
+ expect(importHistoryLinks.at(1).props('id')).toBe(FAKE_GROUPS[3].id);
+ });
+
it('correctly maintains root namespace as last import target', async () => {
createComponent({
bulkImportSourceGroups: () => ({
@@ -260,6 +297,42 @@ describe('import table', () => {
});
});
+ describe('when importGroup query is using stale data from LocalStorageCache', () => {
+ it('displays error', async () => {
+ const mockMutationWithProgressInvalid = jest.fn().mockResolvedValue({
+ __typename: 'ClientBulkImportSourceGroup',
+ id: 1,
+ lastImportTarget: { id: 1, targetNamespace: 'root', newName: 'group1' },
+ progress: {
+ __typename: 'ClientBulkImportProgress',
+ id: null,
+ status: 'failed',
+ message: '',
+ },
+ });
+
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: [FAKE_GROUP],
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
+ importGroups: mockMutationWithProgressInvalid,
+ });
+
+ await waitForPromises();
+ await findRowImportDropdownAtIndex(0).trigger('click');
+ await waitForPromises();
+
+ expect(mockMutationWithProgressInvalid).toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Importing the group failed.',
+ captureError: true,
+ error: expect.any(Error),
+ });
+ });
+ });
+
it('displays error if importing group fails', async () => {
createComponent({
bulkImportSourceGroups: () => ({
@@ -276,11 +349,11 @@ describe('import table', () => {
await findRowImportDropdownAtIndex(0).trigger('click');
await waitForPromises();
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: i18n.ERROR_IMPORT,
- }),
- );
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Importing the group failed.',
+ captureError: true,
+ error: expect.any(Error),
+ });
});
it('displays inline error if importing group reports rate limit', async () => {
@@ -302,7 +375,9 @@ describe('import table', () => {
await waitForPromises();
expect(createAlert).not.toHaveBeenCalled();
- expect(wrapper.find('tbody tr').text()).toContain(i18n.ERROR_TOO_MANY_REQUESTS);
+ expect(wrapper.find('tbody tr').text()).toContain(
+ 'Over six imports in one minute were attempted. Wait at least one minute and try again.',
+ );
});
it('displays inline error if backend returns validation error', async () => {
@@ -316,6 +391,7 @@ describe('import table', () => {
__typename: 'ClientBulkImportProgress',
id: null,
status: 'failed',
+ hasFailures: true,
message: mockValidationError,
},
});
diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
index 540c42a2854..0976a3294c2 100644
--- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
+++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
@@ -72,6 +72,7 @@ describe('Bulk import resolvers', () => {
progress: {
id: 'DEMO',
status: 'cached',
+ hasFailures: true,
},
};
localStorageCache.get.mockReturnValueOnce(CACHED_DATA);
@@ -234,7 +235,7 @@ describe('Bulk import resolvers', () => {
data: { updateImportStatus: statusInResponse },
} = await client.mutate({
mutation: updateImportStatusMutation,
- variables: { id, status: NEW_STATUS },
+ variables: { id, status: NEW_STATUS, hasFailures: true },
});
expect(statusInResponse).toStrictEqual({
@@ -242,6 +243,7 @@ describe('Bulk import resolvers', () => {
id,
message: null,
status: NEW_STATUS,
+ hasFailures: true,
});
});
});
diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js
index 7530e9fc348..edc2d1a2381 100644
--- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js
+++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js
@@ -1,7 +1,7 @@
import { STATUSES } from '~/import_entities/constants';
import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory';
-export const generateFakeEntry = ({ id, status, message, ...rest }) => ({
+export const generateFakeEntry = ({ id, status, hasFailures = false, message, ...rest }) => ({
__typename: clientTypenames.BulkImportSourceGroup,
webUrl: `https://fake.host/${id}`,
fullPath: `fake_group_${id}`,
@@ -19,6 +19,7 @@ export const generateFakeEntry = ({ id, status, message, ...rest }) => ({
__typename: clientTypenames.BulkImportProgress,
id,
status,
+ hasFailures,
message: message || '',
},
...rest,
diff --git a/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js
index b44a2767ad8..d1ecd47b498 100644
--- a/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js
+++ b/spec/frontend/import_entities/import_groups/graphql/services/local_storage_cache_spec.js
@@ -40,10 +40,11 @@ describe('Local storage cache', () => {
progress: {
id: JOB_ID,
status: 'original',
+ hasFailures: false,
},
});
- cache.updateStatusByJobId(JOB_ID, CHANGED_STATUS);
+ cache.updateStatusByJobId(JOB_ID, CHANGED_STATUS, true);
expect(storage.setItem).toHaveBeenCalledWith(
KEY,
@@ -52,6 +53,7 @@ describe('Local storage cache', () => {
progress: {
id: JOB_ID,
status: CHANGED_STATUS,
+ hasFailures: true,
},
},
}),
diff --git a/spec/frontend/import_entities/import_groups/utils_spec.js b/spec/frontend/import_entities/import_groups/utils_spec.js
index 2892c5c217b..3db57170ed3 100644
--- a/spec/frontend/import_entities/import_groups/utils_spec.js
+++ b/spec/frontend/import_entities/import_groups/utils_spec.js
@@ -5,7 +5,7 @@ const FINISHED_STATUSES = [STATUSES.FINISHED, STATUSES.FAILED, STATUSES.TIMEOUT]
const OTHER_STATUSES = Object.values(STATUSES).filter(
(status) => !FINISHED_STATUSES.includes(status),
);
-describe('gitlab migration status utils', () => {
+describe('Direct transfer status utils', () => {
describe('isFinished', () => {
it.each(FINISHED_STATUSES.map((s) => [s]))(
'reports group as finished when import status is %s',
diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
index 92d064846bd..056155a560f 100644
--- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
@@ -36,6 +36,7 @@ describe('ImportProjectsTable', () => {
.filter((w) => w.props().variant === 'confirm')
.at(0);
const findImportAllModal = () => wrapper.findComponent({ ref: 'importAllModal' });
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const importAllFn = jest.fn();
const importAllModalShowFn = jest.fn();
@@ -203,13 +204,13 @@ describe('ImportProjectsTable', () => {
describe('when paginatable is set to true', () => {
const initState = {
namespaces: [{ fullPath: 'path' }],
- pageInfo: { page: 1, hasNextPage: true },
+ pageInfo: { page: 1, hasNextPage: false },
repositories: [
{ importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE },
],
};
- describe('with hasNextPage true', () => {
+ describe('with hasNextPage false', () => {
beforeEach(() => {
createComponent({
state: initState,
@@ -217,26 +218,14 @@ describe('ImportProjectsTable', () => {
});
});
- it('does not call fetchRepos on mount', () => {
- expect(fetchReposFn).not.toHaveBeenCalled();
- });
-
- it('renders intersection observer component', () => {
- expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true);
- });
-
- it('calls fetchRepos when intersection observer appears', async () => {
- wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
-
- await nextTick();
-
- expect(fetchReposFn).toHaveBeenCalled();
+ it('does not render intersection observer component', () => {
+ expect(findIntersectionObserver().exists()).toBe(false);
});
});
- describe('with hasNextPage false', () => {
+ describe('with hasNextPage true', () => {
beforeEach(() => {
- initState.pageInfo.hasNextPage = false;
+ initState.pageInfo.hasNextPage = true;
createComponent({
state: initState,
@@ -244,8 +233,16 @@ describe('ImportProjectsTable', () => {
});
});
- it('does not render intersection observer component', () => {
- expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(false);
+ it('renders intersection observer component', () => {
+ expect(findIntersectionObserver().exists()).toBe(true);
+ });
+
+ it('calls fetchRepos again when intersection observer appears', async () => {
+ findIntersectionObserver().vm.$emit('appear');
+
+ await nextTick();
+
+ expect(fetchReposFn).toHaveBeenCalledTimes(2);
});
});
});
diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js
index 3b94db37801..918821dfa59 100644
--- a/spec/frontend/import_entities/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js
@@ -17,6 +17,7 @@ import {
SET_PAGE,
SET_FILTER,
SET_PAGE_CURSORS,
+ SET_HAS_NEXT_PAGE,
} from '~/import_entities/import_projects/store/mutation_types';
import state from '~/import_entities/import_projects/store/state';
import axios from '~/lib/utils/axios_utils';
@@ -143,6 +144,44 @@ describe('import_projects store actions', () => {
);
});
});
+
+ describe('when provider is BITBUCKET_SERVER', () => {
+ beforeEach(() => {
+ localState.provider = PROVIDERS.BITBUCKET_SERVER;
+ });
+
+ describe.each`
+ reposLength | expectedHasNextPage
+ ${0} | ${false}
+ ${12} | ${false}
+ ${20} | ${false}
+ ${25} | ${true}
+ `('when reposLength is $reposLength', ({ reposLength, expectedHasNextPage }) => {
+ beforeEach(() => {
+ payload.provider_repos = Array(reposLength).fill({});
+
+ mock.onGet(MOCK_ENDPOINT).reply(HTTP_STATUS_OK, payload);
+ });
+
+ it('commits SET_HAS_NEXT_PAGE', () => {
+ return testAction(
+ fetchRepos,
+ null,
+ localState,
+ [
+ { type: REQUEST_REPOS },
+ { type: SET_PAGE, payload: 1 },
+ { type: SET_HAS_NEXT_PAGE, payload: expectedHasNextPage },
+ {
+ type: RECEIVE_REPOS_SUCCESS,
+ payload: convertObjectPropsToCamelCase(payload, { deep: true }),
+ },
+ ],
+ [],
+ );
+ });
+ });
+ });
});
it('commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => {
diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
index 07d247630cc..90053f79bdf 100644
--- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
@@ -332,6 +332,16 @@ describe('import_projects store mutations', () => {
});
});
+ describe(`${types.SET_HAS_NEXT_PAGE}`, () => {
+ it('sets hasNextPage in pageInfo', () => {
+ const NEW_HAS_NEXT_PAGE = true;
+ state = { pageInfo: { hasNextPage: false } };
+
+ mutations[types.SET_HAS_NEXT_PAGE](state, NEW_HAS_NEXT_PAGE);
+ expect(state.pageInfo.hasNextPage).toBe(NEW_HAS_NEXT_PAGE);
+ });
+ });
+
describe(`${types.CANCEL_IMPORT_SUCCESS}`, () => {
const payload = { repoId: 1 };
diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js
index 95d15eb2c00..bf9a77074f4 100644
--- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js
+++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js
@@ -169,7 +169,7 @@ describe('DynamicField', () => {
expect(findGlFormInput().exists()).toBe(true);
expect(findGlFormInput().attributes()).toMatchObject({
type: 'text',
- id: 'service_project_url',
+ id: 'service-project_url',
name: 'service[project_url]',
placeholder: mockField.placeholder,
required: expect.any(String),
diff --git a/spec/frontend/integrations/edit/components/integration_form_actions_spec.js b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js
index e95e30a1899..d7ee31cc857 100644
--- a/spec/frontend/integrations/edit/components/integration_form_actions_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js
@@ -28,7 +28,7 @@ describe('IntegrationFormActions', () => {
const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal);
const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal);
const findResetButton = () => wrapper.findByTestId('reset-button');
- const findSaveButton = () => wrapper.findByTestId('save-button');
+ const findSaveButton = () => wrapper.findByTestId('save-changes-button');
const findTestButton = () => wrapper.findByTestId('test-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
diff --git a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
index a038b63d28c..08f758c1382 100644
--- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
@@ -1,11 +1,15 @@
import { GlFormCheckbox } from '@gitlab/ui';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+// eslint-disable-next-line no-restricted-imports
+import Vuex from 'vuex';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
+Vue.use(Vuex);
+
describe('JiraTriggerFields', () => {
let wrapper;
+ let store;
const defaultProps = {
initialTriggerCommit: false,
@@ -14,12 +18,16 @@ describe('JiraTriggerFields', () => {
};
const createComponent = (props, isInheriting = false) => {
- wrapper = mountExtended(JiraTriggerFields, {
- propsData: { ...defaultProps, ...props },
- computed: {
+ store = new Vuex.Store({
+ getters: {
isInheriting: () => isInheriting,
},
});
+
+ wrapper = mountExtended(JiraTriggerFields, {
+ propsData: { ...defaultProps, ...props },
+ store,
+ });
};
const findCommentSettings = () => wrapper.findByTestId('comment-settings');
diff --git a/spec/frontend/integrations/edit/components/trigger_field_spec.js b/spec/frontend/integrations/edit/components/trigger_field_spec.js
index b3d6784959f..1dad3b27618 100644
--- a/spec/frontend/integrations/edit/components/trigger_field_spec.js
+++ b/spec/frontend/integrations/edit/components/trigger_field_spec.js
@@ -1,12 +1,17 @@
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+// eslint-disable-next-line no-restricted-imports
+import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import { GlFormCheckbox, GlFormInput } from '@gitlab/ui';
import TriggerField from '~/integrations/edit/components/trigger_field.vue';
import { integrationTriggerEventTitles } from '~/integrations/constants';
+Vue.use(Vuex);
+
describe('TriggerField', () => {
let wrapper;
+ let store;
const defaultProps = {
event: { name: 'push_events' },
@@ -15,12 +20,16 @@ describe('TriggerField', () => {
const mockField = { name: 'push_channel' };
const createComponent = ({ props = {}, isInheriting = false } = {}) => {
- wrapper = shallowMount(TriggerField, {
- propsData: { ...defaultProps, ...props },
- computed: {
+ store = new Vuex.Store({
+ getters: {
isInheriting: () => isInheriting,
},
});
+
+ wrapper = shallowMount(TriggerField, {
+ propsData: { ...defaultProps, ...props },
+ store,
+ });
};
const findGlFormCheckbox = () => wrapper.findComponent(GlFormCheckbox);
diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
index defa02aefd2..97ac01e2f26 100644
--- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js
@@ -1,23 +1,32 @@
import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
+import Vuex from 'vuex';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { placeholderForType } from 'jh_else_ce/integrations/constants';
-
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
+Vue.use(Vuex);
+
describe('TriggerFields', () => {
let wrapper;
+ let store;
const defaultProps = {
type: 'slack',
};
const createComponent = (props, isInheriting = false) => {
- wrapper = mountExtended(TriggerFields, {
- propsData: { ...defaultProps, ...props },
- computed: {
+ store = new Vuex.Store({
+ getters: {
isInheriting: () => isInheriting,
},
});
+
+ wrapper = mountExtended(TriggerFields, {
+ propsData: { ...defaultProps, ...props },
+ store,
+ });
};
const findTriggerLabel = () => wrapper.findByTestId('trigger-fields-group').find('label');
diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
index 4136de75545..358d70d8117 100644
--- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
@@ -77,6 +77,16 @@ describe('InviteGroupsModal', () => {
const clickInviteButton = emitClickFromModal('invite-modal-submit');
const clickCancelButton = emitClickFromModal('invite-modal-cancel');
+ describe('passes correct props to InviteModalBase', () => {
+ it('set accessLevel', () => {
+ createInviteGroupToProjectWrapper();
+
+ expect(findBase().props('accessLevels')).toMatchObject({
+ validRoles: propsData.accessLevels,
+ });
+ });
+ });
+
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
it('includes the correct type, and formatted intro text', () => {
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 19b7fad5fc8..ad3174b8946 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -128,6 +128,7 @@ describe('InviteMembersModal', () => {
});
const findModal = () => wrapper.findComponent(GlModal);
+ const findBase = () => wrapper.findComponent(InviteModalBase);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findEmptyInvitesAlert = () => wrapper.findByTestId('empty-invites-alert');
const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error');
@@ -168,6 +169,22 @@ describe('InviteMembersModal', () => {
await nextTick();
};
+ describe('passes correct props to InviteModalBase', () => {
+ it('set defaultMemberRoleId', () => {
+ createInviteMembersToProjectWrapper();
+
+ expect(findBase().props('defaultMemberRoleId')).toBeNull();
+ });
+
+ it('set accessLevel', () => {
+ createInviteMembersToProjectWrapper();
+
+ expect(findBase().props('accessLevels')).toMatchObject({
+ validRoles: propsData.accessLevels,
+ });
+ });
+ });
+
describe('rendering with tracking considerations', () => {
describe('when inviting to a project', () => {
describe('when inviting members', () => {
diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
index 58c40a49b3c..f14d24538d8 100644
--- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -4,7 +4,6 @@ import InviteMembersTrigger from '~/invite_members/components/invite_members_tri
import eventHub from '~/invite_members/event_hub';
import {
TRIGGER_ELEMENT_BUTTON,
- TRIGGER_DEFAULT_QA_SELECTOR,
TRIGGER_ELEMENT_WITH_EMOJI,
TRIGGER_ELEMENT_DROPDOWN_WITH_EMOJI,
TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN,
@@ -66,18 +65,6 @@ describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => {
expect(findButton().text()).toBe(displayText);
});
-
- it('uses the default qa selector value', () => {
- createComponent();
-
- expect(findButton().attributes('data-qa-selector')).toBe(TRIGGER_DEFAULT_QA_SELECTOR);
- });
-
- it('sets the qa selector value', () => {
- createComponent({ qaSelector: '_qaSelector_' });
-
- expect(findButton().attributes('data-qa-selector')).toBe('_qaSelector_');
- });
});
describe('clicking the link', () => {
diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js
index e70c83a424e..c26d1d921a5 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -1,5 +1,5 @@
import {
- GlFormSelect,
+ GlCollapsibleListbox,
GlDatepicker,
GlFormGroup,
GlLink,
@@ -7,9 +7,14 @@ import {
GlModal,
GlIcon,
} from '@gitlab/ui';
+import { nextTick } from 'vue';
import { stubComponent } from 'helpers/stub_component';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ mountExtended,
+ shallowMountExtended,
+ extendedWrapper,
+} from 'helpers/vue_test_utils_helper';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
import ContentTransition from '~/vue_shared/components/content_transition.vue';
@@ -31,7 +36,7 @@ describe('InviteModalBase', () => {
? {}
: {
ContentTransition,
- GlFormSelect: true,
+ GlCollapsibleListbox: true,
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback'],
@@ -41,6 +46,7 @@ describe('InviteModalBase', () => {
wrapper = mountFn(InviteModalBase, {
propsData: {
...propsData,
+ accessLevels: { validRoles: propsData.accessLevels },
...props,
},
stubs: {
@@ -54,8 +60,8 @@ describe('InviteModalBase', () => {
});
};
- const findFormSelect = () => wrapper.findComponent(GlFormSelect);
- const findFormSelectOptions = () => findFormSelect().findAllComponents('option');
+ const findCollapsibleListbox = () => extendedWrapper(wrapper.findComponent(GlCollapsibleListbox));
+ const findCollapsibleListboxOptions = () => findCollapsibleListbox().findAllByRole('option');
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink);
const findIcon = () => wrapper.findComponent(GlIcon);
@@ -91,7 +97,6 @@ describe('InviteModalBase', () => {
const actionButton = findActionButton();
expect(actionButton.text()).toBe(INVITE_BUTTON_TEXT);
- expect(actionButton.attributes('data-qa-selector')).toBe('invite_button');
expect(actionButton.props()).toMatchObject({
variant: 'confirm',
@@ -103,17 +108,47 @@ describe('InviteModalBase', () => {
describe('rendering the access levels dropdown', () => {
beforeEach(() => {
createComponent({
+ props: { isLoadingRoles: true },
mountFn: mountExtended,
});
});
+ it('passes `isLoadingRoles` prop to the dropdown', () => {
+ expect(findCollapsibleListbox().props('loading')).toBe(true);
+ });
+
it('sets the default dropdown text to the default access level name', () => {
- expect(findFormSelect().exists()).toBe(true);
- expect(findFormSelect().element.value).toBe('10');
+ expect(findCollapsibleListbox().exists()).toBe(true);
+ const option = findCollapsibleListbox().find('[aria-selected]');
+ expect(option.text()).toBe('Reporter');
+ });
+
+ it('updates the selection base on changes in the dropdown', async () => {
+ wrapper.setProps({ accessLevels: { validRoles: [] } });
+ expect(findCollapsibleListbox().props('selected')).not.toHaveLength(0);
+ await nextTick();
+
+ expect(findCollapsibleListboxOptions()).toHaveLength(0);
+ expect(findCollapsibleListbox().props('selected')).toHaveLength(0);
+ });
+
+ it('reset the dropdown to the default option', async () => {
+ const developerOption = findCollapsibleListboxOptions().at(2);
+ await developerOption.trigger('click');
+
+ let option;
+ option = findCollapsibleListbox().find('[aria-selected]');
+ expect(option.text()).toBe('Developer');
+
+ // Reset the dropdown by clicking cancel button
+ await findCancelButton().trigger('click');
+
+ option = findCollapsibleListbox().find('[aria-selected]');
+ expect(option.text()).toBe('Reporter');
});
it('renders dropdown items for each accessLevel', () => {
- expect(findFormSelectOptions()).toHaveLength(5);
+ expect(findCollapsibleListboxOptions()).toHaveLength(5);
});
});
@@ -211,7 +246,7 @@ describe('InviteModalBase', () => {
it('renders correct blocks', () => {
expect(findIcon().exists()).toBe(false);
expect(findDisabledInput().exists()).toBe(false);
- expect(findFormSelect().exists()).toBe(true);
+ expect(findCollapsibleListbox().exists()).toBe(true);
expect(findDatepicker().exists()).toBe(true);
expect(wrapper.findComponent(GlModal).text()).toMatch(textRegex);
});
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 a4b8a8b0197..a2b21367388 100644
--- a/spec/frontend/invite_members/components/members_token_select_spec.js
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -6,23 +6,32 @@ import waitForPromises from 'helpers/wait_for_promises';
import * as UserApi from '~/api/user_api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { VALID_TOKEN_BACKGROUND, INVALID_TOKEN_BACKGROUND } from '~/invite_members/constants';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
const label = 'testgroup';
const placeholder = 'Search for a member';
+const rootGroupId = '31';
const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' };
const allUsers = [user1, user2];
+const handleEnterSpy = jest.fn();
-const createComponent = (props) => {
+const createComponent = (props = {}, glFeatures = {}) => {
return shallowMount(MembersTokenSelect, {
propsData: {
ariaLabelledby: label,
invalidMembers: {},
placeholder,
+ rootGroupId,
...props,
},
+ provide: { glFeatures },
stubs: {
- GlTokenSelector: stubComponent(GlTokenSelector),
+ GlTokenSelector: stubComponent(GlTokenSelector, {
+ methods: {
+ handleEnter: handleEnterSpy,
+ },
+ }),
},
});
};
@@ -84,23 +93,11 @@ describe('MembersTokenSelect', () => {
wrapper = createComponent();
});
- describe('when input is focused for the first time (modal auto-focus)', () => {
- it('does not call the API', async () => {
- findTokenSelector().vm.$emit('focus');
-
- await waitForPromises();
-
- expect(UserApi.getUsers).not.toHaveBeenCalled();
- });
- });
-
describe('when input is manually focused', () => {
it('calls the API and sets dropdown items as request result', async () => {
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('focus');
- tokenSelector.vm.$emit('blur');
- tokenSelector.vm.$emit('focus');
await waitForPromises();
@@ -173,6 +170,29 @@ describe('MembersTokenSelect', () => {
});
});
});
+
+ describe('when API search fails', () => {
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'captureException');
+ jest.spyOn(UserApi, 'getUsers').mockRejectedValue('error');
+ });
+
+ it('reports to sentry', async () => {
+ tokenSelector.vm.$emit('text-input', 'Den');
+
+ await waitForPromises();
+
+ expect(Sentry.captureException).toHaveBeenCalledWith('error');
+ });
+ });
+
+ it('allows tab to function as enter', () => {
+ tokenSelector.vm.$emit('text-input', 'username');
+
+ tokenSelector.vm.$emit('keydown', new KeyboardEvent('keydown', { key: 'Tab' }));
+
+ expect(handleEnterSpy).toHaveBeenCalled();
+ });
});
describe('when user is selected', () => {
@@ -215,31 +235,45 @@ describe('MembersTokenSelect', () => {
});
});
- describe('when component is mounted for a group using a saml provider', () => {
+ describe('when component is mounted for a group using a SAML provider', () => {
const searchParam = 'name';
- const samlProviderId = 123;
- let resolveApiRequest;
beforeEach(() => {
- jest.spyOn(UserApi, 'getUsers').mockImplementation(
- () =>
- new Promise((resolve) => {
- resolveApiRequest = resolve;
- }),
- );
+ jest.spyOn(UserApi, 'getGroupUsers').mockResolvedValue({ data: allUsers });
- wrapper = createComponent({ filterId: samlProviderId, usersFilter: 'saml_provider_id' });
+ wrapper = createComponent({ usersFilter: 'saml_provider_id' }, { groupUserSaml: true });
findTokenSelector().vm.$emit('text-input', searchParam);
});
- it('calls the API with the saml provider ID param', () => {
- resolveApiRequest({ data: allUsers });
-
- expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
+ it('calls the group API with correct parameters', () => {
+ expect(UserApi.getGroupUsers).toHaveBeenCalledWith(searchParam, rootGroupId, {
active: true,
- without_project_bots: true,
- saml_provider_id: samlProviderId,
+ include_saml_users: true,
+ include_service_accounts: true,
+ });
+ });
+ });
+
+ describe('when group_user_saml feature flag is disabled', () => {
+ describe('when component is mounted for a group using a SAML provider', () => {
+ const searchParam = 'name';
+ const samlProviderId = 123;
+
+ beforeEach(() => {
+ jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers });
+
+ wrapper = createComponent({ filterId: samlProviderId, usersFilter: 'saml_provider_id' });
+
+ findTokenSelector().vm.$emit('text-input', searchParam);
+ });
+
+ it('calls the API with the saml provider ID param', () => {
+ expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
+ active: true,
+ without_project_bots: true,
+ saml_provider_id: samlProviderId,
+ });
});
});
});
diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js
index 8cde13bf69c..0c0e669b894 100644
--- a/spec/frontend/invite_members/mock_data/member_modal.js
+++ b/spec/frontend/invite_members/mock_data/member_modal.js
@@ -40,6 +40,7 @@ export const user6 = {
export const postData = {
user_id: `${user1.id},${user2.id}`,
access_level: propsData.defaultAccessLevel,
+ member_role_id: null,
expires_at: undefined,
invite_source: inviteSource,
format: 'json',
@@ -47,6 +48,7 @@ export const postData = {
export const emailPostData = {
access_level: propsData.defaultAccessLevel,
+ member_role_id: null,
expires_at: undefined,
email: `${user3.name}`,
invite_source: inviteSource,
@@ -55,6 +57,7 @@ export const emailPostData = {
export const singleUserPostData = {
access_level: propsData.defaultAccessLevel,
+ member_role_id: null,
expires_at: undefined,
user_id: `${user1.id}`,
email: `${user3.name}`,
diff --git a/spec/frontend/invite_members/mock_data/modal_base.js b/spec/frontend/invite_members/mock_data/modal_base.js
index 565e8d4df1e..c44e890da3d 100644
--- a/spec/frontend/invite_members/mock_data/modal_base.js
+++ b/spec/frontend/invite_members/mock_data/modal_base.js
@@ -3,7 +3,7 @@ export const propsData = {
modalId: '_modal_id_',
name: '_name_',
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
- defaultAccessLevel: 10,
+ defaultAccessLevel: 20,
helpLink: 'https://example.com',
labelIntroText: '_label_intro_text_',
labelSearchField: '_label_search_field_',
diff --git a/spec/frontend/issuable/popover/components/mr_popover_spec.js b/spec/frontend/issuable/popover/components/mr_popover_spec.js
index 4ed783da853..80b04c05524 100644
--- a/spec/frontend/issuable/popover/components/mr_popover_spec.js
+++ b/spec/frontend/issuable/popover/components/mr_popover_spec.js
@@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import MRPopover from '~/issuable/popover/components/mr_popover.vue';
import mergeRequestQuery from '~/issuable/popover/queries/merge_request.query.graphql';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
describe('MR Popover', () => {
let wrapper;
diff --git a/spec/frontend/issues/dashboard/components/index_spec.js b/spec/frontend/issues/dashboard/components/index_spec.js
new file mode 100644
index 00000000000..51cb5c0acf6
--- /dev/null
+++ b/spec/frontend/issues/dashboard/components/index_spec.js
@@ -0,0 +1,18 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { mountIssuesDashboardApp } from '~/issues/dashboard';
+
+describe('IssueDashboardRoot', () => {
+ beforeEach(() => {
+ setHTMLFixture(
+ '<div class="js-issues-dashboard" data-has-issue-date-filter-feature="true"></div>',
+ );
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it('mounts without errors and vue warnings', async () => {
+ await expect(mountIssuesDashboardApp()).resolves.toBeTruthy();
+ });
+});
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 6bd952cd215..b432a29ee5c 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -19,6 +19,7 @@ import {
getIssuesCountsQueryResponse,
getIssuesQueryEmptyResponse,
getIssuesQueryResponse,
+ groupedFilteredTokens,
locationSearch,
setSortPreferenceMutationResponse,
setSortPreferenceMutationResponseWithErrors,
@@ -507,6 +508,13 @@ describe('CE IssuesListApp component', () => {
});
describe('filter tokens', () => {
+ it('groups url params of assignee and author', () => {
+ setWindowLocation(locationSearch);
+ wrapper = mountComponent({ provide: { glFeatures: { groupMultiSelectTokens: true } } });
+
+ expect(findIssuableList().props('initialFilterValue')).toEqual(groupedFilteredTokens);
+ });
+
it('is set from the url params', () => {
setWindowLocation(locationSearch);
wrapper = mountComponent();
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index b9a8bc171db..e387c924418 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -231,19 +231,33 @@ export const locationSearchWithSpecialValues = [
'health_status=None',
].join('&');
-export const filteredTokens = [
+const makeFilteredTokens = ({ grouped }) => [
{ type: FILTERED_SEARCH_TERM, value: { data: 'find issues', operator: 'undefined' } },
{ type: TOKEN_TYPE_AUTHOR, value: { data: 'homer', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } },
- { type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } },
- { type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } },
+ ...(grouped
+ ? [
+ { type: TOKEN_TYPE_AUTHOR, value: { data: ['marge'], operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_AUTHOR, value: { data: ['burns', 'smithers'], operator: OPERATOR_OR } },
+ ]
+ : [
+ { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } },
+ { type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } },
+ ]),
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lisa', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: '5', operator: OPERATOR_IS } },
- { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } },
- { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } },
- { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } },
- { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } },
+ ...(grouped
+ ? [
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: ['patty', 'selma'], operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: ['carl', 'lenny'], operator: OPERATOR_OR } },
+ ]
+ : [
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } },
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } },
+ { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } },
+ ]),
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 3', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 4', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_NOT } },
@@ -279,6 +293,9 @@ export const filteredTokens = [
{ type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: OPERATOR_NOT } },
];
+export const filteredTokens = makeFilteredTokens({ grouped: false });
+export const groupedFilteredTokens = makeFilteredTokens({ grouped: true });
+
export const filteredTokensWithSpecialValues = [
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: '123', operator: OPERATOR_IS } },
{ type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } },
diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js
index c14dcf96c98..e13a69b7444 100644
--- a/spec/frontend/issues/list/utils_spec.js
+++ b/spec/frontend/issues/list/utils_spec.js
@@ -5,6 +5,7 @@ import {
apiParamsWithSpecialValues,
filteredTokens,
filteredTokensWithSpecialValues,
+ groupedFilteredTokens,
locationSearch,
locationSearchWithSpecialValues,
urlParams,
@@ -19,6 +20,7 @@ import {
getInitialPageParams,
getSortKey,
getSortOptions,
+ groupMultiSelectFilterTokens,
isSortKey,
} from '~/issues/list/utils';
import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
@@ -163,3 +165,14 @@ describe('convertToSearchQuery', () => {
expect(convertToSearchQuery(filteredTokens)).toBe('find issues');
});
});
+
+describe('groupMultiSelectFilterTokens', () => {
+ it('groups multiSelect filter tokens with || and != operators', () => {
+ expect(
+ groupMultiSelectFilterTokens(filteredTokens, [
+ { type: 'assignee', multiSelect: true },
+ { type: 'author', multiSelect: true },
+ ]),
+ ).toEqual(groupedFilteredTokens);
+ });
+});
diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js
index 8999952c54c..f9ce7c20ad6 100644
--- a/spec/frontend/issues/show/components/app_spec.js
+++ b/spec/frontend/issues/show/components/app_spec.js
@@ -94,6 +94,10 @@ describe('Issuable output', () => {
axiosMock.onPut().reply(HTTP_STATUS_OK, putRequest);
});
+ afterEach(() => {
+ document.body.classList?.remove('issuable-sticky-header-visible');
+ });
+
describe('update', () => {
beforeEach(async () => {
await createComponent();
@@ -334,6 +338,29 @@ describe('Issuable output', () => {
});
},
);
+
+ describe('document body class', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { canUpdate: false } });
+ });
+
+ it('adds the css class to the document body', () => {
+ wrapper.findComponent(StickyHeader).vm.$emit('show');
+ expect(document.body.classList?.contains('issuable-sticky-header-visible')).toBe(true);
+ });
+
+ it('removes the css class from the document body', () => {
+ wrapper.findComponent(StickyHeader).vm.$emit('show');
+ wrapper.findComponent(StickyHeader).vm.$emit('hide');
+ expect(document.body.classList?.contains('issuable-sticky-header-visible')).toBe(false);
+ });
+
+ it('removes the css class from the document body when unmounting', () => {
+ wrapper.findComponent(StickyHeader).vm.$emit('show');
+ wrapper.vm.$destroy();
+ expect(document.body.classList?.contains('issuable-sticky-header-visible')).toBe(false);
+ });
+ });
});
describe('Composable description component', () => {
diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js
index d0c2a1a5f1b..33fd9d39feb 100644
--- a/spec/frontend/issues/show/components/header_actions_spec.js
+++ b/spec/frontend/issues/show/components/header_actions_spec.js
@@ -6,13 +6,13 @@ import {
GlModal,
GlButton,
} from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { mockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import {
STATUS_CLOSED,
@@ -132,11 +132,11 @@ describe('HeaderActions component', () => {
const findDesktopDropdownItems = () =>
findDesktopDropdown().findAllComponents(GlDisclosureDropdownItem);
const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
- const findReportAbuseButton = () => wrapper.find(`[data-testid="report-abuse-item"]`);
- const findNotificationWidget = () => wrapper.find(`[data-testid="notification-toggle"]`);
- const findLockIssueWidget = () => wrapper.find(`[data-testid="lock-issue-toggle"]`);
- const findCopyRefenceDropdownItem = () => wrapper.find(`[data-testid="copy-reference"]`);
- const findCopyEmailItem = () => wrapper.find(`[data-testid="copy-email"]`);
+ const findReportAbuseButton = () => wrapper.findByTestId('report-abuse-item');
+ const findNotificationWidget = () => wrapper.findByTestId('notification-toggle');
+ const findLockIssueWidget = () => wrapper.findByTestId('lock-issue-toggle');
+ const findCopyRefenceDropdownItem = () => wrapper.findByTestId('copy-reference');
+ const findCopyEmailItem = () => wrapper.findByTestId('copy-email');
const findModal = () => wrapper.findComponent(GlModal);
@@ -176,7 +176,7 @@ describe('HeaderActions component', () => {
window.gon.current_user_id = 1;
}
- return shallowMount(HeaderActions, {
+ return shallowMountExtended(HeaderActions, {
apolloProvider: createMockApollo(handlers),
store,
provide: {
@@ -625,6 +625,10 @@ describe('HeaderActions component', () => {
expect(toast).toHaveBeenCalledWith('Reference copied');
});
+
+ it('contains copy reference class', () => {
+ expect(findCopyRefenceDropdownItem().classes()).toContain('js-copy-reference');
+ });
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js
index efe89100e90..74c998bfc51 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js
@@ -6,6 +6,7 @@ import {
PREREQUISITES_DOC_LINK,
OAUTH_SELF_MANAGED_DOC_LINK,
SET_UP_INSTANCE_DOC_LINK,
+ JIRA_USER_REQUIREMENTS_DOC_LINK,
} from '~/jira_connect/subscriptions/constants';
import SetupInstructions from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue';
@@ -15,6 +16,7 @@ describe('SetupInstructions', () => {
const findPrerequisitesGlLink = () => wrapper.findAllComponents(GlLink).at(0);
const findOAuthGlLink = () => wrapper.findAllComponents(GlLink).at(1);
const findSetUpInstanceGlLink = () => wrapper.findAllComponents(GlLink).at(2);
+ const findJiraUserRequirementsGlLink = () => wrapper.findAllComponents(GlLink).at(3);
const findBackButton = () => wrapper.findAllComponents(GlButton).at(0);
const findNextButton = () => wrapper.findAllComponents(GlButton).at(1);
const findCheckboxAtIndex = (index) => wrapper.findAllComponents(GlFormCheckbox).at(index);
@@ -40,6 +42,12 @@ describe('SetupInstructions', () => {
expect(findSetUpInstanceGlLink().attributes('href')).toBe(SET_UP_INSTANCE_DOC_LINK);
});
+ it('renders "Jira user requirements" link to documentation', () => {
+ expect(findJiraUserRequirementsGlLink().attributes('href')).toBe(
+ JIRA_USER_REQUIREMENTS_DOC_LINK,
+ );
+ });
+
describe('NextButton', () => {
it('emits next event when clicked and all steps checked', async () => {
createComponent();
@@ -47,6 +55,7 @@ describe('SetupInstructions', () => {
findCheckboxAtIndex(0).vm.$emit('input', true);
findCheckboxAtIndex(1).vm.$emit('input', true);
findCheckboxAtIndex(2).vm.$emit('input', true);
+ findCheckboxAtIndex(3).vm.$emit('input', true);
await nextTick();
diff --git a/spec/frontend/kubernetes_dashboard/components/page_title_spec.js b/spec/frontend/kubernetes_dashboard/components/page_title_spec.js
new file mode 100644
index 00000000000..ee2ac44d6a3
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/components/page_title_spec.js
@@ -0,0 +1,35 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon, GlSprintf } from '@gitlab/ui';
+import PageTitle from '~/kubernetes_dashboard/components/page_title.vue';
+
+const agent = {
+ name: 'my-agent',
+ id: '123',
+};
+
+let wrapper;
+
+const createWrapper = () => {
+ wrapper = shallowMount(PageTitle, {
+ provide: {
+ agent,
+ },
+ stubs: { GlSprintf },
+ });
+};
+
+const findIcon = () => wrapper.findComponent(GlIcon);
+
+describe('Page title component', () => {
+ it('renders Kubernetes agent icon', () => {
+ createWrapper();
+
+ expect(findIcon().props('name')).toBe('kubernetes-agent');
+ });
+
+ it('renders agent information', () => {
+ createWrapper();
+
+ expect(wrapper.text()).toMatchInterpolatedText('Agent my-agent ID #123');
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/components/workload_details_item_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_details_item_spec.js
new file mode 100644
index 00000000000..72af25e72e5
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/components/workload_details_item_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+import WorkloadDetailsItem from '~/kubernetes_dashboard/components/workload_details_item.vue';
+
+let wrapper;
+
+const propsData = {
+ label: 'name',
+};
+const slots = {
+ default: '<b>slot value</b>',
+};
+
+const createWrapper = () => {
+ wrapper = shallowMount(WorkloadDetailsItem, {
+ propsData,
+ slots,
+ });
+};
+
+const findLabel = () => wrapper.findComponent('label');
+
+describe('Workload details item component', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders the correct label', () => {
+ expect(findLabel().text()).toBe(propsData.label);
+ });
+
+ it('renders slot content', () => {
+ expect(wrapper.html()).toContain(slots.default);
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/components/workload_details_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_details_spec.js
new file mode 100644
index 00000000000..fc47c658ebe
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/components/workload_details_spec.js
@@ -0,0 +1,53 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlBadge, GlTruncate } from '@gitlab/ui';
+import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue';
+import WorkloadDetailsItem from '~/kubernetes_dashboard/components/workload_details_item.vue';
+import { WORKLOAD_STATUS_BADGE_VARIANTS } from '~/kubernetes_dashboard/constants';
+import { mockPodsTableItems } from '../graphql/mock_data';
+
+let wrapper;
+
+const defaultItem = mockPodsTableItems[0];
+
+const createWrapper = (item = defaultItem) => {
+ wrapper = shallowMount(WorkloadDetails, {
+ propsData: {
+ item,
+ },
+ stubs: { GlTruncate },
+ });
+};
+
+const findAllWorkloadDetailsItems = () => wrapper.findAllComponents(WorkloadDetailsItem);
+const findWorkloadDetailsItem = (at) => findAllWorkloadDetailsItems().at(at);
+const findAllBadges = () => wrapper.findAllComponents(GlBadge);
+const findBadge = (at) => findAllBadges().at(at);
+
+describe('Workload details component', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it.each`
+ label | data | index
+ ${'Name'} | ${defaultItem.name} | ${0}
+ ${'Kind'} | ${defaultItem.kind} | ${1}
+ ${'Labels'} | ${'key=value'} | ${2}
+ ${'Status'} | ${defaultItem.status} | ${3}
+ ${'Annotations'} | ${'annotation: text another: text'} | ${4}
+ `('renders a list item for each not empty value', ({ label, data, index }) => {
+ expect(findWorkloadDetailsItem(index).props('label')).toBe(label);
+ expect(findWorkloadDetailsItem(index).text()).toMatchInterpolatedText(data);
+ });
+
+ it('renders a badge for each of the labels', () => {
+ const label = 'key=value';
+ expect(findBadge(0).text()).toBe(label);
+ });
+
+ it('renders a badge for the status value', () => {
+ const { status } = defaultItem;
+ expect(findBadge(1).text()).toBe(status);
+ expect(findBadge(1).props('variant')).toBe(WORKLOAD_STATUS_BADGE_VARIANTS[status]);
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js
new file mode 100644
index 00000000000..1dc5bd4f165
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js
@@ -0,0 +1,141 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLoadingIcon, GlAlert, GlDrawer } from '@gitlab/ui';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue';
+import WorkloadTable from '~/kubernetes_dashboard/components/workload_table.vue';
+import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue';
+import { mockPodStats, mockPodsTableItems } from '../graphql/mock_data';
+
+let wrapper;
+
+const defaultProps = {
+ stats: mockPodStats,
+ items: mockPodsTableItems,
+};
+
+const createWrapper = (propsData = {}) => {
+ wrapper = shallowMount(WorkloadLayout, {
+ propsData: {
+ ...defaultProps,
+ ...propsData,
+ },
+ stubs: { GlDrawer },
+ });
+};
+
+const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+const findErrorAlert = () => wrapper.findComponent(GlAlert);
+const findDrawer = () => wrapper.findComponent(GlDrawer);
+const findWorkloadStats = () => wrapper.findComponent(WorkloadStats);
+const findWorkloadTable = () => wrapper.findComponent(WorkloadTable);
+const findWorkloadDetails = () => wrapper.findComponent(WorkloadDetails);
+
+describe('Workload layout component', () => {
+ describe('when loading', () => {
+ beforeEach(() => {
+ createWrapper({ loading: true, errorMessage: 'error' });
+ });
+
+ it('renders a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it("doesn't render an error message", () => {
+ expect(findErrorAlert().exists()).toBe(false);
+ });
+
+ it("doesn't render workload stats", () => {
+ expect(findWorkloadStats().exists()).toBe(false);
+ });
+
+ it("doesn't render workload table", () => {
+ expect(findWorkloadTable().exists()).toBe(false);
+ });
+
+ it("doesn't render details drawer", () => {
+ expect(findDrawer().exists()).toBe(false);
+ });
+ });
+
+ describe('when received an error', () => {
+ beforeEach(() => {
+ createWrapper({ errorMessage: 'error' });
+ });
+
+ it("doesn't render a loading icon", () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders an error alert with the correct message and props', () => {
+ expect(findErrorAlert().text()).toBe('error');
+ expect(findErrorAlert().props()).toMatchObject({ variant: 'danger', dismissible: false });
+ });
+
+ it("doesn't render workload stats", () => {
+ expect(findWorkloadStats().exists()).toBe(false);
+ });
+
+ it("doesn't render workload table", () => {
+ expect(findWorkloadTable().exists()).toBe(false);
+ });
+
+ it("doesn't render details drawer", () => {
+ expect(findDrawer().exists()).toBe(false);
+ });
+ });
+
+ describe('when received the data', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it("doesn't render a loading icon", () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it("doesn't render an error message", () => {
+ expect(findErrorAlert().exists()).toBe(false);
+ });
+
+ it('renders workload-stats component with the correct props', () => {
+ expect(findWorkloadStats().props('stats')).toBe(mockPodStats);
+ });
+
+ it('renders workload-table component with the correct props', () => {
+ expect(findWorkloadTable().props('items')).toBe(mockPodsTableItems);
+ });
+
+ it('renders a drawer', () => {
+ expect(findDrawer().exists()).toBe(true);
+ });
+
+ describe('drawer', () => {
+ it('is closed by default', () => {
+ expect(findDrawer().props('open')).toBe(false);
+ });
+
+ it('is opened when an item was selected', async () => {
+ await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]);
+ expect(findDrawer().props('open')).toBe(true);
+ });
+
+ it('is closed when clicked on a cross button', async () => {
+ await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]);
+ expect(findDrawer().props('open')).toBe(true);
+
+ await findDrawer().vm.$emit('close');
+ expect(findDrawer().props('open')).toBe(false);
+ });
+
+ it('renders a title with the selected item name', async () => {
+ await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]);
+ expect(findDrawer().text()).toContain(mockPodsTableItems[0].name);
+ });
+
+ it('renders WorkloadDetails with the correct props', async () => {
+ await findWorkloadTable().vm.$emit('select-item', mockPodsTableItems[0]);
+ expect(findWorkloadDetails().props('item')).toBe(mockPodsTableItems[0]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/components/workload_stats_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_stats_spec.js
new file mode 100644
index 00000000000..d1bee0c0a16
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/components/workload_stats_spec.js
@@ -0,0 +1,43 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue';
+import { mockPodStats } from '../graphql/mock_data';
+
+let wrapper;
+
+const createWrapper = () => {
+ wrapper = shallowMount(WorkloadStats, {
+ propsData: {
+ stats: mockPodStats,
+ },
+ });
+};
+
+const findAllStats = () => wrapper.findAllComponents(GlSingleStat);
+const findSingleStat = (at) => findAllStats().at(at);
+
+describe('Workload stats component', () => {
+ it('renders GlSingleStat component for each stat', () => {
+ createWrapper();
+
+ expect(findAllStats()).toHaveLength(4);
+ });
+
+ it.each`
+ count | title | index
+ ${2} | ${'Running'} | ${0}
+ ${1} | ${'Pending'} | ${1}
+ ${1} | ${'Succeeded'} | ${2}
+ ${2} | ${'Failed'} | ${3}
+ `(
+ 'renders stat with title "$title" and count "$count" at index $index',
+ ({ count, title, index }) => {
+ createWrapper();
+
+ expect(findSingleStat(index).props()).toMatchObject({
+ value: count,
+ title,
+ });
+ },
+ );
+});
diff --git a/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js
new file mode 100644
index 00000000000..369b8f32c2d
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js
@@ -0,0 +1,128 @@
+import { mount } from '@vue/test-utils';
+import { GlTable, GlBadge, GlPagination } from '@gitlab/ui';
+import WorkloadTable from '~/kubernetes_dashboard/components/workload_table.vue';
+import { TABLE_HEADING_CLASSES, PAGE_SIZE } from '~/kubernetes_dashboard/constants';
+import { mockPodsTableItems } from '../graphql/mock_data';
+
+let wrapper;
+
+const createWrapper = (propsData = {}) => {
+ wrapper = mount(WorkloadTable, {
+ propsData,
+ });
+};
+
+const findTable = () => wrapper.findComponent(GlTable);
+const findAllRows = () => findTable().find('tbody').findAll('tr');
+const findRow = (at) => findAllRows().at(at);
+const findAllBadges = () => wrapper.findAllComponents(GlBadge);
+const findBadge = (at) => findAllBadges().at(at);
+const findPagination = () => wrapper.findComponent(GlPagination);
+
+describe('Workload table component', () => {
+ it('renders GlTable component with the default fields if no fields specified in props', () => {
+ createWrapper({ items: mockPodsTableItems });
+ const defaultFields = [
+ {
+ key: 'name',
+ label: 'Name',
+ thClass: TABLE_HEADING_CLASSES,
+ sortable: true,
+ },
+ {
+ key: 'status',
+ label: 'Status',
+ thClass: TABLE_HEADING_CLASSES,
+ sortable: true,
+ },
+ {
+ key: 'namespace',
+ label: 'Namespace',
+ thClass: TABLE_HEADING_CLASSES,
+ sortable: true,
+ },
+ {
+ key: 'age',
+ label: 'Age',
+ thClass: TABLE_HEADING_CLASSES,
+ sortable: true,
+ },
+ ];
+
+ expect(findTable().props('fields')).toEqual(defaultFields);
+ });
+
+ it('renders GlTable component fields specified in props', () => {
+ const customFields = [
+ {
+ key: 'field-1',
+ label: 'Field-1',
+ thClass: TABLE_HEADING_CLASSES,
+ sortable: true,
+ },
+ {
+ key: 'field-2',
+ label: 'Field-2',
+ thClass: TABLE_HEADING_CLASSES,
+ sortable: true,
+ },
+ ];
+ createWrapper({ items: mockPodsTableItems, fields: customFields });
+
+ expect(findTable().props('fields')).toEqual(customFields);
+ });
+
+ describe('table rows', () => {
+ beforeEach(() => {
+ createWrapper({ items: mockPodsTableItems });
+ });
+
+ it('displays the correct number of rows', () => {
+ expect(findAllRows()).toHaveLength(mockPodsTableItems.length);
+ });
+
+ it('emits an event on row click', () => {
+ mockPodsTableItems.forEach((data, index) => {
+ findRow(index).trigger('click');
+
+ expect(wrapper.emitted('select-item')[index]).toEqual([data]);
+ });
+ });
+
+ it('renders correct data for each row', () => {
+ mockPodsTableItems.forEach((data, index) => {
+ expect(findRow(index).text()).toContain(data.name);
+ expect(findRow(index).text()).toContain(data.namespace);
+ expect(findRow(index).text()).toContain(data.status);
+ expect(findRow(index).text()).toContain(data.age);
+ });
+ });
+
+ it('renders a badge for the status', () => {
+ expect(findAllBadges()).toHaveLength(mockPodsTableItems.length);
+ });
+
+ it.each`
+ status | variant | index
+ ${'Running'} | ${'info'} | ${0}
+ ${'Running'} | ${'info'} | ${1}
+ ${'Pending'} | ${'warning'} | ${2}
+ ${'Succeeded'} | ${'success'} | ${3}
+ ${'Failed'} | ${'danger'} | ${4}
+ ${'Failed'} | ${'danger'} | ${5}
+ `(
+ 'renders "$variant" badge for status "$status" at index "$index"',
+ ({ status, variant, index }) => {
+ expect(findBadge(index).text()).toBe(status);
+ expect(findBadge(index).props('variant')).toBe(variant);
+ },
+ );
+
+ it('renders pagination', () => {
+ expect(findPagination().props()).toMatchObject({
+ totalItems: mockPodsTableItems.length,
+ perPage: PAGE_SIZE,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js
new file mode 100644
index 00000000000..674425a5bc9
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js
@@ -0,0 +1,353 @@
+const runningPod = {
+ status: { phase: 'Running' },
+ metadata: {
+ name: 'pod-1',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: { key: 'value' },
+ annotations: { annotation: 'text', another: 'text' },
+ },
+};
+const pendingPod = {
+ status: { phase: 'Pending' },
+ metadata: {
+ name: 'pod-2',
+ namespace: 'new-namespace',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+const succeededPod = {
+ status: { phase: 'Succeeded' },
+ metadata: {
+ name: 'pod-3',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: {},
+ annotations: {},
+ },
+};
+const failedPod = {
+ status: { phase: 'Failed' },
+ metadata: {
+ name: 'pod-4',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+export const k8sPodsMock = [runningPod, runningPod, pendingPod, succeededPod, failedPod, failedPod];
+
+export const mockPodStats = [
+ {
+ title: 'Running',
+ value: 2,
+ },
+ {
+ title: 'Pending',
+ value: 1,
+ },
+ {
+ title: 'Succeeded',
+ value: 1,
+ },
+ {
+ title: 'Failed',
+ value: 2,
+ },
+];
+
+export const mockPodsTableItems = [
+ {
+ name: 'pod-1',
+ namespace: 'default',
+ status: 'Running',
+ age: '114d',
+ labels: { key: 'value' },
+ annotations: { annotation: 'text', another: 'text' },
+ kind: 'Pod',
+ },
+ {
+ name: 'pod-1',
+ namespace: 'default',
+ status: 'Running',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'Pod',
+ },
+ {
+ name: 'pod-2',
+ namespace: 'new-namespace',
+ status: 'Pending',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Pod',
+ },
+ {
+ name: 'pod-3',
+ namespace: 'default',
+ status: 'Succeeded',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'Pod',
+ },
+ {
+ name: 'pod-4',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Pod',
+ },
+ {
+ name: 'pod-4',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Pod',
+ },
+];
+
+const pendingDeployment = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'False' },
+ { type: 'Progressing', status: 'True' },
+ ],
+ },
+ metadata: {
+ name: 'deployment-1',
+ namespace: 'new-namespace',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+const readyDeployment = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'True' },
+ { type: 'Progressing', status: 'False' },
+ ],
+ },
+ metadata: {
+ name: 'deployment-2',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: {},
+ annotations: {},
+ },
+};
+const failedDeployment = {
+ status: {
+ conditions: [
+ { type: 'Available', status: 'False' },
+ { type: 'Progressing', status: 'False' },
+ ],
+ },
+ metadata: {
+ name: 'deployment-3',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+export const k8sDeploymentsMock = [
+ pendingDeployment,
+ readyDeployment,
+ readyDeployment,
+ failedDeployment,
+];
+
+export const mockDeploymentsStats = [
+ {
+ title: 'Ready',
+ value: 2,
+ },
+ {
+ title: 'Failed',
+ value: 1,
+ },
+ {
+ title: 'Pending',
+ value: 1,
+ },
+];
+
+export const mockDeploymentsTableItems = [
+ {
+ name: 'deployment-1',
+ namespace: 'new-namespace',
+ status: 'Pending',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Deployment',
+ },
+ {
+ name: 'deployment-2',
+ namespace: 'default',
+ status: 'Ready',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'Deployment',
+ },
+ {
+ name: 'deployment-2',
+ namespace: 'default',
+ status: 'Ready',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'Deployment',
+ },
+ {
+ name: 'deployment-3',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Deployment',
+ },
+];
+
+const readyStatefulSet = {
+ status: { readyReplicas: 2 },
+ spec: { replicas: 2 },
+ metadata: {
+ name: 'statefulSet-2',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: {},
+ annotations: {},
+ },
+};
+const failedStatefulSet = {
+ status: { readyReplicas: 1 },
+ spec: { replicas: 2 },
+ metadata: {
+ name: 'statefulSet-3',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+export const k8sStatefulSetsMock = [readyStatefulSet, readyStatefulSet, failedStatefulSet];
+
+export const mockStatefulSetsStats = [
+ {
+ title: 'Ready',
+ value: 2,
+ },
+ {
+ title: 'Failed',
+ value: 1,
+ },
+];
+
+export const mockStatefulSetsTableItems = [
+ {
+ name: 'statefulSet-2',
+ namespace: 'default',
+ status: 'Ready',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'StatefulSet',
+ },
+ {
+ name: 'statefulSet-2',
+ namespace: 'default',
+ status: 'Ready',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'StatefulSet',
+ },
+ {
+ name: 'statefulSet-3',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'StatefulSet',
+ },
+];
+
+export const k8sReplicaSetsMock = [readyStatefulSet, readyStatefulSet, failedStatefulSet];
+
+export const mockReplicaSetsTableItems = mockStatefulSetsTableItems.map((item) => {
+ return { ...item, kind: 'ReplicaSet' };
+});
+
+const readyDaemonSet = {
+ status: { numberMisscheduled: 0, numberReady: 2, desiredNumberScheduled: 2 },
+ metadata: {
+ name: 'daemonSet-1',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+const failedDaemonSet = {
+ status: { numberMisscheduled: 1, numberReady: 1, desiredNumberScheduled: 2 },
+ metadata: {
+ name: 'daemonSet-2',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+export const mockDaemonSetsStats = [
+ {
+ title: 'Ready',
+ value: 1,
+ },
+ {
+ title: 'Failed',
+ value: 1,
+ },
+];
+
+export const mockDaemonSetsTableItems = [
+ {
+ name: 'daemonSet-1',
+ namespace: 'default',
+ status: 'Ready',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'DaemonSet',
+ },
+ {
+ name: 'daemonSet-2',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'DaemonSet',
+ },
+];
+
+export const k8sDaemonSetsMock = [readyDaemonSet, failedDaemonSet];
diff --git a/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js
new file mode 100644
index 00000000000..516d91af947
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js
@@ -0,0 +1,459 @@
+import { CoreV1Api, WatchApi, AppsV1Api } from '@gitlab/cluster-client';
+import { resolvers } from '~/kubernetes_dashboard/graphql/resolvers';
+import k8sDashboardPodsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql';
+import k8sDashboardDeploymentsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_deployments.query.graphql';
+import k8sDashboardStatefulSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_stateful_sets.query.graphql';
+import k8sDashboardReplicaSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_replica_sets.query.graphql';
+import k8sDashboardDaemonSetsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_daemon_sets.query.graphql';
+import {
+ k8sPodsMock,
+ k8sDeploymentsMock,
+ k8sStatefulSetsMock,
+ k8sReplicaSetsMock,
+ k8sDaemonSetsMock,
+} from '../mock_data';
+
+describe('~/frontend/environments/graphql/resolvers', () => {
+ let mockResolvers;
+
+ const configuration = {
+ basePath: 'kas-proxy/',
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ beforeEach(() => {
+ mockResolvers = resolvers;
+ });
+
+ describe('k8sPods', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockPodsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockPodsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sPodsMock,
+ });
+ });
+
+ const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn);
+
+ describe('when the pods data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces')
+ .mockImplementation(mockAllPodsListFn);
+ jest.spyOn(mockWatcher, 'subscribeToStream').mockImplementation(mockPodsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all pods from the cluster_client library and watch the events', async () => {
+ const pods = await mockResolvers.Query.k8sPods(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllPodsListFn).toHaveBeenCalled();
+ expect(mockPodsListWatcherFn).toHaveBeenCalled();
+
+ expect(pods).toEqual(k8sPodsMock);
+ });
+
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sDashboardPodsQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sPods: [] },
+ });
+ });
+ });
+
+ it('should not watch pods from the cluster_client library when the pods data is not present', async () => {
+ jest.spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sPods(null, { configuration }, { client });
+
+ expect(mockPodsListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sPods(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+
+ describe('k8sDeployments', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockDeploymentsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockDeploymentsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sDeploymentsMock,
+ });
+ });
+
+ const mockAllDeploymentsListFn = jest.fn().mockImplementation(mockDeploymentsListFn);
+
+ describe('when the deployments data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces')
+ .mockImplementation(mockAllDeploymentsListFn);
+ jest
+ .spyOn(mockWatcher, 'subscribeToStream')
+ .mockImplementation(mockDeploymentsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all deployments from the cluster_client library and watch the events', async () => {
+ const deployments = await mockResolvers.Query.k8sDeployments(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllDeploymentsListFn).toHaveBeenCalled();
+ expect(mockDeploymentsListWatcherFn).toHaveBeenCalled();
+
+ expect(deployments).toEqual(k8sDeploymentsMock);
+ });
+
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sDeployments(
+ null,
+ { configuration, namespace: '' },
+ { client },
+ );
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sDashboardDeploymentsQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sDeployments: [] },
+ });
+ });
+ });
+
+ it('should not watch deployments from the cluster_client library when the deployments data is not present', async () => {
+ jest.spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sDeployments(null, { configuration }, { client });
+
+ expect(mockDeploymentsListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sDeployments(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+
+ describe('k8sStatefulSets', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockStatefulSetsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockStatefulSetsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sStatefulSetsMock,
+ });
+ });
+
+ const mockAllStatefulSetsListFn = jest.fn().mockImplementation(mockStatefulSetsListFn);
+
+ describe('when the StatefulSets data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1StatefulSetForAllNamespaces')
+ .mockImplementation(mockAllStatefulSetsListFn);
+ jest
+ .spyOn(mockWatcher, 'subscribeToStream')
+ .mockImplementation(mockStatefulSetsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all StatefulSets from the cluster_client library and watch the events', async () => {
+ const StatefulSets = await mockResolvers.Query.k8sStatefulSets(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllStatefulSetsListFn).toHaveBeenCalled();
+ expect(mockStatefulSetsListWatcherFn).toHaveBeenCalled();
+
+ expect(StatefulSets).toEqual(k8sStatefulSetsMock);
+ });
+
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sStatefulSets(
+ null,
+ { configuration, namespace: '' },
+ { client },
+ );
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sDashboardStatefulSetsQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sStatefulSets: [] },
+ });
+ });
+ });
+
+ it('should not watch StatefulSets from the cluster_client library when the StatefulSets data is not present', async () => {
+ jest.spyOn(AppsV1Api.prototype, 'listAppsV1StatefulSetForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sStatefulSets(null, { configuration }, { client });
+
+ expect(mockStatefulSetsListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1StatefulSetForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sStatefulSets(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+
+ describe('k8sReplicaSets', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockReplicaSetsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockReplicaSetsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sReplicaSetsMock,
+ });
+ });
+
+ const mockAllReplicaSetsListFn = jest.fn().mockImplementation(mockReplicaSetsListFn);
+
+ describe('when the ReplicaSets data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces')
+ .mockImplementation(mockAllReplicaSetsListFn);
+ jest
+ .spyOn(mockWatcher, 'subscribeToStream')
+ .mockImplementation(mockReplicaSetsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all ReplicaSets from the cluster_client library and watch the events', async () => {
+ const ReplicaSets = await mockResolvers.Query.k8sReplicaSets(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllReplicaSetsListFn).toHaveBeenCalled();
+ expect(mockReplicaSetsListWatcherFn).toHaveBeenCalled();
+
+ expect(ReplicaSets).toEqual(k8sReplicaSetsMock);
+ });
+
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sReplicaSets(
+ null,
+ { configuration, namespace: '' },
+ { client },
+ );
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sDashboardReplicaSetsQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sReplicaSets: [] },
+ });
+ });
+ });
+
+ it('should not watch ReplicaSets from the cluster_client library when the ReplicaSets data is not present', async () => {
+ jest.spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sReplicaSets(null, { configuration }, { client });
+
+ expect(mockReplicaSetsListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1ReplicaSetForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sReplicaSets(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+
+ describe('k8sDaemonSets', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockDaemonSetsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockDaemonSetsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sDaemonSetsMock,
+ });
+ });
+
+ const mockAllDaemonSetsListFn = jest.fn().mockImplementation(mockDaemonSetsListFn);
+
+ describe('when the DaemonSets data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1DaemonSetForAllNamespaces')
+ .mockImplementation(mockAllDaemonSetsListFn);
+ jest
+ .spyOn(mockWatcher, 'subscribeToStream')
+ .mockImplementation(mockDaemonSetsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all DaemonSets from the cluster_client library and watch the events', async () => {
+ const DaemonSets = await mockResolvers.Query.k8sDaemonSets(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllDaemonSetsListFn).toHaveBeenCalled();
+ expect(mockDaemonSetsListWatcherFn).toHaveBeenCalled();
+
+ expect(DaemonSets).toEqual(k8sDaemonSetsMock);
+ });
+
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sDaemonSets(null, { configuration, namespace: '' }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sDashboardDaemonSetsQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sDaemonSets: [] },
+ });
+ });
+ });
+
+ it('should not watch DaemonSets from the cluster_client library when the DaemonSets data is not present', async () => {
+ jest.spyOn(AppsV1Api.prototype, 'listAppsV1DaemonSetForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sDaemonSets(null, { configuration }, { client });
+
+ expect(mockDaemonSetsListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(AppsV1Api.prototype, 'listAppsV1DaemonSetForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sDaemonSets(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js b/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js
new file mode 100644
index 00000000000..2892d657aea
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js
@@ -0,0 +1,93 @@
+import {
+ getAge,
+ calculateDeploymentStatus,
+ calculateStatefulSetStatus,
+ calculateDaemonSetStatus,
+} from '~/kubernetes_dashboard/helpers/k8s_integration_helper';
+import { useFakeDate } from 'helpers/fake_date';
+
+describe('k8s_integration_helper', () => {
+ describe('getAge', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it.each`
+ condition | measures | timestamp | expected
+ ${'timestamp > 1 day'} | ${'days'} | ${'2023-07-31T11:50:59Z'} | ${'114d'}
+ ${'timestamp = 1 day'} | ${'days'} | ${'2023-11-21T11:50:59Z'} | ${'1d'}
+ ${'1 day > timestamp > 1 hour'} | ${'hours'} | ${'2023-11-22T11:50:59Z'} | ${'22h'}
+ ${'timestamp = 1 hour'} | ${'hours'} | ${'2023-11-23T08:50:59Z'} | ${'1h'}
+ ${'1 hour > timestamp >1 minute'} | ${'minutes'} | ${'2023-11-23T09:50:59Z'} | ${'19m'}
+ ${'timestamp = 1 minute'} | ${'minutes'} | ${'2023-11-23T10:08:59Z'} | ${'1m'}
+ ${'1 minute > timestamp'} | ${'seconds'} | ${'2023-11-23T10:09:17Z'} | ${'43s'}
+ ${'timestamp = 1 second'} | ${'seconds'} | ${'2023-11-23T10:09:59Z'} | ${'1s'}
+ `('returns age in $measures when $condition', ({ timestamp, expected }) => {
+ expect(getAge(timestamp)).toBe(expected);
+ });
+ });
+
+ describe('calculateDeploymentStatus', () => {
+ const pending = {
+ conditions: [
+ { type: 'Available', status: 'False' },
+ { type: 'Progressing', status: 'True' },
+ ],
+ };
+ const ready = {
+ conditions: [
+ { type: 'Available', status: 'True' },
+ { type: 'Progressing', status: 'False' },
+ ],
+ };
+ const failed = {
+ conditions: [
+ { type: 'Available', status: 'False' },
+ { type: 'Progressing', status: 'False' },
+ ],
+ };
+
+ it.each`
+ condition | status | expected
+ ${'Available is false and Progressing is true'} | ${pending} | ${'Pending'}
+ ${'Available is true and Progressing is false'} | ${ready} | ${'Ready'}
+ ${'Available is false and Progressing is false'} | ${failed} | ${'Failed'}
+ `('returns status as $expected when $condition', ({ status, expected }) => {
+ expect(calculateDeploymentStatus({ status })).toBe(expected);
+ });
+ });
+
+ describe('calculateStatefulSetStatus', () => {
+ const ready = {
+ status: { readyReplicas: 2 },
+ spec: { replicas: 2 },
+ };
+ const failed = {
+ status: { readyReplicas: 1 },
+ spec: { replicas: 2 },
+ };
+
+ it.each`
+ condition | item | expected
+ ${'there are less readyReplicas than replicas in spec'} | ${failed} | ${'Failed'}
+ ${'there are the same amount of readyReplicas as in spec'} | ${ready} | ${'Ready'}
+ `('returns status as $expected when $condition', ({ item, expected }) => {
+ expect(calculateStatefulSetStatus(item)).toBe(expected);
+ });
+ });
+
+ describe('calculateDaemonSetStatus', () => {
+ const ready = {
+ status: { numberMisscheduled: 0, numberReady: 2, desiredNumberScheduled: 2 },
+ };
+ const failed = {
+ status: { numberMisscheduled: 1, numberReady: 1, desiredNumberScheduled: 2 },
+ };
+
+ it.each`
+ condition | item | expected
+ ${'there are less numberReady than desiredNumberScheduled or the numberMisscheduled is present'} | ${failed} | ${'Failed'}
+ ${'there are the same amount of numberReady and desiredNumberScheduled'} | ${ready} | ${'Ready'}
+ `('returns status as $expected when $condition', ({ item, expected }) => {
+ expect(calculateDaemonSetStatus(item)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/pages/app_spec.js b/spec/frontend/kubernetes_dashboard/pages/app_spec.js
new file mode 100644
index 00000000000..7d3b9cd2ee6
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/app_spec.js
@@ -0,0 +1,40 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { shallowMount } from '@vue/test-utils';
+import createRouter from '~/kubernetes_dashboard/router/index';
+import { PODS_ROUTE_PATH } from '~/kubernetes_dashboard/router/constants';
+import App from '~/kubernetes_dashboard/pages/app.vue';
+import PageTitle from '~/kubernetes_dashboard/components/page_title.vue';
+
+Vue.use(VueRouter);
+
+let wrapper;
+let router;
+const base = 'base/path';
+
+const mountApp = async (route = PODS_ROUTE_PATH) => {
+ await router.push(route);
+
+ wrapper = shallowMount(App, {
+ router,
+ provide: {
+ agent: {},
+ },
+ });
+};
+
+const findPageTitle = () => wrapper.findComponent(PageTitle);
+
+describe('Kubernetes dashboard app component', () => {
+ beforeEach(() => {
+ router = createRouter({
+ base,
+ });
+ });
+
+ it(`sets the correct title for '${PODS_ROUTE_PATH}' path`, async () => {
+ await mountApp();
+
+ expect(findPageTitle().text()).toBe('Pods');
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/pages/daemon_sets_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/daemon_sets_page_spec.js
new file mode 100644
index 00000000000..a987f46fd78
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/daemon_sets_page_spec.js
@@ -0,0 +1,106 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import DaemonSetsPage from '~/kubernetes_dashboard/pages/daemon_sets_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import {
+ k8sDaemonSetsMock,
+ mockDaemonSetsStats,
+ mockDaemonSetsTableItems,
+} from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard daemonSets page', () => {
+ let wrapper;
+
+ const configuration = {
+ basePath: 'kas/tunnel/url',
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sDaemonSets: jest.fn().mockReturnValue(k8sDaemonSetsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(DaemonSetsPage, {
+ provide: { configuration },
+ apolloProvider,
+ });
+ };
+
+ describe('mounted', () => {
+ it('renders WorkloadLayout component', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().exists()).toBe(true);
+ });
+
+ it('sets loading prop for the WorkloadLayout', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().props('loading')).toBe(true);
+ });
+
+ it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets pods data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual(mockDaemonSetsStats);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockDaemonSetsTableItems);
+ });
+ });
+
+ describe('when gets an error from the cluster_client API', () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sDaemonSets: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ beforeEach(async () => {
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+ });
+
+ it('sets errorMessage prop for the WorkloadLayout', () => {
+ expect(findWorkloadLayout().props('errorMessage')).toBe(error.message);
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/pages/deployments_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/deployments_page_spec.js
new file mode 100644
index 00000000000..371116f0495
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/deployments_page_spec.js
@@ -0,0 +1,106 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import DeploymentsPage from '~/kubernetes_dashboard/pages/deployments_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import {
+ k8sDeploymentsMock,
+ mockDeploymentsStats,
+ mockDeploymentsTableItems,
+} from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard deployments page', () => {
+ let wrapper;
+
+ const configuration = {
+ basePath: 'kas/tunnel/url',
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sDeployments: jest.fn().mockReturnValue(k8sDeploymentsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(DeploymentsPage, {
+ provide: { configuration },
+ apolloProvider,
+ });
+ };
+
+ describe('mounted', () => {
+ it('renders WorkloadLayout component', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().exists()).toBe(true);
+ });
+
+ it('sets loading prop for the WorkloadLayout', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().props('loading')).toBe(true);
+ });
+
+ it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets pods data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual(mockDeploymentsStats);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockDeploymentsTableItems);
+ });
+ });
+
+ describe('when gets an error from the cluster_client API', () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sDeployments: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ beforeEach(async () => {
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+ });
+
+ it('sets errorMessage prop for the WorkloadLayout', () => {
+ expect(findWorkloadLayout().props('errorMessage')).toBe(error.message);
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js
new file mode 100644
index 00000000000..28a98bad211
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js
@@ -0,0 +1,102 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import PodsPage from '~/kubernetes_dashboard/pages/pods_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import { k8sPodsMock, mockPodStats, mockPodsTableItems } from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard pods page', () => {
+ let wrapper;
+
+ const configuration = {
+ basePath: 'kas/tunnel/url',
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sPods: jest.fn().mockReturnValue(k8sPodsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(PodsPage, {
+ provide: { configuration },
+ apolloProvider,
+ });
+ };
+
+ describe('mounted', () => {
+ it('renders WorkloadLayout component', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().exists()).toBe(true);
+ });
+
+ it('sets loading prop for the WorkloadLayout', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().props('loading')).toBe(true);
+ });
+
+ it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets pods data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual(mockPodStats);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockPodsTableItems);
+ });
+ });
+
+ describe('when gets an error from the cluster_client API', () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sPods: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ beforeEach(async () => {
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+ });
+
+ it('sets errorMessage prop for the WorkloadLayout', () => {
+ expect(findWorkloadLayout().props('errorMessage')).toBe(error.message);
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js
new file mode 100644
index 00000000000..0e442ec8328
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/replica_sets_page_spec.js
@@ -0,0 +1,106 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import ReplicaSetsPage from '~/kubernetes_dashboard/pages/replica_sets_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import {
+ k8sReplicaSetsMock,
+ mockStatefulSetsStats,
+ mockReplicaSetsTableItems,
+} from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard replicaSets page', () => {
+ let wrapper;
+
+ const configuration = {
+ basePath: 'kas/tunnel/url',
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sReplicaSets: jest.fn().mockReturnValue(k8sReplicaSetsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(ReplicaSetsPage, {
+ provide: { configuration },
+ apolloProvider,
+ });
+ };
+
+ describe('mounted', () => {
+ it('renders WorkloadLayout component', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().exists()).toBe(true);
+ });
+
+ it('sets loading prop for the WorkloadLayout', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().props('loading')).toBe(true);
+ });
+
+ it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets pods data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual(mockStatefulSetsStats);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockReplicaSetsTableItems);
+ });
+ });
+
+ describe('when gets an error from the cluster_client API', () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sReplicaSets: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ beforeEach(async () => {
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+ });
+
+ it('sets errorMessage prop for the WorkloadLayout', () => {
+ expect(findWorkloadLayout().props('errorMessage')).toBe(error.message);
+ });
+ });
+});
diff --git a/spec/frontend/kubernetes_dashboard/pages/stateful_sets_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/stateful_sets_page_spec.js
new file mode 100644
index 00000000000..3e9bd9a42de
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/stateful_sets_page_spec.js
@@ -0,0 +1,106 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import StatefulSetsPage from '~/kubernetes_dashboard/pages/stateful_sets_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import {
+ k8sStatefulSetsMock,
+ mockStatefulSetsStats,
+ mockStatefulSetsTableItems,
+} from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard statefulSets page', () => {
+ let wrapper;
+
+ const configuration = {
+ basePath: 'kas/tunnel/url',
+ baseOptions: {
+ headers: { 'GitLab-Agent-Id': '1' },
+ },
+ };
+
+ const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout);
+
+ const createApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sStatefulSets: jest.fn().mockReturnValue(k8sStatefulSetsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(StatefulSetsPage, {
+ provide: { configuration },
+ apolloProvider,
+ });
+ };
+
+ describe('mounted', () => {
+ it('renders WorkloadLayout component', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().exists()).toBe(true);
+ });
+
+ it('sets loading prop for the WorkloadLayout', () => {
+ createWrapper();
+
+ expect(findWorkloadLayout().props('loading')).toBe(true);
+ });
+
+ it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets pods data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual(mockStatefulSetsStats);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockStatefulSetsTableItems);
+ });
+ });
+
+ describe('when gets an error from the cluster_client API', () => {
+ const error = new Error('Error from the cluster_client API');
+ const createErroredApolloProvider = () => {
+ const mockResolvers = {
+ Query: {
+ k8sStatefulSets: jest.fn().mockRejectedValueOnce(error),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ beforeEach(async () => {
+ createWrapper(createErroredApolloProvider());
+ await waitForPromises();
+ });
+
+ it('sets errorMessage prop for the WorkloadLayout', () => {
+ expect(findWorkloadLayout().props('errorMessage')).toBe(error.message);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/breadcrumbs_spec.js b/spec/frontend/lib/utils/breadcrumbs_spec.js
index 3c29e3723d3..481e3db521c 100644
--- a/spec/frontend/lib/utils/breadcrumbs_spec.js
+++ b/spec/frontend/lib/utils/breadcrumbs_spec.js
@@ -26,24 +26,20 @@ describe('Breadcrumbs utils', () => {
`;
const mockRouter = jest.fn();
- let MockComponent;
- let mockApolloProvider;
- beforeEach(() => {
- MockComponent = Vue.component('MockComponent', {
- render: (createElement) =>
- createElement('span', {
- attrs: {
- 'data-testid': 'mock-component',
- },
- }),
- });
- mockApolloProvider = createMockApollo();
+ const MockComponent = Vue.component('MockComponent', {
+ render: (createElement) =>
+ createElement('span', {
+ attrs: {
+ 'data-testid': 'mock-component',
+ },
+ }),
});
+ const mockApolloProvider = createMockApollo();
+
afterEach(() => {
resetHTMLFixture();
- MockComponent = null;
});
describe('injectVueAppBreadcrumbs', () => {
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 6295914b127..5c2bcd48f3e 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -151,7 +151,7 @@ describe('common_utils', () => {
jest.spyOn(window, 'scrollBy');
document.body.innerHTML += `
<div id="parent">
- <div class="navbar-gitlab" style="position: fixed; top: 0; height: 50px;"></div>
+ <div class="header-logged-out" style="position: fixed; top: 0; height: 50px;"></div>
<div style="height: 2000px; margin-top: 50px;"></div>
<div id="user-content-test" style="height: 2000px;"></div>
</div>
diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
index 65018fe1625..79b09654f00 100644
--- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
@@ -122,12 +122,12 @@ describe('date_format_utility.js', () => {
describe('formatTimeAsSummary', () => {
it.each`
unit | value | result
- ${'months'} | ${1.5} | ${'1.5M'}
- ${'weeks'} | ${1.25} | ${'1.5w'}
- ${'days'} | ${2} | ${'2d'}
- ${'hours'} | ${10} | ${'10h'}
- ${'minutes'} | ${20} | ${'20m'}
- ${'seconds'} | ${10} | ${'<1m'}
+ ${'months'} | ${1.5} | ${'1.5 months'}
+ ${'weeks'} | ${1.25} | ${'1.5 weeks'}
+ ${'days'} | ${2} | ${'2 days'}
+ ${'hours'} | ${10} | ${'10 hours'}
+ ${'minutes'} | ${20} | ${'20 minutes'}
+ ${'seconds'} | ${10} | ${'<1 minute'}
${'seconds'} | ${0} | ${'-'}
`('will format $value $unit to $result', ({ unit, value, result }) => {
expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result);
diff --git a/spec/frontend/lib/utils/datetime/locale_dateformat_spec.js b/spec/frontend/lib/utils/datetime/locale_dateformat_spec.js
new file mode 100644
index 00000000000..3200f0cc7d7
--- /dev/null
+++ b/spec/frontend/lib/utils/datetime/locale_dateformat_spec.js
@@ -0,0 +1,177 @@
+import { DATE_TIME_FORMATS, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
+import { setLanguage } from 'jest/__helpers__/locale_helper';
+import * as localeFns from '~/locale';
+
+describe('localeDateFormat (en-US)', () => {
+ const date = new Date('1983-07-09T14:15:23.123Z');
+ const sameDay = new Date('1983-07-09T18:27:09.198Z');
+ const sameMonth = new Date('1983-07-12T12:36:02.654Z');
+ const nextYear = new Date('1984-01-10T07:47:54.947Z');
+
+ beforeEach(() => {
+ setLanguage('en-US');
+ localeDateFormat.reset();
+ });
+
+ /*
+ Depending on the ICU/Intl version, formatted strings might contain
+ characters which aren't a normal space, e.g. U+2009 THIN SPACE in formatRange or
+ U+202F NARROW NO-BREAK SPACE between time and AM/PM.
+
+ In order for the specs to be more portable and easier to read, as git/gitlab aren't
+ great at rendering these other spaces, we replace them U+0020 SPACE
+ */
+ function expectDateString(str) {
+ // eslint-disable-next-line jest/valid-expect
+ return expect(str.replace(/[\s\u2009]+/g, ' '));
+ }
+
+ describe('#asDateTime', () => {
+ it('exposes a working date formatter', () => {
+ expectDateString(localeDateFormat.asDateTime.format(date)).toBe('Jul 9, 1983, 2:15 PM');
+ expectDateString(localeDateFormat.asDateTime.format(nextYear)).toBe('Jan 10, 1984, 7:47 AM');
+ });
+
+ it('exposes a working date range formatter', () => {
+ expectDateString(localeDateFormat.asDateTime.formatRange(date, nextYear)).toBe(
+ 'Jul 9, 1983, 2:15 PM – Jan 10, 1984, 7:47 AM',
+ );
+ expectDateString(localeDateFormat.asDateTime.formatRange(date, sameMonth)).toBe(
+ 'Jul 9, 1983, 2:15 PM – Jul 12, 1983, 12:36 PM',
+ );
+ expectDateString(localeDateFormat.asDateTime.formatRange(date, sameDay)).toBe(
+ 'Jul 9, 1983, 2:15 – 6:27 PM',
+ );
+ });
+
+ it.each([
+ ['automatic', 0, '2:15 PM'],
+ ['h12 preference', 1, '2:15 PM'],
+ ['h24 preference', 2, '14:15'],
+ ])("respects user's hourCycle preference: %s", (_, timeDisplayFormat, result) => {
+ window.gon.time_display_format = timeDisplayFormat;
+ expectDateString(localeDateFormat.asDateTime.format(date)).toContain(result);
+ expectDateString(localeDateFormat.asDateTime.formatRange(date, nextYear)).toContain(result);
+ });
+ });
+
+ describe('#asDateTimeFull', () => {
+ it('exposes a working date formatter', () => {
+ expectDateString(localeDateFormat.asDateTimeFull.format(date)).toBe(
+ 'July 9, 1983 at 2:15:23 PM GMT',
+ );
+ expectDateString(localeDateFormat.asDateTimeFull.format(nextYear)).toBe(
+ 'January 10, 1984 at 7:47:54 AM GMT',
+ );
+ });
+
+ it('exposes a working date range formatter', () => {
+ expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, nextYear)).toBe(
+ 'July 9, 1983 at 2:15:23 PM GMT – January 10, 1984 at 7:47:54 AM GMT',
+ );
+ expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, sameMonth)).toBe(
+ 'July 9, 1983 at 2:15:23 PM GMT – July 12, 1983 at 12:36:02 PM GMT',
+ );
+ expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, sameDay)).toBe(
+ 'July 9, 1983, 2:15:23 PM GMT – 6:27:09 PM GMT',
+ );
+ });
+
+ it.each([
+ ['automatic', 0, '2:15:23 PM'],
+ ['h12 preference', 1, '2:15:23 PM'],
+ ['h24 preference', 2, '14:15:23'],
+ ])("respects user's hourCycle preference: %s", (_, timeDisplayFormat, result) => {
+ window.gon.time_display_format = timeDisplayFormat;
+ expectDateString(localeDateFormat.asDateTimeFull.format(date)).toContain(result);
+ expectDateString(localeDateFormat.asDateTimeFull.formatRange(date, nextYear)).toContain(
+ result,
+ );
+ });
+ });
+
+ describe('#asDate', () => {
+ it('exposes a working date formatter', () => {
+ expectDateString(localeDateFormat.asDate.format(date)).toBe('Jul 9, 1983');
+ expectDateString(localeDateFormat.asDate.format(nextYear)).toBe('Jan 10, 1984');
+ });
+
+ it('exposes a working date range formatter', () => {
+ expectDateString(localeDateFormat.asDate.formatRange(date, nextYear)).toBe(
+ 'Jul 9, 1983 – Jan 10, 1984',
+ );
+ expectDateString(localeDateFormat.asDate.formatRange(date, sameMonth)).toBe(
+ 'Jul 9 – 12, 1983',
+ );
+ expectDateString(localeDateFormat.asDate.formatRange(date, sameDay)).toBe('Jul 9, 1983');
+ });
+ });
+
+ describe('#asTime', () => {
+ it('exposes a working date formatter', () => {
+ expectDateString(localeDateFormat.asTime.format(date)).toBe('2:15 PM');
+ expectDateString(localeDateFormat.asTime.format(nextYear)).toBe('7:47 AM');
+ });
+
+ it('exposes a working date range formatter', () => {
+ expectDateString(localeDateFormat.asTime.formatRange(date, nextYear)).toBe(
+ '7/9/1983, 2:15 PM – 1/10/1984, 7:47 AM',
+ );
+ expectDateString(localeDateFormat.asTime.formatRange(date, sameMonth)).toBe(
+ '7/9/1983, 2:15 PM – 7/12/1983, 12:36 PM',
+ );
+ expectDateString(localeDateFormat.asTime.formatRange(date, sameDay)).toBe('2:15 – 6:27 PM');
+ });
+
+ it.each([
+ ['automatic', 0, '2:15 PM'],
+ ['h12 preference', 1, '2:15 PM'],
+ ['h24 preference', 2, '14:15'],
+ ])("respects user's hourCycle preference: %s", (_, timeDisplayFormat, result) => {
+ window.gon.time_display_format = timeDisplayFormat;
+ expectDateString(localeDateFormat.asTime.format(date)).toContain(result);
+ expectDateString(localeDateFormat.asTime.formatRange(date, nextYear)).toContain(result);
+ });
+ });
+
+ describe('#reset', () => {
+ it('removes the cached formatters', () => {
+ const spy = jest.spyOn(localeFns, 'createDateTimeFormat');
+
+ localeDateFormat.asDate.format(date);
+ localeDateFormat.asDate.format(date);
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ localeDateFormat.reset();
+
+ localeDateFormat.asDate.format(date);
+ localeDateFormat.asDate.format(date);
+ expect(spy).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe.each(DATE_TIME_FORMATS)('formatter for %p', (format) => {
+ it('is defined', () => {
+ expect(localeDateFormat[format]).toBeDefined();
+ expect(localeDateFormat[format].format(date)).toBeDefined();
+ expect(localeDateFormat[format].formatRange(date, nextYear)).toBeDefined();
+ });
+
+ it('getting the formatter multiple times, just calls the Intl API once', () => {
+ const spy = jest.spyOn(localeFns, 'createDateTimeFormat');
+
+ localeDateFormat[format].format(date);
+ localeDateFormat[format].format(date);
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('getting the formatter memoized the correct formatter', () => {
+ const spy = jest.spyOn(localeFns, 'createDateTimeFormat');
+
+ expect(localeDateFormat[format].format(date)).toBe(localeDateFormat[format].format(date));
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
index 44db4cf88a2..53ed524116e 100644
--- a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
@@ -1,5 +1,6 @@
-import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants';
import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility';
+import { DATE_ONLY_FORMAT, localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
+
import { s__ } from '~/locale';
import '~/commons/bootstrap';
@@ -143,7 +144,7 @@ describe('TimeAgo utils', () => {
it.each`
updateTooltip | title
${false} | ${'some time'}
- ${true} | ${'Feb 18, 2020 10:22pm UTC'}
+ ${true} | ${'February 18, 2020 at 10:22:32 PM GMT'}
`(
`has content: '${text}' and tooltip: '$title' with updateTooltip = $updateTooltip`,
({ updateTooltip, title }) => {
@@ -168,6 +169,7 @@ describe('TimeAgo utils', () => {
${1} | ${'12-hour'} | ${'Feb 18, 2020, 10:22 PM'}
${2} | ${'24-hour'} | ${'Feb 18, 2020, 22:22'}
`(`'$display' renders as '$text'`, ({ timeDisplayFormat, text }) => {
+ localeDateFormat.reset();
gon.time_display_relative = false;
gon.time_display_format = timeDisplayFormat;
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 330bfca7029..73a4af2c85d 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -800,6 +800,21 @@ describe('date addition/subtraction methods', () => {
);
});
+ describe('nYearsBefore', () => {
+ it.each`
+ date | numberOfYears | expected
+ ${'2020-07-06'} | ${4} | ${'2016-07-06'}
+ ${'2020-07-06'} | ${1} | ${'2019-07-06'}
+ `(
+ 'returns $expected for "$numberOfYears year(s) before $date"',
+ ({ date, numberOfYears, expected }) => {
+ expect(datetimeUtility.nYearsBefore(new Date(date), numberOfYears)).toEqual(
+ new Date(expected),
+ );
+ },
+ );
+ });
+
describe('nMonthsBefore', () => {
// The previous month (February) has 28 days
const march2019 = '2019-03-15T00:00:00.000Z';
diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js
index 761062f0340..a8da6e8969f 100644
--- a/spec/frontend/lib/utils/secret_detection_spec.js
+++ b/spec/frontend/lib/utils/secret_detection_spec.js
@@ -31,6 +31,7 @@ describe('containsSensitiveToken', () => {
'token: gloas-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693',
'https://example.com/feed?feed_token=123456789_abcdefghij',
'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ 'token: gldt-cgyKc1k_AsnEpmP-5fRL',
];
it.each(sensitiveMessages)('returns true for message: %s', (message) => {
diff --git a/spec/frontend/lib/utils/vuex_module_mappers_spec.js b/spec/frontend/lib/utils/vuex_module_mappers_spec.js
deleted file mode 100644
index 9070903728b..00000000000
--- a/spec/frontend/lib/utils/vuex_module_mappers_spec.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { mount } from '@vue/test-utils';
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import {
- mapVuexModuleActions,
- mapVuexModuleGetters,
- mapVuexModuleState,
- REQUIRE_STRING_ERROR_MESSAGE,
-} from '~/lib/utils/vuex_module_mappers';
-
-const TEST_MODULE_NAME = 'testModuleName';
-
-Vue.use(Vuex);
-
-// setup test component and store ----------------------------------------------
-//
-// These are used to indirectly test `vuex_module_mappers`.
-const TestComponent = {
- props: {
- vuexModule: {
- type: String,
- required: true,
- },
- },
- computed: {
- ...mapVuexModuleState((vm) => vm.vuexModule, { name: 'name', value: 'count' }),
- ...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasValue', 'hasName']),
- stateJson() {
- return JSON.stringify({
- name: this.name,
- value: this.value,
- });
- },
- gettersJson() {
- return JSON.stringify({
- hasValue: this.hasValue,
- hasName: this.hasName,
- });
- },
- },
- methods: {
- ...mapVuexModuleActions((vm) => vm.vuexModule, ['increment']),
- },
- template: `
-<div>
- <pre data-testid="state">{{ stateJson }}</pre>
- <pre data-testid="getters">{{ gettersJson }}</pre>
-</div>`,
-};
-
-const createTestStore = () => {
- return new Vuex.Store({
- modules: {
- [TEST_MODULE_NAME]: {
- namespaced: true,
- state: {
- name: 'Lorem',
- count: 0,
- },
- mutations: {
- INCREMENT: (state, amount) => {
- state.count += amount;
- },
- },
- actions: {
- increment({ commit }, amount) {
- commit('INCREMENT', amount);
- },
- },
- getters: {
- hasValue: (state) => state.count > 0,
- hasName: (state) => Boolean(state.name.length),
- },
- },
- },
- });
-};
-
-describe('~/lib/utils/vuex_module_mappers', () => {
- let store;
- let wrapper;
-
- const getJsonInTemplate = (testId) =>
- JSON.parse(wrapper.find(`[data-testid="${testId}"]`).text());
- const getMappedState = () => getJsonInTemplate('state');
- const getMappedGetters = () => getJsonInTemplate('getters');
-
- beforeEach(() => {
- store = createTestStore();
-
- wrapper = mount(TestComponent, {
- propsData: {
- vuexModule: TEST_MODULE_NAME,
- },
- store,
- });
- });
-
- describe('from module defined by prop', () => {
- it('maps state', () => {
- expect(getMappedState()).toEqual({
- name: store.state[TEST_MODULE_NAME].name,
- value: store.state[TEST_MODULE_NAME].count,
- });
- });
-
- it('maps getters', () => {
- expect(getMappedGetters()).toEqual({
- hasName: true,
- hasValue: false,
- });
- });
-
- it('maps action', () => {
- jest.spyOn(store, 'dispatch');
-
- expect(store.dispatch).not.toHaveBeenCalled();
-
- wrapper.vm.increment(10);
-
- expect(store.dispatch).toHaveBeenCalledWith(`${TEST_MODULE_NAME}/increment`, 10);
- });
- });
-
- describe('with non-string object value', () => {
- it('throws helpful error', () => {
- expect(() => mapVuexModuleActions((vm) => vm.bogus, { foo: () => {} })).toThrow(
- REQUIRE_STRING_ERROR_MESSAGE,
- );
- });
- });
-});
diff --git a/spec/frontend/loading_icon_for_legacy_js_spec.js b/spec/frontend/loading_icon_for_legacy_js_spec.js
index 46deee555ba..1e4acffdfd0 100644
--- a/spec/frontend/loading_icon_for_legacy_js_spec.js
+++ b/spec/frontend/loading_icon_for_legacy_js_spec.js
@@ -8,7 +8,7 @@ describe('loadingIconForLegacyJS', () => {
expect(el.className).toBe('gl-spinner-container');
expect(el.querySelector('.gl-spinner-sm')).toEqual(expect.any(HTMLElement));
expect(el.querySelector('.gl-spinner-dark')).toEqual(expect.any(HTMLElement));
- expect(el.querySelector('[aria-label="Loading"]')).toEqual(expect.any(HTMLElement));
+ expect(el.getAttribute('aria-label')).toEqual('Loading');
expect(el.getAttribute('role')).toBe('status');
});
@@ -31,7 +31,7 @@ describe('loadingIconForLegacyJS', () => {
it('can render a different aria-label', () => {
const el = loadingIconForLegacyJS({ label: 'Foo' });
- expect(el.querySelector('[aria-label="Foo"]')).toEqual(expect.any(HTMLElement));
+ expect(el.getAttribute('aria-label')).toEqual('Foo');
});
it('can render additional classes', () => {
diff --git a/spec/frontend/logo_spec.js b/spec/frontend/logo_spec.js
new file mode 100644
index 00000000000..8e39e75bd3b
--- /dev/null
+++ b/spec/frontend/logo_spec.js
@@ -0,0 +1,55 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import { initPortraitLogoDetection } from '~/logo';
+
+describe('initPortraitLogoDetection', () => {
+ let img;
+
+ const loadImage = () => {
+ const loadEvent = new Event('load');
+ img.dispatchEvent(loadEvent);
+ };
+
+ beforeEach(() => {
+ setHTMLFixture('<img class="gl-visibility-hidden gl-h-9 js-portrait-logo-detection" />');
+ initPortraitLogoDetection();
+ img = document.querySelector('img');
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ describe('when logo does not have portrait format', () => {
+ beforeEach(() => {
+ img.height = 10;
+ img.width = 10;
+ });
+
+ it('removes gl-visibility-hidden', () => {
+ expect(img.classList).toContain('gl-visibility-hidden');
+ expect(img.classList).toContain('gl-h-9');
+
+ loadImage();
+
+ expect(img.classList).not.toContain('gl-visibility-hidden');
+ expect(img.classList).toContain('gl-h-9');
+ });
+ });
+
+ describe('when logo has portrait format', () => {
+ beforeEach(() => {
+ img.height = 11;
+ img.width = 10;
+ });
+
+ it('removes gl-visibility-hidden', () => {
+ expect(img.classList).toContain('gl-visibility-hidden');
+ expect(img.classList).toContain('gl-h-9');
+
+ loadImage();
+
+ expect(img.classList).not.toContain('gl-visibility-hidden');
+ expect(img.classList).toContain('gl-w-10');
+ });
+ });
+});
diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/max_role_spec.js
index 62275a05dc5..75e1e05afb1 100644
--- a/spec/frontend/members/components/table/role_dropdown_spec.js
+++ b/spec/frontend/members/components/table/max_role_spec.js
@@ -1,4 +1,4 @@
-import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
+import { GlBadge, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
@@ -6,16 +6,19 @@ import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import waitForPromises from 'helpers/wait_for_promises';
-import RoleDropdown from '~/members/components/table/role_dropdown.vue';
+import MaxRole from '~/members/components/table/max_role.vue';
import { MEMBER_TYPES } from '~/members/constants';
import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action';
import { member } from '../../mock_data';
Vue.use(Vuex);
+
jest.mock('ee_else_ce/members/guest_overage_confirm_action');
jest.mock('~/sentry/sentry_browser_wrapper');
-describe('RoleDropdown', () => {
+guestOverageConfirmAction.mockReturnValue(true);
+
+describe('MaxRole', () => {
let wrapper;
let actions;
const $toast = {
@@ -35,7 +38,7 @@ describe('RoleDropdown', () => {
};
const createComponent = (propsData = {}, store = createStore()) => {
- wrapper = mount(RoleDropdown, {
+ wrapper = mount(MaxRole, {
provide: {
namespace: MEMBER_TYPES.user,
group: {
@@ -45,7 +48,9 @@ describe('RoleDropdown', () => {
},
propsData: {
member,
- permissions: {},
+ permissions: {
+ canUpdate: true,
+ },
...propsData,
},
store,
@@ -55,6 +60,7 @@ describe('RoleDropdown', () => {
});
};
+ const findBadge = () => wrapper.findComponent(GlBadge);
const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
const findListboxItems = () => wrapper.findAllComponents(GlListboxItem);
const findListboxItemByText = (text) =>
@@ -64,6 +70,18 @@ describe('RoleDropdown', () => {
gon.features = { showOverageOnRolePromotion: true };
});
+ describe('when member can not be updated', () => {
+ it('renders a badge instead of a collapsible listbox', () => {
+ createComponent({
+ permissions: {
+ canUpdate: false,
+ },
+ });
+
+ expect(findBadge().text()).toBe('Owner');
+ });
+ });
+
it('has correct header text props', () => {
createComponent();
expect(findListbox().props('headerText')).toBe('Change role');
@@ -77,14 +95,12 @@ describe('RoleDropdown', () => {
describe('when listbox is open', () => {
beforeEach(async () => {
- guestOverageConfirmAction.mockReturnValue(true);
createComponent();
await findListbox().vm.$emit('click');
});
it('sets dropdown toggle and checks selected role', () => {
- expect(findListbox().props('toggleText')).toBe('Owner');
expect(findListbox().find('[aria-selected=true]').text()).toBe('Owner');
});
@@ -100,7 +116,8 @@ describe('RoleDropdown', () => {
expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), {
memberId: member.id,
- accessLevel: { integerValue: 30, memberRoleId: null },
+ accessLevel: 30,
+ memberRoleId: null,
});
});
@@ -108,7 +125,7 @@ describe('RoleDropdown', () => {
it('displays toast', async () => {
await findListboxItemByText('Developer').trigger('click');
- await nextTick();
+ await waitForPromises();
expect($toast.show).toHaveBeenCalledWith('Role updated successfully.');
});
@@ -146,7 +163,7 @@ describe('RoleDropdown', () => {
it('does not display toast', async () => {
await findListboxItemByText('Developer').trigger('click');
- await nextTick();
+ await waitForPromises();
expect($toast.show).not.toHaveBeenCalled();
});
@@ -176,12 +193,6 @@ describe('RoleDropdown', () => {
});
});
- it("sets initial dropdown toggle value to member's role", () => {
- createComponent();
-
- expect(findListbox().props('toggleText')).toBe('Owner');
- });
-
it('sets the dropdown alignment to right on mobile', async () => {
jest.spyOn(bp, 'isDesktop').mockReturnValue(false);
createComponent();
@@ -199,54 +210,4 @@ describe('RoleDropdown', () => {
expect(findListbox().props('placement')).toBe('left');
});
-
- describe('guestOverageConfirmAction', () => {
- const mockConfirmAction = ({ confirmed }) => {
- guestOverageConfirmAction.mockResolvedValueOnce(confirmed);
- };
-
- beforeEach(() => {
- createComponent();
-
- findListbox().vm.$emit('click');
- });
-
- afterEach(() => {
- guestOverageConfirmAction.mockReset();
- });
-
- describe('when guestOverageConfirmAction returns true', () => {
- beforeEach(() => {
- mockConfirmAction({ confirmed: true });
-
- findListboxItemByText('Reporter').trigger('click');
- });
-
- it('calls updateMemberRole', () => {
- expect(actions.updateMemberRole).toHaveBeenCalled();
- });
- });
-
- describe('when guestOverageConfirmAction returns false', () => {
- beforeEach(() => {
- mockConfirmAction({ confirmed: false });
-
- findListboxItemByText('Reporter').trigger('click');
- });
-
- it('does not call updateMemberRole', () => {
- expect(actions.updateMemberRole).not.toHaveBeenCalled();
- });
-
- it('re-enables dropdown', async () => {
- await waitForPromises();
-
- expect(findListbox().props('disabled')).toBe(false);
- });
-
- it('resets selected dropdown item', () => {
- expect(findListbox().props('selected')).toMatch(/role-static-\d+/);
- });
- });
- });
});
diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js
index 791155fcd1b..c2400fbc142 100644
--- a/spec/frontend/members/components/table/members_table_spec.js
+++ b/spec/frontend/members/components/table/members_table_spec.js
@@ -1,4 +1,4 @@
-import { GlBadge, GlPagination, GlTable } from '@gitlab/ui';
+import { GlPagination, GlTable } from '@gitlab/ui';
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
@@ -11,7 +11,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue';
import MemberSource from '~/members/components/table/member_source.vue';
import MemberActivity from '~/members/components/table/member_activity.vue';
import MembersTable from '~/members/components/table/members_table.vue';
-import RoleDropdown from '~/members/components/table/role_dropdown.vue';
+import MaxRole from '~/members/components/table/max_role.vue';
import {
MEMBER_TYPES,
MEMBER_STATE_CREATED,
@@ -74,7 +74,7 @@ describe('MembersTable', () => {
'member-source',
'created-at',
'member-actions',
- 'role-dropdown',
+ 'max-role',
'remove-group-link-modal',
'remove-member-modal',
'expiration-datepicker',
@@ -110,7 +110,7 @@ describe('MembersTable', () => {
${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
- ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
+ ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${MaxRole}
${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
${'activity'} | ${'Activity'} | ${memberMock} | ${MemberActivity}
`('renders the $label field', ({ field, label, member, expectedComponent }) => {
@@ -274,16 +274,6 @@ describe('MembersTable', () => {
});
});
- describe('when member can not be updated', () => {
- it('renders badge in "Max role" field', () => {
- createComponent({ members: [memberMock], tableFields: ['maxRole'] });
-
- expect(
- wrapper.find(`[data-label="Max role"][role="cell"]`).findComponent(GlBadge).text(),
- ).toBe(memberMock.accessLevel.stringValue);
- });
- });
-
it('adds QA selector to table', () => {
createComponent();
diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js
index e0dc765b9e4..f550039bfdc 100644
--- a/spec/frontend/members/mock_data.js
+++ b/spec/frontend/members/mock_data.js
@@ -51,6 +51,7 @@ export const member = {
'Minimal access': 5,
},
customRoles: [],
+ customPermissions: [],
};
export const group = {
diff --git a/spec/frontend/members/store/actions_spec.js b/spec/frontend/members/store/actions_spec.js
index 3df3d85c4f1..30ea83abf22 100644
--- a/spec/frontend/members/store/actions_spec.js
+++ b/spec/frontend/members/store/actions_spec.js
@@ -37,28 +37,21 @@ describe('Vuex members actions', () => {
describe('updateMemberRole', () => {
const memberId = members[0].id;
- const accessLevel = { integerValue: 30, memberRoleId: 90 };
+ const accessLevel = 30;
+ const memberRoleId = 90;
- const payload = {
- memberId,
- accessLevel,
- };
+ const payload = { memberId, accessLevel, memberRoleId };
describe('successful request', () => {
- it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => {
+ it(`updates member role`, async () => {
mock.onPut().replyOnce(HTTP_STATUS_OK);
- await testAction(updateMemberRole, payload, state, [
- {
- type: types.RECEIVE_MEMBER_ROLE_SUCCESS,
- payload,
- },
- ]);
+ await testAction(updateMemberRole, payload, state, []);
expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238');
expect(mockedRequestFormatter).toHaveBeenCalledWith({
- accessLevel: accessLevel.integerValue,
- memberRoleId: accessLevel.memberRoleId,
+ accessLevel,
+ memberRoleId,
});
});
});
@@ -142,7 +135,7 @@ describe('Vuex members actions', () => {
describe('showRemoveGroupLinkModal', () => {
it(`commits ${types.SHOW_REMOVE_GROUP_LINK_MODAL} mutation`, () => {
- testAction(showRemoveGroupLinkModal, group, state, [
+ return testAction(showRemoveGroupLinkModal, group, state, [
{
type: types.SHOW_REMOVE_GROUP_LINK_MODAL,
payload: group,
@@ -153,7 +146,7 @@ describe('Vuex members actions', () => {
describe('hideRemoveGroupLinkModal', () => {
it(`commits ${types.HIDE_REMOVE_GROUP_LINK_MODAL} mutation`, () => {
- testAction(hideRemoveGroupLinkModal, group, state, [
+ return testAction(hideRemoveGroupLinkModal, group, state, [
{
type: types.HIDE_REMOVE_GROUP_LINK_MODAL,
},
@@ -170,7 +163,7 @@ describe('Vuex members actions', () => {
describe('showRemoveMemberModal', () => {
it(`commits ${types.SHOW_REMOVE_MEMBER_MODAL} mutation`, () => {
- testAction(showRemoveMemberModal, modalData, state, [
+ return testAction(showRemoveMemberModal, modalData, state, [
{
type: types.SHOW_REMOVE_MEMBER_MODAL,
payload: modalData,
@@ -181,7 +174,7 @@ describe('Vuex members actions', () => {
describe('hideRemoveMemberModal', () => {
it(`commits ${types.HIDE_REMOVE_MEMBER_MODAL} mutation`, () => {
- testAction(hideRemoveMemberModal, undefined, state, [
+ return testAction(hideRemoveMemberModal, undefined, state, [
{
type: types.HIDE_REMOVE_MEMBER_MODAL,
},
diff --git a/spec/frontend/members/store/mutations_spec.js b/spec/frontend/members/store/mutations_spec.js
index 8160cc373d8..240a14b2836 100644
--- a/spec/frontend/members/store/mutations_spec.js
+++ b/spec/frontend/members/store/mutations_spec.js
@@ -14,19 +14,6 @@ describe('Vuex members mutations', () => {
};
});
- describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => {
- it('updates member', () => {
- const accessLevel = { integerValue: 30, stringValue: 'Developer' };
-
- mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, {
- memberId: members[0].id,
- accessLevel,
- });
-
- expect(state.members[0].accessLevel).toEqual(accessLevel);
- });
- });
-
describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => {
describe('when error does not have a message', () => {
it('shows default error message', () => {
diff --git a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
index edd18c57f43..1a45ada98f9 100644
--- a/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
+++ b/spec/frontend/merge_conflicts/components/merge_conflict_resolver_app_spec.js
@@ -6,6 +6,7 @@ import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_he
import InlineConflictLines from '~/merge_conflicts/components/inline_conflict_lines.vue';
import ParallelConflictLines from '~/merge_conflicts/components/parallel_conflict_lines.vue';
import component from '~/merge_conflicts/merge_conflict_resolver_app.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { createStore } from '~/merge_conflicts/store';
import { decorateFiles } from '~/merge_conflicts/utils';
import { conflictsMock } from '../mock_data';
@@ -49,6 +50,7 @@ describe('Merge Conflict Resolver App', () => {
const findInlineConflictLines = (w = wrapper) => w.findComponent(InlineConflictLines);
const findParallelConflictLines = (w = wrapper) => w.findComponent(ParallelConflictLines);
const findCommitMessageTextarea = () => wrapper.findByTestId('commit-message');
+ const findClipboardButton = (w = wrapper) => w.findComponent(ClipboardButton);
it('shows the amount of conflicts', () => {
mountComponent();
@@ -131,6 +133,21 @@ describe('Merge Conflict Resolver App', () => {
expect(parallelConflictLinesComponent.props('file')).toEqual(decoratedMockFiles[0]);
});
});
+
+ describe('clipboard button', () => {
+ it('exists', () => {
+ mountComponent();
+ expect(findClipboardButton().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+ expect(findClipboardButton().attributes()).toMatchObject({
+ text: decoratedMockFiles[0].filePath,
+ title: 'Copy file path',
+ });
+ });
+ });
});
describe('submit form', () => {
diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js
index d2c4c8b796c..6fbd17af5af 100644
--- a/spec/frontend/merge_conflicts/store/actions_spec.js
+++ b/spec/frontend/merge_conflicts/store/actions_spec.js
@@ -134,7 +134,7 @@ describe('merge conflicts actions', () => {
describe('setLoadingState', () => {
it('commits the right mutation', () => {
- testAction(
+ return testAction(
actions.setLoadingState,
true,
{},
@@ -151,7 +151,7 @@ describe('merge conflicts actions', () => {
describe('setErrorState', () => {
it('commits the right mutation', () => {
- testAction(
+ return testAction(
actions.setErrorState,
true,
{},
@@ -168,7 +168,7 @@ describe('merge conflicts actions', () => {
describe('setFailedRequest', () => {
it('commits the right mutation', () => {
- testAction(
+ return testAction(
actions.setFailedRequest,
'errors in the request',
{},
@@ -207,7 +207,7 @@ describe('merge conflicts actions', () => {
describe('setSubmitState', () => {
it('commits the right mutation', () => {
- testAction(
+ return testAction(
actions.setSubmitState,
true,
{},
@@ -224,7 +224,7 @@ describe('merge conflicts actions', () => {
describe('updateCommitMessage', () => {
it('commits the right mutation', () => {
- testAction(
+ return testAction(
actions.updateCommitMessage,
'some message',
{},
diff --git a/spec/frontend/milestones/stores/actions_spec.js b/spec/frontend/milestones/stores/actions_spec.js
index 4355ea71fb2..4be12f17f9e 100644
--- a/spec/frontend/milestones/stores/actions_spec.js
+++ b/spec/frontend/milestones/stores/actions_spec.js
@@ -28,7 +28,7 @@ describe('Milestone combobox Vuex store actions', () => {
describe('setProjectId', () => {
it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => {
const projectId = '4';
- testAction(actions.setProjectId, projectId, state, [
+ return testAction(actions.setProjectId, projectId, state, [
{ type: types.SET_PROJECT_ID, payload: projectId },
]);
});
@@ -37,7 +37,7 @@ describe('Milestone combobox Vuex store actions', () => {
describe('setGroupId', () => {
it(`commits ${types.SET_GROUP_ID} with the new group ID`, () => {
const groupId = '123';
- testAction(actions.setGroupId, groupId, state, [
+ return testAction(actions.setGroupId, groupId, state, [
{ type: types.SET_GROUP_ID, payload: groupId },
]);
});
@@ -46,16 +46,19 @@ describe('Milestone combobox Vuex store actions', () => {
describe('setGroupMilestonesAvailable', () => {
it(`commits ${types.SET_GROUP_MILESTONES_AVAILABLE} with the boolean indicating if group milestones are available (Premium)`, () => {
state.groupMilestonesAvailable = true;
- testAction(actions.setGroupMilestonesAvailable, state.groupMilestonesAvailable, state, [
- { type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable },
- ]);
+ return testAction(
+ actions.setGroupMilestonesAvailable,
+ state.groupMilestonesAvailable,
+ state,
+ [{ type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable }],
+ );
});
});
describe('setSelectedMilestones', () => {
it(`commits ${types.SET_SELECTED_MILESTONES} with the new selected milestones name`, () => {
const selectedMilestones = ['v1.2.3'];
- testAction(actions.setSelectedMilestones, selectedMilestones, state, [
+ return testAction(actions.setSelectedMilestones, selectedMilestones, state, [
{ type: types.SET_SELECTED_MILESTONES, payload: selectedMilestones },
]);
});
@@ -63,7 +66,7 @@ describe('Milestone combobox Vuex store actions', () => {
describe('clearSelectedMilestones', () => {
it(`commits ${types.CLEAR_SELECTED_MILESTONES} with the new selected milestones name`, () => {
- testAction(actions.clearSelectedMilestones, null, state, [
+ return testAction(actions.clearSelectedMilestones, null, state, [
{ type: types.CLEAR_SELECTED_MILESTONES },
]);
});
@@ -72,14 +75,14 @@ describe('Milestone combobox Vuex store actions', () => {
describe('toggleMilestones', () => {
const selectedMilestone = 'v1.2.3';
it(`commits ${types.ADD_SELECTED_MILESTONE} with the new selected milestone name`, () => {
- testAction(actions.toggleMilestones, selectedMilestone, state, [
+ return testAction(actions.toggleMilestones, selectedMilestone, state, [
{ type: types.ADD_SELECTED_MILESTONE, payload: selectedMilestone },
]);
});
it(`commits ${types.REMOVE_SELECTED_MILESTONE} with the new selected milestone name`, () => {
state.selectedMilestones = [selectedMilestone];
- testAction(actions.toggleMilestones, selectedMilestone, state, [
+ return testAction(actions.toggleMilestones, selectedMilestone, state, [
{ type: types.REMOVE_SELECTED_MILESTONE, payload: selectedMilestone },
]);
});
@@ -93,7 +96,7 @@ describe('Milestone combobox Vuex store actions', () => {
};
const searchQuery = 'v1.0';
- testAction(
+ return testAction(
actions.search,
searchQuery,
{ ...state, ...getters },
@@ -106,7 +109,7 @@ describe('Milestone combobox Vuex store actions', () => {
describe('when project does not have license to add group milestones', () => {
it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project milestones`, () => {
const searchQuery = 'v1.0';
- testAction(
+ return testAction(
actions.search,
searchQuery,
state,
@@ -192,7 +195,7 @@ describe('Milestone combobox Vuex store actions', () => {
groupMilestonesEnabled: () => true,
};
- testAction(
+ return testAction(
actions.fetchMilestones,
undefined,
{ ...state, ...getters },
@@ -204,7 +207,7 @@ describe('Milestone combobox Vuex store actions', () => {
describe('when project does not have license to add group milestones', () => {
it(`dispatchs fetchProjectMilestones`, () => {
- testAction(
+ return testAction(
actions.fetchMilestones,
undefined,
state,
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 296728af46a..3999e906cec 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,206 +1,39 @@
-import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMount } from '@vue/test-utils';
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,
- NO_PARAMETERS_MESSAGE,
- NO_METRICS_MESSAGE,
- NO_METADATA_MESSAGE,
- NO_CI_MESSAGE,
-} from '~/ml/experiment_tracking/routes/candidates/show/translations';
import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue';
+import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue';
import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue';
-import { stubComponent } from 'helpers/stub_component';
-import { newCandidate } from './mock_data';
+import { newCandidate } from 'jest/ml/model_registry/mock_data';
describe('MlCandidatesShow', () => {
let wrapper;
const CANDIDATE = newCandidate();
- const USER_ROW = 1;
- const INFO_SECTION = 0;
- const CI_SECTION = 1;
- const PARAMETER_SECTION = 2;
- const METADATA_SECTION = 3;
-
- const createWrapper = (createCandidate = () => CANDIDATE) => {
- wrapper = shallowMountExtended(MlCandidatesShow, {
- propsData: { candidate: createCandidate() },
- stubs: {
- GlTableLite: { ...stubComponent(GlTableLite), props: ['items', 'fields'] },
- },
+ const createWrapper = () => {
+ wrapper = shallowMount(MlCandidatesShow, {
+ propsData: { candidate: CANDIDATE },
});
};
const findDeleteButton = () => wrapper.findComponent(DeleteButton);
const findHeader = () => wrapper.findComponent(ModelExperimentsHeader);
- const findSection = (section) => wrapper.findAll('section').at(section);
- const findRowInSection = (section, row) =>
- findSection(section).findAllComponents(DetailRow).at(row);
- const findLinkAtRow = (section, rowIndex) =>
- findRowInSection(section, rowIndex).findComponent(GlLink);
- const findNoDataMessage = (label) => wrapper.findByText(label);
- const findLabel = (label) => wrapper.find(`[label='${label}']`);
- const findCiUserDetailRow = () => findRowInSection(CI_SECTION, USER_ROW);
- const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled);
- const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink);
- const findMetricsTable = () => wrapper.findComponent(GlTableLite);
-
- describe('Header', () => {
- beforeEach(() => createWrapper());
-
- it('shows delete button', () => {
- expect(findDeleteButton().exists()).toBe(true);
- });
+ const findCandidateDetail = () => wrapper.findComponent(CandidateDetail);
- it('passes the delete path to delete button', () => {
- expect(findDeleteButton().props('deletePath')).toBe('path_to_candidate');
- });
+ beforeEach(() => createWrapper());
- it('passes the right title', () => {
- expect(findHeader().props('pageTitle')).toBe(TITLE_LABEL);
- });
+ it('shows delete button', () => {
+ expect(findDeleteButton().exists()).toBe(true);
});
- describe('Detail Table', () => {
- 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_SECTION, 0, 'ID', CANDIDATE.info.iid],
- [INFO_SECTION, 1, 'MLflow run ID', CANDIDATE.info.eid],
- [INFO_SECTION, 2, 'Status', CANDIDATE.info.status],
- [INFO_SECTION, 3, 'Experiment', CANDIDATE.info.experiment_name],
- [INFO_SECTION, 4, 'Artifacts', 'Artifacts'],
- [CI_SECTION, 0, 'Job', CANDIDATE.info.ci_job.name],
- [CI_SECTION, 1, 'Triggered by', 'CI User'],
- [CI_SECTION, 2, 'Merge request', mrText],
- [PARAMETER_SECTION, 0, CANDIDATE.params[0].name, CANDIDATE.params[0].value],
- [PARAMETER_SECTION, 1, CANDIDATE.params[1].name, CANDIDATE.params[1].value],
- [METADATA_SECTION, 0, CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value],
- [METADATA_SECTION, 1, CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value],
- ];
-
- it.each(expectedTable)('row %s is created correctly', (section, rowIndex, label, text) => {
- const row = findRowInSection(section, rowIndex);
-
- expect(row.props()).toMatchObject({ label });
- expect(row.text()).toBe(text);
- });
-
- describe('Table links', () => {
- const linkRows = [
- [INFO_SECTION, 3, CANDIDATE.info.path_to_experiment],
- [INFO_SECTION, 4, CANDIDATE.info.path_to_artifact],
- [CI_SECTION, 0, CANDIDATE.info.ci_job.path],
- [CI_SECTION, 2, CANDIDATE.info.ci_job.merge_request.path],
- ];
-
- it.each(linkRows)('row %s is created correctly', (section, rowIndex, href) => {
- expect(findLinkAtRow(section, rowIndex).attributes().href).toBe(href);
- });
- });
-
- describe('Metrics table', () => {
- it('computes metrics table items correctly', () => {
- expect(findMetricsTable().props('items')).toEqual([
- { name: 'AUC', 0: '.55' },
- { name: 'Accuracy', 1: '.99', 2: '.98', 3: '.97' },
- { name: 'F1', 3: '.1' },
- ]);
- });
-
- it('computes metrics table fields correctly', () => {
- expect(findMetricsTable().props('fields')).toEqual([
- expect.objectContaining({ key: 'name', label: 'Metric' }),
- expect.objectContaining({ key: '0', label: 'Step 0' }),
- expect.objectContaining({ key: '1', label: 'Step 1' }),
- expect.objectContaining({ key: '2', label: 'Step 2' }),
- expect.objectContaining({ key: '3', label: 'Step 3' }),
- ]);
- });
- });
-
- 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');
- });
- });
- });
-
- describe('No artifact path', () => {
- beforeEach(() =>
- createWrapper(() => {
- const candidate = newCandidate();
- delete candidate.info.path_to_artifact;
- return candidate;
- }),
- );
-
- it('does not render artifact row', () => {
- expect(findLabel('Artifacts').exists()).toBe(false);
- });
- });
-
- describe('No params, metrics, ci or metadata available', () => {
- beforeEach(() =>
- createWrapper(() => {
- const candidate = newCandidate();
- delete candidate.params;
- delete candidate.metrics;
- delete candidate.metadata;
- delete candidate.info.ci_job;
- return candidate;
- }),
- );
-
- it('does not render params', () => {
- expect(findNoDataMessage(NO_PARAMETERS_MESSAGE).exists()).toBe(true);
- });
-
- it('does not render metadata', () => {
- expect(findNoDataMessage(NO_METADATA_MESSAGE).exists()).toBe(true);
- });
-
- it('does not render metrics', () => {
- expect(findNoDataMessage(NO_METRICS_MESSAGE).exists()).toBe(true);
- });
-
- it('does not render CI info', () => {
- expect(findNoDataMessage(NO_CI_MESSAGE).exists()).toBe(true);
- });
- });
-
- 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('passes the delete path to delete button', () => {
+ expect(findDeleteButton().props('deletePath')).toBe('path_to_candidate');
+ });
- it('does not render MR info', () => {
- expect(findLabel('Merge request').exists()).toBe(false);
- });
+ it('passes the right title', () => {
+ expect(findHeader().props('pageTitle')).toBe('Model candidate details');
+ });
- it('does not render CI user info', () => {
- expect(findLabel('Triggered by').exists()).toBe(false);
- });
- });
+ it('creates the candidate detail section', () => {
+ expect(findCandidateDetail().props('candidate')).toBe(CANDIDATE);
});
});
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
deleted file mode 100644
index 4ea23ed2513..00000000000
--- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js
+++ /dev/null
@@ -1,41 +0,0 @@
-export const newCandidate = () => ({
- params: [
- { name: 'Algorithm', value: 'Decision Tree' },
- { name: 'MaxDepth', value: '3' },
- ],
- metrics: [
- { name: 'AUC', value: '.55', step: 0 },
- { name: 'Accuracy', value: '.99', step: 1 },
- { name: 'Accuracy', value: '.98', step: 2 },
- { name: 'Accuracy', value: '.97', step: 3 },
- { name: 'F1', value: '.1', step: 3 },
- ],
- metadata: [
- { name: 'FileName', value: 'test.py' },
- { name: 'ExecutionTime', value: '.0856' },
- ],
- info: {
- iid: 'candidate_iid',
- eid: 'abcdefg',
- path_to_artifact: 'path_to_artifact',
- experiment_name: 'The Experiment',
- 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/model_registry/apps/index_ml_models_spec.js b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js
index 6e0ab2ebe2d..66a447e73d3 100644
--- a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js
+++ b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js
@@ -1,12 +1,13 @@
+import { GlBadge } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { IndexMlModels } from '~/ml/model_registry/apps';
import ModelRow from '~/ml/model_registry/components/model_row.vue';
-import { TITLE_LABEL, NO_MODELS_LABEL } from '~/ml/model_registry/translations';
import Pagination from '~/vue_shared/components/incubation/pagination.vue';
import SearchBar from '~/ml/model_registry/components/search_bar.vue';
-import { BASE_SORT_FIELDS } from '~/ml/model_registry/constants';
+import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '~/ml/model_registry/constants';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import EmptyState from '~/ml/model_registry/components/empty_state.vue';
import { mockModels, startCursor, defaultPageInfo } from '../mock_data';
let wrapper;
@@ -18,17 +19,18 @@ const createWrapper = (
const findModelRow = (index) => wrapper.findAllComponents(ModelRow).at(index);
const findPagination = () => wrapper.findComponent(Pagination);
-const findEmptyLabel = () => wrapper.findByText(NO_MODELS_LABEL);
+const findEmptyState = () => wrapper.findComponent(EmptyState);
const findSearchBar = () => wrapper.findComponent(SearchBar);
const findTitleArea = () => wrapper.findComponent(TitleArea);
const findModelCountMetadataItem = () => findTitleArea().findComponent(MetadataItem);
+const findBadge = () => wrapper.findComponent(GlBadge);
describe('MlModelsIndex', () => {
describe('empty state', () => {
beforeEach(() => createWrapper({ models: [], pageInfo: defaultPageInfo }));
- it('displays empty state when no experiment', () => {
- expect(findEmptyLabel().exists()).toBe(true);
+ it('shows empty state', () => {
+ expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.model);
});
it('does not show pagination', () => {
@@ -46,12 +48,16 @@ describe('MlModelsIndex', () => {
});
it('does not show empty state', () => {
- expect(findEmptyLabel().exists()).toBe(false);
+ expect(findEmptyState().exists()).toBe(false);
});
describe('header', () => {
it('displays the title', () => {
- expect(findTitleArea().props('title')).toBe(TITLE_LABEL);
+ expect(findTitleArea().text()).toContain('Model registry');
+ });
+
+ it('displays the experiment badge', () => {
+ expect(findBadge().attributes().href).toBe('/help/user/project/ml/model_registry/index.md');
});
it('sets model metadata item to model count', () => {
diff --git a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js
index bc4770976a9..1fe0f5f88b3 100644
--- a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js
+++ b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js
@@ -1,22 +1,41 @@
import { GlBadge, GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { ShowMlModel } from '~/ml/model_registry/apps';
+import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue';
+import CandidateList from '~/ml/model_registry/components/candidate_list.vue';
+import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue';
+import EmptyState from '~/ml/model_registry/components/empty_state.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
-import { NO_VERSIONS_LABEL } from '~/ml/model_registry/translations';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { MODEL_ENTITIES } from '~/ml/model_registry/constants';
import { MODEL, makeModel } from '../mock_data';
+const apolloProvider = createMockApollo([]);
let wrapper;
+
+Vue.use(VueApollo);
+
const createWrapper = (model = MODEL) => {
- wrapper = shallowMount(ShowMlModel, { propsData: { model } });
+ wrapper = shallowMount(ShowMlModel, {
+ apolloProvider,
+ propsData: { model },
+ stubs: { GlTab },
+ });
};
const findDetailTab = () => wrapper.findAllComponents(GlTab).at(0);
const findVersionsTab = () => wrapper.findAllComponents(GlTab).at(1);
const findVersionsCountBadge = () => findVersionsTab().findComponent(GlBadge);
+const findModelVersionList = () => findVersionsTab().findComponent(ModelVersionList);
+const findModelVersionDetail = () => findDetailTab().findComponent(ModelVersionDetail);
const findCandidateTab = () => wrapper.findAllComponents(GlTab).at(2);
+const findCandidateList = () => findCandidateTab().findComponent(CandidateList);
const findCandidatesCountBadge = () => findCandidateTab().findComponent(GlBadge);
const findTitleArea = () => wrapper.findComponent(TitleArea);
+const findEmptyState = () => wrapper.findComponent(EmptyState);
const findVersionCountMetadataItem = () => findTitleArea().findComponent(MetadataItem);
describe('ShowMlModel', () => {
@@ -45,7 +64,11 @@ describe('ShowMlModel', () => {
describe('when it has latest version', () => {
it('displays the version', () => {
- expect(findDetailTab().text()).toContain(MODEL.latestVersion.version);
+ expect(findModelVersionDetail().props('modelVersion')).toBe(MODEL.latestVersion);
+ });
+
+ it('displays the title', () => {
+ expect(findDetailTab().text()).toContain('Latest version: 1.2.3');
});
});
@@ -54,8 +77,12 @@ describe('ShowMlModel', () => {
createWrapper(makeModel({ latestVersion: null }));
});
- it('shows no version message', () => {
- expect(findDetailTab().text()).toContain(NO_VERSIONS_LABEL);
+ it('shows empty state', () => {
+ expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.modelVersion);
+ });
+
+ it('does not render model version detail', () => {
+ expect(findModelVersionDetail().exists()).toBe(false);
});
});
});
@@ -66,6 +93,10 @@ describe('ShowMlModel', () => {
it('shows the number of versions in the tab', () => {
expect(findVersionsCountBadge().text()).toBe(MODEL.versionCount.toString());
});
+
+ it('shows a list of model versions', () => {
+ expect(findModelVersionList().props('modelId')).toBe(MODEL.id);
+ });
});
describe('Candidates tab', () => {
@@ -74,5 +105,9 @@ describe('ShowMlModel', () => {
it('shows the number of candidates in the tab', () => {
expect(findCandidatesCountBadge().text()).toBe(MODEL.candidateCount.toString());
});
+
+ it('shows a list of candidates', () => {
+ expect(findCandidateList().props('modelId')).toBe(MODEL.id);
+ });
});
});
diff --git a/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js b/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js
index 77fca53c00e..2605a75d961 100644
--- a/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js
+++ b/spec/frontend/ml/model_registry/apps/show_ml_model_version_spec.js
@@ -1,5 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { ShowMlModelVersion } from '~/ml/model_registry/apps';
+import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { MODEL_VERSION } from '../mock_data';
let wrapper;
@@ -7,9 +9,17 @@ const createWrapper = () => {
wrapper = shallowMount(ShowMlModelVersion, { propsData: { modelVersion: MODEL_VERSION } });
};
-describe('ShowMlModelVersion', () => {
+const findTitleArea = () => wrapper.findComponent(TitleArea);
+const findModelVersionDetail = () => wrapper.findComponent(ModelVersionDetail);
+
+describe('ml/model_registry/apps/show_model_version.vue', () => {
beforeEach(() => createWrapper());
- it('renders the app', () => {
- expect(wrapper.text()).toContain(`${MODEL_VERSION.model.name} - ${MODEL_VERSION.version}`);
+
+ it('renders the title', () => {
+ expect(findTitleArea().props('title')).toBe('blah / 1.2.3');
+ });
+
+ it('renders the model version detail', () => {
+ expect(findModelVersionDetail().props('modelVersion')).toBe(MODEL_VERSION);
});
});
diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js b/spec/frontend/ml/model_registry/components/candidate_detail_row_spec.js
index cd252560590..24b18b6b42d 100644
--- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js
+++ b/spec/frontend/ml/model_registry/components/candidate_detail_row_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue';
+import DetailRow from '~/ml/model_registry/components/candidate_detail_row.vue';
describe('CandidateDetailRow', () => {
const ROW_LABEL_CELL = 0;
diff --git a/spec/frontend/ml/model_registry/components/candidate_detail_spec.js b/spec/frontend/ml/model_registry/components/candidate_detail_spec.js
new file mode 100644
index 00000000000..94aa65a1690
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/candidate_detail_spec.js
@@ -0,0 +1,191 @@
+import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue';
+import DetailRow from '~/ml/model_registry/components/candidate_detail_row.vue';
+import {
+ NO_PARAMETERS_MESSAGE,
+ NO_METRICS_MESSAGE,
+ NO_METADATA_MESSAGE,
+ NO_CI_MESSAGE,
+} from '~/ml/model_registry/translations';
+import { stubComponent } from 'helpers/stub_component';
+import { newCandidate } from '../mock_data';
+
+describe('ml/model_registry/components/candidate_detail.vue', () => {
+ let wrapper;
+ const CANDIDATE = newCandidate();
+ const USER_ROW = 1;
+
+ const INFO_SECTION = 0;
+ const CI_SECTION = 1;
+ const PARAMETER_SECTION = 2;
+ const METADATA_SECTION = 3;
+
+ const createWrapper = (createCandidate = () => CANDIDATE, showInfoSection = true) => {
+ wrapper = shallowMountExtended(CandidateDetail, {
+ propsData: { candidate: createCandidate(), showInfoSection },
+ stubs: {
+ GlTableLite: { ...stubComponent(GlTableLite), props: ['items', 'fields'] },
+ },
+ });
+ };
+
+ const findSection = (section) => wrapper.findAll('section').at(section);
+ const findRowInSection = (section, row) =>
+ findSection(section).findAllComponents(DetailRow).at(row);
+ const findLinkAtRow = (section, rowIndex) =>
+ findRowInSection(section, rowIndex).findComponent(GlLink);
+ const findNoDataMessage = (label) => wrapper.findByText(label);
+ const findLabel = (label) => wrapper.find(`[label='${label}']`);
+ const findCiUserDetailRow = () => findRowInSection(CI_SECTION, USER_ROW);
+ const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled);
+ const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink);
+ const findMetricsTable = () => wrapper.findComponent(GlTableLite);
+
+ describe('All info available', () => {
+ beforeEach(() => createWrapper());
+
+ const mrText = `!${CANDIDATE.info.ciJob.mergeRequest.iid} ${CANDIDATE.info.ciJob.mergeRequest.title}`;
+ const expectedTable = [
+ [INFO_SECTION, 0, 'ID', CANDIDATE.info.iid],
+ [INFO_SECTION, 1, 'MLflow run ID', CANDIDATE.info.eid],
+ [INFO_SECTION, 2, 'Status', CANDIDATE.info.status],
+ [INFO_SECTION, 3, 'Experiment', CANDIDATE.info.experimentName],
+ [INFO_SECTION, 4, 'Artifacts', 'Artifacts'],
+ [CI_SECTION, 0, 'Job', CANDIDATE.info.ciJob.name],
+ [CI_SECTION, 1, 'Triggered by', 'CI User'],
+ [CI_SECTION, 2, 'Merge request', mrText],
+ [PARAMETER_SECTION, 0, CANDIDATE.params[0].name, CANDIDATE.params[0].value],
+ [PARAMETER_SECTION, 1, CANDIDATE.params[1].name, CANDIDATE.params[1].value],
+ [METADATA_SECTION, 0, CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value],
+ [METADATA_SECTION, 1, CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value],
+ ];
+
+ it.each(expectedTable)('row %s is created correctly', (section, rowIndex, label, text) => {
+ const row = findRowInSection(section, rowIndex);
+
+ expect(row.props()).toMatchObject({ label });
+ expect(row.text()).toBe(text);
+ });
+
+ describe('Table links', () => {
+ const linkRows = [
+ [INFO_SECTION, 3, CANDIDATE.info.pathToExperiment],
+ [INFO_SECTION, 4, CANDIDATE.info.pathToArtifact],
+ [CI_SECTION, 0, CANDIDATE.info.ciJob.path],
+ [CI_SECTION, 2, CANDIDATE.info.ciJob.mergeRequest.path],
+ ];
+
+ it.each(linkRows)('row %s is created correctly', (section, rowIndex, href) => {
+ expect(findLinkAtRow(section, rowIndex).attributes().href).toBe(href);
+ });
+ });
+
+ describe('Metrics table', () => {
+ it('computes metrics table items correctly', () => {
+ expect(findMetricsTable().props('items')).toEqual([
+ { name: 'AUC', 0: '.55' },
+ { name: 'Accuracy', 1: '.99', 2: '.98', 3: '.97' },
+ { name: 'F1', 3: '.1' },
+ ]);
+ });
+
+ it('computes metrics table fields correctly', () => {
+ expect(findMetricsTable().props('fields')).toEqual([
+ expect.objectContaining({ key: 'name', label: 'Metric' }),
+ expect.objectContaining({ key: '0', label: 'Step 0' }),
+ expect.objectContaining({ key: '1', label: 'Step 1' }),
+ expect.objectContaining({ key: '2', label: 'Step 2' }),
+ expect.objectContaining({ key: '3', label: 'Step 3' }),
+ ]);
+ });
+ });
+
+ 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');
+ });
+ });
+ });
+
+ describe('No artifact path', () => {
+ beforeEach(() =>
+ createWrapper(() => {
+ const candidate = newCandidate();
+ delete candidate.info.pathToArtifact;
+ return candidate;
+ }),
+ );
+
+ it('does not render artifact row', () => {
+ expect(findLabel('Artifacts').exists()).toBe(false);
+ });
+ });
+
+ describe('No params, metrics, ci or metadata available', () => {
+ beforeEach(() =>
+ createWrapper(() => {
+ const candidate = newCandidate();
+ delete candidate.params;
+ delete candidate.metrics;
+ delete candidate.metadata;
+ delete candidate.info.ciJob;
+ return candidate;
+ }),
+ );
+
+ it('does not render params', () => {
+ expect(findNoDataMessage(NO_PARAMETERS_MESSAGE).exists()).toBe(true);
+ });
+
+ it('does not render metadata', () => {
+ expect(findNoDataMessage(NO_METADATA_MESSAGE).exists()).toBe(true);
+ });
+
+ it('does not render metrics', () => {
+ expect(findNoDataMessage(NO_METRICS_MESSAGE).exists()).toBe(true);
+ });
+
+ it('does not render CI info', () => {
+ expect(findNoDataMessage(NO_CI_MESSAGE).exists()).toBe(true);
+ });
+ });
+
+ describe('Has CI, but no user or mr', () => {
+ beforeEach(() =>
+ createWrapper(() => {
+ const candidate = newCandidate();
+ delete candidate.info.ciJob.user;
+ delete candidate.info.ciJob.mergeRequest;
+ 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);
+ });
+ });
+
+ describe('showInfoSection is set to false', () => {
+ beforeEach(() => createWrapper(() => CANDIDATE, false));
+
+ it('does not render the info section', () => {
+ expect(findLabel('MLflow run ID').exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/candidate_list_row_spec.js b/spec/frontend/ml/model_registry/components/candidate_list_row_spec.js
new file mode 100644
index 00000000000..5ac8d07ff01
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/candidate_list_row_spec.js
@@ -0,0 +1,39 @@
+import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue';
+import { graphqlCandidates } from '../graphql_mock_data';
+
+const CANDIDATE = graphqlCandidates[0];
+
+let wrapper;
+const createWrapper = (candidate = CANDIDATE) => {
+ wrapper = shallowMount(CandidateListRow, {
+ propsData: { candidate },
+ stubs: {
+ GlSprintf,
+ GlTruncate,
+ },
+ });
+};
+
+const findListItem = () => wrapper.findComponent(ListItem);
+const findLink = () => findListItem().findComponent(GlLink);
+const findTruncated = () => findLink().findComponent(GlTruncate);
+const findTooltip = () => findListItem().findComponent(TimeAgoTooltip);
+
+describe('ml/model_registry/components/candidate_list_row.vue', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('Has a link to the candidate', () => {
+ expect(findTruncated().props('text')).toBe(CANDIDATE.name);
+ expect(findLink().attributes('href')).toBe(CANDIDATE._links.showPath);
+ });
+
+ it('Shows created at', () => {
+ expect(findTooltip().props('time')).toBe(CANDIDATE.createdAt);
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/candidate_list_spec.js b/spec/frontend/ml/model_registry/components/candidate_list_spec.js
new file mode 100644
index 00000000000..c10222a99fd
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/candidate_list_spec.js
@@ -0,0 +1,182 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import CandidateList from '~/ml/model_registry/components/candidate_list.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue';
+import getModelCandidatesQuery from '~/ml/model_registry/graphql/queries/get_model_candidates.query.graphql';
+import { GRAPHQL_PAGE_SIZE } from '~/ml/model_registry/constants';
+import {
+ emptyCandidateQuery,
+ modelCandidatesQuery,
+ graphqlCandidates,
+ graphqlPageInfo,
+} from '../graphql_mock_data';
+
+Vue.use(VueApollo);
+
+describe('ml/model_registry/components/candidate_list.vue', () => {
+ let wrapper;
+ let apolloProvider;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLoader = () => wrapper.findComponent(PackagesListLoader);
+ const findRegistryList = () => wrapper.findComponent(RegistryList);
+ const findListRow = () => wrapper.findComponent(CandidateListRow);
+ const findAllRows = () => wrapper.findAllComponents(CandidateListRow);
+
+ const mountComponent = ({
+ props = {},
+ resolver = jest.fn().mockResolvedValue(modelCandidatesQuery()),
+ } = {}) => {
+ const requestHandlers = [[getModelCandidatesQuery, resolver]];
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMount(CandidateList, {
+ apolloProvider,
+ propsData: {
+ modelId: 2,
+ ...props,
+ },
+ stubs: {
+ RegistryList,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'captureException').mockImplementation();
+ });
+
+ describe('when list is loaded and has no data', () => {
+ const resolver = jest.fn().mockResolvedValue(emptyCandidateQuery);
+ beforeEach(async () => {
+ mountComponent({ resolver });
+ await waitForPromises();
+ });
+
+ it('displays empty slot message', () => {
+ expect(wrapper.text()).toContain('This model has no candidates');
+ });
+
+ it('does not display loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not display rows', () => {
+ expect(findListRow().exists()).toBe(false);
+ });
+
+ it('does not display registry list', () => {
+ expect(findRegistryList().exists()).toBe(false);
+ });
+
+ it('does not display alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('if load fails, alert', () => {
+ beforeEach(async () => {
+ const error = new Error('Failure!');
+ mountComponent({ resolver: jest.fn().mockRejectedValue(error) });
+
+ await waitForPromises();
+ });
+
+ it('is displayed', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('shows error message', () => {
+ expect(findAlert().text()).toContain('Failed to load model candidates with error: Failure!');
+ });
+
+ it('is not dismissible', () => {
+ expect(findAlert().props('dismissible')).toBe(false);
+ });
+
+ it('is of variant danger', () => {
+ expect(findAlert().attributes('variant')).toBe('danger');
+ });
+
+ it('error is logged in sentry', () => {
+ expect(Sentry.captureException).toHaveBeenCalled();
+ });
+ });
+
+ describe('when list is loaded with data', () => {
+ beforeEach(async () => {
+ mountComponent();
+ await waitForPromises();
+ });
+
+ it('displays package registry list', () => {
+ expect(findRegistryList().exists()).toEqual(true);
+ });
+
+ it('binds the right props', () => {
+ expect(findRegistryList().props()).toMatchObject({
+ items: graphqlCandidates,
+ pagination: {},
+ isLoading: false,
+ hiddenDelete: true,
+ });
+ });
+
+ it('displays candidate rows', () => {
+ expect(findAllRows().exists()).toEqual(true);
+ expect(findAllRows()).toHaveLength(graphqlCandidates.length);
+ });
+
+ it('binds the correct props', () => {
+ expect(findAllRows().at(0).props()).toMatchObject({
+ candidate: expect.objectContaining(graphqlCandidates[0]),
+ });
+
+ expect(findAllRows().at(1).props()).toMatchObject({
+ candidate: expect.objectContaining(graphqlCandidates[1]),
+ });
+ });
+
+ it('does not display loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not display empty message', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when user interacts with pagination', () => {
+ const resolver = jest.fn().mockResolvedValue(modelCandidatesQuery());
+
+ beforeEach(async () => {
+ mountComponent({ resolver });
+ await waitForPromises();
+ });
+
+ it('when list emits next-page fetches the next set of records', async () => {
+ findRegistryList().vm.$emit('next-page');
+ await waitForPromises();
+
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
+ );
+ });
+
+ it('when list emits prev-page fetches the prev set of records', async () => {
+ findRegistryList().vm.$emit('prev-page');
+ await waitForPromises();
+
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/empty_state_spec.js b/spec/frontend/ml/model_registry/components/empty_state_spec.js
new file mode 100644
index 00000000000..e9477518f7d
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/empty_state_spec.js
@@ -0,0 +1,47 @@
+import { GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { MODEL_ENTITIES } from '~/ml/model_registry/constants';
+import EmptyState from '~/ml/model_registry/components/empty_state.vue';
+
+let wrapper;
+const createWrapper = (entityType) => {
+ wrapper = shallowMount(EmptyState, { propsData: { entityType } });
+};
+
+const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+describe('ml/model_registry/components/empty_state.vue', () => {
+ describe('when entity type is model', () => {
+ beforeEach(() => {
+ createWrapper(MODEL_ENTITIES.model);
+ });
+
+ it('shows the correct empty state', () => {
+ expect(findEmptyState().props()).toMatchObject({
+ title: 'Start tracking your machine learning models',
+ description: 'Store and manage your machine learning models and versions',
+ primaryButtonText: 'Add a model',
+ primaryButtonLink:
+ '/help/user/project/ml/model_registry/index#creating-machine-learning-models-and-model-versions',
+ svgPath: 'file-mock',
+ });
+ });
+ });
+
+ describe('when entity type is model version', () => {
+ beforeEach(() => {
+ createWrapper(MODEL_ENTITIES.modelVersion);
+ });
+
+ it('shows the correct empty state', () => {
+ expect(findEmptyState().props()).toMatchObject({
+ title: 'Manage versions of your machine learning model',
+ description: 'Use versions to track performance, parameters, and metadata',
+ primaryButtonText: 'Create a model version',
+ primaryButtonLink:
+ '/help/user/project/ml/model_registry/index#creating-machine-learning-models-and-model-versions',
+ svgPath: 'file-mock',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/model_version_detail_spec.js b/spec/frontend/ml/model_registry/components/model_version_detail_spec.js
new file mode 100644
index 00000000000..d1874346ad7
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/model_version_detail_spec.js
@@ -0,0 +1,66 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import ModelVersionDetail from '~/ml/model_registry/components/model_version_detail.vue';
+import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
+import CandidateDetail from '~/ml/model_registry/components/candidate_detail.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { makeModelVersion, MODEL_VERSION } from '../mock_data';
+
+Vue.use(VueApollo);
+
+let wrapper;
+const createWrapper = (modelVersion = MODEL_VERSION) => {
+ const apolloProvider = createMockApollo([]);
+ wrapper = shallowMount(ModelVersionDetail, { apolloProvider, propsData: { modelVersion } });
+};
+
+const findPackageFiles = () => wrapper.findComponent(PackageFiles);
+const findCandidateDetail = () => wrapper.findComponent(CandidateDetail);
+
+describe('ml/model_registry/components/model_version_detail.vue', () => {
+ describe('base behaviour', () => {
+ beforeEach(() => createWrapper());
+
+ it('shows the description', () => {
+ expect(wrapper.text()).toContain(MODEL_VERSION.description);
+ });
+
+ it('shows the candidate', () => {
+ expect(findCandidateDetail().props('candidate')).toBe(MODEL_VERSION.candidate);
+ });
+
+ it('shows the mlflow label string', () => {
+ expect(wrapper.text()).toContain('MLflow run ID');
+ });
+
+ it('shows the mlflow id', () => {
+ expect(wrapper.text()).toContain(MODEL_VERSION.candidate.info.eid);
+ });
+
+ it('renders files', () => {
+ expect(findPackageFiles().props()).toEqual({
+ packageId: 'gid://gitlab/Packages::Package/12',
+ projectPath: MODEL_VERSION.projectPath,
+ packageType: 'ml_model',
+ canDelete: false,
+ });
+ });
+ });
+
+ describe('if package does not exist', () => {
+ beforeEach(() => createWrapper(makeModelVersion({ packageId: 0 })));
+
+ it('does not render files', () => {
+ expect(findPackageFiles().exists()).toBe(false);
+ });
+ });
+
+ describe('if model version does not have description', () => {
+ beforeEach(() => createWrapper(makeModelVersion({ description: null })));
+
+ it('renders no description provided label', () => {
+ expect(wrapper.text()).toContain('No description provided');
+ });
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/model_version_list_spec.js b/spec/frontend/ml/model_registry/components/model_version_list_spec.js
new file mode 100644
index 00000000000..41f7e71c543
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/model_version_list_spec.js
@@ -0,0 +1,184 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import ModelVersionRow from '~/ml/model_registry/components/model_version_row.vue';
+import getModelVersionsQuery from '~/ml/model_registry/graphql/queries/get_model_versions.query.graphql';
+import EmptyState from '~/ml/model_registry/components/empty_state.vue';
+import { GRAPHQL_PAGE_SIZE, MODEL_ENTITIES } from '~/ml/model_registry/constants';
+import {
+ emptyModelVersionsQuery,
+ modelVersionsQuery,
+ graphqlModelVersions,
+ graphqlPageInfo,
+} from '../graphql_mock_data';
+
+Vue.use(VueApollo);
+
+describe('ModelVersionList', () => {
+ let wrapper;
+ let apolloProvider;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLoader = () => wrapper.findComponent(PackagesListLoader);
+ const findRegistryList = () => wrapper.findComponent(RegistryList);
+ const findEmptyState = () => wrapper.findComponent(EmptyState);
+ const findListRow = () => wrapper.findComponent(ModelVersionRow);
+ const findAllRows = () => wrapper.findAllComponents(ModelVersionRow);
+
+ const mountComponent = ({
+ props = {},
+ resolver = jest.fn().mockResolvedValue(modelVersionsQuery()),
+ } = {}) => {
+ const requestHandlers = [[getModelVersionsQuery, resolver]];
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMountExtended(ModelVersionList, {
+ apolloProvider,
+ propsData: {
+ modelId: 2,
+ ...props,
+ },
+ stubs: {
+ RegistryList,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'captureException').mockImplementation();
+ });
+
+ describe('when list is loaded and has no data', () => {
+ const resolver = jest.fn().mockResolvedValue(emptyModelVersionsQuery);
+ beforeEach(async () => {
+ mountComponent({ resolver });
+ await waitForPromises();
+ });
+
+ it('shows empty state', () => {
+ expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.modelVersion);
+ });
+
+ it('does not display loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not display rows', () => {
+ expect(findListRow().exists()).toBe(false);
+ });
+
+ it('does not display registry list', () => {
+ expect(findRegistryList().exists()).toBe(false);
+ });
+
+ it('does not display alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('if load fails, alert', () => {
+ beforeEach(async () => {
+ const error = new Error('Failure!');
+ mountComponent({ resolver: jest.fn().mockRejectedValue(error) });
+
+ await waitForPromises();
+ });
+
+ it('is displayed', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('shows error message', () => {
+ expect(findAlert().text()).toContain('Failed to load model versions with error: Failure!');
+ });
+
+ it('is not dismissible', () => {
+ expect(findAlert().props('dismissible')).toBe(false);
+ });
+
+ it('is of variant danger', () => {
+ expect(findAlert().attributes('variant')).toBe('danger');
+ });
+
+ it('error is logged in sentry', () => {
+ expect(Sentry.captureException).toHaveBeenCalled();
+ });
+ });
+
+ describe('when list is loaded with data', () => {
+ beforeEach(async () => {
+ mountComponent();
+ await waitForPromises();
+ });
+
+ it('displays package registry list', () => {
+ expect(findRegistryList().exists()).toEqual(true);
+ });
+
+ it('binds the right props', () => {
+ expect(findRegistryList().props()).toMatchObject({
+ items: graphqlModelVersions,
+ pagination: {},
+ isLoading: false,
+ hiddenDelete: true,
+ });
+ });
+
+ it('displays package version rows', () => {
+ expect(findAllRows().exists()).toEqual(true);
+ expect(findAllRows()).toHaveLength(graphqlModelVersions.length);
+ });
+
+ it('binds the correct props', () => {
+ expect(findAllRows().at(0).props()).toMatchObject({
+ modelVersion: expect.objectContaining(graphqlModelVersions[0]),
+ });
+
+ expect(findAllRows().at(1).props()).toMatchObject({
+ modelVersion: expect.objectContaining(graphqlModelVersions[1]),
+ });
+ });
+
+ it('does not display loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not display empty state', () => {
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('when user interacts with pagination', () => {
+ const resolver = jest.fn().mockResolvedValue(modelVersionsQuery());
+
+ beforeEach(async () => {
+ mountComponent({ resolver });
+ await waitForPromises();
+ });
+
+ it('when list emits next-page fetches the next set of records', async () => {
+ findRegistryList().vm.$emit('next-page');
+ await waitForPromises();
+
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
+ );
+ });
+
+ it('when list emits prev-page fetches the prev set of records', async () => {
+ findRegistryList().vm.$emit('prev-page');
+ await waitForPromises();
+
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/model_version_row_spec.js b/spec/frontend/ml/model_registry/components/model_version_row_spec.js
new file mode 100644
index 00000000000..9f709f2e072
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/model_version_row_spec.js
@@ -0,0 +1,37 @@
+import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import ModelVersionRow from '~/ml/model_registry/components/model_version_row.vue';
+import { graphqlModelVersions } from '../graphql_mock_data';
+
+let wrapper;
+const createWrapper = (modelVersion = graphqlModelVersions[0]) => {
+ wrapper = shallowMount(ModelVersionRow, {
+ propsData: { modelVersion },
+ stubs: {
+ GlSprintf,
+ GlTruncate,
+ },
+ });
+};
+
+const findListItem = () => wrapper.findComponent(ListItem);
+const findLink = () => findListItem().findComponent(GlLink);
+const findTruncated = () => findLink().findComponent(GlTruncate);
+const findTooltip = () => findListItem().findComponent(TimeAgoTooltip);
+
+describe('ModelVersionRow', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('Has a link to the model version', () => {
+ expect(findTruncated().props('text')).toBe(graphqlModelVersions[0].version);
+ expect(findLink().attributes('href')).toBe(graphqlModelVersions[0]._links.showPath);
+ });
+
+ it('Shows created at', () => {
+ expect(findTooltip().props('time')).toBe(graphqlModelVersions[0].createdAt);
+ });
+});
diff --git a/spec/frontend/ml/model_registry/graphql_mock_data.js b/spec/frontend/ml/model_registry/graphql_mock_data.js
new file mode 100644
index 00000000000..1c31ee4627f
--- /dev/null
+++ b/spec/frontend/ml/model_registry/graphql_mock_data.js
@@ -0,0 +1,116 @@
+import { defaultPageInfo } from './mock_data';
+
+export const graphqlPageInfo = {
+ ...defaultPageInfo,
+ __typename: 'PageInfo',
+};
+
+export const graphqlModelVersions = [
+ {
+ createdAt: '2021-08-10T09:33:54Z',
+ id: 'gid://gitlab/Ml::ModelVersion/243',
+ version: '1.0.1',
+ _links: {
+ showPath: '/path/to/modelversion/243',
+ },
+ __typename: 'MlModelVersion',
+ },
+ {
+ createdAt: '2021-08-10T09:33:54Z',
+ id: 'gid://gitlab/Ml::ModelVersion/244',
+ version: '1.0.2',
+ _links: {
+ showPath: '/path/to/modelversion/244',
+ },
+ __typename: 'MlModelVersion',
+ },
+];
+
+export const modelVersionsQuery = (versions = graphqlModelVersions) => ({
+ data: {
+ mlModel: {
+ id: 'gid://gitlab/Ml::Model/2',
+ versions: {
+ count: versions.length,
+ nodes: versions,
+ pageInfo: graphqlPageInfo,
+ __typename: 'MlModelConnection',
+ },
+ __typename: 'MlModelType',
+ },
+ },
+});
+
+export const graphqlCandidates = [
+ {
+ id: 'gid://gitlab/Ml::Candidate/1',
+ name: 'narwhal-aardvark-heron-6953',
+ createdAt: '2023-12-06T12:41:48Z',
+ _links: {
+ showPath: '/path/to/candidate/1',
+ },
+ },
+ {
+ id: 'gid://gitlab/Ml::Candidate/2',
+ name: 'anteater-chimpanzee-snake-1254',
+ createdAt: '2023-12-06T12:41:48Z',
+ _links: {
+ showPath: '/path/to/candidate/2',
+ },
+ },
+];
+
+export const modelCandidatesQuery = (candidates = graphqlCandidates) => ({
+ data: {
+ mlModel: {
+ id: 'gid://gitlab/Ml::Model/2',
+ candidates: {
+ count: candidates.length,
+ nodes: candidates,
+ pageInfo: graphqlPageInfo,
+ __typename: 'MlCandidateConnection',
+ },
+ __typename: 'MlModelType',
+ },
+ },
+});
+
+export const emptyModelVersionsQuery = {
+ data: {
+ mlModel: {
+ id: 'gid://gitlab/Ml::Model/2',
+ versions: {
+ count: 0,
+ nodes: [],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ endCursor: 'endCursor',
+ startCursor: 'startCursor',
+ },
+ __typename: 'MlModelConnection',
+ },
+ __typename: 'MlModelType',
+ },
+ },
+};
+
+export const emptyCandidateQuery = {
+ data: {
+ mlModel: {
+ id: 'gid://gitlab/Ml::Model/2',
+ candidates: {
+ count: 0,
+ nodes: [],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ endCursor: 'endCursor',
+ startCursor: 'startCursor',
+ },
+ __typename: 'MlCandidateConnection',
+ },
+ __typename: 'MlModelType',
+ },
+ },
+};
diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js
index a820c323103..4399df38990 100644
--- a/spec/frontend/ml/model_registry/mock_data.js
+++ b/spec/frontend/ml/model_registry/mock_data.js
@@ -1,3 +1,45 @@
+export const newCandidate = () => ({
+ params: [
+ { name: 'Algorithm', value: 'Decision Tree' },
+ { name: 'MaxDepth', value: '3' },
+ ],
+ metrics: [
+ { name: 'AUC', value: '.55', step: 0 },
+ { name: 'Accuracy', value: '.99', step: 1 },
+ { name: 'Accuracy', value: '.98', step: 2 },
+ { name: 'Accuracy', value: '.97', step: 3 },
+ { name: 'F1', value: '.1', step: 3 },
+ ],
+ metadata: [
+ { name: 'FileName', value: 'test.py' },
+ { name: 'ExecutionTime', value: '.0856' },
+ ],
+ info: {
+ iid: 'candidate_iid',
+ eid: 'abcdefg',
+ pathToArtifact: 'path_to_artifact',
+ experimentName: 'The Experiment',
+ pathToExperiment: 'path/to/experiment',
+ status: 'SUCCESS',
+ path: 'path_to_candidate',
+ ciJob: {
+ name: 'test',
+ path: 'path/to/job',
+ mergeRequest: {
+ path: 'path/to/mr',
+ iid: 1,
+ title: 'Some MR',
+ },
+ user: {
+ path: 'path/to/ci/user',
+ name: 'CI User',
+ username: 'ciuser',
+ avatar: '/img.png',
+ },
+ },
+ },
+});
+
const LATEST_VERSION = {
version: '1.2.3',
};
@@ -14,7 +56,21 @@ export const makeModel = ({ latestVersion } = { latestVersion: LATEST_VERSION })
export const MODEL = makeModel();
-export const MODEL_VERSION = { version: '1.2.3', model: MODEL };
+export const makeModelVersion = ({
+ version = '1.2.3',
+ model = MODEL,
+ packageId = 12,
+ description = 'Model version description',
+} = {}) => ({
+ version,
+ model,
+ packageId,
+ description,
+ projectPath: 'path/to/project',
+ candidate: newCandidate(),
+});
+
+export const MODEL_VERSION = makeModelVersion();
export const mockModels = [
{
diff --git a/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js b/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js
deleted file mode 100644
index 841a543606f..00000000000
--- a/spec/frontend/ml/model_registry/routes/models/index/components/mock_data.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export const mockModels = [
- {
- name: 'model_1',
- version: '1.0',
- path: 'path/to/model_1',
- versionCount: 3,
- },
- {
- name: 'model_2',
- version: '1.1',
- path: 'path/to/model_2',
- versionCount: 1,
- },
-];
-
-export const modelWithoutVersion = {
- name: 'model_without_version',
- path: 'path/to/model_without_version',
- versionCount: 0,
-};
-
-export const startCursor = 'eyJpZCI6IjE2In0';
-
-export const defaultPageInfo = Object.freeze({
- startCursor,
- endCursor: 'eyJpZCI6IjIifQ',
- hasNextPage: true,
- hasPreviousPage: true,
-});
diff --git a/spec/frontend/nav/components/new_nav_toggle_spec.js b/spec/frontend/nav/components/new_nav_toggle_spec.js
deleted file mode 100644
index cf8e59d6522..00000000000
--- a/spec/frontend/nav/components/new_nav_toggle_spec.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import { mount, createWrapper } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import { getByText as getByTextHelper } from '@testing-library/dom';
-import { GlDisclosureDropdownItem, GlToggle } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
-import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
-import waitForPromises from 'helpers/wait_for_promises';
-import { createAlert } from '~/alert';
-import { s__ } from '~/locale';
-import { mockTracking } from 'helpers/tracking_helper';
-
-jest.mock('~/alert');
-
-const TEST_ENDPONT = 'https://example.com/toggle';
-
-describe('NewNavToggle', () => {
- useMockLocationHelper();
-
- let wrapper;
- let trackingSpy;
-
- const findToggle = () => wrapper.findComponent(GlToggle);
- const findDisclosureItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
-
- const createComponent = (propsData = { enabled: false }) => {
- wrapper = mount(NewNavToggle, {
- propsData: {
- endpoint: TEST_ENDPONT,
- ...propsData,
- },
- });
-
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- };
-
- const getByText = (text, options) =>
- createWrapper(getByTextHelper(wrapper.element, text, options));
-
- describe('When rendered in scope of the new navigation', () => {
- it('renders the disclosure item', () => {
- createComponent({ newNavigation: true, enabled: true });
- expect(findDisclosureItem().exists()).toBe(true);
- });
-
- describe('when user preference is enabled', () => {
- beforeEach(() => {
- createComponent({ newNavigation: true, enabled: true });
- });
-
- it('renders the toggle as enabled', () => {
- expect(findToggle().props('value')).toBe(true);
- });
- });
-
- describe('when user preference is disabled', () => {
- beforeEach(() => {
- createComponent({ enabled: false });
- });
-
- it('renders the toggle as disabled', () => {
- expect(findToggle().props('value')).toBe(false);
- });
- });
-
- describe.each`
- desc | actFn | toggleValue | trackingLabel | trackingProperty
- ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
- ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
- ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
- ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
- `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- createComponent({ enabled: toggleValue });
- });
-
- it('reloads the page on success', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
-
- actFn();
- await waitForPromises();
-
- expect(window.location.reload).toHaveBeenCalled();
- });
-
- it('shows an alert on error', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- actFn();
- await waitForPromises();
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: s__(
- 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.',
- ),
- }),
- );
- expect(window.location.reload).not.toHaveBeenCalled();
- });
-
- it('changes the toggle', async () => {
- await actFn();
-
- expect(findToggle().props('value')).toBe(!toggleValue);
- });
-
- it('tracks the Snowplow event', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
- await actFn();
- await waitForPromises();
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
- label: trackingLabel,
- property: trackingProperty,
- });
- });
-
- afterEach(() => {
- mock.restore();
- });
- });
- });
-
- describe('When rendered in scope of the current navigation', () => {
- it('renders its title', () => {
- createComponent();
- expect(getByText('Navigation redesign').exists()).toBe(true);
- });
-
- describe('when user preference is enabled', () => {
- beforeEach(() => {
- createComponent({ enabled: true });
- });
-
- it('renders the toggle as enabled', () => {
- expect(findToggle().props('value')).toBe(true);
- });
- });
-
- describe('when user preference is disabled', () => {
- beforeEach(() => {
- createComponent({ enabled: false });
- });
-
- it('renders the toggle as disabled', () => {
- expect(findToggle().props('value')).toBe(false);
- });
- });
-
- describe.each`
- desc | actFn | toggleValue | trackingLabel | trackingProperty
- ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
- ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${false} | ${'enable_new_nav_beta'} | ${'navigation_top'}
- ${'when toggle button is clicked'} | ${() => findToggle().trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
- ${'when menu item text is clicked'} | ${() => getByText('New navigation').trigger('click')} | ${true} | ${'disable_new_nav_beta'} | ${'nav_user_menu'}
- `('$desc', ({ actFn, toggleValue, trackingLabel, trackingProperty }) => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- createComponent({ enabled: toggleValue });
- });
-
- it('reloads the page on success', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
-
- actFn();
- await waitForPromises();
-
- expect(window.location.reload).toHaveBeenCalled();
- });
-
- it('shows an alert on error', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- actFn();
- await waitForPromises();
-
- expect(createAlert).toHaveBeenCalledWith(
- expect.objectContaining({
- message: s__(
- 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.',
- ),
- }),
- );
- expect(window.location.reload).not.toHaveBeenCalled();
- });
-
- it('changes the toggle', async () => {
- await actFn();
-
- expect(findToggle().props('value')).toBe(!toggleValue);
- });
-
- it('tracks the Snowplow event', async () => {
- mock.onPut(TEST_ENDPONT).reply(HTTP_STATUS_OK);
- await actFn();
- await waitForPromises();
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
- label: trackingLabel,
- property: trackingProperty,
- });
- });
-
- afterEach(() => {
- mock.restore();
- });
- });
- });
-});
diff --git a/spec/frontend/nav/components/responsive_app_spec.js b/spec/frontend/nav/components/responsive_app_spec.js
deleted file mode 100644
index 9d3b43520ec..00000000000
--- a/spec/frontend/nav/components/responsive_app_spec.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import ResponsiveApp from '~/nav/components/responsive_app.vue';
-import ResponsiveHeader from '~/nav/components/responsive_header.vue';
-import ResponsiveHome from '~/nav/components/responsive_home.vue';
-import TopNavContainerView from '~/nav/components/top_nav_container_view.vue';
-import { resetMenuItemsActive } from '~/nav/utils/reset_menu_items_active';
-import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
-import { TEST_NAV_DATA } from '../mock_data';
-
-describe('~/nav/components/responsive_app.vue', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = shallowMount(ResponsiveApp, {
- propsData: {
- navData: TEST_NAV_DATA,
- },
- stubs: {
- KeepAliveSlots,
- },
- });
- };
- const findHome = () => wrapper.findComponent(ResponsiveHome);
- const findMobileOverlay = () => wrapper.find('[data-testid="mobile-overlay"]');
- const findSubviewHeader = () => wrapper.findComponent(ResponsiveHeader);
- const findSubviewContainer = () => wrapper.findComponent(TopNavContainerView);
- const hasMobileOverlayVisible = () => findMobileOverlay().classes('mobile-nav-open');
-
- beforeEach(() => {
- document.body.innerHTML = '';
- // Add test class to reset state + assert that we're adding classes correctly
- document.body.className = 'test-class';
- });
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('shows home by default', () => {
- expect(findHome().isVisible()).toBe(true);
- expect(findHome().props()).toEqual({
- navData: resetMenuItemsActive(TEST_NAV_DATA),
- });
- });
-
- it.each`
- events | expectation
- ${[]} | ${false}
- ${['bv::dropdown::show']} | ${true}
- ${['bv::dropdown::show', 'bv::dropdown::hide']} | ${false}
- `(
- 'with root events $events, movile overlay visible = $expectation',
- async ({ events, expectation }) => {
- // `await...reduce(async` is like doing an `forEach(async (...))` excpet it works
- await events.reduce(async (acc, evt) => {
- await acc;
-
- wrapper.vm.$root.$emit(evt);
-
- await nextTick();
- }, Promise.resolve());
-
- expect(hasMobileOverlayVisible()).toBe(expectation);
- },
- );
- });
-
- const projectsContainerProps = {
- containerClass: 'gl-px-3',
- frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.namespace,
- frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_PROJECTS.vuexModule,
- currentItem: {},
- linksPrimary: TEST_NAV_DATA.views.projects.linksPrimary,
- linksSecondary: TEST_NAV_DATA.views.projects.linksSecondary,
- };
- const groupsContainerProps = {
- containerClass: 'gl-px-3',
- frequentItemsDropdownType: ResponsiveApp.FREQUENT_ITEMS_GROUPS.namespace,
- frequentItemsVuexModule: ResponsiveApp.FREQUENT_ITEMS_GROUPS.vuexModule,
- currentItem: {},
- linksPrimary: TEST_NAV_DATA.views.groups.linksPrimary,
- linksSecondary: TEST_NAV_DATA.views.groups.linksSecondary,
- };
-
- describe.each`
- view | header | containerProps
- ${'projects'} | ${'Projects'} | ${projectsContainerProps}
- ${'groups'} | ${'Groups'} | ${groupsContainerProps}
- `('when menu item with $view is clicked', ({ view, header, containerProps }) => {
- beforeEach(async () => {
- createComponent();
-
- findHome().vm.$emit('menu-item-click', { view });
-
- await nextTick();
- });
-
- it('shows header', () => {
- expect(findSubviewHeader().text()).toBe(header);
- });
-
- it('shows container subview', () => {
- expect(findSubviewContainer().props()).toEqual(containerProps);
- });
-
- it('hides home', () => {
- expect(findHome().isVisible()).toBe(false);
- });
-
- describe('when header back button is clicked', () => {
- beforeEach(() => {
- findSubviewHeader().vm.$emit('menu-item-click', { view: 'home' });
- });
-
- it('shows home', () => {
- expect(findHome().isVisible()).toBe(true);
- });
- });
- });
-});
diff --git a/spec/frontend/nav/components/responsive_header_spec.js b/spec/frontend/nav/components/responsive_header_spec.js
deleted file mode 100644
index 2514035270a..00000000000
--- a/spec/frontend/nav/components/responsive_header_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import ResponsiveHeader from '~/nav/components/responsive_header.vue';
-import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
-
-const TEST_SLOT_CONTENT = 'Test slot content';
-
-describe('~/nav/components/top_nav_menu_sections.vue', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = shallowMount(ResponsiveHeader, {
- slots: {
- default: TEST_SLOT_CONTENT,
- },
- directives: {
- GlTooltip: createMockDirective('gl-tooltip'),
- },
- });
- };
-
- const findMenuItem = () => wrapper.findComponent(TopNavMenuItem);
-
- beforeEach(() => {
- createComponent();
- });
-
- it('renders slot', () => {
- expect(wrapper.text()).toBe(TEST_SLOT_CONTENT);
- });
-
- it('renders back button', () => {
- const button = findMenuItem();
-
- const tooltip = getBinding(button.element, 'gl-tooltip').value.title;
-
- expect(tooltip).toBe('Go back');
- expect(button.props()).toEqual({
- menuItem: {
- id: 'home',
- view: 'home',
- icon: 'chevron-lg-left',
- },
- iconOnly: true,
- });
- });
-
- it('emits nothing', () => {
- expect(wrapper.emitted()).toEqual({});
- });
-
- describe('when back button is clicked', () => {
- beforeEach(() => {
- findMenuItem().vm.$emit('click');
- });
-
- it('emits menu-item-click', () => {
- expect(wrapper.emitted()).toEqual({
- 'menu-item-click': [[{ id: 'home', view: 'home', icon: 'chevron-lg-left' }]],
- });
- });
- });
-});
diff --git a/spec/frontend/nav/components/responsive_home_spec.js b/spec/frontend/nav/components/responsive_home_spec.js
deleted file mode 100644
index 5a5cfc93607..00000000000
--- a/spec/frontend/nav/components/responsive_home_spec.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import ResponsiveHome from '~/nav/components/responsive_home.vue';
-import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
-import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
-import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue';
-import { TEST_NAV_DATA } from '../mock_data';
-
-const TEST_SEARCH_MENU_ITEM = {
- id: 'search',
- title: 'search',
- icon: 'search',
- href: '/search',
-};
-
-const TEST_NEW_DROPDOWN_VIEW_MODEL = {
- title: 'new',
- menu_sections: [],
-};
-
-describe('~/nav/components/responsive_home.vue', () => {
- let wrapper;
- let menuItemClickListener;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(ResponsiveHome, {
- propsData: {
- navData: TEST_NAV_DATA,
- ...props,
- },
- directives: {
- GlTooltip: createMockDirective('gl-tooltip'),
- },
- listeners: {
- 'menu-item-click': menuItemClickListener,
- },
- });
- };
-
- const findSearchMenuItem = () => wrapper.findComponent(TopNavMenuItem);
- const findNewDropdown = () => wrapper.findComponent(TopNavNewDropdown);
- const findMenuSections = () => wrapper.findComponent(TopNavMenuSections);
-
- beforeEach(() => {
- menuItemClickListener = jest.fn();
- });
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it.each`
- desc | fn
- ${'does not show search menu item'} | ${findSearchMenuItem}
- ${'does not show new dropdown'} | ${findNewDropdown}
- `('$desc', ({ fn }) => {
- expect(fn().exists()).toBe(false);
- });
-
- it('shows menu sections', () => {
- expect(findMenuSections().props('sections')).toEqual([
- { id: 'primary', menuItems: TEST_NAV_DATA.primary },
- { id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
- ]);
- });
-
- it('emits when menu sections emits', () => {
- expect(menuItemClickListener).not.toHaveBeenCalled();
-
- findMenuSections().vm.$emit('menu-item-click', TEST_NAV_DATA.primary[0]);
-
- expect(menuItemClickListener).toHaveBeenCalledWith(TEST_NAV_DATA.primary[0]);
- });
- });
-
- describe('without secondary', () => {
- beforeEach(() => {
- createComponent({ navData: { ...TEST_NAV_DATA, secondary: null } });
- });
-
- it('shows menu sections', () => {
- expect(findMenuSections().props('sections')).toEqual([
- { id: 'primary', menuItems: TEST_NAV_DATA.primary },
- ]);
- });
- });
-
- describe('with search view', () => {
- beforeEach(() => {
- createComponent({
- navData: {
- ...TEST_NAV_DATA,
- views: { search: TEST_SEARCH_MENU_ITEM },
- },
- });
- });
-
- it('shows search menu item', () => {
- expect(findSearchMenuItem().props()).toEqual({
- menuItem: TEST_SEARCH_MENU_ITEM,
- iconOnly: true,
- });
- });
-
- it('shows tooltip for search', () => {
- const tooltip = getBinding(findSearchMenuItem().element, 'gl-tooltip');
- expect(tooltip.value).toEqual({ title: TEST_SEARCH_MENU_ITEM.title });
- });
- });
-
- describe('with new view', () => {
- beforeEach(() => {
- createComponent({
- navData: {
- ...TEST_NAV_DATA,
- views: { new: TEST_NEW_DROPDOWN_VIEW_MODEL },
- },
- });
- });
-
- it('shows new dropdown', () => {
- expect(findNewDropdown().props()).toEqual({
- viewModel: TEST_NEW_DROPDOWN_VIEW_MODEL,
- });
- });
-
- it('shows tooltip for new dropdown', () => {
- const tooltip = getBinding(findNewDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toEqual({ title: TEST_NEW_DROPDOWN_VIEW_MODEL.title });
- });
- });
-});
diff --git a/spec/frontend/nav/components/top_nav_app_spec.js b/spec/frontend/nav/components/top_nav_app_spec.js
deleted file mode 100644
index 7f39552eb42..00000000000
--- a/spec/frontend/nav/components/top_nav_app_spec.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import { GlNavItemDropdown } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
-import { mockTracking } from 'helpers/tracking_helper';
-import TopNavApp from '~/nav/components/top_nav_app.vue';
-import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
-import { TEST_NAV_DATA } from '../mock_data';
-
-describe('~/nav/components/top_nav_app.vue', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = mount(TopNavApp, {
- propsData: {
- navData: TEST_NAV_DATA,
- },
- });
- };
-
- const createComponentShallow = () => {
- wrapper = shallowMount(TopNavApp, {
- propsData: {
- navData: TEST_NAV_DATA,
- },
- });
- };
-
- const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown);
- const findNavItemDropdowToggle = () => findNavItemDropdown().find('.js-top-nav-dropdown-toggle');
- const findMenu = () => wrapper.findComponent(TopNavDropdownMenu);
-
- describe('default', () => {
- beforeEach(() => {
- createComponentShallow();
- });
-
- it('renders nav item dropdown', () => {
- expect(findNavItemDropdown().attributes('href')).toBeUndefined();
- expect(findNavItemDropdown().attributes()).toMatchObject({
- icon: '',
- text: '',
- 'no-flip': '',
- 'no-caret': '',
- });
- });
-
- it('renders top nav dropdown menu', () => {
- expect(findMenu().props()).toStrictEqual({
- primary: TEST_NAV_DATA.primary,
- secondary: TEST_NAV_DATA.secondary,
- views: TEST_NAV_DATA.views,
- });
- });
- });
-
- describe('tracking', () => {
- it('emits a tracking event when the toggle is clicked', () => {
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- createComponent();
-
- findNavItemDropdowToggle().trigger('click');
-
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_nav', {
- label: 'hamburger_menu',
- property: 'navigation_top',
- });
- });
- });
-});
diff --git a/spec/frontend/nav/components/top_nav_container_view_spec.js b/spec/frontend/nav/components/top_nav_container_view_spec.js
deleted file mode 100644
index 388ac243648..00000000000
--- a/spec/frontend/nav/components/top_nav_container_view_spec.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { merge } from 'lodash';
-import { nextTick } from 'vue';
-import FrequentItemsApp from '~/frequent_items/components/app.vue';
-import { FREQUENT_ITEMS_PROJECTS } from '~/frequent_items/constants';
-import eventHub from '~/frequent_items/event_hub';
-import TopNavContainerView from '~/nav/components/top_nav_container_view.vue';
-import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
-import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
-import { TEST_NAV_DATA } from '../mock_data';
-
-const DEFAULT_PROPS = {
- frequentItemsDropdownType: FREQUENT_ITEMS_PROJECTS.namespace,
- frequentItemsVuexModule: FREQUENT_ITEMS_PROJECTS.vuexModule,
- linksPrimary: TEST_NAV_DATA.primary,
- linksSecondary: TEST_NAV_DATA.secondary,
- containerClass: 'test-frequent-items-container-class',
-};
-const TEST_OTHER_PROPS = {
- namespace: 'projects',
- currentUserName: 'test-user',
- currentItem: { id: 'test' },
-};
-
-describe('~/nav/components/top_nav_container_view.vue', () => {
- let wrapper;
-
- const createComponent = (props = {}, options = {}) => {
- wrapper = shallowMount(TopNavContainerView, {
- propsData: {
- ...DEFAULT_PROPS,
- ...TEST_OTHER_PROPS,
- ...props,
- },
- ...options,
- });
- };
-
- const findMenuSections = () => wrapper.findComponent(TopNavMenuSections);
- const findFrequentItemsApp = () => {
- const parent = wrapper.findComponent(VuexModuleProvider);
-
- return {
- vuexModule: parent.props('vuexModule'),
- props: parent.findComponent(FrequentItemsApp).props(),
- attributes: parent.findComponent(FrequentItemsApp).attributes(),
- };
- };
- const findFrequentItemsContainer = () => wrapper.find('[data-testid="frequent-items-container"]');
-
- it.each(['projects', 'groups'])(
- 'emits frequent items event to event hub (%s)',
- async (frequentItemsDropdownType) => {
- const listener = jest.fn();
- eventHub.$on(`${frequentItemsDropdownType}-dropdownOpen`, listener);
- createComponent({ frequentItemsDropdownType });
-
- expect(listener).not.toHaveBeenCalled();
-
- await nextTick();
-
- expect(listener).toHaveBeenCalled();
- },
- );
-
- describe('default', () => {
- const EXTRA_ATTRS = { 'data-test-attribute': 'foo' };
-
- beforeEach(() => {
- createComponent({}, { attrs: EXTRA_ATTRS });
- });
-
- it('does not inherit extra attrs', () => {
- expect(wrapper.attributes()).toEqual({
- class: expect.any(String),
- });
- });
-
- it('renders frequent items app', () => {
- expect(findFrequentItemsApp()).toEqual({
- vuexModule: DEFAULT_PROPS.frequentItemsVuexModule,
- props: expect.objectContaining(
- merge({ currentItem: { lastAccessedOn: Date.now() } }, TEST_OTHER_PROPS),
- ),
- attributes: expect.objectContaining(EXTRA_ATTRS),
- });
- });
-
- it('renders given container class', () => {
- expect(findFrequentItemsContainer().classes(DEFAULT_PROPS.containerClass)).toBe(true);
- });
-
- it('renders menu sections', () => {
- const sections = [
- { id: 'primary', menuItems: TEST_NAV_DATA.primary },
- { id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
- ];
-
- expect(findMenuSections().props()).toEqual({
- sections,
- withTopBorder: true,
- isPrimarySection: false,
- });
- });
- });
-
- describe('without secondary links', () => {
- beforeEach(() => {
- createComponent({
- linksSecondary: [],
- });
- });
-
- it('renders one menu item group', () => {
- expect(findMenuSections().props('sections')).toEqual([
- { id: 'primary', menuItems: TEST_NAV_DATA.primary },
- ]);
- });
- });
-});
diff --git a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js b/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js
deleted file mode 100644
index 1d516240306..00000000000
--- a/spec/frontend/nav/components/top_nav_dropdown_menu_spec.js
+++ /dev/null
@@ -1,146 +0,0 @@
-import { shallowMount, mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
-import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
-import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
-import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
-import { TEST_NAV_DATA } from '../mock_data';
-import { stubComponent } from '../../__helpers__/stub_component';
-
-describe('~/nav/components/top_nav_dropdown_menu.vue', () => {
- let wrapper;
-
- const createComponent = (props = {}, mountFn = shallowMount) => {
- wrapper = mountFn(TopNavDropdownMenu, {
- propsData: {
- primary: TEST_NAV_DATA.primary,
- secondary: TEST_NAV_DATA.secondary,
- views: TEST_NAV_DATA.views,
- ...props,
- },
- stubs: {
- // Stub the keep-alive-slots so we don't render frequent items which uses a store
- KeepAliveSlots: stubComponent(KeepAliveSlots),
- },
- });
- };
-
- const findMenuItems = () => wrapper.findAllComponents(TopNavMenuItem);
- const findMenuSections = () => wrapper.findComponent(TopNavMenuSections);
- const findMenuSidebar = () => wrapper.find('[data-testid="menu-sidebar"]');
- const findMenuSubview = () => wrapper.findComponent(KeepAliveSlots);
- const hasFullWidthMenuSidebar = () => findMenuSidebar().classes('gl-w-full');
-
- const withActiveIndex = (menuItems, activeIndex) =>
- menuItems.map((x, idx) => ({
- ...x,
- active: idx === activeIndex,
- }));
-
- beforeEach(() => {
- jest.spyOn(console, 'error').mockImplementation();
- });
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders menu sections', () => {
- expect(findMenuSections().props()).toEqual({
- sections: [
- { id: 'primary', menuItems: TEST_NAV_DATA.primary },
- { id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
- ],
- withTopBorder: false,
- isPrimarySection: true,
- });
- });
-
- it('has full width menu sidebar', () => {
- expect(hasFullWidthMenuSidebar()).toBe(true);
- });
-
- it('renders hidden subview with no slot key', () => {
- const subview = findMenuSubview();
-
- expect(subview.isVisible()).toBe(false);
- expect(subview.props()).toEqual({ slotKey: '' });
- });
- });
-
- describe('with pre-initialized active view', () => {
- beforeEach(() => {
- // We opt for a small integration test, to make sure the event is handled correctly
- // as it would in prod.
- createComponent(
- {
- primary: withActiveIndex(TEST_NAV_DATA.primary, 1),
- },
- mount,
- );
- });
-
- it('renders menu sections', () => {
- expect(findMenuSections().props('sections')).toStrictEqual([
- { id: 'primary', menuItems: withActiveIndex(TEST_NAV_DATA.primary, 1) },
- { id: 'secondary', menuItems: TEST_NAV_DATA.secondary },
- ]);
- });
-
- it('does not have full width menu sidebar', () => {
- expect(hasFullWidthMenuSidebar()).toBe(false);
- });
-
- it('renders visible subview with slot key', () => {
- const subview = findMenuSubview();
-
- expect(subview.isVisible()).toBe(true);
- expect(subview.props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view);
- });
-
- it('does not change view if non-view menu item is clicked', async () => {
- const secondaryLink = findMenuItems().at(TEST_NAV_DATA.primary.length);
-
- // Ensure this doesn't have a view
- expect(secondaryLink.props('menuItem').view).toBeUndefined();
-
- secondaryLink.vm.$emit('click');
-
- await nextTick();
-
- expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[1].view);
- });
-
- describe('when menu item is clicked', () => {
- let primaryLink;
-
- beforeEach(async () => {
- primaryLink = findMenuItems().at(0);
- primaryLink.vm.$emit('click');
- await nextTick();
- });
-
- it('clicked on link with view', () => {
- expect(primaryLink.props('menuItem').view).toBe(TEST_NAV_DATA.views.projects.namespace);
- });
-
- it('changes active view', () => {
- expect(findMenuSubview().props('slotKey')).toBe(TEST_NAV_DATA.primary[0].view);
- });
-
- it('changes active status on menu item', () => {
- expect(findMenuSections().props('sections')).toStrictEqual([
- {
- id: 'primary',
- menuItems: withActiveIndex(TEST_NAV_DATA.primary, 0),
- },
- {
- id: 'secondary',
- menuItems: withActiveIndex(TEST_NAV_DATA.secondary, -1),
- },
- ]);
- });
- });
- });
-});
diff --git a/spec/frontend/nav/components/top_nav_menu_item_spec.js b/spec/frontend/nav/components/top_nav_menu_item_spec.js
deleted file mode 100644
index b9cf39b8c1d..00000000000
--- a/spec/frontend/nav/components/top_nav_menu_item_spec.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import { GlButton, GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import TopNavMenuItem from '~/nav/components/top_nav_menu_item.vue';
-
-const TEST_MENU_ITEM = {
- title: 'Cheeseburger',
- icon: 'search',
- href: '/pretty/good/burger',
- view: 'burger-view',
- data: { qa_selector: 'not-a-real-selector', method: 'post', testFoo: 'test' },
-};
-
-describe('~/nav/components/top_nav_menu_item.vue', () => {
- let listener;
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(TopNavMenuItem, {
- propsData: {
- menuItem: TEST_MENU_ITEM,
- ...props,
- },
- listeners: {
- click: listener,
- },
- });
- };
-
- const findButton = () => wrapper.findComponent(GlButton);
- const findButtonIcons = () =>
- findButton()
- .findAllComponents(GlIcon)
- .wrappers.map((x) => ({
- name: x.props('name'),
- classes: x.classes(),
- }));
-
- beforeEach(() => {
- listener = jest.fn();
- });
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders button href and text', () => {
- const button = findButton();
-
- expect(button.attributes('href')).toBe(TEST_MENU_ITEM.href);
- expect(button.text()).toBe(TEST_MENU_ITEM.title);
- });
-
- it('renders button data attributes', () => {
- const button = findButton();
-
- expect(button.attributes()).toMatchObject({
- 'data-qa-selector': TEST_MENU_ITEM.data.qa_selector,
- 'data-method': TEST_MENU_ITEM.data.method,
- 'data-test-foo': TEST_MENU_ITEM.data.testFoo,
- });
- });
-
- it('passes listeners to button', () => {
- expect(listener).not.toHaveBeenCalled();
-
- findButton().vm.$emit('click', 'TEST');
-
- expect(listener).toHaveBeenCalledWith('TEST');
- });
-
- it('renders expected icons', () => {
- expect(findButtonIcons()).toEqual([
- {
- name: TEST_MENU_ITEM.icon,
- classes: ['gl-mr-3!'],
- },
- {
- name: 'chevron-right',
- classes: ['gl-ml-auto'],
- },
- ]);
- });
- });
-
- describe('with icon-only', () => {
- beforeEach(() => {
- createComponent({ iconOnly: true });
- });
-
- it('does not render title or view icon', () => {
- expect(wrapper.text()).toBe('');
- });
-
- it('only renders menuItem icon', () => {
- expect(findButtonIcons()).toEqual([
- {
- name: TEST_MENU_ITEM.icon,
- classes: [],
- },
- ]);
- });
- });
-
- describe.each`
- desc | menuItem | expectedIcons
- ${'with no icon'} | ${{ ...TEST_MENU_ITEM, icon: null }} | ${['chevron-right']}
- ${'with no view'} | ${{ ...TEST_MENU_ITEM, view: null }} | ${[TEST_MENU_ITEM.icon]}
- ${'with no icon or view'} | ${{ ...TEST_MENU_ITEM, view: null, icon: null }} | ${[]}
- `('$desc', ({ menuItem, expectedIcons }) => {
- beforeEach(() => {
- createComponent({ menuItem });
- });
-
- it(`renders expected icons ${JSON.stringify(expectedIcons)}`, () => {
- expect(findButtonIcons().map((x) => x.name)).toEqual(expectedIcons);
- });
- });
-
- describe.each`
- desc | active | cssClass | expectedClasses
- ${'default'} | ${false} | ${''} | ${[]}
- ${'with css class'} | ${false} | ${'test-css-class testing-123'} | ${['test-css-class', 'testing-123']}
- ${'with css class & active'} | ${true} | ${'test-css-class'} | ${['test-css-class', ...TopNavMenuItem.ACTIVE_CLASS.split(' ')]}
- `('$desc', ({ active, cssClass, expectedClasses }) => {
- beforeEach(() => {
- createComponent({
- menuItem: {
- ...TEST_MENU_ITEM,
- active,
- css_class: cssClass,
- },
- });
- });
-
- it('renders expected classes', () => {
- expect(wrapper.classes()).toStrictEqual([
- 'top-nav-menu-item',
- 'gl-display-block',
- 'gl-pr-3!',
- ...expectedClasses,
- ]);
- });
- });
-});
diff --git a/spec/frontend/nav/components/top_nav_menu_sections_spec.js b/spec/frontend/nav/components/top_nav_menu_sections_spec.js
deleted file mode 100644
index 7a3e58fd964..00000000000
--- a/spec/frontend/nav/components/top_nav_menu_sections_spec.js
+++ /dev/null
@@ -1,138 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import TopNavMenuSections from '~/nav/components/top_nav_menu_sections.vue';
-
-const TEST_SECTIONS = [
- {
- id: 'primary',
- menuItems: [
- { type: 'header', title: 'Heading' },
- { type: 'item', id: 'test', href: '/test/href' },
- { type: 'header', title: 'Another Heading' },
- { type: 'item', id: 'foo' },
- { type: 'item', id: 'bar' },
- ],
- },
- {
- id: 'secondary',
- menuItems: [
- { type: 'item', id: 'lorem' },
- { type: 'item', id: 'ipsum' },
- ],
- },
-];
-
-describe('~/nav/components/top_nav_menu_sections.vue', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(TopNavMenuSections, {
- propsData: {
- sections: TEST_SECTIONS,
- ...props,
- },
- });
- };
-
- const findMenuItemModels = (parent) =>
- parent.findAll('[data-testid="menu-header"],[data-testid="menu-item"]').wrappers.map((x) => {
- return {
- menuItem: x.vm
- ? {
- type: 'item',
- ...x.props('menuItem'),
- }
- : {
- type: 'header',
- title: x.text(),
- },
- classes: x.classes(),
- };
- });
- const findSectionModels = () =>
- wrapper.findAll('[data-testid="menu-section"]').wrappers.map((x) => ({
- classes: x.classes(),
- menuItems: findMenuItemModels(x),
- }));
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders sections with menu items', () => {
- const headerClasses = ['gl-px-4', 'gl-py-2', 'gl-text-gray-900', 'gl-display-block'];
- const itemClasses = ['gl-w-full'];
-
- expect(findSectionModels()).toEqual([
- {
- classes: [],
- menuItems: TEST_SECTIONS[0].menuItems.map((menuItem, index) => {
- const classes = menuItem.type === 'header' ? [...headerClasses] : [...itemClasses];
- if (index > 0) classes.push(menuItem.type === 'header' ? 'gl-pt-3!' : 'gl-mt-1');
- return {
- menuItem,
- classes,
- };
- }),
- },
- {
- classes: [
- ...TopNavMenuSections.BORDER_CLASSES.split(' '),
- 'gl-border-gray-50',
- 'gl-mt-3',
- ],
- menuItems: TEST_SECTIONS[1].menuItems.map((menuItem, index) => {
- const classes = menuItem.type === 'header' ? [...headerClasses] : [...itemClasses];
- if (index > 0) classes.push(menuItem.type === 'header' ? 'gl-pt-3!' : 'gl-mt-1');
- return {
- menuItem,
- classes,
- };
- }),
- },
- ]);
- });
-
- it('when clicked menu item with href, does nothing', () => {
- const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(0);
-
- menuItem.vm.$emit('click');
-
- expect(wrapper.emitted()).toEqual({});
- });
-
- it('when clicked menu item without href, emits "menu-item-click"', () => {
- const menuItem = wrapper.findAll('[data-testid="menu-item"]').at(1);
-
- menuItem.vm.$emit('click');
-
- expect(wrapper.emitted('menu-item-click')).toEqual([[TEST_SECTIONS[0].menuItems[3]]]);
- });
- });
-
- describe('with withTopBorder=true', () => {
- beforeEach(() => {
- createComponent({ withTopBorder: true });
- });
-
- it('renders border classes for top section', () => {
- expect(findSectionModels().map((x) => x.classes)).toEqual([
- [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-border-gray-50'],
- [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-border-gray-50', 'gl-mt-3'],
- ]);
- });
- });
-
- describe('with isPrimarySection=true', () => {
- beforeEach(() => {
- createComponent({ isPrimarySection: true });
- });
-
- it('renders border classes for top section', () => {
- expect(findSectionModels().map((x) => x.classes)).toEqual([
- [],
- [...TopNavMenuSections.BORDER_CLASSES.split(' '), 'gl-border-gray-100', 'gl-mt-3'],
- ]);
- });
- });
-});
diff --git a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js
deleted file mode 100644
index 432ee5e9ecd..00000000000
--- a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js
+++ /dev/null
@@ -1,142 +0,0 @@
-import { GlDropdown } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import TopNavNewDropdown from '~/nav/components/top_nav_new_dropdown.vue';
-import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
-import { TOP_NAV_INVITE_MEMBERS_COMPONENT } from '~/invite_members/constants';
-
-const TEST_VIEW_MODEL = {
- title: 'Dropdown',
- menu_sections: [
- {
- title: 'Section 1',
- menu_items: [
- { id: 'foo-1', title: 'Foo 1', href: '/foo/1' },
- { id: 'foo-2', title: 'Foo 2', href: '/foo/2' },
- { id: 'foo-3', title: 'Foo 3', href: '/foo/3' },
- ],
- },
- {
- title: 'Section 2',
- menu_items: [
- { id: 'bar-1', title: 'Bar 1', href: '/bar/1' },
- { id: 'bar-2', title: 'Bar 2', href: '/bar/2' },
- {
- id: 'invite',
- title: '_invite members title_',
- component: TOP_NAV_INVITE_MEMBERS_COMPONENT,
- icon: '_icon_',
- data: {
- trigger_element: '_trigger_element_',
- trigger_source: '_trigger_source_',
- },
- },
- ],
- },
- ],
-};
-
-describe('~/nav/components/top_nav_menu_sections.vue', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(TopNavNewDropdown, {
- propsData: {
- viewModel: TEST_VIEW_MODEL,
- ...props,
- },
- });
- };
-
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger);
- const findDropdownContents = () =>
- findDropdown()
- .findAll('[data-testid]')
- .wrappers.map((child) => {
- const type = child.attributes('data-testid');
-
- if (type === 'divider') {
- return { type };
- }
- if (type === 'header') {
- return { type, text: child.text() };
- }
-
- return {
- type,
- text: child.text(),
- href: child.attributes('href'),
- };
- });
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders dropdown parent', () => {
- expect(findDropdown().props()).toMatchObject({
- text: TEST_VIEW_MODEL.title,
- textSrOnly: true,
- icon: 'plus',
- });
- });
-
- it('renders dropdown content', () => {
- const hrefItems = TEST_VIEW_MODEL.menu_sections[1].menu_items.filter((item) =>
- Boolean(item.href),
- );
-
- expect(findDropdownContents()).toEqual([
- {
- type: 'header',
- text: TEST_VIEW_MODEL.menu_sections[0].title,
- },
- ...TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({
- type: 'item',
- href,
- text: title,
- })),
- {
- type: 'divider',
- },
- {
- type: 'header',
- text: TEST_VIEW_MODEL.menu_sections[1].title,
- },
- ...hrefItems.map(({ title, href }) => ({
- type: 'item',
- href,
- text: title,
- })),
- ]);
- expect(findInviteMembersTrigger().props()).toMatchObject({
- displayText: '_invite members title_',
- icon: '_icon_',
- triggerElement: 'dropdown-_trigger_element_',
- triggerSource: '_trigger_source_',
- });
- });
- });
-
- describe('with only 1 section', () => {
- beforeEach(() => {
- createComponent({
- viewModel: {
- ...TEST_VIEW_MODEL,
- menu_sections: TEST_VIEW_MODEL.menu_sections.slice(0, 1),
- },
- });
- });
-
- it('renders dropdown content without headers and dividers', () => {
- expect(findDropdownContents()).toEqual(
- TEST_VIEW_MODEL.menu_sections[0].menu_items.map(({ title, href }) => ({
- type: 'item',
- href,
- text: title,
- })),
- );
- });
- });
-});
diff --git a/spec/frontend/nav/mock_data.js b/spec/frontend/nav/mock_data.js
deleted file mode 100644
index 2052acfe001..00000000000
--- a/spec/frontend/nav/mock_data.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { range } from 'lodash';
-
-export const TEST_NAV_DATA = {
- menuTitle: 'Test Menu Title',
- primary: [
- ...['projects', 'groups'].map((view) => ({
- id: view,
- href: null,
- title: view,
- view,
- })),
- ...range(0, 2).map((idx) => ({
- id: `primary-link-${idx}`,
- href: `/path/to/primary/${idx}`,
- title: `Title ${idx}`,
- })),
- ],
- secondary: range(0, 2).map((idx) => ({
- id: `secondary-link-${idx}`,
- href: `/path/to/secondary/${idx}`,
- title: `SecTitle ${idx}`,
- })),
- views: {
- projects: {
- namespace: 'projects',
- currentUserName: '',
- currentItem: {},
- linksPrimary: [{ id: 'project-link', href: '/path/to/projects', title: 'Project Link' }],
- linksSecondary: [],
- },
- groups: {
- namespace: 'groups',
- currentUserName: '',
- currentItem: {},
- linksPrimary: [],
- linksSecondary: [{ id: 'group-link', href: '/path/to/groups', title: 'Group Link' }],
- },
- },
-};
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index 1309fd79c14..8f761476c7c 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -9,7 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch';
import { createAlert } from '~/alert';
import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
@@ -26,7 +26,7 @@ import { mockTracking } from 'helpers/tracking_helper';
import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
jest.mock('autosize');
-jest.mock('~/commons/nav/user_merge_requests');
+jest.mock('~/super_sidebar/user_counts_fetch');
jest.mock('~/alert');
Vue.use(Vuex);
@@ -586,7 +586,7 @@ describe('issue_comment_form component', () => {
await nextTick();
- expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
+ expect(fetchUserCounts).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index 87ccb5b7394..dfc901bf1b3 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -6,7 +6,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import { TEST_HOST } from 'helpers/test_constants';
import createEventHub from '~/helpers/event_hub_factory';
-
+import * as urlUtility from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import DiscussionFilter from '~/notes/components/discussion_filter.vue';
@@ -40,7 +40,7 @@ describe('DiscussionFilter component', () => {
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
- const mountComponent = () => {
+ const mountComponent = ({ propsData = {} } = {}) => {
const discussions = [
{
...discussionMock,
@@ -63,11 +63,12 @@ describe('DiscussionFilter component', () => {
store.state.discussions = discussions;
- return mount(DiscussionFilter, {
+ wrapper = mount(DiscussionFilter, {
store,
propsData: {
filters: discussionFiltersMock,
selectedValue: DISCUSSION_FILTERS_DEFAULT_VALUE,
+ ...propsData,
},
});
};
@@ -88,7 +89,7 @@ describe('DiscussionFilter component', () => {
describe('default', () => {
beforeEach(() => {
- wrapper = mountComponent();
+ mountComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
});
@@ -105,7 +106,7 @@ describe('DiscussionFilter component', () => {
describe('when asc', () => {
beforeEach(() => {
- wrapper = mountComponent();
+ mountComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
});
@@ -125,7 +126,7 @@ describe('DiscussionFilter component', () => {
describe('when desc', () => {
beforeEach(() => {
- wrapper = mountComponent();
+ mountComponent();
store.state.discussionSortOrder = DESC;
jest.spyOn(store, 'dispatch').mockImplementation();
});
@@ -150,7 +151,7 @@ describe('DiscussionFilter component', () => {
describe('discussion filter functionality', () => {
beforeEach(() => {
- wrapper = mountComponent();
+ mountComponent();
});
it('renders the all filters', () => {
@@ -215,7 +216,7 @@ describe('DiscussionFilter component', () => {
currentTab: 'show',
};
- wrapper = mountComponent();
+ mountComponent();
});
afterEach(() => {
@@ -239,7 +240,7 @@ describe('DiscussionFilter component', () => {
it('does not update the filter when the current filter is "Show all activity"', async () => {
window.location.hash = `note_${discussionMock.notes[0].id}`;
- wrapper = mountComponent();
+ mountComponent();
await nextTick();
const filtered = findGlDisclosureDropdownItems().filter((el) => el.classes('is-active'));
@@ -250,7 +251,7 @@ describe('DiscussionFilter component', () => {
it('only updates filter when the URL links to a note', async () => {
window.location.hash = `testing123`;
- wrapper = mountComponent();
+ mountComponent();
await nextTick();
const filtered = findGlDisclosureDropdownItems().filter((el) => el.classes('is-active'));
@@ -260,12 +261,32 @@ describe('DiscussionFilter component', () => {
});
it('does not fetch discussions when there is no hash', async () => {
- window.location.hash = '';
- const selectFilterSpy = jest.spyOn(wrapper.vm, 'selectFilter').mockImplementation(() => {});
- wrapper = mountComponent();
+ mountComponent();
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
await nextTick();
- expect(selectFilterSpy).not.toHaveBeenCalled();
+ expect(dispatchSpy).not.toHaveBeenCalled();
+ });
+
+ describe('selected value is not default state', () => {
+ beforeEach(() => {
+ mountComponent({
+ propsData: { selectedValue: 2 },
+ });
+ });
+ it('fetch discussions when there is hash', async () => {
+ jest.spyOn(urlUtility, 'getLocationHash').mockReturnValueOnce('note_123');
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ window.dispatchEvent(new Event('hashchange'));
+
+ await nextTick();
+ expect(dispatchSpy).toHaveBeenCalledWith('filterDiscussion', {
+ filter: 0,
+ path: 'http://test.host/example',
+ persistFilter: false,
+ });
+ });
});
});
});
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index fc50afcb01d..47663360ce8 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -1,5 +1,5 @@
import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { stubComponent } from 'helpers/stub_component';
@@ -43,11 +43,10 @@ describe('noteActions', () => {
store.state.isPromoteCommentToTimelineEventInProgress = isPromotionInProgress;
};
- const mountNoteActions = (propsData, computed) => {
- return mount(noteActions, {
+ const mountNoteActions = (propsData) => {
+ return shallowMount(noteActions, {
store,
propsData,
- computed,
stubs: {
GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
methods: {
@@ -190,15 +189,14 @@ describe('noteActions', () => {
};
beforeEach(() => {
- wrapper = mountNoteActions(props, {
- targetType: () => 'issue',
- });
+ wrapper = mountNoteActions(props);
store.state.noteableData = {
current_user: {
can_set_issue_metadata: true,
},
};
store.state.userData = userDataMock;
+ store.state.noteableData.targetType = 'issue';
});
afterEach(() => {
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index f07ba1e032f..938ca1f5939 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -1129,9 +1129,12 @@ describe('Actions Notes Store', () => {
describe('setConfidentiality', () => {
it('calls the correct mutation with the correct args', () => {
- testAction(actions.setConfidentiality, true, { noteableData: { confidential: false } }, [
- { type: mutationTypes.SET_ISSUE_CONFIDENTIAL, payload: true },
- ]);
+ return testAction(
+ actions.setConfidentiality,
+ true,
+ { noteableData: { confidential: false } },
+ [{ type: mutationTypes.SET_ISSUE_CONFIDENTIAL, payload: true }],
+ );
});
});
diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js
index b41b303f57d..e7b68a2346e 100644
--- a/spec/frontend/observability/client_spec.js
+++ b/spec/frontend/observability/client_spec.js
@@ -18,6 +18,7 @@ describe('buildClient', () => {
const servicesUrl = 'https://example.com/services';
const operationsUrl = 'https://example.com/services/$SERVICE_NAME$/operations';
const metricsUrl = 'https://example.com/metrics';
+ const metricsSearchUrl = 'https://example.com/metrics/search';
const FETCHING_TRACES_ERROR = 'traces are missing/invalid in the response';
const apiConfig = {
@@ -26,6 +27,7 @@ describe('buildClient', () => {
servicesUrl,
operationsUrl,
metricsUrl,
+ metricsSearchUrl,
};
const getQueryParam = () => decodeURIComponent(axios.get.mock.calls[0][1].params.toString());
@@ -311,6 +313,16 @@ describe('buildClient', () => {
expect(getQueryParam()).toBe(`sort=${SORTING_OPTIONS.TIMESTAMP_DESC}`);
});
+ it('ignores non-array filters', async () => {
+ await client.fetchTraces({
+ filters: {
+ traceId: { operator: '=', value: 'foo' },
+ },
+ });
+
+ expect(getQueryParam()).toBe(`sort=${SORTING_OPTIONS.TIMESTAMP_DESC}`);
+ });
+
it('ignores unsupported operators', async () => {
await client.fetchTraces({
filters: {
@@ -429,10 +441,84 @@ describe('buildClient', () => {
expect(axios.get).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledWith(metricsUrl, {
withCredentials: true,
+ params: expect.any(URLSearchParams),
});
expect(result).toEqual(mockResponse);
});
+ describe('query filter', () => {
+ beforeEach(() => {
+ axiosMock.onGet(metricsUrl).reply(200, {
+ metrics: [],
+ });
+ });
+
+ it('does not set any query param without filters', async () => {
+ await client.fetchMetrics();
+
+ expect(getQueryParam()).toBe('');
+ });
+
+ it('sets the start_with query param based on the search filter', async () => {
+ await client.fetchMetrics({
+ filters: { search: [{ value: 'foo' }, { value: 'bar' }, { value: ' ' }] },
+ });
+ expect(getQueryParam()).toBe('starts_with=foo+bar');
+ });
+
+ it('ignores empty search', async () => {
+ await client.fetchMetrics({
+ filters: {
+ search: [{ value: ' ' }, { value: '' }, { value: null }, { value: undefined }],
+ },
+ });
+ expect(getQueryParam()).toBe('');
+ });
+
+ it('ignores unsupported filters', async () => {
+ await client.fetchMetrics({
+ filters: {
+ unsupportedFilter: [{ operator: '=', value: 'foo' }],
+ },
+ });
+
+ expect(getQueryParam()).toBe('');
+ });
+
+ it('ignores non-array search filters', async () => {
+ await client.fetchMetrics({
+ filters: {
+ search: { value: 'foo' },
+ },
+ });
+
+ expect(getQueryParam()).toBe('');
+ });
+
+ it('adds the search limit param if specified with the search filter', async () => {
+ await client.fetchMetrics({
+ filters: { search: [{ value: 'foo' }] },
+ limit: 50,
+ });
+ expect(getQueryParam()).toBe('starts_with=foo&limit=50');
+ });
+
+ it('does not add the search limit param if the search filter is missing', async () => {
+ await client.fetchMetrics({
+ limit: 50,
+ });
+ expect(getQueryParam()).toBe('');
+ });
+
+ it('does not add the search limit param if the search filter is empty', async () => {
+ await client.fetchMetrics({
+ limit: 50,
+ search: [{ value: ' ' }, { value: '' }, { value: null }, { value: undefined }],
+ });
+ expect(getQueryParam()).toBe('');
+ });
+ });
+
it('rejects if metrics are missing', async () => {
axiosMock.onGet(metricsUrl).reply(200, {});
@@ -447,4 +533,40 @@ describe('buildClient', () => {
expectErrorToBeReported(new Error(FETCHING_METRICS_ERROR));
});
});
+
+ describe('fetchMetric', () => {
+ it('fetches the metric from the API', async () => {
+ const data = { results: [] };
+ axiosMock.onGet(metricsSearchUrl).reply(200, data);
+
+ const result = await client.fetchMetric('name', 'type');
+
+ expect(axios.get).toHaveBeenCalledTimes(1);
+ expect(axios.get).toHaveBeenCalledWith(metricsSearchUrl, {
+ withCredentials: true,
+ params: new URLSearchParams({ mname: 'name', mtype: 'type' }),
+ });
+ expect(result).toEqual(data.results);
+ });
+
+ it('rejects if results is missing from the response', async () => {
+ axiosMock.onGet(metricsSearchUrl).reply(200, {});
+ const e = 'metrics are missing/invalid in the response';
+
+ await expect(client.fetchMetric('name', 'type')).rejects.toThrow(e);
+ expectErrorToBeReported(new Error(e));
+ });
+
+ it('rejects if metric name is missing', async () => {
+ const e = 'fetchMetric() - metric name is required.';
+ await expect(client.fetchMetric()).rejects.toThrow(e);
+ expectErrorToBeReported(new Error(e));
+ });
+
+ it('rejects if metric type is missing', async () => {
+ const e = 'fetchMetric() - metric type is required.';
+ await expect(client.fetchMetric('name')).rejects.toThrow(e);
+ expectErrorToBeReported(new Error(e));
+ });
+ });
});
diff --git a/spec/frontend/organizations/index/components/app_spec.js b/spec/frontend/organizations/index/components/app_spec.js
index 175b1e1c552..670eb34bffd 100644
--- a/spec/frontend/organizations/index/components/app_spec.js
+++ b/spec/frontend/organizations/index/components/app_spec.js
@@ -5,9 +5,9 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
-import { organizations } from '~/organizations/mock_data';
-import resolvers from '~/organizations/shared/graphql/resolvers';
-import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql';
+import { DEFAULT_PER_PAGE } from '~/api';
+import { organizations as nodes, pageInfo, pageInfoEmpty } from '~/organizations/mock_data';
+import organizationsQuery from '~/organizations/shared/graphql/queries/organizations.query.graphql';
import OrganizationsIndexApp from '~/organizations/index/components/app.vue';
import OrganizationsView from '~/organizations/index/components/organizations_view.vue';
import { MOCK_NEW_ORG_URL } from '../mock_data';
@@ -20,8 +20,27 @@ describe('OrganizationsIndexApp', () => {
let wrapper;
let mockApollo;
- const createComponent = (mockResolvers = resolvers) => {
- mockApollo = createMockApollo([[organizationsQuery, mockResolvers]]);
+ const organizations = {
+ nodes,
+ pageInfo,
+ };
+
+ const organizationEmpty = {
+ nodes: [],
+ pageInfo: pageInfoEmpty,
+ };
+
+ const successHandler = jest.fn().mockResolvedValue({
+ data: {
+ currentUser: {
+ id: 'gid://gitlab/User/1',
+ organizations,
+ },
+ },
+ });
+
+ const createComponent = (handler = successHandler) => {
+ mockApollo = createMockApollo([[organizationsQuery, handler]]);
wrapper = shallowMountExtended(OrganizationsIndexApp, {
apolloProvider: mockApollo,
@@ -35,53 +54,168 @@ describe('OrganizationsIndexApp', () => {
mockApollo = null;
});
+ // Finders
const findOrganizationHeaderText = () => wrapper.findByText('Organizations');
const findNewOrganizationButton = () => wrapper.findComponent(GlButton);
const findOrganizationsView = () => wrapper.findComponent(OrganizationsView);
- const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {}));
- const successfulResolver = (nodes) =>
- jest.fn().mockResolvedValue({
- data: { currentUser: { id: 1, organizations: { nodes } } },
+ // Assertions
+ const itRendersHeaderText = () => {
+ it('renders the header text', () => {
+ expect(findOrganizationHeaderText().exists()).toBe(true);
+ });
+ };
+
+ const itRendersNewOrganizationButton = () => {
+ it('render new organization button with correct link', () => {
+ expect(findNewOrganizationButton().attributes('href')).toBe(MOCK_NEW_ORG_URL);
+ });
+ };
+
+ const itDoesNotRenderErrorMessage = () => {
+ it('does not render an error message', () => {
+ expect(createAlert).not.toHaveBeenCalled();
+ });
+ };
+
+ const itDoesNotRenderHeaderText = () => {
+ it('does not render the header text', () => {
+ expect(findOrganizationHeaderText().exists()).toBe(false);
+ });
+ };
+
+ const itDoesNotRenderNewOrganizationButton = () => {
+ it('does not render new organization button', () => {
+ expect(findNewOrganizationButton().exists()).toBe(false);
+ });
+ };
+
+ describe('when API call is loading', () => {
+ beforeEach(() => {
+ createComponent(jest.fn().mockReturnValue(new Promise(() => {})));
+ });
+
+ itRendersHeaderText();
+ itRendersNewOrganizationButton();
+ itDoesNotRenderErrorMessage();
+
+ it('renders the organizations view with loading prop set to true', () => {
+ expect(findOrganizationsView().props('loading')).toBe(true);
+ });
+ });
+
+ describe('when API call is successful', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ itRendersHeaderText();
+ itRendersNewOrganizationButton();
+ itDoesNotRenderErrorMessage();
+
+ it('passes organizations to view component', () => {
+ expect(findOrganizationsView().props()).toMatchObject({
+ loading: false,
+ organizations,
+ });
});
- const errorResolver = jest.fn().mockRejectedValue('error');
+ });
- describe.each`
- description | mockResolver | headerText | newOrgLink | loading | orgsData | error
- ${'when API call is loading'} | ${loadingResolver} | ${true} | ${MOCK_NEW_ORG_URL} | ${true} | ${[]} | ${false}
- ${'when API returns successful with results'} | ${successfulResolver(organizations)} | ${true} | ${MOCK_NEW_ORG_URL} | ${false} | ${organizations} | ${false}
- ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${false} | ${false} | ${[]} | ${false}
- ${'when API returns error'} | ${errorResolver} | ${false} | ${false} | ${false} | ${[]} | ${true}
- `('$description', ({ mockResolver, headerText, newOrgLink, loading, orgsData, error }) => {
+ describe('when API call is successful and returns no organizations', () => {
beforeEach(async () => {
- createComponent(mockResolver);
+ createComponent(
+ jest.fn().mockResolvedValue({
+ data: {
+ currentUser: {
+ id: 'gid://gitlab/User/1',
+ organizations: organizationEmpty,
+ },
+ },
+ }),
+ );
await waitForPromises();
});
- it(`does ${headerText ? '' : 'not '}render the header text`, () => {
- expect(findOrganizationHeaderText().exists()).toBe(headerText);
+ itDoesNotRenderHeaderText();
+ itDoesNotRenderNewOrganizationButton();
+ itDoesNotRenderErrorMessage();
+
+ it('renders view component with correct organizations and loading props', () => {
+ expect(findOrganizationsView().props()).toMatchObject({
+ loading: false,
+ organizations: organizationEmpty,
+ });
});
+ });
+
+ describe('when API call is not successful', () => {
+ const error = new Error();
- it(`does ${newOrgLink ? '' : 'not '}render new organization button with correct link`, () => {
- expect(
- findNewOrganizationButton().exists() && findNewOrganizationButton().attributes('href'),
- ).toBe(newOrgLink);
+ beforeEach(async () => {
+ createComponent(jest.fn().mockRejectedValue(error));
+ await waitForPromises();
});
- it(`renders the organizations view with ${loading} loading prop`, () => {
- expect(findOrganizationsView().props('loading')).toBe(loading);
+ itDoesNotRenderHeaderText();
+ itDoesNotRenderNewOrganizationButton();
+
+ it('renders view component with correct organizations and loading props', () => {
+ expect(findOrganizationsView().props()).toMatchObject({
+ loading: false,
+ organizations: {},
+ });
});
- it(`renders the organizations view with ${
- orgsData ? 'correct' : 'empty'
- } organizations array prop`, () => {
- expect(findOrganizationsView().props('organizations')).toStrictEqual(orgsData);
+ it('renders error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message:
+ 'An error occurred loading user organizations. Please refresh the page to try again.',
+ error,
+ captureError: true,
+ });
});
+ });
+
+ describe('when view component emits `next` event', () => {
+ const endCursor = 'mockEndCursor';
+
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('calls GraphQL query with correct pageInfo variables', async () => {
+ findOrganizationsView().vm.$emit('next', endCursor);
+ await waitForPromises();
+
+ expect(successHandler).toHaveBeenCalledWith({
+ first: DEFAULT_PER_PAGE,
+ after: endCursor,
+ last: null,
+ before: null,
+ });
+ });
+ });
+
+ describe('when view component emits `prev` event', () => {
+ const startCursor = 'mockStartCursor';
+
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('calls GraphQL query with correct pageInfo variables', async () => {
+ findOrganizationsView().vm.$emit('prev', startCursor);
+ await waitForPromises();
- it(`does ${error ? '' : 'not '}render an error message`, () => {
- return error
- ? expect(createAlert).toHaveBeenCalled()
- : expect(createAlert).not.toHaveBeenCalled();
+ expect(successHandler).toHaveBeenCalledWith({
+ first: null,
+ after: null,
+ last: DEFAULT_PER_PAGE,
+ before: startCursor,
+ });
});
});
});
diff --git a/spec/frontend/organizations/index/components/organizations_list_spec.js b/spec/frontend/organizations/index/components/organizations_list_spec.js
index 0b59c212314..7d904ee802f 100644
--- a/spec/frontend/organizations/index/components/organizations_list_spec.js
+++ b/spec/frontend/organizations/index/components/organizations_list_spec.js
@@ -1,28 +1,84 @@
+import { GlKeysetPagination } from '@gitlab/ui';
+import { omit } from 'lodash';
import { shallowMount } from '@vue/test-utils';
import OrganizationsList from '~/organizations/index/components/organizations_list.vue';
import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue';
-import { organizations } from '~/organizations/mock_data';
+import { organizations as nodes, pageInfo, pageInfoOnePage } from '~/organizations/mock_data';
describe('OrganizationsList', () => {
let wrapper;
- const createComponent = () => {
+ const createComponent = ({ propsData = {} } = {}) => {
wrapper = shallowMount(OrganizationsList, {
propsData: {
- organizations,
+ organizations: {
+ nodes,
+ pageInfo,
+ },
+ ...propsData,
},
});
};
const findAllOrganizationsListItem = () => wrapper.findAllComponents(OrganizationsListItem);
+ const findPagination = () => wrapper.findComponent(GlKeysetPagination);
describe('template', () => {
- beforeEach(() => {
+ it('renders a list item for each organization', () => {
createComponent();
+
+ expect(findAllOrganizationsListItem()).toHaveLength(nodes.length);
});
- it('renders a list item for each organization', () => {
- expect(findAllOrganizationsListItem()).toHaveLength(organizations.length);
+ describe('when there is one page of organizations', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ organizations: {
+ nodes,
+ pageInfo: pageInfoOnePage,
+ },
+ },
+ });
+ });
+
+ it('does not render pagination', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('when there are multiple pages of organizations', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders pagination', () => {
+ expect(findPagination().props()).toMatchObject(omit(pageInfo, '__typename'));
+ });
+
+ describe('when `GlKeysetPagination` emits `next` event', () => {
+ const endCursor = 'mockEndCursor';
+
+ beforeEach(() => {
+ findPagination().vm.$emit('next', endCursor);
+ });
+
+ it('emits `next` event', () => {
+ expect(wrapper.emitted('next')).toEqual([[endCursor]]);
+ });
+ });
+
+ describe('when `GlKeysetPagination` emits `prev` event', () => {
+ const startCursor = 'startEndCursor';
+
+ beforeEach(() => {
+ findPagination().vm.$emit('prev', startCursor);
+ });
+
+ it('emits `prev` event', () => {
+ expect(wrapper.emitted('prev')).toEqual([[startCursor]]);
+ });
+ });
});
});
});
diff --git a/spec/frontend/organizations/index/components/organizations_view_spec.js b/spec/frontend/organizations/index/components/organizations_view_spec.js
index 85a1c11a2b1..fe167a1418f 100644
--- a/spec/frontend/organizations/index/components/organizations_view_spec.js
+++ b/spec/frontend/organizations/index/components/organizations_view_spec.js
@@ -31,7 +31,7 @@ describe('OrganizationsView', () => {
${'when not loading and has no organizations'} | ${false} | ${[]} | ${MOCK_ORG_EMPTY_STATE_SVG} | ${MOCK_NEW_ORG_URL}
`('$description', ({ loading, orgsData, emptyStateSvg, emptyStateUrl }) => {
beforeEach(() => {
- createComponent({ loading, organizations: orgsData });
+ createComponent({ loading, organizations: { nodes: orgsData, pageInfo: {} } });
});
it(`does ${loading ? '' : 'not '}render loading icon`, () => {
@@ -54,4 +54,30 @@ describe('OrganizationsView', () => {
).toBe(emptyStateUrl);
});
});
+
+ describe('when `OrganizationsList` emits `next` event', () => {
+ const endCursor = 'mockEndCursor';
+
+ beforeEach(() => {
+ createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } });
+ findOrganizationsList().vm.$emit('next', endCursor);
+ });
+
+ it('emits `next` event', () => {
+ expect(wrapper.emitted('next')).toEqual([[endCursor]]);
+ });
+ });
+
+ describe('when `OrganizationsList` emits `prev` event', () => {
+ const startCursor = 'mockStartCursor';
+
+ beforeEach(() => {
+ createComponent({ loading: false, organizations: { nodes: organizations, pageInfo: {} } });
+ findOrganizationsList().vm.$emit('prev', startCursor);
+ });
+
+ it('emits `next` event', () => {
+ expect(wrapper.emitted('prev')).toEqual([[startCursor]]);
+ });
+ });
});
diff --git a/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js b/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js
new file mode 100644
index 00000000000..34793200b0d
--- /dev/null
+++ b/spec/frontend/organizations/settings/general/components/advanced_settings_spec.js
@@ -0,0 +1,25 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import AdvancedSettings from '~/organizations/settings/general/components/advanced_settings.vue';
+import ChangeUrl from '~/organizations/settings/general/components/change_url.vue';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+
+describe('AdvancedSettings', () => {
+ let wrapper;
+ const createComponent = () => {
+ wrapper = shallowMountExtended(AdvancedSettings);
+ };
+
+ const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders settings block', () => {
+ expect(findSettingsBlock().exists()).toBe(true);
+ });
+
+ it('renders `ChangeUrl` component', () => {
+ expect(findSettingsBlock().findComponent(ChangeUrl).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/organizations/settings/general/components/app_spec.js b/spec/frontend/organizations/settings/general/components/app_spec.js
index 6d75f8a9949..e954b927715 100644
--- a/spec/frontend/organizations/settings/general/components/app_spec.js
+++ b/spec/frontend/organizations/settings/general/components/app_spec.js
@@ -1,8 +1,9 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import OrganizationSettings from '~/organizations/settings/general/components/organization_settings.vue';
+import AdvancedSettings from '~/organizations/settings/general/components/advanced_settings.vue';
import App from '~/organizations/settings/general/components/app.vue';
-describe('OrganizationSettings', () => {
+describe('OrganizationSettingsGeneralApp', () => {
let wrapper;
const createComponent = () => {
@@ -16,4 +17,8 @@ describe('OrganizationSettings', () => {
it('renders `Organization settings` section', () => {
expect(wrapper.findComponent(OrganizationSettings).exists()).toBe(true);
});
+
+ it('renders `Advanced` section', () => {
+ expect(wrapper.findComponent(AdvancedSettings).exists()).toBe(true);
+ });
});
diff --git a/spec/frontend/organizations/settings/general/components/change_url_spec.js b/spec/frontend/organizations/settings/general/components/change_url_spec.js
new file mode 100644
index 00000000000..a4e3db0557c
--- /dev/null
+++ b/spec/frontend/organizations/settings/general/components/change_url_spec.js
@@ -0,0 +1,191 @@
+import { GlButton, GlForm } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
+
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import ChangeUrl from '~/organizations/settings/general/components/change_url.vue';
+import organizationUpdateMutation from '~/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql';
+import {
+ organizationUpdateResponse,
+ organizationUpdateResponseWithErrors,
+} from '~/organizations/mock_data';
+import { createAlert } from '~/alert';
+import { visitUrlWithAlerts } from '~/lib/utils/url_utility';
+import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/alert');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrlWithAlerts: jest.fn(),
+}));
+
+Vue.use(VueApollo);
+
+describe('ChangeUrl', () => {
+ let wrapper;
+ let mockApollo;
+
+ const defaultProvide = {
+ organization: {
+ id: 1,
+ name: 'GitLab',
+ path: 'foo-bar',
+ },
+ organizationsPath: '/-/organizations',
+ rootUrl: 'http://127.0.0.1:3000/',
+ };
+
+ const successfulResponseHandler = jest.fn().mockResolvedValue(organizationUpdateResponse);
+
+ const createComponent = ({
+ handlers = [[organizationUpdateMutation, successfulResponseHandler]],
+ } = {}) => {
+ mockApollo = createMockApollo(handlers);
+
+ wrapper = mountExtended(ChangeUrl, {
+ attachTo: document.body,
+ provide: defaultProvide,
+ apolloProvider: mockApollo,
+ });
+ };
+
+ const findSubmitButton = () => wrapper.findComponent(GlButton);
+ const findOrganizationUrlField = () => wrapper.findByLabelText('Organization URL');
+ const submitForm = async () => {
+ await wrapper.findComponent(GlForm).trigger('submit');
+ await nextTick();
+ };
+
+ afterEach(() => {
+ mockApollo = null;
+ });
+
+ it('renders `Organization URL` field', () => {
+ createComponent();
+
+ expect(findOrganizationUrlField().exists()).toBe(true);
+ });
+
+ it('disables submit button until `Organization URL` field is changed', async () => {
+ createComponent();
+
+ expect(findSubmitButton().props('disabled')).toBe(true);
+
+ await findOrganizationUrlField().setValue('foo-bar-baz');
+
+ expect(findSubmitButton().props('disabled')).toBe(false);
+ });
+
+ describe('when form is submitted', () => {
+ it('requires `Organization URL` field', async () => {
+ createComponent();
+
+ await findOrganizationUrlField().setValue('');
+ await submitForm();
+
+ expect(wrapper.findByText('Organization URL is required.').exists()).toBe(true);
+ });
+
+ it('requires `Organization URL` field to be a minimum of two characters', async () => {
+ createComponent();
+
+ await findOrganizationUrlField().setValue('f');
+ await submitForm();
+
+ expect(
+ wrapper.findByText('Organization URL is too short (minimum is 2 characters).').exists(),
+ ).toBe(true);
+ });
+
+ describe('when API is loading', () => {
+ beforeEach(async () => {
+ createComponent({
+ handlers: [
+ [organizationUpdateMutation, jest.fn().mockReturnValueOnce(new Promise(() => {}))],
+ ],
+ });
+
+ await findOrganizationUrlField().setValue('foo-bar-baz');
+ await submitForm();
+ });
+
+ it('shows submit button as loading', () => {
+ expect(findSubmitButton().props('loading')).toBe(true);
+ });
+ });
+
+ describe('when API request is successful', () => {
+ beforeEach(async () => {
+ createComponent();
+ await findOrganizationUrlField().setValue('foo-bar-baz');
+ await submitForm();
+ await waitForPromises();
+ });
+
+ it('calls mutation with correct variables and redirects user to new organization settings page with success alert', () => {
+ expect(successfulResponseHandler).toHaveBeenCalledWith({
+ input: {
+ id: 'gid://gitlab/Organizations::Organization/1',
+ path: 'foo-bar-baz',
+ },
+ });
+ expect(visitUrlWithAlerts).toHaveBeenCalledWith(
+ `${organizationUpdateResponse.data.organizationUpdate.organization.webUrl}/settings/general`,
+ [
+ {
+ id: 'organization-url-successfully-changed',
+ message: 'Organization URL successfully changed.',
+ variant: 'info',
+ },
+ ],
+ );
+ });
+ });
+
+ describe('when API request is not successful', () => {
+ describe('when there is a network error', () => {
+ const error = new Error();
+
+ beforeEach(async () => {
+ createComponent({
+ handlers: [[organizationUpdateMutation, jest.fn().mockRejectedValue(error)]],
+ });
+ await findOrganizationUrlField().setValue('foo-bar-baz');
+ await submitForm();
+ await waitForPromises();
+ });
+
+ it('displays error alert', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred changing your organization URL. Please try again.',
+ error,
+ captureError: true,
+ });
+ });
+ });
+
+ describe('when there are GraphQL errors', () => {
+ beforeEach(async () => {
+ createComponent({
+ handlers: [
+ [
+ organizationUpdateMutation,
+ jest.fn().mockResolvedValue(organizationUpdateResponseWithErrors),
+ ],
+ ],
+ });
+ await submitForm();
+ await waitForPromises();
+ });
+
+ it('displays form errors alert', () => {
+ expect(wrapper.findComponent(FormErrorsAlert).props('errors')).toEqual(
+ organizationUpdateResponseWithErrors.data.organizationUpdate.errors,
+ );
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/organizations/settings/general/components/organization_settings_spec.js b/spec/frontend/organizations/settings/general/components/organization_settings_spec.js
index 7645b41e3bd..d1c637331a8 100644
--- a/spec/frontend/organizations/settings/general/components/organization_settings_spec.js
+++ b/spec/frontend/organizations/settings/general/components/organization_settings_spec.js
@@ -6,14 +6,26 @@ import OrganizationSettings from '~/organizations/settings/general/components/or
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import NewEditForm from '~/organizations/shared/components/new_edit_form.vue';
import { FORM_FIELD_NAME, FORM_FIELD_ID } from '~/organizations/shared/constants';
-import resolvers from '~/organizations/shared/graphql/resolvers';
-import { createAlert, VARIANT_INFO } from '~/alert';
+import organizationUpdateMutation from '~/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql';
+import {
+ organizationUpdateResponse,
+ organizationUpdateResponseWithErrors,
+} from '~/organizations/mock_data';
+import { createAlert } from '~/alert';
+import { visitUrlWithAlerts } from '~/lib/utils/url_utility';
+import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
Vue.use(VueApollo);
-jest.useFakeTimers();
jest.mock('~/alert');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrlWithAlerts: jest.fn(),
+}));
+
+useMockLocationHelper();
describe('OrganizationSettings', () => {
let wrapper;
@@ -26,8 +38,12 @@ describe('OrganizationSettings', () => {
},
};
- const createComponent = ({ mockResolvers = resolvers } = {}) => {
- mockApollo = createMockApollo([], mockResolvers);
+ const successfulResponseHandler = jest.fn().mockResolvedValue(organizationUpdateResponse);
+
+ const createComponent = ({
+ handlers = [[organizationUpdateMutation, successfulResponseHandler]],
+ } = {}) => {
+ mockApollo = createMockApollo(handlers);
wrapper = shallowMountExtended(OrganizationSettings, {
provide: defaultProvide,
@@ -66,13 +82,11 @@ describe('OrganizationSettings', () => {
describe('when form is submitted', () => {
describe('when API is loading', () => {
beforeEach(async () => {
- const mockResolvers = {
- Mutation: {
- updateOrganization: jest.fn().mockReturnValueOnce(new Promise(() => {})),
- },
- };
-
- createComponent({ mockResolvers });
+ createComponent({
+ handlers: [
+ [organizationUpdateMutation, jest.fn().mockReturnValueOnce(new Promise(() => {}))],
+ ],
+ });
await submitForm();
});
@@ -86,39 +100,65 @@ describe('OrganizationSettings', () => {
beforeEach(async () => {
createComponent();
await submitForm();
- jest.runAllTimers();
await waitForPromises();
});
- it('displays info alert', () => {
- expect(createAlert).toHaveBeenCalledWith({
- message: 'Organization was successfully updated.',
- variant: VARIANT_INFO,
+ it('calls mutation with correct variables and displays info alert', () => {
+ expect(successfulResponseHandler).toHaveBeenCalledWith({
+ input: {
+ id: 'gid://gitlab/Organizations::Organization/1',
+ name: 'Foo bar',
+ },
});
+ expect(visitUrlWithAlerts).toHaveBeenCalledWith(window.location.href, [
+ {
+ id: 'organization-successfully-updated',
+ message: 'Organization was successfully updated.',
+ variant: 'info',
+ },
+ ]);
});
});
describe('when API request is not successful', () => {
- const error = new Error();
-
- beforeEach(async () => {
- const mockResolvers = {
- Mutation: {
- updateOrganization: jest.fn().mockRejectedValueOnce(error),
- },
- };
+ describe('when there is a network error', () => {
+ const error = new Error();
+
+ beforeEach(async () => {
+ createComponent({
+ handlers: [[organizationUpdateMutation, jest.fn().mockRejectedValue(error)]],
+ });
+ await submitForm();
+ await waitForPromises();
+ });
- createComponent({ mockResolvers });
- await submitForm();
- jest.runAllTimers();
- await waitForPromises();
+ it('displays error alert', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'An error occurred updating your organization. Please try again.',
+ error,
+ captureError: true,
+ });
+ });
});
- it('displays error alert', () => {
- expect(createAlert).toHaveBeenCalledWith({
- message: 'An error occurred updating your organization. Please try again.',
- error,
- captureError: true,
+ describe('when there are GraphQL errors', () => {
+ beforeEach(async () => {
+ createComponent({
+ handlers: [
+ [
+ organizationUpdateMutation,
+ jest.fn().mockResolvedValue(organizationUpdateResponseWithErrors),
+ ],
+ ],
+ });
+ await submitForm();
+ await waitForPromises();
+ });
+
+ it('displays form errors alert', () => {
+ expect(wrapper.findComponent(FormErrorsAlert).props('errors')).toEqual(
+ organizationUpdateResponseWithErrors.data.organizationUpdate.errors,
+ );
});
});
});
diff --git a/spec/frontend/organizations/shared/components/new_edit_form_spec.js b/spec/frontend/organizations/shared/components/new_edit_form_spec.js
index 93f022a3259..1fcfc20bf1a 100644
--- a/spec/frontend/organizations/shared/components/new_edit_form_spec.js
+++ b/spec/frontend/organizations/shared/components/new_edit_form_spec.js
@@ -1,6 +1,8 @@
-import { GlButton, GlInputGroupText, GlTruncate } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
+import { nextTick } from 'vue';
import NewEditForm from '~/organizations/shared/components/new_edit_form.vue';
+import OrganizationUrlField from '~/organizations/shared/components/organization_url_field.vue';
import { FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_PATH } from '~/organizations/shared/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
@@ -29,7 +31,12 @@ describe('NewEditForm', () => {
const findNameField = () => wrapper.findByLabelText('Organization name');
const findIdField = () => wrapper.findByLabelText('Organization ID');
- const findUrlField = () => wrapper.findByLabelText('Organization URL');
+ const findUrlField = () => wrapper.findComponent(OrganizationUrlField);
+
+ const setUrlFieldValue = async (value) => {
+ findUrlField().vm.$emit('input', value);
+ await nextTick();
+ };
const submitForm = async () => {
await wrapper.findByRole('button', { name: 'Create organization' }).trigger('click');
};
@@ -43,20 +50,17 @@ describe('NewEditForm', () => {
it('renders `Organization URL` field', () => {
createComponent();
- expect(wrapper.findComponent(GlInputGroupText).findComponent(GlTruncate).props('text')).toBe(
- 'http://127.0.0.1:3000/-/organizations/',
- );
expect(findUrlField().exists()).toBe(true);
});
it('requires `Organization URL` field to be a minimum of two characters', async () => {
createComponent();
- await findUrlField().setValue('f');
+ await setUrlFieldValue('f');
await submitForm();
expect(
- wrapper.findByText('Organization URL must be a minimum of two characters.').exists(),
+ wrapper.findByText('Organization URL is too short (minimum is 2 characters).').exists(),
).toBe(true);
});
@@ -89,7 +93,7 @@ describe('NewEditForm', () => {
it('sets initial values for fields', () => {
expect(findNameField().element.value).toBe('Foo bar');
expect(findIdField().element.value).toBe('1');
- expect(findUrlField().element.value).toBe('foo-bar');
+ expect(findUrlField().props('value')).toBe('foo-bar');
});
});
@@ -116,7 +120,7 @@ describe('NewEditForm', () => {
createComponent();
await findNameField().setValue('Foo bar');
- await findUrlField().setValue('foo-bar');
+ await setUrlFieldValue('foo-bar');
await submitForm();
});
@@ -134,7 +138,7 @@ describe('NewEditForm', () => {
});
it('sets `Organization URL` when typing in `Organization name`', () => {
- expect(findUrlField().element.value).toBe('foo-bar');
+ expect(findUrlField().props('value')).toBe('foo-bar');
});
});
@@ -142,13 +146,13 @@ describe('NewEditForm', () => {
beforeEach(async () => {
createComponent();
- await findUrlField().setValue('foo-bar-baz');
+ await setUrlFieldValue('foo-bar-baz');
await findNameField().setValue('Foo bar');
await submitForm();
});
it('does not modify `Organization URL` when typing in `Organization name`', () => {
- expect(findUrlField().element.value).toBe('foo-bar-baz');
+ expect(findUrlField().props('value')).toBe('foo-bar-baz');
});
});
diff --git a/spec/frontend/organizations/shared/components/organization_url_field_spec.js b/spec/frontend/organizations/shared/components/organization_url_field_spec.js
new file mode 100644
index 00000000000..d854134e596
--- /dev/null
+++ b/spec/frontend/organizations/shared/components/organization_url_field_spec.js
@@ -0,0 +1,66 @@
+import { GlFormInputGroup, GlInputGroupText, GlTruncate, GlFormInput } from '@gitlab/ui';
+
+import OrganizedUrlField from '~/organizations/shared/components/organization_url_field.vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('OrganizationUrlField', () => {
+ let wrapper;
+
+ const defaultProvide = {
+ organizationsPath: '/-/organizations',
+ rootUrl: 'http://127.0.0.1:3000/',
+ };
+
+ const defaultPropsData = {
+ id: 'organization-url',
+ value: 'foo-bar',
+ validation: {
+ invalidFeedback: 'Invalid',
+ state: false,
+ },
+ };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = mountExtended(OrganizedUrlField, {
+ attachTo: document.body,
+ provide: defaultProvide,
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
+ });
+ };
+
+ const findInputGroup = () => wrapper.findComponent(GlFormInputGroup);
+ const findInput = () => findInputGroup().findComponent(GlFormInput);
+
+ it('renders organization url field with correct props', () => {
+ createComponent();
+
+ expect(
+ findInputGroup().findComponent(GlInputGroupText).findComponent(GlTruncate).props('text'),
+ ).toBe('http://127.0.0.1:3000/-/organizations/');
+ expect(findInput().attributes('id')).toBe(defaultPropsData.id);
+ expect(findInput().vm.$attrs).toMatchObject({
+ value: defaultPropsData.value,
+ invalidFeedback: defaultPropsData.validation.invalidFeedback,
+ state: defaultPropsData.validation.state,
+ });
+ });
+
+ it('emits `input` event', () => {
+ createComponent();
+
+ findInput().vm.$emit('input', 'foo');
+
+ expect(wrapper.emitted('input')).toEqual([['foo']]);
+ });
+
+ it('emits `blur` event', () => {
+ createComponent();
+
+ findInput().vm.$emit('blur', true);
+
+ expect(wrapper.emitted('blur')).toEqual([[true]]);
+ });
+});
diff --git a/spec/frontend/organizations/users/components/app_spec.js b/spec/frontend/organizations/users/components/app_spec.js
index b30fd984099..30380bcf6a5 100644
--- a/spec/frontend/organizations/users/components/app_spec.js
+++ b/spec/frontend/organizations/users/components/app_spec.js
@@ -4,9 +4,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
+import { ORGANIZATION_USERS_PER_PAGE } from '~/organizations/constants';
import organizationUsersQuery from '~/organizations/users/graphql/organization_users.query.graphql';
import OrganizationsUsersApp from '~/organizations/users/components/app.vue';
-import { MOCK_ORGANIZATION_GID, MOCK_USERS } from '../mock_data';
+import OrganizationsUsersView from '~/organizations/users/components/users_view.vue';
+import {
+ MOCK_ORGANIZATION_GID,
+ MOCK_USERS,
+ MOCK_USERS_FORMATTED,
+ MOCK_PAGE_INFO,
+} from '../mock_data';
jest.mock('~/alert');
@@ -15,10 +22,11 @@ Vue.use(VueApollo);
const mockError = new Error();
const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {}));
-const successfulResolver = (nodes) =>
- jest.fn().mockResolvedValue({
- data: { organization: { id: 1, organizationUsers: { nodes } } },
+const successfulResolver = (nodes, pageInfo = {}) => {
+ return jest.fn().mockResolvedValue({
+ data: { organization: { id: 1, organizationUsers: { nodes, pageInfo } } },
});
+};
const errorResolver = jest.fn().mockRejectedValueOnce(mockError);
describe('OrganizationsUsersApp', () => {
@@ -40,31 +48,31 @@ describe('OrganizationsUsersApp', () => {
mockApollo = null;
});
- const findOrganizationUsersLoading = () => wrapper.findByText('Loading');
- const findOrganizationUsers = () => wrapper.findByTestId('organization-users');
+ const findOrganizationUsersView = () => wrapper.findComponent(OrganizationsUsersView);
describe.each`
- description | mockResolver | loading | userData | error
- ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${false}
- ${'when API returns successful with results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS} | ${false}
- ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${false}
- ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${true}
- `('$description', ({ mockResolver, loading, userData, error }) => {
+ description | mockResolver | loading | userData | pageInfo | error
+ ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${{}} | ${false}
+ ${'when API returns successful with one page of results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS_FORMATTED} | ${{}} | ${false}
+ ${'when API returns successful with multiple pages of results'} | ${successfulResolver(MOCK_USERS, MOCK_PAGE_INFO)} | ${false} | ${MOCK_USERS_FORMATTED} | ${MOCK_PAGE_INFO} | ${false}
+ ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${{}} | ${false}
+ ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${{}} | ${true}
+ `('$description', ({ mockResolver, loading, userData, pageInfo, error }) => {
beforeEach(async () => {
createComponent(mockResolver);
await waitForPromises();
});
- it(`does ${
- loading ? '' : 'not '
- }render the organization users view with loading placeholder`, () => {
- expect(findOrganizationUsersLoading().exists()).toBe(loading);
+ it(`renders OrganizationUsersView with loading prop set to ${loading}`, () => {
+ expect(findOrganizationUsersView().props('loading')).toBe(loading);
});
- it(`renders the organization users view with ${
- userData.length ? 'correct' : 'empty'
- } users array raw data`, () => {
- expect(JSON.parse(findOrganizationUsers().text())).toStrictEqual(userData);
+ it('renders OrganizationUsersView with correct users prop', () => {
+ expect(findOrganizationUsersView().props('users')).toStrictEqual(userData);
+ });
+
+ it('renders OrganizationUsersView with correct pageInfo prop', () => {
+ expect(findOrganizationUsersView().props('pageInfo')).toStrictEqual(pageInfo);
});
it(`does ${error ? '' : 'not '}render an error message`, () => {
@@ -78,4 +86,40 @@ describe('OrganizationsUsersApp', () => {
: expect(createAlert).not.toHaveBeenCalled();
});
});
+
+ describe('Pagination', () => {
+ const mockResolver = successfulResolver(MOCK_USERS, MOCK_PAGE_INFO);
+
+ beforeEach(async () => {
+ createComponent(mockResolver);
+ await waitForPromises();
+ mockResolver.mockClear();
+ });
+
+ it('handleNextPage calls organizationUsersQuery with correct pagination data', async () => {
+ findOrganizationUsersView().vm.$emit('next');
+ await waitForPromises();
+
+ expect(mockResolver).toHaveBeenCalledWith({
+ id: MOCK_ORGANIZATION_GID,
+ before: '',
+ after: MOCK_PAGE_INFO.endCursor,
+ first: ORGANIZATION_USERS_PER_PAGE,
+ last: null,
+ });
+ });
+
+ it('handlePrevPage calls organizationUsersQuery with correct pagination data', async () => {
+ findOrganizationUsersView().vm.$emit('prev');
+ await waitForPromises();
+
+ expect(mockResolver).toHaveBeenCalledWith({
+ id: MOCK_ORGANIZATION_GID,
+ before: MOCK_PAGE_INFO.startCursor,
+ after: '',
+ first: ORGANIZATION_USERS_PER_PAGE,
+ last: null,
+ });
+ });
+ });
});
diff --git a/spec/frontend/organizations/users/components/users_view_spec.js b/spec/frontend/organizations/users/components/users_view_spec.js
new file mode 100644
index 00000000000..d665c60d425
--- /dev/null
+++ b/spec/frontend/organizations/users/components/users_view_spec.js
@@ -0,0 +1,68 @@
+import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import UsersView from '~/organizations/users/components/users_view.vue';
+import UsersTable from '~/vue_shared/components/users_table/users_table.vue';
+import { MOCK_PATHS, MOCK_USERS_FORMATTED, MOCK_PAGE_INFO } from '../mock_data';
+
+describe('UsersView', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(UsersView, {
+ propsData: {
+ loading: false,
+ users: MOCK_USERS_FORMATTED,
+ pageInfo: MOCK_PAGE_INFO,
+ ...props,
+ },
+ provide: {
+ paths: MOCK_PATHS,
+ },
+ });
+ };
+
+ const findGlLoading = () => wrapper.findComponent(GlLoadingIcon);
+ const findUsersTable = () => wrapper.findComponent(UsersTable);
+ const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
+
+ describe.each`
+ description | loading | usersData
+ ${'when loading'} | ${true} | ${[]}
+ ${'when not loading and has users'} | ${false} | ${MOCK_USERS_FORMATTED}
+ ${'when not loading and has no users'} | ${false} | ${[]}
+ `('$description', ({ loading, usersData }) => {
+ beforeEach(() => {
+ createComponent({ loading, users: usersData });
+ });
+
+ it(`does ${loading ? '' : 'not '}render loading icon`, () => {
+ expect(findGlLoading().exists()).toBe(loading);
+ });
+
+ it(`does ${!loading ? '' : 'not '}render users table`, () => {
+ expect(findUsersTable().exists()).toBe(!loading);
+ });
+
+ it(`does ${!loading ? '' : 'not '}render pagination`, () => {
+ expect(findGlKeysetPagination().exists()).toBe(Boolean(!loading));
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('@next event forwards up to the parent component', () => {
+ findGlKeysetPagination().vm.$emit('next');
+
+ expect(wrapper.emitted('next')).toHaveLength(1);
+ });
+
+ it('@prev event forwards up to the parent component', () => {
+ findGlKeysetPagination().vm.$emit('prev');
+
+ expect(wrapper.emitted('prev')).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/organizations/users/mock_data.js b/spec/frontend/organizations/users/mock_data.js
index 4f159c70c2c..16b3ec3bbcb 100644
--- a/spec/frontend/organizations/users/mock_data.js
+++ b/spec/frontend/organizations/users/mock_data.js
@@ -1,15 +1,31 @@
+const createUser = (id) => {
+ return {
+ id: `gid://gitlab/User/${id}`,
+ username: `test_user_${id}`,
+ avatarUrl: `/path/test_user_${id}`,
+ name: `Test User ${id}`,
+ publicEmail: `test_user_${id}@gitlab.com`,
+ createdAt: Date.now(),
+ lastActivityOn: Date.now(),
+ };
+};
+
export const MOCK_ORGANIZATION_GID = 'gid://gitlab/Organizations::Organization/1';
+export const MOCK_PATHS = {
+ adminUser: '/admin/users/:id',
+};
+
export const MOCK_USERS = [
{
badges: [],
id: 'gid://gitlab/Organizations::OrganizationUser/3',
- user: { id: 'gid://gitlab/User/3' },
+ user: createUser(3),
},
{
badges: [],
id: 'gid://gitlab/Organizations::OrganizationUser/2',
- user: { id: 'gid://gitlab/User/2' },
+ user: createUser(2),
},
{
badges: [
@@ -17,6 +33,18 @@ export const MOCK_USERS = [
{ text: "It's you!", variant: 'muted' },
],
id: 'gid://gitlab/Organizations::OrganizationUser/1',
- user: { id: 'gid://gitlab/User/1' },
+ user: createUser(1),
},
];
+
+export const MOCK_USERS_FORMATTED = MOCK_USERS.map(({ badges, user }) => {
+ return { ...user, badges, email: user.publicEmail };
+});
+
+export const MOCK_PAGE_INFO = {
+ startCursor: 'aaaa',
+ endCursor: 'bbbb',
+ hasNextPage: true,
+ hasPreviousPage: true,
+ __typename: 'PageInfo',
+};
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
index 09e2c35d449..9f3431ef5a5 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
@@ -342,6 +342,13 @@ describe('tags list row', () => {
expect(findDetailsRows().length).toBe(3);
});
+ it('has 2 details rows when revision is empty', async () => {
+ mountComponent({ tag: { ...tag, revision: '' } });
+ await nextTick();
+
+ expect(findDetailsRows().length).toBe(2);
+ });
+
describe.each`
name | finderFunction | text | icon | clipboard
${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 13:29:38 UTC on 2020-11-03'} | ${'clock'} | ${false}
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap
index 8e757c136ec..a544a679ff4 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap
@@ -2,9 +2,9 @@
exports[`FileSha renders 1`] = `
<div
- class="gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all"
+ class="gl-align-items-top gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all"
>
- <span>
+ <div>
<div
class="gl-px-4"
>
@@ -23,6 +23,6 @@ exports[`FileSha renders 1`] = `
variant="default"
/>
</div>
- </span>
+ </div>
</div>
`;
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap
index edba81da1f5..75cc7e5b78d 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap
@@ -9,10 +9,10 @@ exports[`packages_list_row renders 1`] = `
class="gl-align-items-center gl-display-flex gl-py-3"
>
<div
- class="gl-align-items-stretch gl-display-flex gl-flex-grow-1 gl-justify-content-space-between gl-xs-flex-direction-column"
+ class="gl-align-items-stretch gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-justify-content-space-between gl-sm-flex-direction-row"
>
<div
- class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-min-w-0 gl-xs-mb-3"
+ class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-mb-3 gl-min-w-0 gl-sm-mb-0"
>
<div
class="gl-align-items-center gl-display-flex gl-font-weight-bold gl-min-h-6 gl-min-w-0 gl-text-body"
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap
index 8e757c136ec..a544a679ff4 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap
@@ -2,9 +2,9 @@
exports[`FileSha renders 1`] = `
<div
- class="gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all"
+ class="gl-align-items-top gl-border-b-1 gl-border-b-solid gl-border-gray-100 gl-display-flex gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all"
>
- <span>
+ <div>
<div
class="gl-px-4"
>
@@ -23,6 +23,6 @@ exports[`FileSha renders 1`] = `
variant="default"
/>
</div>
- </span>
+ </div>
</div>
`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
index 133941bbb2e..283c394a135 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
@@ -13,7 +13,7 @@ import {
pypiMetadata,
packageMetadataQuery,
} from 'jest/packages_and_registries/package_registry/mock_data';
-import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
+import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
import {
FETCH_PACKAGE_METADATA_ERROR_MESSAGE,
PACKAGE_TYPE_NUGET,
@@ -52,12 +52,9 @@ describe('Package Additional metadata', () => {
const requestHandlers = [[getPackageMetadata, resolver]];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMountExtended(component, {
+ wrapper = shallowMountExtended(AdditionalMetadata, {
apolloProvider,
propsData: { ...defaultProps, ...props },
- stubs: {
- component: { template: '<div data-testid="component-is"></div>' },
- },
});
};
@@ -91,7 +88,7 @@ describe('Package Additional metadata', () => {
const title = findTitle();
expect(title.exists()).toBe(true);
- expect(title.text()).toMatchInterpolatedText(component.i18n.componentTitle);
+ expect(title.text()).toMatchInterpolatedText(AdditionalMetadata.i18n.componentTitle);
});
it('does not render gl-alert', () => {
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js
index 67f5fbc9e80..39b525efdbc 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js
@@ -21,14 +21,20 @@ describe('Package Additional Metadata', () => {
};
const findPypiRequiredPython = () => wrapper.findByTestId('pypi-required-python');
+ const findPypiAuthorEmail = () => wrapper.findByTestId('pypi-author-email');
+ const findPypiSummary = () => wrapper.findByTestId('pypi-summary');
+ const findPypiKeywords = () => wrapper.findByTestId('pypi-keywords');
beforeEach(() => {
mountComponent();
});
it.each`
- name | finderFunction | text | icon
- ${'pypi-required-python'} | ${findPypiRequiredPython} | ${'Required Python: 1.0.0'} | ${'information-o'}
+ name | finderFunction | text | icon
+ ${'pypi-required-python'} | ${findPypiRequiredPython} | ${'Required Python: 1.0.0'} | ${'information-o'}
+ ${'pypi-author-email'} | ${findPypiAuthorEmail} | ${'Author email: "C. Schultz" <cschultz@example.com>'} | ${'mail'}
+ ${'pypi-summary'} | ${findPypiSummary} | ${'Summary: A module for collecting votes from beagles.'} | ${'doc-text'}
+ ${'pypi-keywords'} | ${findPypiKeywords} | ${'Keywords: dog,puppy,voting,election'} | ${'doc-text'}
`('$name element', ({ finderFunction, text, icon }) => {
const element = finderFunction();
expect(element.exists()).toBe(true);
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
index 40fcd290b33..cbf2184d879 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
@@ -17,10 +17,10 @@ exports[`packages_list_row renders 1`] = `
/>
</div>
<div
- class="gl-align-items-stretch gl-display-flex gl-flex-grow-1 gl-justify-content-space-between gl-xs-flex-direction-column"
+ class="gl-align-items-stretch gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-justify-content-space-between gl-sm-flex-direction-row"
>
<div
- class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-min-w-0 gl-xs-mb-3"
+ class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-mb-3 gl-min-w-0 gl-sm-mb-0"
>
<div
class="gl-align-items-center gl-display-flex gl-font-weight-bold gl-min-h-6 gl-min-w-0 gl-text-body"
@@ -82,7 +82,7 @@ exports[`packages_list_row renders 1`] = `
Published
<time
datetime="2020-05-17T14:23:32Z"
- title="May 17, 2020 2:23pm UTC"
+ title="May 17, 2020 at 2:23:32 PM GMT"
>
1 month ago
</time>
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
index f4e36f51c27..6a1c34df596 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
@@ -1,4 +1,5 @@
import { nextTick } from 'vue';
+import { GlFilteredSearchToken } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { sortableFields } from '~/packages_and_registries/package_registry/utils';
import component from '~/packages_and_registries/package_registry/components/list/package_search.vue';
@@ -7,7 +8,11 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants';
-import { TOKEN_TYPE_TYPE } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ OPERATORS_IS,
+ TOKEN_TYPE_TYPE,
+ TOKEN_TYPE_VERSION,
+} from '~/vue_shared/components/filtered_search_bar/constants';
describe('Package Search', () => {
let wrapper;
@@ -74,6 +79,13 @@ describe('Package Search', () => {
token: PackageTypeToken,
type: TOKEN_TYPE_TYPE,
icon: 'package',
+ operators: OPERATORS_IS,
+ }),
+ expect.objectContaining({
+ token: GlFilteredSearchToken,
+ type: TOKEN_TYPE_VERSION,
+ icon: 'doc-versions',
+ operators: OPERATORS_IS,
}),
]),
sortableFields: sortableFields(isGroupPage),
@@ -102,6 +114,7 @@ describe('Package Search', () => {
filters: {
packageName: '',
packageType: undefined,
+ packageVersion: '',
},
sort: payload.sort,
sorting: payload.sorting,
@@ -114,6 +127,7 @@ describe('Package Search', () => {
sort: 'CREATED_FOO',
filters: [
{ type: 'type', value: { data: 'Generic', operator: '=' }, id: 'token-3' },
+ { type: 'version', value: { data: '1.0.1', operator: '=' }, id: 'token-6' },
{ id: 'token-4', type: 'filtered-search-term', value: { data: 'gl' } },
{ id: 'token-5', type: 'filtered-search-term', value: { data: '' } },
],
@@ -133,6 +147,7 @@ describe('Package Search', () => {
filters: {
packageName: 'gl',
packageType: 'GENERIC',
+ packageVersion: '1.0.1',
},
sort: payload.sort,
sorting: payload.sorting,
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 6c03f91b73d..fdd64cbe6a5 100644
--- a/spec/frontend/packages_and_registries/package_registry/mock_data.js
+++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js
@@ -183,7 +183,10 @@ export const composerMetadata = () => ({
export const pypiMetadata = () => ({
__typename: 'PypiMetadata',
id: 'pypi-1',
+ authorEmail: '"C. Schultz" <cschultz@example.com>',
+ keywords: 'dog,puppy,voting,election',
requiredPython: '1.0.0',
+ summary: 'A module for collecting votes from beagles.',
});
export const mavenMetadata = () => ({
diff --git a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
index 0ce2b86b9a4..db86be3b8ee 100644
--- a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
@@ -44,7 +44,7 @@ describe('PackagesListApp', () => {
const searchPayload = {
sort: 'VERSION_DESC',
- filters: { packageName: 'foo', packageType: 'CONAN' },
+ filters: { packageName: 'foo', packageType: 'CONAN', packageVersion: '1.0.1' },
};
const findPackageTitle = () => wrapper.findComponent(PackageTitle);
@@ -304,7 +304,12 @@ describe('PackagesListApp', () => {
await waitForFirstRequest();
- findSearch().vm.$emit('update', searchPayload);
+ findSearch().vm.$emit('update', {
+ sort: 'VERSION_DESC',
+ filters: {
+ packageName: 'test',
+ },
+ });
return nextTick();
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
index 12425909454..dfcabd14489 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
@@ -6,6 +6,7 @@ import * as commonUtils from '~/lib/utils/common_utils';
import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue';
import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue';
import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue';
+import DependencyProxyPackagesSettings from 'ee_component/packages_and_registries/settings/project/components/dependency_proxy_packages_settings.vue';
import {
SHOW_SETUP_SUCCESS_ALERT,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
@@ -18,11 +19,16 @@ describe('Registry Settings app', () => {
const findContainerExpirationPolicy = () => wrapper.findComponent(ContainerExpirationPolicy);
const findPackagesCleanupPolicy = () => wrapper.findComponent(PackagesCleanupPolicy);
+ const findDependencyProxyPackagesSettings = () =>
+ wrapper.findComponent(DependencyProxyPackagesSettings);
const findAlert = () => wrapper.findComponent(GlAlert);
const defaultProvide = {
+ projectPath: 'path',
showContainerRegistrySettings: true,
showPackageRegistrySettings: true,
+ showDependencyProxySettings: false,
+ ...(IS_EE && { showDependencyProxySettings: true }),
};
const mountComponent = (provide = defaultProvide) => {
@@ -82,6 +88,7 @@ describe('Registry Settings app', () => {
'container cleanup policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings',
({ showContainerRegistrySettings, showPackageRegistrySettings }) => {
mountComponent({
+ ...defaultProvide,
showContainerRegistrySettings,
showPackageRegistrySettings,
});
@@ -90,5 +97,16 @@ describe('Registry Settings app', () => {
expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings);
},
);
+
+ if (IS_EE) {
+ it.each([true, false])('when showDependencyProxySettings is %s', (value) => {
+ mountComponent({
+ ...defaultProvide,
+ showDependencyProxySettings: value,
+ });
+
+ expect(findDependencyProxyPackagesSettings().exists()).toBe(value);
+ });
+ }
});
});
diff --git a/spec/frontend/packages_and_registries/shared/utils_spec.js b/spec/frontend/packages_and_registries/shared/utils_spec.js
index 1dc6bb261de..4676544c324 100644
--- a/spec/frontend/packages_and_registries/shared/utils_spec.js
+++ b/spec/frontend/packages_and_registries/shared/utils_spec.js
@@ -41,19 +41,20 @@ describe('Packages And Registries shared utils', () => {
});
describe('extractFilterAndSorting', () => {
it.each`
- search | type | sort | orderBy | result
- ${['one']} | ${'myType'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
- ${['one']} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
- ${[]} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
- ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
- ${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }}
- ${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }}
+ search | type | version | sort | orderBy | result
+ ${['one']} | ${'myType'} | ${'1.0.1'} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: 'type', value: { data: 'myType' } }, { type: 'version', value: { data: '1.0.1' } }, { type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
+ ${['one']} | ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'one' } }] }}
+ ${[]} | ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
+ ${null} | ${null} | ${null} | ${'asc'} | ${'foo'} | ${{ sorting: { sort: 'asc', orderBy: 'foo' }, filters: [] }}
+ ${null} | ${null} | ${null} | ${null} | ${'foo'} | ${{ sorting: { orderBy: 'foo' }, filters: [] }}
+ ${null} | ${null} | ${null} | ${null} | ${null} | ${{ sorting: {}, filters: [] }}
`(
'returns sorting and filters objects in the correct form',
- ({ search, type, sort, orderBy, result }) => {
+ ({ search, type, version, sort, orderBy, result }) => {
const queryObject = {
search,
type,
+ version,
sort,
orderBy,
};
diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
index be50858bc88..3db77469d6b 100644
--- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
+++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
@@ -1,16 +1,23 @@
import { GlEmptyState, GlLoadingIcon, GlTableLite } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { getParameterValues } from '~/lib/utils/url_utility';
+
+import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ getParameterValues: jest.fn().mockReturnValue([]),
+}));
describe('BulkImportsHistoryApp', () => {
- const API_URL = '/api/v4/bulk_imports/entities';
+ const BULK_IMPORTS_API_URL = '/api/v4/bulk_imports/entities';
const DEFAULT_HEADERS = {
'x-page': 1,
@@ -73,14 +80,14 @@ describe('BulkImportsHistoryApp', () => {
}
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
+ const findPaginationBar = () => wrapper.findComponent(PaginationBar);
beforeEach(() => {
gon.api_version = 'v4';
- });
- beforeEach(() => {
+ getParameterValues.mockReturnValue([]);
mock = new MockAdapter(axios);
- mock.onGet(API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS);
});
afterEach(() => {
@@ -94,9 +101,9 @@ describe('BulkImportsHistoryApp', () => {
});
it('renders empty state when no data is available', async () => {
- mock.onGet(API_URL).reply(HTTP_STATUS_OK, [], DEFAULT_HEADERS);
+ mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, [], DEFAULT_HEADERS);
createComponent();
- await axios.waitForAll();
+ await waitForPromises();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true);
@@ -104,7 +111,7 @@ describe('BulkImportsHistoryApp', () => {
it('renders table with data when history is available', async () => {
createComponent();
- await axios.waitForAll();
+ await waitForPromises();
const table = wrapper.findComponent(GlTableLite);
expect(table.exists()).toBe(true);
@@ -116,26 +123,46 @@ describe('BulkImportsHistoryApp', () => {
const NEW_PAGE = 4;
createComponent();
- await axios.waitForAll();
+ await waitForPromises();
mock.resetHistory();
- wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE);
- await axios.waitForAll();
+ findPaginationBar().vm.$emit('set-page', NEW_PAGE);
+ await waitForPromises();
expect(mock.history.get.length).toBe(1);
expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE }));
});
});
+ describe('when filtering by bulk_import_id param', () => {
+ const mockId = 2;
+
+ beforeEach(() => {
+ getParameterValues.mockReturnValue([mockId]);
+ });
+
+ it('makes a request to bulk_import_history endpoint', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(mock.history.get.length).toBe(1);
+ expect(mock.history.get[0].url).toBe(`/api/v4/bulk_imports/${mockId}/entities`);
+ expect(mock.history.get[0].params).toStrictEqual({
+ page: 1,
+ per_page: 20,
+ });
+ });
+ });
+
it('changes page size when requested by pagination bar', async () => {
const NEW_PAGE_SIZE = 4;
createComponent();
- await axios.waitForAll();
+ await waitForPromises();
mock.resetHistory();
- wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE);
- await axios.waitForAll();
+ findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE);
+ await waitForPromises();
expect(mock.history.get.length).toBe(1);
expect(mock.history.get[0].params).toStrictEqual(
@@ -146,15 +173,14 @@ describe('BulkImportsHistoryApp', () => {
it('resets page to 1 when page size is changed', async () => {
const NEW_PAGE_SIZE = 4;
- mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
createComponent();
- await axios.waitForAll();
- wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2);
- await axios.waitForAll();
+ await waitForPromises();
+ findPaginationBar().vm.$emit('set-page', 2);
+ await waitForPromises();
mock.resetHistory();
- wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE);
- await axios.waitForAll();
+ findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE);
+ await waitForPromises();
expect(mock.history.get.length).toBe(1);
expect(mock.history.get[0].params).toStrictEqual(
@@ -166,18 +192,18 @@ describe('BulkImportsHistoryApp', () => {
const NEW_PAGE_SIZE = 4;
createComponent();
- await axios.waitForAll();
+ await waitForPromises();
mock.resetHistory();
- wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE);
- await axios.waitForAll();
+ findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE);
+ await waitForPromises();
expect(findLocalStorageSync().props('value')).toBe(NEW_PAGE_SIZE);
});
it('renders link to destination_full_path for destination group', async () => {
createComponent({ shallow: false });
- await axios.waitForAll();
+ await waitForPromises();
expect(wrapper.find('tbody tr a').attributes().href).toBe(
`/${DUMMY_RESPONSE[0].destination_full_path}`,
@@ -187,9 +213,9 @@ describe('BulkImportsHistoryApp', () => {
it('renders destination as text when destination_full_path is not defined', async () => {
const RESPONSE = [{ ...DUMMY_RESPONSE[0], destination_full_path: null }];
- mock.onGet(API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS);
+ mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS);
createComponent({ shallow: false });
- await axios.waitForAll();
+ await waitForPromises();
expect(wrapper.find('tbody tr a').exists()).toBe(false);
expect(wrapper.find('tbody tr span').text()).toBe(
@@ -199,14 +225,14 @@ describe('BulkImportsHistoryApp', () => {
it('adds slash to group urls', async () => {
createComponent({ shallow: false });
- await axios.waitForAll();
+ await waitForPromises();
expect(wrapper.find('tbody tr a').text()).toBe(`${DUMMY_RESPONSE[0].destination_full_path}/`);
});
it('does not prefixes project urls with slash', async () => {
createComponent({ shallow: false });
- await axios.waitForAll();
+ await waitForPromises();
expect(wrapper.findAll('tbody tr a').at(1).text()).toBe(
DUMMY_RESPONSE[1].destination_full_path,
@@ -215,9 +241,9 @@ describe('BulkImportsHistoryApp', () => {
describe('details button', () => {
beforeEach(() => {
- mock.onGet(API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS);
createComponent({ shallow: false });
- return axios.waitForAll();
+ return waitForPromises();
});
it('renders details button if relevant item has failures', () => {
@@ -255,7 +281,7 @@ describe('BulkImportsHistoryApp', () => {
createComponent({ shallow: false });
await waitForPromises();
- expect(mock.history.get.map((x) => x.url)).toEqual([API_URL]);
+ expect(mock.history.get.map((x) => x.url)).toEqual([BULK_IMPORTS_API_URL]);
});
});
@@ -279,7 +305,7 @@ describe('BulkImportsHistoryApp', () => {
const RESPONSE = [mockCreatedImport, ...DUMMY_RESPONSE];
const POLL_HEADERS = { 'poll-interval': pollInterval };
- mock.onGet(API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS);
+ mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS);
mock.onGet(mockRealtimeChangesPath).replyOnce(HTTP_STATUS_OK, [], POLL_HEADERS);
mock
.onGet(mockRealtimeChangesPath)
@@ -293,7 +319,10 @@ describe('BulkImportsHistoryApp', () => {
it('starts polling for realtime changes', () => {
jest.advanceTimersByTime(pollInterval);
- expect(mock.history.get.map((x) => x.url)).toEqual([API_URL, mockRealtimeChangesPath]);
+ expect(mock.history.get.map((x) => x.url)).toEqual([
+ BULK_IMPORTS_API_URL,
+ mockRealtimeChangesPath,
+ ]);
expect(wrapper.findAll('tbody tr').at(0).text()).toContain('Pending');
});
@@ -305,7 +334,7 @@ describe('BulkImportsHistoryApp', () => {
await waitForPromises();
expect(mock.history.get.map((x) => x.url)).toEqual([
- API_URL,
+ BULK_IMPORTS_API_URL,
mockRealtimeChangesPath,
mockRealtimeChangesPath,
]);
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
index f6ecee4cd53..7cb0e3ee38b 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
@@ -7,14 +7,15 @@ describe('Interval Pattern Input Component', () => {
let oldWindowGl;
let wrapper;
+ const mockMinute = 3;
const mockHour = 4;
const mockWeekDayIndex = 1;
const mockDay = 1;
const cronIntervalPresets = {
- everyDay: `0 ${mockHour} * * *`,
- everyWeek: `0 ${mockHour} * * ${mockWeekDayIndex}`,
- everyMonth: `0 ${mockHour} ${mockDay} * *`,
+ everyDay: `${mockMinute} ${mockHour} * * *`,
+ everyWeek: `${mockMinute} ${mockHour} * * ${mockWeekDayIndex}`,
+ everyMonth: `${mockMinute} ${mockHour} ${mockDay} * *`,
};
const customKey = 'custom';
const everyDayKey = 'everyDay';
@@ -40,6 +41,7 @@ describe('Interval Pattern Input Component', () => {
propsData: { ...props },
data() {
return {
+ randomMinute: data?.minute || mockMinute,
randomHour: data?.hour || mockHour,
randomWeekDayIndex: mockWeekDayIndex,
randomDay: mockDay,
@@ -108,12 +110,12 @@ describe('Interval Pattern Input Component', () => {
describe('formattedTime computed property', () => {
it.each`
- desc | hour | expectedValue
- ${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${'1:00pm'}
- ${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${'11:00am'}
- ${'returns "12:00pm" if the value of `random time` is exactly 12'} | ${12} | ${'12:00pm'}
- `('$desc', ({ hour, expectedValue }) => {
- createWrapper({}, { hour });
+ desc | hour | minute | expectedValue
+ ${'returns a time in the afternoon if the value of `random time` is higher than 12'} | ${13} | ${7} | ${'1:07pm'}
+ ${'returns a time in the morning if the value of `random time` is lower than 12'} | ${11} | ${30} | ${'11:30am'}
+ ${'returns "12:05pm" if the value of `random time` is exactly 12 and the value of random minutes is 5'} | ${12} | ${5} | ${'12:05pm'}
+ `('$desc', ({ hour, minute, expectedValue }) => {
+ createWrapper({}, { hour, minute });
expect(wrapper.vm.formattedTime).toBe(expectedValue);
});
@@ -128,9 +130,9 @@ describe('Interval Pattern Input Component', () => {
const labels = findAllLabels().wrappers.map((el) => trimText(el.text()));
expect(labels).toEqual([
- 'Every day (at 4:00am)',
- 'Every week (Monday at 4:00am)',
- 'Every month (Day 1 at 4:00am)',
+ 'Every day (at 4:03am)',
+ 'Every week (Monday at 4:03am)',
+ 'Every month (Day 1 at 4:03am)',
'Custom',
]);
});
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
index 4ac3a511fa2..8145eb6fbd4 100644
--- 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
@@ -1,27 +1,30 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { GlBadge, GlLoadingIcon, GlModal, GlSprintf, GlToggle } from '@gitlab/ui';
+import { 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 BetaBadge from '~/vue_shared/components/badges/beta_badge.vue';
import catalogResourcesCreate from '~/pages/projects/shared/permissions/graphql/mutations/catalog_resources_create.mutation.graphql';
+import catalogResourcesDestroy from '~/pages/projects/shared/permissions/graphql/mutations/catalog_resources_destroy.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 CiCatalogSettings from '~/pages/projects/shared/permissions/components/ci_catalog_settings.vue';
-import { mockCiCatalogSettingsResponse } from './mock_data';
+import { generateCatalogSettingsResponse } from './mock_data';
Vue.use(VueApollo);
jest.mock('~/alert');
+const showToast = jest.fn();
+
describe('CiCatalogSettings', () => {
let wrapper;
let ciCatalogSettingsResponse;
let catalogResourcesCreateResponse;
+ let catalogResourcesDestroyResponse;
const fullPath = 'gitlab-org/gitlab';
@@ -29,6 +32,7 @@ describe('CiCatalogSettings', () => {
const handlers = [
[getCiCatalogSettingsQuery, ciCatalogSettingsHandler],
[catalogResourcesCreate, catalogResourcesCreateResponse],
+ [catalogResourcesDestroy, catalogResourcesDestroyResponse],
];
const mockApollo = createMockApollo(handlers);
@@ -39,6 +43,11 @@ describe('CiCatalogSettings', () => {
stubs: {
GlSprintf,
},
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
apolloProvider: mockApollo,
});
@@ -46,15 +55,34 @@ describe('CiCatalogSettings', () => {
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findBadge = () => wrapper.findComponent(GlBadge);
+ const findBadge = () => wrapper.findComponent(BetaBadge);
const findModal = () => wrapper.findComponent(GlModal);
const findToggle = () => wrapper.findComponent(GlToggle);
-
const findCiCatalogSettings = () => wrapper.findByTestId('ci-catalog-settings');
+ const removeCatalogResource = () => {
+ findToggle().vm.$emit('change');
+ findModal().vm.$emit('primary');
+ return waitForPromises();
+ };
+
+ const setCatalogResource = () => {
+ findToggle().vm.$emit('change');
+ return waitForPromises();
+ };
+
beforeEach(() => {
- ciCatalogSettingsResponse = jest.fn().mockResolvedValue(mockCiCatalogSettingsResponse);
+ ciCatalogSettingsResponse = jest.fn();
+ catalogResourcesDestroyResponse = jest.fn();
catalogResourcesCreateResponse = jest.fn();
+
+ ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse());
+ catalogResourcesCreateResponse.mockResolvedValue({
+ data: { catalogResourcesCreate: { errors: [] } },
+ });
+ catalogResourcesDestroyResponse.mockResolvedValue({
+ data: { catalogResourcesDestroy: { errors: [] } },
+ });
});
describe('when initial queries are loading', () => {
@@ -81,31 +109,68 @@ describe('CiCatalogSettings', () => {
expect(findCiCatalogSettings().exists()).toBe(true);
});
- it('renders the experiment badge', () => {
+ it('renders the beta 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 the project is not a CI/CD resource', () => {
+ beforeEach(async () => {
+ await createComponent();
});
- describe('when queries have loaded', () => {
- beforeEach(() => {
- catalogResourcesCreateResponse.mockResolvedValue(mockCiCatalogSettingsResponse);
+ describe('and the toggle is clicked', () => {
+ it('does not show a confirmation modal', async () => {
+ expect(findModal().props('visible')).toBe(false);
+
+ await findToggle().vm.$emit('change', true);
+
+ expect(findModal().props('visible')).toBe(false);
+ });
+
+ it('calls the mutation with the correct input', async () => {
+ expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(0);
+
+ await setCatalogResource();
+
+ expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(1);
+ expect(catalogResourcesCreateResponse).toHaveBeenCalledWith({
+ input: {
+ projectPath: fullPath,
+ },
+ });
});
- it('shows the modal when the toggle is clicked', async () => {
+ describe('when the mutation is successful', () => {
+ it('shows a toast message with a success message', async () => {
+ expect(showToast).not.toHaveBeenCalled();
+
+ await setCatalogResource();
+
+ expect(showToast).toHaveBeenCalledWith('This project is now a CI/CD Catalog resource.');
+ });
+ });
+ });
+ });
+
+ describe('when the project is a CI/CD resource', () => {
+ beforeEach(async () => {
+ ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse(true));
+ await createComponent();
+ });
+
+ describe('and the toggle is clicked', () => {
+ it('shows a confirmation modal', async () => {
expect(findModal().props('visible')).toBe(false);
- await findToggle().vm.$emit('change', true);
+ await findToggle().vm.$emit('change', false);
expect(findModal().props('visible')).toBe(true);
- expect(findModal().props('actionPrimary').text).toBe(i18n.modal.actionPrimary.text);
+ expect(findModal().props('actionPrimary').text).toBe('Remove from the CI/CD catalog');
});
it('hides the modal when cancel is clicked', () => {
@@ -117,31 +182,85 @@ describe('CiCatalogSettings', () => {
});
it('calls the mutation with the correct input from the modal click', async () => {
- expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(0);
+ expect(catalogResourcesDestroyResponse).toHaveBeenCalledTimes(0);
- findToggle().vm.$emit('change', true);
- findModal().vm.$emit('primary');
- await waitForPromises();
+ await removeCatalogResource();
- expect(catalogResourcesCreateResponse).toHaveBeenCalledTimes(1);
- expect(catalogResourcesCreateResponse).toHaveBeenCalledWith({
+ expect(catalogResourcesDestroyResponse).toHaveBeenCalledTimes(1);
+ expect(catalogResourcesDestroyResponse).toHaveBeenCalledWith({
input: {
projectPath: fullPath,
},
});
});
+
+ it('shows a toast message when the mutation has worked', async () => {
+ expect(showToast).not.toHaveBeenCalled();
+
+ await removeCatalogResource();
+
+ expect(showToast).toHaveBeenCalledWith(
+ 'This project is no longer a CI/CD Catalog resource.',
+ );
+ });
});
});
- describe('when the query is unsuccessful', () => {
- const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+ describe('mutation errors', () => {
+ const createGraphqlError = { data: { catalogResourcesCreate: { errors: ['graphql error'] } } };
+ const destroyGraphqlError = {
+ data: { catalogResourcesDestroy: { errors: ['graphql error'] } },
+ };
- it('throws an error', async () => {
- await createComponent({ ciCatalogSettingsHandler: failedHandler });
+ beforeEach(() => {
+ createAlert.mockClear();
+ });
+ it.each`
+ name | errorType | jestResolver | mockResponse | expectedMessage
+ ${'create'} | ${'unhandled server error with a message'} | ${'mockRejectedValue'} | ${new Error('server error')} | ${'server error'}
+ ${'create'} | ${'unhandled server error without a message'} | ${'mockRejectedValue'} | ${new Error()} | ${'Unable to set project as a CI/CD Catalog resource.'}
+ ${'create'} | ${'handled Graphql error'} | ${'mockResolvedValue'} | ${createGraphqlError} | ${'graphql error'}
+ ${'destroy'} | ${'unhandled server'} | ${'mockRejectedValue'} | ${new Error('server error')} | ${'server error'}
+ ${'destroy'} | ${'unhandled server'} | ${'mockRejectedValue'} | ${new Error()} | ${'Unable to remove project as a CI/CD Catalog resource.'}
+ ${'destroy'} | ${'handled Graphql error'} | ${'mockResolvedValue'} | ${destroyGraphqlError} | ${'graphql error'}
+ `(
+ 'when $name mutation returns an $errorType',
+ async ({ name, jestResolver, mockResponse, expectedMessage }) => {
+ let mutationMock = catalogResourcesCreateResponse;
+ let toggleAction = setCatalogResource;
+
+ if (name === 'destroy') {
+ mutationMock = catalogResourcesDestroyResponse;
+ toggleAction = removeCatalogResource;
+ ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse(true));
+ }
+
+ await createComponent();
+ mutationMock[jestResolver](mockResponse);
+
+ expect(showToast).not.toHaveBeenCalled();
+ expect(createAlert).not.toHaveBeenCalled();
+
+ await toggleAction();
+
+ expect(showToast).not.toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({ message: expectedMessage });
+ },
+ );
+ });
+
+ describe('when the query is unsuccessful', () => {
+ beforeEach(async () => {
+ const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+ await createComponent({ ciCatalogSettingsHandler: failedHandler });
await waitForPromises();
+ });
- expect(createAlert).toHaveBeenCalledWith({ message: i18n.catalogResourceQueryError });
+ it('throws an error', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'There was a problem fetching the CI/CD Catalog setting.',
+ });
});
});
});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/mock_data.js b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js
index 44bbf2a5eb2..cf51604e1b0 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/mock_data.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/mock_data.js
@@ -1,7 +1,10 @@
-export const mockCiCatalogSettingsResponse = {
- data: {
- catalogResourcesCreate: {
- errors: [],
+export const generateCatalogSettingsResponse = (isCatalogResource = false) => {
+ return {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/149',
+ isCatalogResource,
+ },
},
- },
+ };
};
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 8b672ff3f32..207ce8c1ffa 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
@@ -137,6 +137,7 @@ describe('Settings Panel', () => {
const findMonitorSettings = () => wrapper.findComponent({ ref: 'monitor-settings' });
const findModelExperimentsSettings = () =>
wrapper.findComponent({ ref: 'model-experiments-settings' });
+ const findModelRegistrySettings = () => wrapper.findComponent({ ref: 'model-registry-settings' });
describe('Project Visibility', () => {
it('should set the project visibility help path', () => {
@@ -758,4 +759,11 @@ describe('Settings Panel', () => {
expect(findModelExperimentsSettings().exists()).toBe(true);
});
});
+ describe('Model registry', () => {
+ it('shows model registry toggle', () => {
+ wrapper = mountComponent({});
+
+ expect(findModelRegistrySettings().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js
deleted file mode 100644
index 04f53e048ed..00000000000
--- a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js
+++ /dev/null
@@ -1,160 +0,0 @@
-import { setHTMLFixture } from 'helpers/fixtures';
-import { initSidebarTracking } from '~/pages/shared/nav/sidebar_tracking';
-
-describe('~/pages/shared/nav/sidebar_tracking.js', () => {
- beforeEach(() => {
- setHTMLFixture(`
- <aside class="nav-sidebar">
- <div class="nav-sidebar-inner-scroll">
- <ul class="sidebar-top-level-items">
- <li data-track-label="project_information_menu" class="home">
- <a aria-label="Project information" class="shortcuts-project-information has-sub-items" href="">
- <span class="nav-icon-container">
- <svg class="s16" data-testid="project-icon">
- <use xlink:href="/assets/icons-1b2dadc4c3d49797908ba67b8f10da5d63dd15d859bde28d66fb60bbb97a4dd5.svg#project"></use>
- </svg>
- </span>
- <span class="nav-item-name">Project information</span>
- </a>
- <ul class="sidebar-sub-level-items">
- <li class="fly-out-top-item">
- <a aria-label="Project information" href="#">
- <strong class="fly-out-top-item-name">Project information</strong>
- </a>
- </li>
- <li class="divider fly-out-top-item"></li>
- <li data-track-label="activity" class="">
- <a aria-label="Activity" class="shortcuts-project-activity" href=#">
- <span>Activity</span>
- </a>
- </li>
- <li data-track-label="labels" class="">
- <a aria-label="Labels" href="#">
- <span>Labels</span>
- </a>
- </li>
- <li data-track-label="members" class="">
- <a aria-label="Members" href="#">
- <span>Members</span>
- </a>
- </li>
- </ul>
- </li>
- </ul>
- </div>
- </aside>
- `);
-
- initSidebarTracking();
- });
-
- describe('sidebar is not collapsed', () => {
- describe('menu is not expanded', () => {
- it('sets the proper data tracking attributes when clicking on menu', () => {
- const menu = document.querySelector('li[data-track-label="project_information_menu"]');
- const menuLink = menu.querySelector('a');
-
- menu.classList.add('is-over', 'is-showing-fly-out');
- menuLink.click();
-
- expect(menu).toHaveTrackingAttributes({
- action: 'click_menu',
- extra: JSON.stringify({
- sidebar_display: 'Expanded',
- menu_display: 'Fly out',
- }),
- });
- });
-
- it('sets the proper data tracking attributes when clicking on submenu', () => {
- const menu = document.querySelector('li[data-track-label="activity"]');
- const menuLink = menu.querySelector('a');
- const submenuList = document.querySelector('ul.sidebar-sub-level-items');
-
- submenuList.classList.add('fly-out-list');
- menuLink.click();
-
- expect(menu).toHaveTrackingAttributes({
- action: 'click_menu_item',
- extra: JSON.stringify({
- sidebar_display: 'Expanded',
- menu_display: 'Fly out',
- }),
- });
- });
- });
-
- describe('menu is expanded', () => {
- it('sets the proper data tracking attributes when clicking on menu', () => {
- const menu = document.querySelector('li[data-track-label="project_information_menu"]');
- const menuLink = menu.querySelector('a');
-
- menu.classList.add('active');
- menuLink.click();
-
- expect(menu).toHaveTrackingAttributes({
- action: 'click_menu',
- extra: JSON.stringify({
- sidebar_display: 'Expanded',
- menu_display: 'Expanded',
- }),
- });
- });
-
- it('sets the proper data tracking attributes when clicking on submenu', () => {
- const menu = document.querySelector('li[data-track-label="activity"]');
- const menuLink = menu.querySelector('a');
-
- menu.classList.add('active');
- menuLink.click();
-
- expect(menu).toHaveTrackingAttributes({
- action: 'click_menu_item',
- extra: JSON.stringify({
- sidebar_display: 'Expanded',
- menu_display: 'Expanded',
- }),
- });
- });
- });
- });
-
- describe('sidebar is collapsed', () => {
- beforeEach(() => {
- document.querySelector('aside.nav-sidebar').classList.add('js-sidebar-collapsed');
- });
-
- it('sets the proper data tracking attributes when clicking on menu', () => {
- const menu = document.querySelector('li[data-track-label="project_information_menu"]');
- const menuLink = menu.querySelector('a');
-
- menu.classList.add('is-over', 'is-showing-fly-out');
- menuLink.click();
-
- expect(menu).toHaveTrackingAttributes({
- action: 'click_menu',
- extra: JSON.stringify({
- sidebar_display: 'Collapsed',
- menu_display: 'Fly out',
- }),
- });
- });
-
- it('sets the proper data tracking attributes when clicking on submenu', () => {
- const menu = document.querySelector('li[data-track-label="activity"]');
- const menuLink = menu.querySelector('a');
- const submenuList = document.querySelector('ul.sidebar-sub-level-items');
-
- submenuList.classList.add('fly-out-list');
- menuLink.click();
-
- expect(menu).toHaveTrackingAttributes({
- action: 'click_menu_item',
- extra: JSON.stringify({
- sidebar_display: 'Collapsed',
- menu_display: 'Fly out',
- }),
- });
- });
- });
-});
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js
deleted file mode 100644
index b7002412561..00000000000
--- a/spec/frontend/pages/shared/wikis/components/wiki_export_spec.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import { GlDisclosureDropdown } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import WikiExport from '~/pages/shared/wikis/components/wiki_export.vue';
-import printMarkdownDom from '~/lib/print_markdown_dom';
-
-jest.mock('~/lib/print_markdown_dom');
-
-describe('pages/shared/wikis/components/wiki_export', () => {
- let wrapper;
-
- const createComponent = (provide) => {
- wrapper = shallowMount(WikiExport, {
- provide,
- });
- };
-
- const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
- const findPrintItem = () =>
- findDropdown()
- .props('items')
- .find((x) => x.text === 'Print as PDF');
-
- describe('print', () => {
- beforeEach(() => {
- document.body.innerHTML = '<div id="content-body">Content</div>';
- });
-
- afterEach(() => {
- document.body.innerHTML = '';
- });
-
- it('should print the content', () => {
- createComponent({
- target: '#content-body',
- title: 'test title',
- stylesheet: [],
- });
-
- findPrintItem().action();
-
- expect(printMarkdownDom).toHaveBeenCalledWith({
- target: document.querySelector('#content-body'),
- title: 'test title',
- stylesheet: [],
- });
- });
- });
-});
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_more_dropdown_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_more_dropdown_spec.js
new file mode 100644
index 00000000000..830377ff39f
--- /dev/null
+++ b/spec/frontend/pages/shared/wikis/components/wiki_more_dropdown_spec.js
@@ -0,0 +1,83 @@
+import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WikiMoreDropdown from '~/pages/shared/wikis/components/wiki_more_dropdown.vue';
+import printMarkdownDom from '~/lib/print_markdown_dom';
+
+jest.mock('~/lib/print_markdown_dom');
+
+describe('pages/shared/wikis/components/wiki_more_dropdown', () => {
+ let wrapper;
+
+ const createComponent = (provide) => {
+ wrapper = shallowMountExtended(WikiMoreDropdown, {
+ provide: {
+ history: 'https://history.url/path',
+ print: {
+ target: '#content-body',
+ title: 'test title',
+ stylesheet: [],
+ },
+ ...provide,
+ },
+ stubs: {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ },
+ });
+ };
+
+ const findHistoryItem = () => wrapper.findByTestId('page-history-button');
+ const findPrintItem = () => wrapper.findByTestId('page-print-button');
+
+ describe('history', () => {
+ it('renders if `history` is set', () => {
+ createComponent({ history: false });
+
+ expect(findHistoryItem().exists()).toBe(false);
+
+ createComponent();
+
+ expect(findHistoryItem().exists()).toBe(true);
+ });
+
+ it('should have history page url', () => {
+ createComponent();
+
+ expect(findHistoryItem().attributes('href')).toBe('https://history.url/path');
+ });
+ });
+
+ describe('print', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '<div id="content-body">Content</div>';
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('renders if `print` is set', () => {
+ createComponent({ print: false });
+
+ expect(findPrintItem().exists()).toBe(false);
+
+ createComponent();
+
+ expect(findPrintItem().exists()).toBe(true);
+ });
+
+ it('should print the content', () => {
+ createComponent();
+
+ expect(findPrintItem().exists()).toBe(true);
+
+ findPrintItem().trigger('click');
+
+ expect(printMarkdownDom).toHaveBeenCalledWith({
+ target: document.querySelector('#content-body'),
+ title: 'test title',
+ stylesheet: [],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js
index 376575a8acb..a9bfc0003bf 100644
--- a/spec/frontend/persistent_user_callout_spec.js
+++ b/spec/frontend/persistent_user_callout_spec.js
@@ -24,6 +24,7 @@ describe('PersistentUserCallout', () => {
>
<button type="button" class="js-close js-close-primary"></button>
<button type="button" class="js-close js-close-secondary"></button>
+ <a class="js-close-and-follow-link" href="/somewhere-pleasant">A Link</a>
</div>
`;
@@ -65,6 +66,8 @@ describe('PersistentUserCallout', () => {
return fixture;
}
+ useMockLocationHelper();
+
describe('dismiss', () => {
const buttons = {};
let mockAxios;
@@ -178,8 +181,6 @@ describe('PersistentUserCallout', () => {
let mockAxios;
let persistentUserCallout;
- useMockLocationHelper();
-
beforeEach(() => {
const fixture = createFollowLinkFixture();
const container = fixture.querySelector('.container');
@@ -222,6 +223,53 @@ describe('PersistentUserCallout', () => {
});
});
+ describe('dismiss and follow links', () => {
+ let link;
+ let mockAxios;
+ let persistentUserCallout;
+
+ beforeEach(() => {
+ const fixture = createFixture();
+ const container = fixture.querySelector('.container');
+ link = fixture.querySelector('.js-close-and-follow-link');
+ mockAxios = new MockAdapter(axios);
+
+ persistentUserCallout = new PersistentUserCallout(container);
+ jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ it('uses a link to trigger callout and defers following until callout is finished', async () => {
+ const { href } = link;
+ mockAxios.onPost(dismissEndpoint).replyOnce(HTTP_STATUS_OK);
+
+ link.click();
+
+ await waitForPromises();
+
+ expect(window.location.assign).toHaveBeenCalledWith(href);
+ expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
+ expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
+ });
+
+ it('invokes Flash when the dismiss request fails', async () => {
+ mockAxios.onPost(dismissEndpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ link.click();
+
+ await waitForPromises();
+
+ expect(window.location.assign).not.toHaveBeenCalled();
+ expect(createAlert).toHaveBeenCalledWith({
+ message:
+ 'An error occurred while acknowledging the notification. Refresh the page and try again.',
+ });
+ });
+ });
+
describe('factory', () => {
it('returns an instance of PersistentUserCallout with the provided container property', () => {
const fixture = createFixture();
diff --git a/spec/frontend/profile/edit/components/profile_edit_app_spec.js b/spec/frontend/profile/edit/components/profile_edit_app_spec.js
index 31a368aefa9..39bf597352b 100644
--- a/spec/frontend/profile/edit/components/profile_edit_app_spec.js
+++ b/spec/frontend/profile/edit/components/profile_edit_app_spec.js
@@ -3,7 +3,6 @@ import MockAdapter from 'axios-mock-adapter';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { readFileAsDataURL } from '~/lib/utils/file_utility';
import axios from '~/lib/utils/axios_utils';
import ProfileEditApp from '~/profile/edit/components/profile_edit_app.vue';
import UserAvatar from '~/profile/edit/components/user_avatar.vue';
@@ -103,6 +102,8 @@ describe('Profile Edit App', () => {
});
it('syncs header avatars', async () => {
+ jest.spyOn(document, 'dispatchEvent');
+ jest.spyOn(URL, 'createObjectURL');
mockAxios.onPut(stubbedProfilePath).reply(200, {
message: successMessage,
});
@@ -112,7 +113,8 @@ describe('Profile Edit App', () => {
await waitForPromises();
- expect(readFileAsDataURL).toHaveBeenCalledWith(mockAvatarFile);
+ expect(URL.createObjectURL).toHaveBeenCalledWith(mockAvatarFile);
+ expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('userAvatar:update'));
});
it('contains changes from the status form', async () => {
diff --git a/spec/frontend/profile/edit/components/user_avatar_spec.js b/spec/frontend/profile/edit/components/user_avatar_spec.js
index caa3356b49f..7c4f74d6bfb 100644
--- a/spec/frontend/profile/edit/components/user_avatar_spec.js
+++ b/spec/frontend/profile/edit/components/user_avatar_spec.js
@@ -46,6 +46,7 @@ describe('Edit User Avatar', () => {
...defaultProvides,
...provides,
},
+ attachTo: document.body,
});
};
@@ -65,7 +66,7 @@ describe('Edit User Avatar', () => {
modalCrop: '.modal-profile-crop',
pickImageEl: '.js-choose-user-avatar-button',
uploadImageBtn: '.js-upload-user-avatar',
- modalCropImg: '.modal-profile-crop-image',
+ modalCropImg: expect.any(HTMLImageElement),
onBlobChange: expect.any(Function),
});
expect(glCropDataMock).toHaveBeenCalledWith('glcrop');
diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js
index adb87142fee..7ff1af86f35 100644
--- a/spec/frontend/projects/commit/store/actions_spec.js
+++ b/spec/frontend/projects/commit/store/actions_spec.js
@@ -25,7 +25,7 @@ describe('Commit form modal store actions', () => {
describe('clearModal', () => {
it('commits CLEAR_MODAL mutation', () => {
- testAction(actions.clearModal, {}, {}, [
+ return testAction(actions.clearModal, {}, {}, [
{
type: types.CLEAR_MODAL,
},
@@ -35,7 +35,7 @@ describe('Commit form modal store actions', () => {
describe('requestBranches', () => {
it('commits REQUEST_BRANCHES mutation', () => {
- testAction(actions.requestBranches, {}, {}, [
+ return testAction(actions.requestBranches, {}, {}, [
{
type: types.REQUEST_BRANCHES,
},
@@ -74,7 +74,7 @@ describe('Commit form modal store actions', () => {
describe('setBranch', () => {
it('commits SET_BRANCH mutation', () => {
- testAction(
+ return testAction(
actions.setBranch,
{},
{},
@@ -96,7 +96,7 @@ describe('Commit form modal store actions', () => {
describe('setSelectedBranch', () => {
it('commits SET_SELECTED_BRANCH mutation', () => {
- testAction(actions.setSelectedBranch, {}, {}, [
+ return testAction(actions.setSelectedBranch, {}, {}, [
{
type: types.SET_SELECTED_BRANCH,
payload: {},
@@ -109,7 +109,7 @@ describe('Commit form modal store actions', () => {
it('commits SET_BRANCHES_ENDPOINT mutation', () => {
const endpoint = 'some/endpoint';
- testAction(actions.setBranchesEndpoint, endpoint, {}, [
+ return testAction(actions.setBranchesEndpoint, endpoint, {}, [
{
type: types.SET_BRANCHES_ENDPOINT,
payload: endpoint,
@@ -122,7 +122,7 @@ describe('Commit form modal store actions', () => {
const id = 1;
it('commits SET_SELECTED_PROJECT mutation', () => {
- testAction(
+ return testAction(
actions.setSelectedProject,
id,
{},
diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js
index 8afa2a6fb8f..e42587d5aad 100644
--- a/spec/frontend/projects/commits/store/actions_spec.js
+++ b/spec/frontend/projects/commits/store/actions_spec.js
@@ -53,7 +53,7 @@ describe('Project commits actions', () => {
const data = [{ id: 1 }];
mock.onGet(path).replyOnce(HTTP_STATUS_OK, data);
- testAction(
+ return testAction(
actions.fetchAuthors,
null,
state,
@@ -66,7 +66,7 @@ describe('Project commits actions', () => {
const path = '/-/autocomplete/users.json';
mock.onGet(path).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
- testAction(actions.fetchAuthors, null, state, [], [{ type: 'receiveAuthorsError' }]);
+ return testAction(actions.fetchAuthors, null, state, [], [{ type: 'receiveAuthorsError' }]);
});
});
});
diff --git a/spec/frontend/projects/components/shared/delete_modal_spec.js b/spec/frontend/projects/components/shared/delete_modal_spec.js
index c6213fd4b6d..7e040db4beb 100644
--- a/spec/frontend/projects/components/shared/delete_modal_spec.js
+++ b/spec/frontend/projects/components/shared/delete_modal_spec.js
@@ -49,7 +49,7 @@ describe('DeleteModal', () => {
attributes: {
variant: 'danger',
disabled: true,
- 'data-qa-selector': 'confirm_delete_button',
+ 'data-testid': 'confirm-delete-button',
},
},
actionCancel: {
diff --git a/spec/frontend/projects/settings/components/default_branch_selector_spec.js b/spec/frontend/projects/settings/components/default_branch_selector_spec.js
index 9baea5c5517..aa50683b185 100644
--- a/spec/frontend/projects/settings/components/default_branch_selector_spec.js
+++ b/spec/frontend/projects/settings/components/default_branch_selector_spec.js
@@ -4,6 +4,7 @@ import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES } from '~/ref/constants';
describe('projects/settings/components/default_branch_selector', () => {
+ const disabled = true;
const persistedDefaultBranch = 'main';
const projectId = '123';
let wrapper;
@@ -13,6 +14,7 @@ describe('projects/settings/components/default_branch_selector', () => {
const buildWrapper = () => {
wrapper = shallowMount(DefaultBranchSelector, {
propsData: {
+ disabled,
persistedDefaultBranch,
projectId,
},
@@ -25,6 +27,7 @@ describe('projects/settings/components/default_branch_selector', () => {
it('displays a RefSelector component', () => {
expect(findRefSelector().props()).toEqual({
+ disabled,
value: persistedDefaultBranch,
enabledRefTypes: [REF_TYPE_BRANCHES],
projectId,
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 7c8cc1bb38d..4e3554131c6 100644
--- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
@@ -8,6 +8,7 @@ import {
import { last } from 'lodash';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api';
import AccessDropdown, { i18n } from '~/projects/settings/components/access_dropdown.vue';
@@ -77,6 +78,7 @@ describe('Access Level Dropdown', () => {
label,
disabled,
preselectedItems,
+ stubs = {},
} = {}) => {
wrapper = shallowMountExtended(AccessDropdown, {
propsData: {
@@ -90,6 +92,7 @@ describe('Access Level Dropdown', () => {
stubs: {
GlSprintf,
GlDropdown,
+ ...stubs,
},
});
};
@@ -373,15 +376,22 @@ describe('Access Level Dropdown', () => {
});
describe('on dropdown open', () => {
+ const focusInput = jest.fn();
+
beforeEach(() => {
- createComponent();
+ createComponent({
+ stubs: {
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ methods: { focusInput },
+ }),
+ },
+ });
});
it('should set the search input focus', () => {
- wrapper.vm.$refs.search.focusInput = jest.fn();
findDropdown().vm.$emit('shown');
- expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled();
+ expect(focusInput).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js
index 2808a25296d..0a593f3812a 100644
--- a/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/custom_email_spec.js
@@ -3,7 +3,6 @@ import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import CustomEmail from '~/projects/settings_service_desk/components/custom_email.vue';
import {
- I18N_VERIFICATION_ERRORS,
I18N_STATE_VERIFICATION_STARTED,
I18N_STATE_VERIFICATION_FAILED,
I18N_STATE_VERIFICATION_FAILED_RESET_PARAGRAPH,
@@ -15,6 +14,7 @@ describe('CustomEmail', () => {
let wrapper;
const defaultProps = {
+ incomingEmail: 'incoming+test-1-issue-@example.com',
customEmail: 'user@example.com',
smtpAddress: 'smtp.example.com',
verificationState: 'started',
@@ -70,18 +70,21 @@ describe('CustomEmail', () => {
});
describe('verification error', () => {
- it.each([
- 'smtp_host_issue',
- 'invalid_credentials',
- 'mail_not_received_within_timeframe',
- 'incorrect_from',
- 'incorrect_token',
- ])('displays %s label and description', (error) => {
+ it.each`
+ error | label | description
+ ${'smtp_host_issue'} | ${'SMTP host issue'} | ${'A connection to the specified host could not be made or an SSL issue occurred.'}
+ ${'invalid_credentials'} | ${'Invalid credentials'} | ${'The given credentials (username and password) were rejected by the SMTP server, or you need to explicitly set an authentication method.'}
+ ${'mail_not_received_within_timeframe'} | ${'Verification email not received within timeframe'} | ${"The verification email wasn't received in time. There is a 30 minutes timeframe for verification emails to appear in your instance's Service Desk. Make sure that you have set up email forwarding correctly."}
+ ${'incorrect_from'} | ${'Incorrect From header'} | ${'Check your forwarding settings and make sure the original email sender remains in the From header.'}
+ ${'incorrect_token'} | ${'Incorrect verification token'} | ${"The received email didn't contain the verification token that was sent to your email address."}
+ ${'read_timeout'} | ${'Read timeout'} | ${'The SMTP server did not respond in time.'}
+ ${'incorrect_forwarding_target'} | ${'Incorrect forwarding target'} | ${`Forward all emails to the custom email address to ${defaultProps.incomingEmail}`}
+ `('displays $error label and description', ({ error, label, description }) => {
createWrapper({ verificationError: error });
const text = wrapper.text();
- expect(text).toContain(I18N_VERIFICATION_ERRORS[error].label);
- expect(text).toContain(I18N_VERIFICATION_ERRORS[error].description);
+ expect(text).toContain(label);
+ expect(text).toContain(description);
});
});
diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js
index 174e05ceeee..8d3a7a5fde5 100644
--- a/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js
@@ -38,6 +38,12 @@ describe('CustomEmailWrapper', () => {
customEmailEndpoint: '/flightjs/Flight/-/service_desk/custom_email',
};
+ const defaultCustomEmailProps = {
+ incomingEmail: defaultProps.incomingEmail,
+ customEmail: 'user@example.com',
+ smtpAddress: 'smtp.example.com',
+ };
+
const showToast = jest.fn();
const createWrapper = (props = {}) => {
@@ -117,8 +123,7 @@ describe('CustomEmailWrapper', () => {
expect(showToast).toHaveBeenCalledWith(I18N_TOAST_SAVED);
expect(findCustomEmail().props()).toEqual({
- customEmail: 'user@example.com',
- smtpAddress: 'smtp.example.com',
+ ...defaultCustomEmailProps,
verificationState: 'started',
verificationError: null,
isEnabled: false,
@@ -140,8 +145,7 @@ describe('CustomEmailWrapper', () => {
it('displays CustomEmail component', () => {
expect(findCustomEmail().props()).toEqual({
- customEmail: 'user@example.com',
- smtpAddress: 'smtp.example.com',
+ ...defaultCustomEmailProps,
verificationState: 'started',
verificationError: null,
isEnabled: false,
@@ -193,8 +197,7 @@ describe('CustomEmailWrapper', () => {
it('fetches data from endpoint and displays CustomEmail component', () => {
expect(findCustomEmail().props()).toEqual({
- customEmail: 'user@example.com',
- smtpAddress: 'smtp.example.com',
+ ...defaultCustomEmailProps,
verificationState: 'failed',
verificationError: 'smtp_host_issue',
isEnabled: false,
@@ -225,8 +228,7 @@ describe('CustomEmailWrapper', () => {
it('fetches data from endpoint and displays CustomEmail component', () => {
expect(findCustomEmail().props()).toEqual({
- customEmail: 'user@example.com',
- smtpAddress: 'smtp.example.com',
+ ...defaultCustomEmailProps,
verificationState: 'finished',
verificationError: null,
isEnabled: false,
@@ -257,8 +259,7 @@ describe('CustomEmailWrapper', () => {
expect(showToast).toHaveBeenCalledWith(I18N_TOAST_ENABLED);
expect(findCustomEmail().props()).toEqual({
- customEmail: 'user@example.com',
- smtpAddress: 'smtp.example.com',
+ ...defaultCustomEmailProps,
verificationState: 'finished',
verificationError: null,
isEnabled: true,
@@ -279,8 +280,7 @@ describe('CustomEmailWrapper', () => {
it('fetches data from endpoint and displays CustomEmail component', () => {
expect(findCustomEmail().props()).toEqual({
- customEmail: 'user@example.com',
- smtpAddress: 'smtp.example.com',
+ ...defaultCustomEmailProps,
verificationState: 'finished',
verificationError: null,
isEnabled: true,
@@ -301,8 +301,7 @@ describe('CustomEmailWrapper', () => {
expect(showToast).toHaveBeenCalledWith(I18N_TOAST_DISABLED);
expect(findCustomEmail().props()).toEqual({
- customEmail: 'user@example.com',
- smtpAddress: 'smtp.example.com',
+ ...defaultCustomEmailProps,
verificationState: 'finished',
verificationError: null,
isEnabled: false,
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 0eec981b67d..185a85cdb80 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
@@ -22,15 +22,13 @@ describe('ServiceDeskRoot', () => {
isIssueTrackerEnabled: true,
outgoingName: 'GitLab Support Bot',
projectKey: 'key',
+ reopenIssueOnExternalParticipantNote: true,
addExternalParticipantsFromCc: true,
selectedTemplate: 'Bug',
selectedFileTemplateProjectId: 42,
templates: ['Bug', 'Documentation'],
publicProject: false,
customEmailEndpoint: '/gitlab-org/gitlab-test/-/service_desk/custom_email',
- glFeatures: {
- serviceDeskCustomEmail: true,
- },
};
const getAlertText = () => wrapper.findComponent(GlAlert).text();
@@ -63,6 +61,8 @@ describe('ServiceDeskRoot', () => {
incomingEmail: provideData.initialIncomingEmail,
initialOutgoingName: provideData.outgoingName,
initialProjectKey: provideData.projectKey,
+ initialReopenIssueOnExternalParticipantNote:
+ provideData.reopenIssueOnExternalParticipantNote,
initialAddExternalParticipantsFromCc: provideData.addExternalParticipantsFromCc,
initialSelectedTemplate: provideData.selectedTemplate,
initialSelectedFileTemplateProjectId: provideData.selectedFileTemplateProjectId,
@@ -87,7 +87,7 @@ describe('ServiceDeskRoot', () => {
const alertBodyLink = alertEl.findComponent(GlLink);
expect(alertBodyLink.exists()).toBe(true);
expect(alertBodyLink.attributes('href')).toBe(
- '/help/user/project/service_desk.html#use-an-additional-service-desk-alias-email',
+ '/help/user/project/service_desk/configure.html#use-an-additional-service-desk-alias-email',
);
expect(alertBodyLink.text()).toBe('How do I create a custom email address?');
});
@@ -149,6 +149,7 @@ describe('ServiceDeskRoot', () => {
selectedTemplate: 'Bug',
outgoingName: 'GitLab Support Bot',
projectKey: 'key',
+ reopenIssueOnExternalParticipantNote: true,
addExternalParticipantsFromCc: true,
};
@@ -163,6 +164,7 @@ describe('ServiceDeskRoot', () => {
outgoing_name: 'GitLab Support Bot',
project_key: 'key',
service_desk_enabled: true,
+ reopen_issue_on_external_participant_note: true,
add_external_participants_from_cc: true,
});
});
@@ -182,6 +184,7 @@ describe('ServiceDeskRoot', () => {
selectedTemplate: 'Bug',
outgoingName: 'GitLab Support Bot',
projectKey: 'key',
+ reopen_issue_on_external_participant_note: true,
addExternalParticipantsFromCc: true,
};
@@ -227,15 +230,5 @@ describe('ServiceDeskRoot', () => {
expect(wrapper.findComponent(CustomEmailWrapper).exists()).toBe(false);
});
});
-
- describe('when feature flag service_desk_custom_email is disabled', () => {
- beforeEach(() => {
- wrapper = createComponent({ glFeatures: { serviceDeskCustomEmail: false } });
- });
-
- it('is not rendered', () => {
- expect(wrapper.findComponent(CustomEmailWrapper).exists()).toBe(false);
- });
- });
});
});
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 6449f9bb68e..f7bdb2455e9 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,4 +1,4 @@
-import { GlButton, GlDropdown, GlFormCheckbox, GlLoadingIcon, GlToggle, GlAlert } 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';
@@ -19,7 +19,10 @@ describe('ServiceDeskSetting', () => {
const findSuffixFormGroup = () => wrapper.findByTestId('suffix-form-group');
const findIssueTrackerInfo = () => wrapper.findComponent(GlAlert);
const findIssueHelpLink = () => wrapper.findByTestId('issue-help-page');
- const findAddExternalParticipantsFromCcCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+ const findReopenIssueOnExternalParticipantNoteCheckbox = () =>
+ wrapper.findByTestId('reopen-issue-on-external-participant-note');
+ const findAddExternalParticipantsFromCcCheckbox = () =>
+ wrapper.findByTestId('add-external-participants-from-cc');
const createComponent = ({ props = {}, provide = {} } = {}) =>
extendedWrapper(
@@ -212,6 +215,27 @@ describe('ServiceDeskSetting', () => {
});
});
+ describe('reopen issue on external participant note checkbox', () => {
+ it('is rendered', () => {
+ wrapper = createComponent();
+ expect(findReopenIssueOnExternalParticipantNoteCheckbox().exists()).toBe(true);
+ });
+
+ it('forwards false as initial value to the checkbox', () => {
+ wrapper = createComponent({ props: { initialReopenIssueOnExternalParticipantNote: false } });
+ expect(findReopenIssueOnExternalParticipantNoteCheckbox().find('input').element.checked).toBe(
+ false,
+ );
+ });
+
+ it('forwards true as initial value to the checkbox', () => {
+ wrapper = createComponent({ props: { initialReopenIssueOnExternalParticipantNote: true } });
+ expect(findReopenIssueOnExternalParticipantNoteCheckbox().find('input').element.checked).toBe(
+ true,
+ );
+ });
+ });
+
describe('add external participants from cc checkbox', () => {
it('is rendered', () => {
wrapper = createComponent();
@@ -249,7 +273,8 @@ describe('ServiceDeskSetting', () => {
initialSelectedFileTemplateProjectId: 42,
initialOutgoingName: 'GitLab Support Bot',
initialProjectKey: 'key',
- initialAddExternalParticipantsFromCc: false,
+ initialReopenIssueOnExternalParticipantNote: true,
+ initialAddExternalParticipantsFromCc: true,
},
});
@@ -262,7 +287,8 @@ describe('ServiceDeskSetting', () => {
fileTemplateProjectId: 42,
outgoingName: 'GitLab Support Bot',
projectKey: 'key',
- addExternalParticipantsFromCc: false,
+ reopenIssueOnExternalParticipantNote: true,
+ addExternalParticipantsFromCc: true,
};
expect(wrapper.emitted('save')[0]).toEqual([payload]);
@@ -288,6 +314,10 @@ describe('ServiceDeskSetting', () => {
expect(findButton().exists()).toBe(false);
});
+ it('does not render reopen issue on external participant note checkbox', () => {
+ expect(findReopenIssueOnExternalParticipantNoteCheckbox().exists()).toBe(false);
+ });
+
it('does not render add external participants from cc checkbox', () => {
expect(findAddExternalParticipantsFromCcCheckbox().exists()).toBe(false);
});
diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js
index 5f7bd32e231..9b25c56f193 100644
--- a/spec/frontend/read_more_spec.js
+++ b/spec/frontend/read_more_spec.js
@@ -1,4 +1,3 @@
-import htmlProjectsOverview from 'test_fixtures/projects/overview.html';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initReadMore from '~/read_more';
@@ -11,7 +10,12 @@ describe('Read more click-to-expand functionality', () => {
describe('expands target element', () => {
beforeEach(() => {
- setHTMLFixture(htmlProjectsOverview);
+ setHTMLFixture(`
+ <p class="read-more-container">Target</p>
+ <button type="button" class="js-read-more-trigger">
+ <span>Button text</span>
+ </button>
+ `);
});
it('adds "is-expanded" class to target element', () => {
diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js
index 26010a1cfa6..39924a3a77a 100644
--- a/spec/frontend/ref/components/ref_selector_spec.js
+++ b/spec/frontend/ref/components/ref_selector_spec.js
@@ -46,7 +46,7 @@ describe('Ref selector component', () => {
let commitApiCallSpy;
let requestSpies;
- const createComponent = (mountOverrides = {}, propsData = {}) => {
+ const createComponent = ({ overrides = {}, propsData = {} } = {}) => {
wrapper = mountExtended(
RefSelector,
merge(
@@ -64,7 +64,7 @@ describe('Ref selector component', () => {
},
store: createStore(),
},
- mountOverrides,
+ overrides,
),
);
};
@@ -211,7 +211,7 @@ describe('Ref selector component', () => {
const id = 'git-ref';
beforeEach(() => {
- createComponent({ attrs: { id } });
+ createComponent({ overrides: { attrs: { id } } });
return waitForRequests();
});
@@ -326,7 +326,7 @@ describe('Ref selector component', () => {
describe('branches', () => {
describe('when the branches search returns results', () => {
beforeEach(() => {
- createComponent({}, { useSymbolicRefNames: true });
+ createComponent({ propsData: { useSymbolicRefNames: true } });
return waitForRequests();
});
@@ -389,7 +389,7 @@ describe('Ref selector component', () => {
describe('tags', () => {
describe('when the tags search returns results', () => {
beforeEach(() => {
- createComponent({}, { useSymbolicRefNames: true });
+ createComponent({ propsData: { useSymbolicRefNames: true } });
return waitForRequests();
});
@@ -569,6 +569,20 @@ describe('Ref selector component', () => {
});
});
});
+
+ describe('disabled', () => {
+ it('does not disable the dropdown', () => {
+ createComponent();
+ expect(findListbox().props('disabled')).toBe(false);
+ });
+
+ it('disables the dropdown', async () => {
+ createComponent({ propsData: { disabled: true } });
+ expect(findListbox().props('disabled')).toBe(true);
+ await selectFirstBranch();
+ expect(wrapper.emitted('input')).toBeUndefined();
+ });
+ });
});
describe('with non-default ref types', () => {
@@ -691,9 +705,7 @@ describe('Ref selector component', () => {
});
beforeEach(() => {
- createComponent({
- scopedSlots: { footer: createFooter },
- });
+ createComponent({ overrides: { scopedSlots: { footer: createFooter } } });
updateQuery('abcd1234');
diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js
index c6aac8c9c98..49e0b36259c 100644
--- a/spec/frontend/ref/stores/actions_spec.js
+++ b/spec/frontend/ref/stores/actions_spec.js
@@ -28,7 +28,7 @@ describe('Ref selector Vuex store actions', () => {
describe('setEnabledRefTypes', () => {
it(`commits ${types.SET_ENABLED_REF_TYPES} with the enabled ref types`, () => {
- testAction(actions.setProjectId, ALL_REF_TYPES, state, [
+ return testAction(actions.setProjectId, ALL_REF_TYPES, state, [
{ type: types.SET_PROJECT_ID, payload: ALL_REF_TYPES },
]);
});
@@ -37,7 +37,7 @@ describe('Ref selector Vuex store actions', () => {
describe('setProjectId', () => {
it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => {
const projectId = '4';
- testAction(actions.setProjectId, projectId, state, [
+ return testAction(actions.setProjectId, projectId, state, [
{ type: types.SET_PROJECT_ID, payload: projectId },
]);
});
@@ -46,7 +46,7 @@ describe('Ref selector Vuex store actions', () => {
describe('setSelectedRef', () => {
it(`commits ${types.SET_SELECTED_REF} with the new selected ref name`, () => {
const selectedRef = 'v1.2.3';
- testAction(actions.setSelectedRef, selectedRef, state, [
+ return testAction(actions.setSelectedRef, selectedRef, state, [
{ type: types.SET_SELECTED_REF, payload: selectedRef },
]);
});
@@ -55,14 +55,16 @@ describe('Ref selector Vuex store actions', () => {
describe('setParams', () => {
it(`commits ${types.SET_PARAMS} with the provided params`, () => {
const params = { sort: 'updated_asc' };
- testAction(actions.setParams, params, state, [{ type: types.SET_PARAMS, payload: params }]);
+ return testAction(actions.setParams, params, state, [
+ { type: types.SET_PARAMS, payload: params },
+ ]);
});
});
describe('search', () => {
it(`commits ${types.SET_QUERY} with the new search query`, () => {
const query = 'hello';
- testAction(actions.search, query, state, [{ type: types.SET_QUERY, payload: query }]);
+ return testAction(actions.search, query, state, [{ type: types.SET_QUERY, payload: query }]);
});
it.each`
@@ -73,7 +75,7 @@ describe('Ref selector Vuex store actions', () => {
`(`dispatches fetch actions for enabled ref types`, ({ enabledRefTypes, expectedActions }) => {
const query = 'hello';
state.enabledRefTypes = enabledRefTypes;
- testAction(
+ return testAction(
actions.search,
query,
state,
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index d18437ccec3..a55b6cdef92 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -93,7 +93,7 @@ describe('Release edit/new actions', () => {
describe('loadDraftRelease', () => {
it(`with no saved release, it commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => {
- testAction({
+ return testAction({
action: actions.loadDraftRelease,
state,
expectedMutations: [{ type: types.INITIALIZE_EMPTY_RELEASE }],
@@ -203,7 +203,7 @@ describe('Release edit/new actions', () => {
describe('saveRelease', () => {
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => {
- testAction({
+ return testAction({
action: actions.saveRelease,
state,
expectedMutations: [{ type: types.REQUEST_SAVE_RELEASE }],
@@ -218,7 +218,7 @@ describe('Release edit/new actions', () => {
describe('initializeRelease', () => {
it('dispatches "fetchRelease"', () => {
- testAction({
+ return testAction({
action: actions.initializeRelease,
state,
expectedActions: [{ type: 'fetchRelease' }],
@@ -228,7 +228,7 @@ describe('Release edit/new actions', () => {
describe('saveRelease', () => {
it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => {
- testAction({
+ return testAction({
action: actions.saveRelease,
state,
expectedMutations: [{ type: types.REQUEST_SAVE_RELEASE }],
diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js
index 5fb683bd370..d779abcbfd6 100644
--- a/spec/frontend/repository/commits_service_spec.js
+++ b/spec/frontend/repository/commits_service_spec.js
@@ -14,7 +14,7 @@ describe('commits service', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
-
+ window.gon.features = { encodingLogsTree: true };
mock.onGet(url).reply(HTTP_STATUS_OK, [], {});
jest.spyOn(axios, 'get');
@@ -48,14 +48,27 @@ describe('commits service', () => {
});
it('encodes the path and ref', async () => {
- const encodedRef = encodeURIComponent(refWithSpecialCharMock);
- const encodedUrl = `/some-project/-/refs/${encodedRef}/logs_tree/with%20%24peci%40l%20ch%40rs%2F`;
+ const encodedRef = encodeURI(refWithSpecialCharMock);
+ const encodedUrl = `/some-project/-/refs/${encodedRef}/logs_tree/with%20$peci@l%20ch@rs/`;
await requestCommits(1, 'some-project', 'with $peci@l ch@rs/', refWithSpecialCharMock);
expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything());
});
+ describe('when encodingLogsTree FF is off', () => {
+ beforeEach(() => {
+ window.gon.features = {};
+ });
+
+ it('encodes the path and ref with encodeURIComponent', async () => {
+ const encodedRef = encodeURIComponent(refWithSpecialCharMock);
+ const encodedUrl = `/some-project/-/refs/${encodedRef}/logs_tree/with%20%24peci%40l%20ch%40rs%2F`;
+ await requestCommits(1, 'some-project', 'with $peci@l ch@rs/', refWithSpecialCharMock);
+ expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything());
+ });
+ });
+
it('calls axios get once per batch', async () => {
await Promise.all([requestCommits(0), requestCommits(1), requestCommits(23)]);
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index e0d2984893b..cd5bc08faf0 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -75,6 +75,7 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute
createMergeRequestIn = userPermissionsMock.createMergeRequestIn,
isBinary,
inject = {},
+ blobBlameInfo = true,
} = mockData;
const blobInfo = {
@@ -138,7 +139,7 @@ const createComponent = async (mockData = {}, mountFn = shallowMount, mockRoute
...inject,
glFeatures: {
highlightJsWorker: false,
- blobBlameInfo: true,
+ blobBlameInfo,
},
},
}),
@@ -185,7 +186,7 @@ describe('Blob content viewer component', () => {
expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(false);
expect(findBlobHeader().props('blob')).toEqual(simpleViewerMock);
expect(findBlobHeader().props('showForkSuggestion')).toEqual(false);
- expect(findBlobHeader().props('showBlameToggle')).toEqual(false);
+ expect(findBlobHeader().props('showBlameToggle')).toEqual(true);
expect(findBlobHeader().props('projectPath')).toEqual(propsMock.projectPath);
expect(findBlobHeader().props('projectId')).toEqual(projectMock.id);
expect(mockRouterPush).not.toHaveBeenCalled();
@@ -197,15 +198,15 @@ describe('Blob content viewer component', () => {
await nextTick();
};
- it('renders a blame toggle for JSON files', async () => {
- await createComponent({ blob: { ...simpleViewerMock, language: 'json' } });
+ it('renders a blame toggle', async () => {
+ await createComponent({ blob: simpleViewerMock });
expect(findBlobHeader().props('showBlameToggle')).toEqual(true);
});
it('adds blame param to the URL and passes `showBlame` to the SourceViewer', async () => {
loadViewer.mockReturnValueOnce(SourceViewerNew);
- await createComponent({ blob: { ...simpleViewerMock, language: 'json' } });
+ await createComponent({ blob: simpleViewerMock });
await triggerBlame();
@@ -217,6 +218,25 @@ describe('Blob content viewer component', () => {
expect(mockRouterPush).toHaveBeenCalledWith({ query: { blame: '0' } });
expect(findSourceViewerNew().props('showBlame')).toBe(false);
});
+
+ describe('blobBlameInfo feature flag disabled', () => {
+ it('does not render a blame toggle', async () => {
+ await createComponent({ blob: simpleViewerMock, blobBlameInfo: false });
+
+ expect(findBlobHeader().props('showBlameToggle')).toEqual(false);
+ });
+ });
+
+ describe('when viewing rich content', () => {
+ it('always shows the blame when clicking on the blame button', async () => {
+ loadViewer.mockReturnValueOnce(SourceViewerNew);
+ const query = { plain: '0', blame: '1' };
+ await createComponent({ blob: simpleViewerMock }, shallowMount, { query });
+ await triggerBlame();
+
+ expect(findSourceViewerNew().props('showBlame')).toBe(true);
+ });
+ });
});
it('creates an alert when the BlobHeader component emits an error', async () => {
@@ -260,6 +280,7 @@ describe('Blob content viewer component', () => {
expect(mockAxios.history.get).toHaveLength(1);
expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl);
+ expect(findBlobHeader().props('showBlameToggle')).toEqual(false);
});
it('loads a legacy viewer when a viewer component is not available', async () => {
diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/blob_controls_spec.js
index 3ced5f6c4d2..53ebabebf1d 100644
--- a/spec/frontend/repository/components/blob_controls_spec.js
+++ b/spec/frontend/repository/components/blob_controls_spec.js
@@ -8,6 +8,7 @@ import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql'
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createRouter from '~/repository/router';
import { updateElementsVisibility } from '~/repository/utils/dom';
+import { resetShortcutsForTests } from '~/behaviors/shortcuts';
import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import { blobControlsDataMock, refMock } from '../mock_data';
@@ -32,6 +33,8 @@ const createComponent = async () => {
mockResolver = jest.fn().mockResolvedValue({ data: { project } });
+ await resetShortcutsForTests();
+
wrapper = shallowMountExtended(BlobControls, {
router,
apolloProvider: createMockApollo([[blobControlsQuery, mockResolver]]),
diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
index e14f41e2ed2..378aacd47fa 100644
--- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
+++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap
@@ -47,7 +47,7 @@ exports[`Repository table row component renders a symlink table row 1`] = `
<gl-intersection-observer-stub>
<timeago-tooltip-stub
cssclass=""
- datetimeformat="DATE_WITH_TIME_FORMAT"
+ datetimeformat="asDateTime"
time="2019-01-01"
tooltipplacement="top"
/>
@@ -103,7 +103,7 @@ exports[`Repository table row component renders table row 1`] = `
<gl-intersection-observer-stub>
<timeago-tooltip-stub
cssclass=""
- datetimeformat="DATE_WITH_TIME_FORMAT"
+ datetimeformat="asDateTime"
time="2019-01-01"
tooltipplacement="top"
/>
@@ -159,7 +159,7 @@ exports[`Repository table row component renders table row for path with special
<gl-intersection-observer-stub>
<timeago-tooltip-stub
cssclass=""
- datetimeformat="DATE_WITH_TIME_FORMAT"
+ datetimeformat="asDateTime"
time="2019-01-01"
tooltipplacement="top"
/>
diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js
index c0eb65b28fe..311e5ca86f8 100644
--- a/spec/frontend/repository/components/tree_content_spec.js
+++ b/spec/frontend/repository/components/tree_content_spec.js
@@ -162,26 +162,19 @@ describe('Repository table component', () => {
describe('commit data', () => {
const path = '';
- it('loads commit data for both top and bottom batches when row-appear event is emitted', () => {
- const rowNumber = 50;
-
+ it('loads commit data for the nearest page', () => {
createComponent({ path });
- findFileTable().vm.$emit('row-appear', rowNumber);
+ findFileTable().vm.$emit('row-appear', 49);
+ findFileTable().vm.$emit('row-appear', 15);
- expect(isRequested).toHaveBeenCalledWith(rowNumber);
+ expect(isRequested).toHaveBeenCalledWith(49);
+ expect(isRequested).toHaveBeenCalledWith(15);
expect(loadCommits.mock.calls).toEqual([
- ['', path, '', rowNumber, 'heads'],
- ['', path, '', rowNumber - 25, 'heads'],
+ ['', path, '', 25, 'heads'],
+ ['', path, '', 0, 'heads'],
]);
});
-
- it('loads commit data once if rowNumber is zero', () => {
- createComponent({ path });
- findFileTable().vm.$emit('row-appear', 0);
-
- expect(loadCommits.mock.calls).toEqual([['', path, '', 0, 'heads']]);
- });
});
describe('error handling', () => {
diff --git a/spec/frontend/repository/mixins/highlight_mixin_spec.js b/spec/frontend/repository/mixins/highlight_mixin_spec.js
index 50cfd71d686..c635c09d1aa 100644
--- a/spec/frontend/repository/mixins/highlight_mixin_spec.js
+++ b/spec/frontend/repository/mixins/highlight_mixin_spec.js
@@ -1,9 +1,18 @@
import { shallowMount } from '@vue/test-utils';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/highlight_utils';
import highlightMixin from '~/repository/mixins/highlight_mixin';
import LineHighlighter from '~/blob/line_highlighter';
+import waitForPromises from 'helpers/wait_for_promises';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { TEXT_FILE_TYPE } from '~/repository/constants';
-import { LINES_PER_CHUNK } from '~/vue_shared/components/source_viewer/constants';
+import {
+ LINES_PER_CHUNK,
+ EVENT_ACTION,
+ EVENT_LABEL_FALLBACK,
+} from '~/vue_shared/components/source_viewer/constants';
+import Tracking from '~/tracking';
const lineHighlighter = new LineHighlighter();
jest.mock('~/blob/line_highlighter', () => jest.fn().mockReturnValue({ highlightHash: jest.fn() }));
@@ -11,6 +20,7 @@ jest.mock('~/vue_shared/components/source_viewer/workers/highlight_utils', () =>
splitIntoChunks: jest.fn().mockResolvedValue([]),
}));
+const mockAxios = new MockAdapter(axios);
const workerMock = { postMessage: jest.fn() };
const onErrorMock = jest.fn();
@@ -21,7 +31,10 @@ describe('HighlightMixin', () => {
const rawTextBlob = contentArray.join('\n');
const languageMock = 'json';
- const createComponent = ({ fileType = TEXT_FILE_TYPE, language = languageMock } = {}) => {
+ const createComponent = (
+ { fileType = TEXT_FILE_TYPE, language = languageMock, externalStorageUrl, rawPath } = {},
+ isUsingLfs = false,
+ ) => {
const simpleViewer = { fileType };
const dummyComponent = {
@@ -32,7 +45,10 @@ describe('HighlightMixin', () => {
},
template: '<div>{{chunks[0]?.highlightedContent}}</div>',
created() {
- this.initHighlightWorker({ rawTextBlob, simpleViewer, language, fileType });
+ this.initHighlightWorker(
+ { rawTextBlob, simpleViewer, language, fileType, externalStorageUrl, rawPath },
+ isUsingLfs,
+ );
},
methods: { onError: onErrorMock },
};
@@ -45,13 +61,6 @@ describe('HighlightMixin', () => {
describe('initHighlightWorker', () => {
const firstSeventyLines = contentArray.slice(0, LINES_PER_CHUNK).join('\n');
- it('does not instruct worker if file is not a JSON file', () => {
- workerMock.postMessage.mockClear();
- createComponent({ language: 'javascript' });
-
- expect(workerMock.postMessage).not.toHaveBeenCalled();
- });
-
it('generates a chunk for the first 70 lines of raw text', () => {
expect(splitIntoChunks).toHaveBeenCalledWith(languageMock, firstSeventyLines);
});
@@ -74,6 +83,23 @@ describe('HighlightMixin', () => {
});
});
+ describe('auto-detects if a language cannot be loaded', () => {
+ const unknownLanguage = 'some_unknown_language';
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ createComponent({ language: unknownLanguage });
+ });
+
+ it('emits a tracking event for the fallback', () => {
+ const eventData = { label: EVENT_LABEL_FALLBACK, property: unknownLanguage };
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ });
+
+ it('calls the onError method', () => {
+ expect(onErrorMock).toHaveBeenCalled();
+ });
+ });
+
describe('worker message handling', () => {
const CHUNK_MOCK = { startingFrom: 0, totalLines: 70, highlightedContent: 'some content' };
@@ -87,4 +113,32 @@ describe('HighlightMixin', () => {
expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash);
});
});
+
+ describe('LFS blobs', () => {
+ const rawPath = '/org/project/-/raw/file.xml';
+ const externalStorageUrl = 'http://127.0.0.1:9000/lfs-objects/91/12/1341234';
+ const mockParams = { content: rawTextBlob, language: languageMock, fileType: TEXT_FILE_TYPE };
+
+ afterEach(() => mockAxios.reset());
+
+ it('Uses externalStorageUrl to fetch content if present', async () => {
+ mockAxios.onGet(externalStorageUrl).replyOnce(HTTP_STATUS_OK, rawTextBlob);
+ createComponent({ rawPath, externalStorageUrl }, true);
+ await waitForPromises();
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0].url).toBe(externalStorageUrl);
+ expect(workerMock.postMessage).toHaveBeenCalledWith(mockParams);
+ });
+
+ it('Falls back to rawPath to fetch content', async () => {
+ mockAxios.onGet(rawPath).replyOnce(HTTP_STATUS_OK, rawTextBlob);
+ createComponent({ rawPath }, true);
+ await waitForPromises();
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0].url).toBe(rawPath);
+ expect(workerMock.postMessage).toHaveBeenCalledWith(mockParams);
+ });
+ });
});
diff --git a/spec/frontend/search/sidebar/components/all_scopes_start_filters_spec.js b/spec/frontend/search/sidebar/components/all_scopes_start_filters_spec.js
new file mode 100644
index 00000000000..cd43214ed38
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/all_scopes_start_filters_spec.js
@@ -0,0 +1,28 @@
+import { shallowMount } from '@vue/test-utils';
+import GroupFilter from '~/search/sidebar/components/group_filter.vue';
+import ProjectFilter from '~/search/sidebar/components/project_filter.vue';
+import AllScopesStartFilters from '~/search/sidebar/components/all_scopes_start_filters.vue';
+
+describe('GlobalSearch AllScopesStartFilters', () => {
+ let wrapper;
+
+ const findGroupFilter = () => wrapper.findComponent(GroupFilter);
+ const findProjectFilter = () => wrapper.findComponent(ProjectFilter);
+
+ const createComponent = () => {
+ wrapper = shallowMount(AllScopesStartFilters);
+ };
+
+ describe('Renders correctly', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+ it('renders ArchivedFilter', () => {
+ expect(findGroupFilter().exists()).toBe(true);
+ });
+
+ it('renders FiltersTemplate', () => {
+ expect(findProjectFilter().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js
index c2d88493d71..3ff6bbf7666 100644
--- a/spec/frontend/search/sidebar/components/app_spec.js
+++ b/spec/frontend/search/sidebar/components/app_spec.js
@@ -18,10 +18,9 @@ import NotesFilters from '~/search/sidebar/components/notes_filters.vue';
import CommitsFilters from '~/search/sidebar/components/commits_filters.vue';
import MilestonesFilters from '~/search/sidebar/components/milestones_filters.vue';
import WikiBlobsFilters from '~/search/sidebar/components/wiki_blobs_filters.vue';
-import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue';
-import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue';
import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import AllScopesStartFilters from '~/search/sidebar/components/all_scopes_start_filters.vue';
jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager');
@@ -45,11 +44,6 @@ describe('GlobalSearchSidebar', () => {
wrapper = shallowMount(GlobalSearchSidebar, {
store,
- provide: {
- glFeatures: {
- searchProjectWikisHideArchivedProjects: true,
- },
- },
});
};
@@ -62,10 +56,9 @@ describe('GlobalSearchSidebar', () => {
const findCommitsFilters = () => wrapper.findComponent(CommitsFilters);
const findMilestonesFilters = () => wrapper.findComponent(MilestonesFilters);
const findWikiBlobsFilters = () => wrapper.findComponent(WikiBlobsFilters);
- const findScopeLegacyNavigation = () => wrapper.findComponent(ScopeLegacyNavigation);
- const findSmallScreenDrawerNavigation = () => wrapper.findComponent(SmallScreenDrawerNavigation);
const findScopeSidebarNavigation = () => wrapper.findComponent(ScopeSidebarNavigation);
const findDomElementListener = () => wrapper.findComponent(DomElementListener);
+ const findAllScopesStartFilters = () => wrapper.findComponent(AllScopesStartFilters);
describe('renders properly', () => {
describe('always', () => {
@@ -79,31 +72,50 @@ describe('GlobalSearchSidebar', () => {
});
describe.each`
- scope | filter | searchType | isShown
- ${'issues'} | ${findIssuesFilters} | ${SEARCH_TYPE_BASIC} | ${true}
- ${'merge_requests'} | ${findMergeRequestsFilters} | ${SEARCH_TYPE_BASIC} | ${true}
- ${'projects'} | ${findProjectsFilters} | ${SEARCH_TYPE_BASIC} | ${true}
- ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${false}
- ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true}
- ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ZOEKT} | ${false}
- ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_BASIC} | ${true}
- ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_ADVANCED} | ${true}
- ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_BASIC} | ${true}
- ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true}
- ${'milestones'} | ${findMilestonesFilters} | ${SEARCH_TYPE_BASIC} | ${true}
- ${'milestones'} | ${findMilestonesFilters} | ${SEARCH_TYPE_ADVANCED} | ${true}
- ${'wiki_blobs'} | ${findWikiBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${true}
- ${'wiki_blobs'} | ${findWikiBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true}
- `('with sidebar $scope scope:', ({ scope, filter, searchType, isShown }) => {
+ scope | filter
+ ${'issues'} | ${findIssuesFilters}
+ ${'issues'} | ${findAllScopesStartFilters}
+ ${'merge_requests'} | ${findMergeRequestsFilters}
+ ${'merge_requests'} | ${findAllScopesStartFilters}
+ ${'projects'} | ${findProjectsFilters}
+ ${'projects'} | ${findAllScopesStartFilters}
+ ${'blobs'} | ${findAllScopesStartFilters}
+ ${'notes'} | ${findNotesFilters}
+ ${'notes'} | ${findAllScopesStartFilters}
+ ${'commits'} | ${findCommitsFilters}
+ ${'commits'} | ${findAllScopesStartFilters}
+ ${'milestones'} | ${findMilestonesFilters}
+ ${'milestones'} | ${findAllScopesStartFilters}
+ ${'wiki_blobs'} | ${findWikiBlobsFilters}
+ ${'wiki_blobs'} | ${findAllScopesStartFilters}
+ `('with sidebar scope: $scope', ({ scope, filter }) => {
+ describe.each([SEARCH_TYPE_BASIC, SEARCH_TYPE_ADVANCED])(
+ 'with search_type %s',
+ (searchType) => {
+ beforeEach(() => {
+ getterSpies.currentScope = jest.fn(() => scope);
+ createComponent({ urlQuery: { scope }, searchType });
+ });
+
+ it(`renders correctly ${filter.name.replace('find', '')}`, () => {
+ expect(filter().exists()).toBe(true);
+ });
+ },
+ );
+ });
+
+ describe.each`
+ scope | filter | searchType | isShown
+ ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${false}
+ ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true}
+ ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ZOEKT} | ${false}
+ `('sidebar blobs scope:', ({ scope, filter, searchType, isShown }) => {
beforeEach(() => {
getterSpies.currentScope = jest.fn(() => scope);
createComponent({ urlQuery: { scope }, searchType });
});
- it(`renders correctly filter ${filter.name.replace(
- 'find',
- '',
- )} when search_type ${searchType}`, () => {
+ it(`renders correctly filter BlobsFilters when search_type ${searchType}`, () => {
expect(filter().exists()).toBe(isShown);
});
});
@@ -129,46 +141,27 @@ describe('GlobalSearchSidebar', () => {
});
});
- describe.each`
- currentScope | sidebarNavShown | legacyNavShown
- ${'issues'} | ${false} | ${true}
- ${'test'} | ${false} | ${true}
- ${'issues'} | ${true} | ${false}
- ${'test'} | ${true} | ${false}
- `(
- 'renders navigation for scope $currentScope',
- ({ currentScope, sidebarNavShown, legacyNavShown }) => {
- beforeEach(() => {
- getterSpies.currentScope = jest.fn(() => currentScope);
- createComponent({ useSidebarNavigation: sidebarNavShown });
- });
-
- it(`renders navigation correctly with legacyNavShown ${legacyNavShown}`, () => {
- expect(findScopeLegacyNavigation().exists()).toBe(legacyNavShown);
- expect(findSmallScreenDrawerNavigation().exists()).toBe(legacyNavShown);
- });
-
- it(`renders navigation correctly with sidebarNavShown ${sidebarNavShown}`, () => {
- expect(findScopeSidebarNavigation().exists()).toBe(sidebarNavShown);
- });
- },
- );
- });
+ describe.each(['issues', 'test'])('for scope %p', (currentScope) => {
+ beforeEach(() => {
+ getterSpies.currentScope = jest.fn(() => currentScope);
+ createComponent();
+ });
- describe('when useSidebarNavigation=true', () => {
- beforeEach(() => {
- createComponent({ useSidebarNavigation: true });
+ it(`renders navigation correctly`, () => {
+ expect(findScopeSidebarNavigation().exists()).toBe(true);
+ });
});
+ });
- it('toggles super sidebar when button is clicked', () => {
- const elListener = findDomElementListener();
+ it('toggles super sidebar when button is clicked', () => {
+ createComponent();
+ const elListener = findDomElementListener();
- expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled();
+ expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled();
- elListener.vm.$emit('click');
+ elListener.vm.$emit('click');
- expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1);
- expect(elListener.props('selector')).toBe('#js-open-mobile-filters');
- });
+ expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1);
+ expect(elListener.props('selector')).toBe('#js-open-mobile-filters');
});
});
diff --git a/spec/frontend/search/sidebar/components/archived_filter_spec.js b/spec/frontend/search/sidebar/components/archived_filter_spec.js
index 9ed677ca297..9e8ababa5da 100644
--- a/spec/frontend/search/sidebar/components/archived_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/archived_filter_spec.js
@@ -33,7 +33,7 @@ describe('ArchivedFilter', () => {
const findCheckboxFilter = () => wrapper.findComponent(GlFormCheckboxGroup);
const findCheckboxFilterLabel = () => wrapper.findByTestId('label');
- const findH5 = () => wrapper.findComponent('h5');
+ const findTitle = () => wrapper.findByTestId('archived-filter-title');
describe('old sidebar', () => {
beforeEach(() => {
@@ -45,8 +45,8 @@ describe('ArchivedFilter', () => {
});
it('renders the divider', () => {
- expect(findH5().exists()).toBe(true);
- expect(findH5().text()).toBe(archivedFilterData.headerLabel);
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe(archivedFilterData.headerLabel);
});
it('wraps the label element with a tooltip', () => {
@@ -66,8 +66,8 @@ describe('ArchivedFilter', () => {
});
it("doesn't render the divider", () => {
- expect(findH5().exists()).toBe(true);
- expect(findH5().text()).toBe(archivedFilterData.headerLabel);
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe(archivedFilterData.headerLabel);
});
it('wraps the label element with a tooltip', () => {
diff --git a/spec/frontend/search/sidebar/components/blobs_filters_spec.js b/spec/frontend/search/sidebar/components/blobs_filters_spec.js
index 245ddb8f8bb..3f1feae8527 100644
--- a/spec/frontend/search/sidebar/components/blobs_filters_spec.js
+++ b/spec/frontend/search/sidebar/components/blobs_filters_spec.js
@@ -17,13 +17,11 @@ describe('GlobalSearch BlobsFilters', () => {
currentScope: () => 'blobs',
};
- const createComponent = ({ initialState = {} } = {}) => {
+ const createComponent = () => {
const store = new Vuex.Store({
state: {
urlQuery: MOCK_QUERY,
- useSidebarNavigation: false,
searchType: SEARCH_TYPE_ADVANCED,
- ...initialState,
},
getters: defaultGetters,
});
@@ -35,10 +33,9 @@ describe('GlobalSearch BlobsFilters', () => {
const findLanguageFilter = () => wrapper.findComponent(LanguageFilter);
const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter);
- const findDividers = () => wrapper.findAll('hr');
beforeEach(() => {
- createComponent({});
+ createComponent();
});
it('renders LanguageFilter', () => {
@@ -48,31 +45,4 @@ describe('GlobalSearch BlobsFilters', () => {
it('renders ArchivedFilter', () => {
expect(findArchivedFilter().exists()).toBe(true);
});
-
- it('renders divider correctly', () => {
- expect(findDividers()).toHaveLength(1);
- });
-
- describe('Renders correctly in new nav', () => {
- beforeEach(() => {
- createComponent({
- initialState: {
- searchType: SEARCH_TYPE_ADVANCED,
- useSidebarNavigation: true,
- },
- });
- });
-
- it('renders correctly LanguageFilter', () => {
- expect(findLanguageFilter().exists()).toBe(true);
- });
-
- it('renders correctly ArchivedFilter', () => {
- expect(findArchivedFilter().exists()).toBe(true);
- });
-
- it("doesn't render dividers", () => {
- expect(findDividers()).toHaveLength(0);
- });
- });
});
diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
index 6444ec10466..fedbd407b0b 100644
--- a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
@@ -22,23 +22,11 @@ describe('ConfidentialityFilter', () => {
const findRadioFilter = () => wrapper.findComponent(RadioFilter);
- describe('old sidebar', () => {
- beforeEach(() => {
- createComponent({ useSidebarNavigation: false });
- });
-
- it('renders the component', () => {
- expect(findRadioFilter().exists()).toBe(true);
- });
+ beforeEach(() => {
+ createComponent();
});
- describe('new sidebar', () => {
- beforeEach(() => {
- createComponent({ useSidebarNavigation: true });
- });
-
- it('renders the component', () => {
- expect(findRadioFilter().exists()).toBe(true);
- });
+ it('renders the component', () => {
+ expect(findRadioFilter().exists()).toBe(true);
});
});
diff --git a/spec/frontend/search/sidebar/components/filters_template_spec.js b/spec/frontend/search/sidebar/components/filters_template_spec.js
index f1a807c5ceb..18144e25ac3 100644
--- a/spec/frontend/search/sidebar/components/filters_template_spec.js
+++ b/spec/frontend/search/sidebar/components/filters_template_spec.js
@@ -52,7 +52,6 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
};
const findForm = () => wrapper.findComponent(GlForm);
- const findDividers = () => wrapper.findAll('hr');
const findApplyButton = () => wrapper.findComponent(GlButton);
const findResetButton = () => wrapper.findComponent(GlLink);
const findSlotContent = () => wrapper.findByText('Filters Content');
@@ -66,10 +65,6 @@ describe('GlobalSearchSidebarLanguageFilter', () => {
expect(findForm().exists()).toBe(true);
});
- it('renders dividers', () => {
- expect(findDividers()).toHaveLength(2);
- });
-
it('renders slot content', () => {
expect(findSlotContent().exists()).toBe(true);
});
diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/sidebar/components/group_filter_spec.js
index fa8036a7f97..a90a8a38267 100644
--- a/spec/frontend/search/topbar/components/group_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/group_filter_spec.js
@@ -1,13 +1,14 @@
import { shallowMount } from '@vue/test-utils';
+import { cloneDeep } from 'lodash';
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { MOCK_GROUP, MOCK_QUERY, CURRENT_SCOPE } from 'jest/search/mock_data';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { GROUPS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
-import GroupFilter from '~/search/topbar/components/group_filter.vue';
-import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
-import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
+import GroupFilter from '~/search/sidebar/components/group_filter.vue';
+import SearchableDropdown from '~/search/sidebar/components/searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/sidebar/constants';
Vue.use(Vuex);
@@ -27,6 +28,7 @@ describe('GroupFilter', () => {
const defaultProps = {
initialData: null,
+ searchHandler: jest.fn(),
};
const createComponent = (initialState, props) => {
@@ -68,19 +70,6 @@ describe('GroupFilter', () => {
createComponent();
});
- describe('when @search is emitted', () => {
- const search = 'test';
-
- beforeEach(() => {
- findSearchableDropdown().vm.$emit('search', search);
- });
-
- it('calls fetchGroups with the search paramter', () => {
- expect(actionSpies.fetchGroups).toHaveBeenCalledTimes(1);
- expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), search);
- });
- });
-
describe('when @change is emitted with Any', () => {
beforeEach(() => {
findSearchableDropdown().vm.$emit('change', ANY_OPTION);
@@ -148,11 +137,12 @@ describe('GroupFilter', () => {
describe('when initialData is set', () => {
beforeEach(() => {
- createComponent({}, { initialData: MOCK_GROUP });
+ createComponent({ groupInitialJson: { ...MOCK_GROUP } }, {});
});
it('sets selectedGroup to ANY_OPTION', () => {
- expect(wrapper.vm.selectedGroup).toBe(MOCK_GROUP);
+ // cloneDeep to fix Property or method `nodeType` is not defined bug
+ expect(cloneDeep(wrapper.vm.selectedGroup)).toStrictEqual(MOCK_GROUP);
});
});
});
@@ -169,7 +159,13 @@ describe('GroupFilter', () => {
initialData ? 'has' : 'does not have'
} an initial group`, () => {
beforeEach(() => {
- createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData });
+ createComponent(
+ {
+ query: { ...MOCK_QUERY, nav_source: navSource },
+ groupInitialJson: { ...initialData },
+ },
+ {},
+ );
});
it(`${callMethod ? 'does' : 'does not'} call setFrequentGroup`, () => {
diff --git a/spec/frontend/search/sidebar/components/issues_filters_spec.js b/spec/frontend/search/sidebar/components/issues_filters_spec.js
index 860c5c147a6..ce9c6c2bb0c 100644
--- a/spec/frontend/search/sidebar/components/issues_filters_spec.js
+++ b/spec/frontend/search/sidebar/components/issues_filters_spec.js
@@ -19,11 +19,10 @@ describe('GlobalSearch IssuesFilters', () => {
currentScope: () => 'issues',
};
- const createComponent = ({ initialState = {}, searchIssueLabelAggregation = true } = {}) => {
+ const createComponent = ({ initialState = {} } = {}) => {
const store = new Vuex.Store({
state: {
urlQuery: MOCK_QUERY,
- useSidebarNavigation: false,
searchType: SEARCH_TYPE_ADVANCED,
...initialState,
},
@@ -32,11 +31,6 @@ describe('GlobalSearch IssuesFilters', () => {
wrapper = shallowMount(IssuesFilters, {
store,
- provide: {
- glFeatures: {
- searchIssueLabelAggregation,
- },
- },
});
};
@@ -44,17 +38,10 @@ describe('GlobalSearch IssuesFilters', () => {
const findConfidentialityFilter = () => wrapper.findComponent(ConfidentialityFilter);
const findLabelFilter = () => wrapper.findComponent(LabelFilter);
const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter);
- const findDividers = () => wrapper.findAll('hr');
- describe.each`
- description | searchIssueLabelAggregation
- ${'Renders correctly with Label Filter disabled'} | ${false}
- ${'Renders correctly with Label Filter enabled'} | ${true}
- `('$description', ({ searchIssueLabelAggregation }) => {
+ describe('Renders filters correctly with advanced search', () => {
beforeEach(() => {
- createComponent({
- searchIssueLabelAggregation,
- });
+ createComponent();
});
it('renders StatusFilter', () => {
@@ -69,17 +56,8 @@ describe('GlobalSearch IssuesFilters', () => {
expect(findArchivedFilter().exists()).toBe(true);
});
- it(`renders correctly LabelFilter when searchIssueLabelAggregation is ${searchIssueLabelAggregation}`, () => {
- expect(findLabelFilter().exists()).toBe(searchIssueLabelAggregation);
- });
-
- it('renders divider correctly', () => {
- // two dividers can't be disabled
- let dividersCount = 2;
- if (searchIssueLabelAggregation) {
- dividersCount += 1;
- }
- expect(findDividers()).toHaveLength(dividersCount);
+ it('renders correctly LabelFilter', () => {
+ expect(findLabelFilter().exists()).toBe(true);
});
});
@@ -102,41 +80,6 @@ describe('GlobalSearch IssuesFilters', () => {
it("doesn't render ArchivedFilter", () => {
expect(findArchivedFilter().exists()).toBe(true);
});
-
- it('renders 1 divider', () => {
- expect(findDividers()).toHaveLength(2);
- });
- });
-
- describe('Renders correctly in new nav', () => {
- beforeEach(() => {
- createComponent({
- initialState: {
- searchType: SEARCH_TYPE_ADVANCED,
- useSidebarNavigation: true,
- },
- searchIssueLabelAggregation: true,
- });
- });
- it('renders StatusFilter', () => {
- expect(findStatusFilter().exists()).toBe(true);
- });
-
- it('renders ConfidentialityFilter', () => {
- expect(findConfidentialityFilter().exists()).toBe(true);
- });
-
- it('renders LabelFilter', () => {
- expect(findLabelFilter().exists()).toBe(true);
- });
-
- it('renders ArchivedFilter', () => {
- expect(findArchivedFilter().exists()).toBe(true);
- });
-
- it("doesn't render dividers", () => {
- expect(findDividers()).toHaveLength(0);
- });
});
describe('Renders correctly with wrong scope', () => {
@@ -159,9 +102,5 @@ describe('GlobalSearch IssuesFilters', () => {
it("doesn't render ArchivedFilter", () => {
expect(findArchivedFilter().exists()).toBe(false);
});
-
- it("doesn't render dividers", () => {
- expect(findDividers()).toHaveLength(0);
- });
});
});
diff --git a/spec/frontend/search/sidebar/components/label_filter_spec.js b/spec/frontend/search/sidebar/components/label_filter_spec.js
index 9d2a0c5e739..7641036b9f6 100644
--- a/spec/frontend/search/sidebar/components/label_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/label_filter_spec.js
@@ -85,11 +85,6 @@ describe('GlobalSearchSidebarLabelFilter', () => {
wrapper = mountExtended(LabelFilter, {
store,
- provide: {
- glFeatures: {
- searchIssueLabelAggregation: true,
- },
- },
});
};
diff --git a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js
index b02228a418f..8cd3cb45a20 100644
--- a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js
+++ b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js
@@ -21,7 +21,6 @@ describe('GlobalSearch MergeRequestsFilters', () => {
const store = new Vuex.Store({
state: {
urlQuery: MOCK_QUERY,
- useSidebarNavigation: false,
searchType: SEARCH_TYPE_ADVANCED,
...initialState,
},
@@ -35,7 +34,6 @@ describe('GlobalSearch MergeRequestsFilters', () => {
const findStatusFilter = () => wrapper.findComponent(StatusFilter);
const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter);
- const findDividers = () => wrapper.findAll('hr');
describe('Renders correctly with Archived Filter', () => {
beforeEach(() => {
@@ -46,8 +44,8 @@ describe('GlobalSearch MergeRequestsFilters', () => {
expect(findStatusFilter().exists()).toBe(true);
});
- it('renders divider correctly', () => {
- expect(findDividers()).toHaveLength(1);
+ it('renders ArchivedFilter', () => {
+ expect(findArchivedFilter().exists()).toBe(true);
});
});
@@ -60,33 +58,9 @@ describe('GlobalSearch MergeRequestsFilters', () => {
expect(findStatusFilter().exists()).toBe(true);
});
- it('renders render ArchivedFilter', () => {
- expect(findArchivedFilter().exists()).toBe(true);
- });
-
- it('renders 1 divider', () => {
- expect(findDividers()).toHaveLength(1);
- });
- });
-
- describe('Renders correctly in new nav', () => {
- beforeEach(() => {
- createComponent({
- searchType: SEARCH_TYPE_ADVANCED,
- useSidebarNavigation: true,
- });
- });
- it('renders StatusFilter', () => {
- expect(findStatusFilter().exists()).toBe(true);
- });
-
it('renders ArchivedFilter', () => {
expect(findArchivedFilter().exists()).toBe(true);
});
-
- it("doesn't render divider", () => {
- expect(findDividers()).toHaveLength(0);
- });
});
describe('Renders correctly with wrong scope', () => {
@@ -101,9 +75,5 @@ describe('GlobalSearch MergeRequestsFilters', () => {
it("doesn't render ArchivedFilter", () => {
expect(findArchivedFilter().exists()).toBe(false);
});
-
- it("doesn't render dividers", () => {
- expect(findDividers()).toHaveLength(0);
- });
});
});
diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/sidebar/components/project_filter_spec.js
index e7808370098..817ec77380f 100644
--- a/spec/frontend/search/topbar/components/project_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/project_filter_spec.js
@@ -1,13 +1,14 @@
import { shallowMount } from '@vue/test-utils';
+import { cloneDeep } from 'lodash';
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { MOCK_PROJECT, MOCK_QUERY, CURRENT_SCOPE } from 'jest/search/mock_data';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants';
-import ProjectFilter from '~/search/topbar/components/project_filter.vue';
-import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
-import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
+import ProjectFilter from '~/search/sidebar/components/project_filter.vue';
+import SearchableDropdown from '~/search/sidebar/components/searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/sidebar/constants';
Vue.use(Vuex);
@@ -27,12 +28,15 @@ describe('ProjectFilter', () => {
const defaultProps = {
initialData: null,
+ projectInitialJson: MOCK_PROJECT,
+ searchHandler: jest.fn(),
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
+ projectInitialJson: MOCK_PROJECT,
...initialState,
},
actions: actionSpies,
@@ -68,18 +72,6 @@ describe('ProjectFilter', () => {
createComponent();
});
- describe('when @search is emitted', () => {
- const search = 'test';
-
- beforeEach(() => {
- findSearchableDropdown().vm.$emit('search', search);
- });
-
- it('calls fetchProjects with the search paramter', () => {
- expect(actionSpies.fetchProjects).toHaveBeenCalledWith(expect.any(Object), search);
- });
- });
-
describe('when @change is emitted', () => {
describe('with Any', () => {
beforeEach(() => {
@@ -139,17 +131,17 @@ describe('ProjectFilter', () => {
describe('selectedProject', () => {
describe('when initialData is null', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ projectInitialJson: ANY_OPTION }, {});
});
it('sets selectedProject to ANY_OPTION', () => {
- expect(wrapper.vm.selectedProject).toBe(ANY_OPTION);
+ expect(cloneDeep(wrapper.vm.selectedProject)).toStrictEqual(ANY_OPTION);
});
});
describe('when initialData is set', () => {
beforeEach(() => {
- createComponent({}, { initialData: MOCK_PROJECT });
+ createComponent({ projectInitialJson: MOCK_PROJECT }, {});
});
it('sets selectedProject to the initialData', () => {
@@ -170,7 +162,13 @@ describe('ProjectFilter', () => {
initialData ? 'has' : 'does not have'
} an initial project`, () => {
beforeEach(() => {
- createComponent({ query: { ...MOCK_QUERY, nav_source: navSource } }, { initialData });
+ createComponent(
+ {
+ query: { ...MOCK_QUERY, nav_source: navSource },
+ projectInitialJson: { ...initialData },
+ },
+ {},
+ );
});
it(`${callMethod ? 'does' : 'does not'} call setFrequentProject`, () => {
diff --git a/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js
deleted file mode 100644
index 63d8b34fcf0..00000000000
--- a/spec/frontend/search/sidebar/components/scope_legacy_navigation_spec.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { MOCK_QUERY, MOCK_NAVIGATION } from 'jest/search/mock_data';
-import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue';
-
-Vue.use(Vuex);
-
-const MOCK_NAVIGATION_ENTRIES = Object.entries(MOCK_NAVIGATION);
-
-describe('ScopeLegacyNavigation', () => {
- let wrapper;
-
- const actionSpies = {
- fetchSidebarCount: jest.fn(),
- };
-
- const getterSpies = {
- currentScope: jest.fn(() => 'issues'),
- };
-
- const createComponent = (initialState) => {
- const store = new Vuex.Store({
- state: {
- urlQuery: MOCK_QUERY,
- navigation: MOCK_NAVIGATION,
- ...initialState,
- },
- actions: actionSpies,
- getters: getterSpies,
- });
-
- wrapper = shallowMount(ScopeLegacyNavigation, {
- store,
- });
- };
-
- const findNavElement = () => wrapper.find('nav');
- const findGlNav = () => wrapper.findComponent(GlNav);
- const findGlNavItems = () => wrapper.findAllComponents(GlNavItem);
- const findGlNavItemActive = () => wrapper.find('[active=true]');
- const findGlNavItemActiveLabel = () => findGlNavItemActive().find('[data-testid="label"]');
- const findGlNavItemActiveCount = () => findGlNavItemActive().find('[data-testid="count"]');
-
- describe('scope navigation', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders section', () => {
- expect(findNavElement().exists()).toBe(true);
- });
-
- it('renders nav component', () => {
- expect(findGlNav().exists()).toBe(true);
- });
-
- it('renders all nav item components', () => {
- expect(findGlNavItems()).toHaveLength(MOCK_NAVIGATION_ENTRIES.length);
- });
-
- it('has all proper links', () => {
- const linkAtPosition = 3;
- const { link } = MOCK_NAVIGATION_ENTRIES[linkAtPosition][1];
-
- expect(findGlNavItems().at(linkAtPosition).attributes('href')).toBe(link);
- });
- });
-
- describe('scope navigation sets proper state with url scope set', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('has correct active item', () => {
- expect(findGlNavItemActive().exists()).toBe(true);
- expect(findGlNavItemActiveLabel().text()).toBe('Issues');
- });
-
- it('has correct active item count', () => {
- expect(findGlNavItemActiveCount().text()).toBe('2.4K');
- });
-
- it('does not have plus sign after count text', () => {
- expect(findGlNavItemActive().findComponent(GlIcon).exists()).toBe(false);
- });
-
- it('has count is highlighted correctly', () => {
- expect(findGlNavItemActiveCount().classes('gl-text-gray-900')).toBe(true);
- });
- });
-
- describe('scope navigation sets proper state with NO url scope set', () => {
- beforeEach(() => {
- getterSpies.currentScope = jest.fn(() => 'projects');
- createComponent({
- urlQuery: {},
- navigation: {
- ...MOCK_NAVIGATION,
- projects: {
- ...MOCK_NAVIGATION.projects,
- active: true,
- },
- issues: {
- ...MOCK_NAVIGATION.issues,
- active: false,
- },
- },
- });
- });
-
- it('has correct active item', () => {
- expect(findGlNavItemActive().exists()).toBe(true);
- expect(findGlNavItemActiveLabel().text()).toBe('Projects');
- });
-
- it('has correct active item count', () => {
- expect(findGlNavItemActiveCount().text()).toBe('10K');
- });
-
- it('has correct active item count and over limit sign', () => {
- expect(findGlNavItemActive().findComponent(GlIcon).exists()).toBe(true);
- });
- });
-
- describe.each`
- searchTherm | hasBeenCalled
- ${null} | ${0}
- ${'test'} | ${1}
- `('fetchSidebarCount', ({ searchTherm, hasBeenCalled }) => {
- beforeEach(() => {
- createComponent({
- urlQuery: {
- search: searchTherm,
- },
- });
- });
-
- it('is only called when search term is set', () => {
- expect(actionSpies.fetchSidebarCount).toHaveBeenCalledTimes(hasBeenCalled);
- });
- });
-});
diff --git a/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js
index d85942b9634..44c243d15f7 100644
--- a/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js
+++ b/spec/frontend/search/sidebar/components/scope_sidebar_navigation_spec.js
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
+import sidebarEventHub from '~/super_sidebar/event_hub';
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';
@@ -49,6 +50,7 @@ describe('ScopeSidebarNavigation', () => {
describe('scope navigation', () => {
beforeEach(() => {
+ jest.spyOn(sidebarEventHub, '$emit');
createComponent({ urlQuery: { ...MOCK_QUERY, search: 'test' } });
});
@@ -71,6 +73,11 @@ describe('ScopeSidebarNavigation', () => {
expect(findNavItems().at(linkAtPosition).findComponent('a').attributes('href')).toBe(link);
});
+
+ it('always emits toggle-menu-header event', () => {
+ expect(sidebarEventHub.$emit).toHaveBeenCalledWith('toggle-menu-header', false);
+ expect(sidebarEventHub.$emit).toHaveBeenCalledTimes(1);
+ });
});
describe('scope navigation sets proper state with url scope set', () => {
diff --git a/spec/frontend/search/sidebar/components/searchable_dropdown_spec.js b/spec/frontend/search/sidebar/components/searchable_dropdown_spec.js
new file mode 100644
index 00000000000..c8f157e4fe4
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/searchable_dropdown_spec.js
@@ -0,0 +1,117 @@
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
+import Vuex from 'vuex';
+import waitForPromises from 'helpers/wait_for_promises';
+import { MOCK_GROUPS, MOCK_QUERY } from 'jest/search/mock_data';
+import SearchableDropdown from '~/search/sidebar/components/searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA } from '~/search/sidebar/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+
+Vue.use(Vuex);
+
+describe('Global Search Searchable Dropdown', () => {
+ let wrapper;
+
+ const defaultProps = {
+ headerText: GROUP_DATA.headerText,
+ name: GROUP_DATA.name,
+ fullName: GROUP_DATA.fullName,
+ loading: false,
+ selectedItem: ANY_OPTION,
+ items: [],
+ frequentItems: [{ ...MOCK_GROUPS[0] }],
+ searchHandler: jest.fn(),
+ };
+
+ const createComponent = (initialState, props) => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ });
+
+ wrapper = shallowMount(SearchableDropdown, {
+ store,
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ const findGlDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlDropdown', () => {
+ expect(findGlDropdown().exists()).toBe(true);
+ });
+
+ const propItems = [
+ { text: '', options: [{ value: ANY_OPTION.name, text: ANY_OPTION.name, ...ANY_OPTION }] },
+ {
+ text: 'Frequently searched',
+ options: [{ value: MOCK_GROUPS[0].id, text: MOCK_GROUPS[0].full_name, ...MOCK_GROUPS[0] }],
+ },
+ {
+ text: 'All available groups',
+ options: [{ value: MOCK_GROUPS[1].id, text: MOCK_GROUPS[1].full_name, ...MOCK_GROUPS[1] }],
+ },
+ ];
+
+ beforeEach(() => {
+ createComponent({}, { items: MOCK_GROUPS });
+ });
+
+ it('contains correct set of items', () => {
+ expect(findGlDropdown().props('items')).toStrictEqual(propItems);
+ });
+
+ it('renders searchable prop', () => {
+ expect(findGlDropdown().props('searchable')).toBe(true);
+ });
+
+ describe('events', () => {
+ it('emits select', () => {
+ findGlDropdown().vm.$emit('select', 1);
+ expect(cloneDeep(wrapper.emitted('change')[0][0])).toStrictEqual(MOCK_GROUPS[0]);
+ });
+
+ it('emits reset', () => {
+ findGlDropdown().vm.$emit('reset');
+ expect(cloneDeep(wrapper.emitted('change')[0][0])).toStrictEqual(ANY_OPTION);
+ });
+
+ it('emits first-open', () => {
+ findGlDropdown().vm.$emit('shown');
+ expect(wrapper.emitted('first-open')).toHaveLength(1);
+ findGlDropdown().vm.$emit('shown');
+ expect(wrapper.emitted('first-open')).toHaveLength(1);
+ });
+ });
+ });
+
+ describe('when @search is emitted', () => {
+ const search = 'test';
+
+ beforeEach(async () => {
+ createComponent();
+ findGlDropdown().vm.$emit('search', search);
+
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await waitForPromises();
+ });
+
+ it('calls fetchGroups with the search paramter', () => {
+ expect(defaultProps.searchHandler).toHaveBeenCalledTimes(1);
+ expect(defaultProps.searchHandler).toHaveBeenCalledWith(search);
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js b/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js
deleted file mode 100644
index 5ab4afba7f0..00000000000
--- a/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import { nextTick } from 'vue';
-import { GlDrawer } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
-import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
-import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue';
-
-describe('ScopeLegacyNavigation', () => {
- let wrapper;
- let closeSpy;
- let toggleSpy;
-
- const createComponent = () => {
- wrapper = shallowMountExtended(SmallScreenDrawerNavigation, {
- slots: {
- default: '<div data-testid="default-slot-content">test</div>',
- },
- });
- };
-
- const findGlDrawer = () => wrapper.findComponent(GlDrawer);
- const findTitle = () => wrapper.findComponent('h2');
- const findSlot = () => wrapper.findByTestId('default-slot-content');
- const findDomElementListener = () => wrapper.findComponent(DomElementListener);
-
- describe('small screen navigation', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders drawer', () => {
- expect(findGlDrawer().exists()).toBe(true);
- expect(findGlDrawer().attributes('zindex')).toBe(DRAWER_Z_INDEX.toString());
- expect(findGlDrawer().attributes('headerheight')).toBe('0');
- });
-
- it('renders title', () => {
- expect(findTitle().exists()).toBe(true);
- });
-
- it('renders slots', () => {
- expect(findSlot().exists()).toBe(true);
- });
- });
-
- describe('actions', () => {
- beforeEach(() => {
- closeSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'closeSmallScreenFilters');
- toggleSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'toggleSmallScreenFilters');
- createComponent();
- });
-
- it('calls onClose', () => {
- findGlDrawer().vm.$emit('close');
- expect(closeSpy).toHaveBeenCalled();
- });
-
- it('calls toggleSmallScreenFilters', async () => {
- expect(findGlDrawer().props('open')).toBe(false);
-
- findDomElementListener().vm.$emit('click');
- await nextTick();
-
- expect(toggleSpy).toHaveBeenCalled();
- expect(findGlDrawer().props('open')).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js
index c230341c172..719932a79ef 100644
--- a/spec/frontend/search/sidebar/components/status_filter_spec.js
+++ b/spec/frontend/search/sidebar/components/status_filter_spec.js
@@ -22,23 +22,9 @@ describe('StatusFilter', () => {
const findRadioFilter = () => wrapper.findComponent(RadioFilter);
- describe('old sidebar', () => {
- beforeEach(() => {
- createComponent({ useSidebarNavigation: false });
- });
-
- it('renders the component', () => {
- expect(findRadioFilter().exists()).toBe(true);
- });
- });
+ it('renders the component', () => {
+ createComponent();
- describe('new sidebar', () => {
- beforeEach(() => {
- createComponent({ useSidebarNavigation: true });
- });
-
- it('renders the component', () => {
- expect(findRadioFilter().exists()).toBe(true);
- });
+ expect(findRadioFilter().exists()).toBe(true);
});
});
diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js
index a517932b0eb..3462d4a326b 100644
--- a/spec/frontend/search/store/mutations_spec.js
+++ b/spec/frontend/search/store/mutations_spec.js
@@ -31,7 +31,7 @@ describe('Global Search Store Mutations', () => {
mutations[types.RECEIVE_GROUPS_SUCCESS](state, MOCK_GROUPS);
expect(state.fetchingGroups).toBe(false);
- expect(state.groups).toBe(MOCK_GROUPS);
+ expect(state.groups).toStrictEqual(MOCK_GROUPS);
});
});
@@ -57,7 +57,7 @@ describe('Global Search Store Mutations', () => {
mutations[types.RECEIVE_PROJECTS_SUCCESS](state, MOCK_PROJECTS);
expect(state.fetchingProjects).toBe(false);
- expect(state.projects).toBe(MOCK_PROJECTS);
+ expect(state.projects).toStrictEqual(MOCK_PROJECTS);
});
});
diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js
index 9704277c86b..d17bdc2a6e1 100644
--- a/spec/frontend/search/topbar/components/app_spec.js
+++ b/spec/frontend/search/topbar/components/app_spec.js
@@ -1,14 +1,14 @@
-import { GlSearchBoxByClick, GlButton } from '@gitlab/ui';
+import { GlSearchBoxByType, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { MOCK_QUERY } from 'jest/search/mock_data';
import { stubComponent } from 'helpers/stub_component';
import GlobalSearchTopbar from '~/search/topbar/components/app.vue';
-import GroupFilter from '~/search/topbar/components/group_filter.vue';
-import ProjectFilter from '~/search/topbar/components/project_filter.vue';
import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue';
+import SearchTypeIndicator from '~/search/topbar/components/search_type_indicator.vue';
+import { ENTER_KEY } from '~/lib/utils/keys';
import {
SYNTAX_OPTIONS_ADVANCED_DOCUMENT,
SYNTAX_OPTIONS_ZOEKT_DOCUMENT,
@@ -41,42 +41,22 @@ describe('GlobalSearchTopbar', () => {
});
};
- const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByClick);
- const findGroupFilter = () => wrapper.findComponent(GroupFilter);
- const findProjectFilter = () => wrapper.findComponent(ProjectFilter);
+ const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findSyntaxOptionButton = () => wrapper.findComponent(GlButton);
const findSyntaxOptionDrawer = () => wrapper.findComponent(MarkdownDrawer);
+ const findSearchTypeIndicator = () => wrapper.findComponent(SearchTypeIndicator);
describe('template', () => {
beforeEach(() => {
createComponent();
});
- describe('Search box', () => {
- it('renders always', () => {
- expect(findGlSearchBox().exists()).toBe(true);
- });
+ it('always renders Search box', () => {
+ expect(findGlSearchBox().exists()).toBe(true);
});
- describe.each`
- snippets | showFilters
- ${null} | ${true}
- ${{ query: { snippets: '' } }} | ${true}
- ${{ query: { snippets: false } }} | ${true}
- ${{ query: { snippets: true } }} | ${false}
- ${{ query: { snippets: 'false' } }} | ${true}
- ${{ query: { snippets: 'true' } }} | ${false}
- `('topbar filters', ({ snippets, showFilters }) => {
- beforeEach(() => {
- createComponent(snippets);
- });
-
- it(`does${showFilters ? '' : ' not'} render when snippets is ${JSON.stringify(
- snippets,
- )}`, () => {
- expect(findGroupFilter().exists()).toBe(showFilters);
- expect(findProjectFilter().exists()).toBe(showFilters);
- });
+ it('always renders Search indicator', () => {
+ expect(findSearchTypeIndicator().exists()).toBe(true);
});
describe.each`
@@ -128,15 +108,15 @@ describe('GlobalSearchTopbar', () => {
});
describe.each`
- state | defaultBranchName | hasSyntaxOptions
- ${{ query: { repository_ref: '' }, searchType: 'basic' }} | ${'master'} | ${false}
- ${{ query: { repository_ref: 'v0.1' }, searchType: 'basic' }} | ${''} | ${false}
- ${{ query: { repository_ref: 'master' }, searchType: 'basic' }} | ${'master'} | ${false}
- ${{ query: { repository_ref: 'master' }, searchType: 'advanced' }} | ${''} | ${false}
- ${{ query: { repository_ref: '' }, searchType: 'advanced' }} | ${'master'} | ${true}
- ${{ query: { repository_ref: 'v0.1' }, searchType: 'advanced' }} | ${''} | ${false}
- ${{ query: { repository_ref: 'master' }, searchType: 'advanced' }} | ${'master'} | ${true}
- ${{ query: { repository_ref: 'master' }, searchType: 'zoekt' }} | ${'master'} | ${true}
+ state | hasSyntaxOptions
+ ${{ query: { repository_ref: '' }, searchType: 'basic', searchLevel: 'project', defaultBranchName: 'master' }} | ${false}
+ ${{ query: { repository_ref: 'v0.1' }, searchType: 'basic', searchLevel: 'project', defaultBranchName: '' }} | ${false}
+ ${{ query: { repository_ref: 'master' }, searchType: 'basic', searchLevel: 'project', defaultBranchName: 'master' }} | ${false}
+ ${{ query: { repository_ref: 'master' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: '' }} | ${false}
+ ${{ query: { repository_ref: '' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: 'master' }} | ${true}
+ ${{ query: { repository_ref: 'v0.1' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: '' }} | ${false}
+ ${{ query: { repository_ref: 'master' }, searchType: 'advanced', searchLevel: 'project', defaultBranchName: 'master' }} | ${true}
+ ${{ query: { repository_ref: 'master' }, searchType: 'zoekt', searchLevel: 'project', defaultBranchName: 'master' }} | ${true}
`(
`the syntax option based on component state`,
({ state, defaultBranchName, hasSyntaxOptions }) => {
@@ -162,9 +142,10 @@ describe('GlobalSearchTopbar', () => {
createComponent();
});
- it('clicking search button inside search box calls applyQuery', () => {
- findGlSearchBox().vm.$emit('submit', { preventDefault: () => {} });
+ it('clicking search button inside search box calls applyQuery', async () => {
+ await nextTick();
+ findGlSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
expect(actionSpies.applyQuery).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/search/topbar/components/search_type_indicator_spec.js b/spec/frontend/search/topbar/components/search_type_indicator_spec.js
new file mode 100644
index 00000000000..d69ca6dfb16
--- /dev/null
+++ b/spec/frontend/search/topbar/components/search_type_indicator_spec.js
@@ -0,0 +1,128 @@
+import Vue from 'vue';
+// eslint-disable-next-line no-restricted-imports
+import Vuex from 'vuex';
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { MOCK_QUERY } from 'jest/search/mock_data';
+import SearchTypeIndicator from '~/search/topbar/components/search_type_indicator.vue';
+
+Vue.use(Vuex);
+
+describe('SearchTypeIndicator', () => {
+ let wrapper;
+
+ const actionSpies = {
+ applyQuery: jest.fn(),
+ setQuery: jest.fn(),
+ preloadStoredFrequentItems: jest.fn(),
+ };
+
+ const createComponent = (initialState = {}) => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMountExtended(SearchTypeIndicator, {
+ store,
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ const findIndicator = (id) => wrapper.findAllByTestId(id);
+ const findDocsLink = () => wrapper.findComponentByTestId('docs-link');
+ const findSyntaxDocsLink = () => wrapper.findComponentByTestId('syntax-docs-link');
+
+ // searchType and search level params cobination in this test reflects
+ // all possible combinations
+
+ describe.each`
+ searchType | searchLevel | repository | showSearchTypeIndicator
+ ${'advanced'} | ${'project'} | ${'master'} | ${'advanced-enabled'}
+ ${'advanced'} | ${'project'} | ${'v0.1'} | ${'advanced-disabled'}
+ ${'advanced'} | ${'group'} | ${'master'} | ${'advanced-enabled'}
+ ${'advanced'} | ${'global'} | ${'master'} | ${'advanced-enabled'}
+ ${'zoekt'} | ${'project'} | ${'master'} | ${'zoekt-enabled'}
+ ${'zoekt'} | ${'project'} | ${'v0.1'} | ${'zoekt-disabled'}
+ ${'zoekt'} | ${'group'} | ${'master'} | ${'zoekt-enabled'}
+ `(
+ 'search type indicator for $searchType $searchLevel',
+ ({ searchType, repository, showSearchTypeIndicator, searchLevel }) => {
+ beforeEach(() => {
+ createComponent({
+ query: { repository_ref: repository },
+ searchType,
+ searchLevel,
+ defaultBranchName: 'master',
+ });
+ });
+ it('renders correctly', () => {
+ expect(findIndicator(showSearchTypeIndicator).exists()).toBe(true);
+ });
+ },
+ );
+
+ describe.each`
+ searchType | repository | showSearchTypeIndicator
+ ${'basic'} | ${'master'} | ${true}
+ ${'basic'} | ${'v0.1'} | ${true}
+ `(
+ 'search type indicator for $searchType and $repository',
+ ({ searchType, repository, showSearchTypeIndicator }) => {
+ beforeEach(() => {
+ createComponent({
+ query: { repository_ref: repository },
+ searchType,
+ defaultBranchName: 'master',
+ });
+ });
+ it.each(['zoekt-enabled', 'zoekt-disabled', 'advanced-enabled', 'advanced-disabled'])(
+ 'renders correct indicator %s',
+ () => {
+ expect(findIndicator(searchType).exists()).toBe(showSearchTypeIndicator);
+ },
+ );
+ },
+ );
+
+ describe.each`
+ searchType | docsLink
+ ${'advanced'} | ${'/help/user/search/advanced_search'}
+ ${'zoekt'} | ${'/help/user/search/exact_code_search'}
+ `('documentation link for $searchType', ({ searchType, docsLink }) => {
+ beforeEach(() => {
+ createComponent({
+ query: { repository_ref: 'master' },
+ searchType,
+ searchLevel: 'project',
+ defaultBranchName: 'master',
+ });
+ });
+ it('has correct link', () => {
+ expect(findDocsLink().attributes('href')).toBe(docsLink);
+ });
+ });
+
+ describe.each`
+ searchType | syntaxdocsLink
+ ${'advanced'} | ${'/help/user/search/advanced_search#use-the-advanced-search-syntax'}
+ ${'zoekt'} | ${'/help/user/search/exact_code_search#syntax'}
+ `('Syntax documentation $searchType', ({ searchType, syntaxdocsLink }) => {
+ beforeEach(() => {
+ createComponent({
+ query: { repository_ref: '000' },
+ searchType,
+ searchLevel: 'project',
+ defaultBranchName: 'master',
+ });
+ });
+ it('has correct link', () => {
+ expect(findSyntaxDocsLink().attributes('href')).toBe(syntaxdocsLink);
+ });
+ });
+});
diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js
deleted file mode 100644
index c911fe53d40..00000000000
--- a/spec/frontend/search/topbar/components/searchable_dropdown_item_spec.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import { GlDropdownItem, GlAvatar } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { MOCK_GROUPS } from 'jest/search/mock_data';
-import { truncateNamespace } from '~/lib/utils/text_utility';
-import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue';
-import { GROUP_DATA } from '~/search/topbar/constants';
-
-describe('Global Search Searchable Dropdown Item', () => {
- let wrapper;
-
- const defaultProps = {
- item: MOCK_GROUPS[0],
- selectedItem: MOCK_GROUPS[0],
- name: GROUP_DATA.name,
- fullName: GROUP_DATA.fullName,
- };
-
- const createComponent = (props) => {
- wrapper = shallowMountExtended(SearchableDropdownItem, {
- propsData: {
- ...defaultProps,
- ...props,
- },
- });
- };
-
- const findGlDropdownItem = () => wrapper.findComponent(GlDropdownItem);
- const findGlAvatar = () => wrapper.findComponent(GlAvatar);
- const findDropdownTitle = () => wrapper.findByTestId('item-title');
- const findDropdownSubtitle = () => wrapper.findByTestId('item-namespace');
-
- describe('template', () => {
- describe('always', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders GlDropdownItem', () => {
- expect(findGlDropdownItem().exists()).toBe(true);
- });
-
- it('renders GlAvatar', () => {
- expect(findGlAvatar().exists()).toBe(true);
- });
-
- it('renders Dropdown Title correctly', () => {
- const titleEl = findDropdownTitle();
-
- expect(titleEl.exists()).toBe(true);
- expect(titleEl.text()).toBe(MOCK_GROUPS[0][GROUP_DATA.name]);
- });
-
- it('renders Dropdown Subtitle correctly', () => {
- const subtitleEl = findDropdownSubtitle();
-
- expect(subtitleEl.exists()).toBe(true);
- expect(subtitleEl.text()).toBe(truncateNamespace(MOCK_GROUPS[0][GROUP_DATA.fullName]));
- });
- });
-
- describe('when item === selectedItem', () => {
- beforeEach(() => {
- createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[0] });
- });
-
- it('marks the dropdown as checked', () => {
- expect(findGlDropdownItem().attributes('ischecked')).toBe('true');
- });
- });
-
- describe('when item !== selectedItem', () => {
- beforeEach(() => {
- createComponent({ item: MOCK_GROUPS[0], selectedItem: MOCK_GROUPS[1] });
- });
-
- it('marks the dropdown as not checked', () => {
- expect(findGlDropdownItem().attributes('ischecked')).toBeUndefined();
- });
- });
- });
-
- describe('actions', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('clicking the dropdown item $emits change with the item', () => {
- findGlDropdownItem().vm.$emit('click');
-
- expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
- });
- });
-});
diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
deleted file mode 100644
index 5acaa1c1900..00000000000
--- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
+++ /dev/null
@@ -1,220 +0,0 @@
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
-import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
-import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants';
-
-Vue.use(Vuex);
-
-describe('Global Search Searchable Dropdown', () => {
- let wrapper;
-
- const defaultProps = {
- headerText: GROUP_DATA.headerText,
- name: GROUP_DATA.name,
- fullName: GROUP_DATA.fullName,
- loading: false,
- selectedItem: ANY_OPTION,
- items: [],
- };
-
- const createComponent = (initialState, props, mountFn = shallowMount) => {
- const store = new Vuex.Store({
- state: {
- query: MOCK_QUERY,
- ...initialState,
- },
- });
-
- wrapper = extendedWrapper(
- mountFn(SearchableDropdown, {
- store,
- propsData: {
- ...defaultProps,
- ...props,
- },
- }),
- );
- };
-
- const findGlDropdown = () => wrapper.findComponent(GlDropdown);
- const findGlDropdownSearch = () => findGlDropdown().findComponent(GlSearchBoxByType);
- const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text');
- const findSearchableDropdownItems = () => wrapper.findAllByTestId('searchable-items');
- const findFrequentDropdownItems = () => wrapper.findAllByTestId('frequent-items');
- const findAnyDropdownItem = () => findGlDropdown().findComponent(GlDropdownItem);
- const findFirstSearchableDropdownItem = () => findSearchableDropdownItems().at(0);
- const findFirstFrequentDropdownItem = () => findFrequentDropdownItems().at(0);
- const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
-
- describe('template', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders GlDropdown', () => {
- expect(findGlDropdown().exists()).toBe(true);
- });
-
- describe('findGlDropdownSearch', () => {
- it('renders always', () => {
- expect(findGlDropdownSearch().exists()).toBe(true);
- });
-
- it('has debounce prop', () => {
- expect(findGlDropdownSearch().attributes('debounce')).toBe('500');
- });
-
- describe('onSearch', () => {
- const search = 'test search';
-
- beforeEach(() => {
- findGlDropdownSearch().vm.$emit('input', search);
- });
-
- it('$emits @search when input event is fired from GlSearchBoxByType', () => {
- expect(wrapper.emitted('search')[0]).toEqual([search]);
- });
- });
- });
-
- describe('Searchable Dropdown Items', () => {
- describe('when loading is false', () => {
- beforeEach(() => {
- createComponent({}, { items: MOCK_GROUPS });
- });
-
- it('does not render loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('renders the Any Dropdown', () => {
- expect(findAnyDropdownItem().exists()).toBe(true);
- });
-
- it('renders searchable dropdown item for each item', () => {
- expect(findSearchableDropdownItems()).toHaveLength(MOCK_GROUPS.length);
- });
- });
-
- describe('when loading is true', () => {
- beforeEach(() => {
- createComponent({}, { loading: true, items: MOCK_GROUPS });
- });
-
- it('does render loader', () => {
- expect(findLoader().exists()).toBe(true);
- });
-
- it('renders the Any Dropdown', () => {
- expect(findAnyDropdownItem().exists()).toBe(true);
- });
-
- it('does not render searchable dropdown items', () => {
- expect(findSearchableDropdownItems()).toHaveLength(0);
- });
- });
- });
-
- describe.each`
- searchText | frequentItems | length
- ${''} | ${[]} | ${0}
- ${''} | ${MOCK_GROUPS} | ${MOCK_GROUPS.length}
- ${'test'} | ${[]} | ${0}
- ${'test'} | ${MOCK_GROUPS} | ${0}
- `('Frequent Dropdown Items', ({ searchText, frequentItems, length }) => {
- describe(`when search is ${searchText} and frequentItems length is ${frequentItems.length}`, () => {
- beforeEach(() => {
- createComponent({}, { frequentItems });
- findGlDropdownSearch().vm.$emit('input', searchText);
- });
-
- it(`should${length ? '' : ' not'} render frequent dropdown items`, () => {
- expect(findFrequentDropdownItems()).toHaveLength(length);
- });
- });
- });
-
- describe('Dropdown Text', () => {
- describe('when selectedItem is any', () => {
- beforeEach(() => {
- createComponent({}, {}, mount);
- });
-
- it('sets dropdown text to Any', () => {
- expect(findDropdownText().text()).toBe(ANY_OPTION.name);
- });
- });
-
- describe('selectedItem is set', () => {
- beforeEach(() => {
- createComponent({}, { selectedItem: MOCK_GROUP }, mount);
- });
-
- it('sets dropdown text to the selectedItem name', () => {
- expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.name]);
- });
- });
- });
- });
-
- describe('actions', () => {
- beforeEach(() => {
- createComponent({}, { items: MOCK_GROUPS, frequentItems: MOCK_GROUPS });
- });
-
- it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => {
- findAnyDropdownItem().vm.$emit('click');
-
- expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]);
- });
-
- it('on searchable item @change, the wrapper $emits change with the item', () => {
- findFirstSearchableDropdownItem().vm.$emit('change', MOCK_GROUPS[0]);
-
- expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
- });
-
- it('on frequent item @change, the wrapper $emits change with the item', () => {
- findFirstFrequentDropdownItem().vm.$emit('change', MOCK_GROUPS[0]);
-
- expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
- });
-
- describe('opening the dropdown', () => {
- beforeEach(() => {
- findGlDropdown().vm.$emit('show');
- });
-
- it('$emits @search and @first-open on the first open', () => {
- expect(wrapper.emitted('search')[0]).toStrictEqual(['']);
- expect(wrapper.emitted('first-open')[0]).toStrictEqual([]);
- });
-
- describe('when the dropdown has been opened', () => {
- it('$emits @search with the searchText', async () => {
- const searchText = 'foo';
-
- findGlDropdownSearch().vm.$emit('input', searchText);
- await nextTick();
-
- expect(wrapper.emitted('search')[1]).toStrictEqual([searchText]);
- expect(wrapper.emitted('first-open')).toHaveLength(1);
- });
-
- it('does not emit @first-open again', async () => {
- expect(wrapper.emitted('first-open')).toHaveLength(1);
-
- findGlDropdownSearch().vm.$emit('input');
- await nextTick();
-
- expect(wrapper.emitted('first-open')).toHaveLength(1);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js
index 364fe733a41..94d888bb067 100644
--- a/spec/frontend/security_configuration/components/app_spec.js
+++ b/spec/frontend/security_configuration/components/app_spec.js
@@ -5,10 +5,10 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
import stubChildren from 'helpers/stub_children';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue';
+import SecurityConfigurationApp from '~/security_configuration/components/app.vue';
import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue';
import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue';
-import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from '~/security_configuration/components/constants';
+import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from '~/security_configuration/constants';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
import { securityFeaturesMock, provideMock } from '../mock_data';
@@ -19,6 +19,8 @@ const { vulnerabilityTrainingDocsPath, projectFullPath } = provideMock;
useLocalStorageSpy();
Vue.use(VueApollo);
+const { i18n } = SecurityConfigurationApp;
+
describe('~/security_configuration/components/app', () => {
let wrapper;
let userCalloutDismissSpy;
diff --git a/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js b/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js
deleted file mode 100644
index 84a468e4dd8..00000000000
--- a/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlBadge, GlToggle } from '@gitlab/ui';
-import VueApollo from 'vue-apollo';
-import Vue from 'vue';
-import ProjectSetContinuousVulnerabilityScanning from '~/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql';
-import ContinuousVulnerabilityScan from '~/security_configuration/components/continuous_vulnerability_scan.vue';
-import createMockApollo from 'helpers/mock_apollo_helper';
-
-Vue.use(VueApollo);
-
-const setCVSMockResponse = {
- data: {
- projectSetContinuousVulnerabilityScanning: {
- continuousVulnerabilityScanningEnabled: true,
- errors: [],
- },
- },
-};
-
-const defaultProvide = {
- continuousVulnerabilityScansEnabled: true,
- projectFullPath: 'project/full/path',
-};
-
-describe('ContinuousVulnerabilityScan', () => {
- let wrapper;
- let apolloProvider;
- let requestHandlers;
-
- const createComponent = (options) => {
- requestHandlers = {
- setCVSMutationHandler: jest.fn().mockResolvedValue(setCVSMockResponse),
- };
-
- apolloProvider = createMockApollo([
- [ProjectSetContinuousVulnerabilityScanning, requestHandlers.setCVSMutationHandler],
- ]);
-
- wrapper = shallowMount(ContinuousVulnerabilityScan, {
- propsData: {
- feature: {
- available: true,
- configured: true,
- },
- },
- provide: {
- glFeatures: {
- dependencyScanningOnAdvisoryIngestion: true,
- },
- ...defaultProvide,
- },
- apolloProvider,
- ...options,
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- apolloProvider = null;
- });
-
- const findBadge = () => wrapper.findComponent(GlBadge);
- const findToggle = () => wrapper.findComponent(GlToggle);
-
- it('renders the component', () => {
- expect(wrapper.exists()).toBe(true);
- });
-
- it('renders the correct title', () => {
- expect(wrapper.text()).toContain('Continuous Vulnerability Scan');
- });
-
- it('renders the badge and toggle component with correct values', () => {
- expect(findBadge().exists()).toBe(true);
- expect(findBadge().text()).toBe('Experiment');
-
- expect(findToggle().exists()).toBe(true);
- expect(findToggle().props('value')).toBe(defaultProvide.continuousVulnerabilityScansEnabled);
- });
-
- it('should disable toggle when feature is not configured', () => {
- createComponent({
- propsData: {
- feature: {
- available: true,
- configured: false,
- },
- },
- });
- expect(findToggle().props('disabled')).toBe(true);
- });
-
- it('calls mutation on toggle change with correct payload', () => {
- findToggle().vm.$emit('change', true);
-
- expect(requestHandlers.setCVSMutationHandler).toHaveBeenCalledWith({
- input: {
- projectPath: 'project/full/path',
- enable: true,
- },
- });
- });
-
- describe('when feature flag is disabled', () => {
- beforeEach(() => {
- createComponent({
- provide: {
- glFeatures: {
- dependencyScanningOnAdvisoryIngestion: false,
- },
- ...defaultProvide,
- },
- });
- });
-
- it('should not render toggle and badge', () => {
- expect(findToggle().exists()).toBe(false);
- expect(findBadge().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js
index c715d01dd58..9efee2a409a 100644
--- a/spec/frontend/security_configuration/components/feature_card_spec.js
+++ b/spec/frontend/security_configuration/components/feature_card_spec.js
@@ -1,8 +1,7 @@
import { GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import Vue from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { securityFeatures } from '~/security_configuration/components/constants';
+import { securityFeatures } from '~/security_configuration/constants';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
@@ -14,10 +13,6 @@ import {
import { manageViaMRErrorMessage } from '../constants';
import { makeFeature } from './utils';
-const MockComponent = Vue.component('MockComponent', {
- render: (createElement) => createElement('span'),
-});
-
describe('FeatureCard component', () => {
let feature;
let wrapper;
@@ -394,17 +389,4 @@ describe('FeatureCard component', () => {
});
});
});
-
- describe('when a slot component is passed', () => {
- beforeEach(() => {
- feature = makeFeature({
- slotComponent: MockComponent,
- });
- createComponent({ feature });
- });
-
- it('renders the component properly', () => {
- expect(wrapper.findComponent(MockComponent).exists()).toBe(true);
- });
- });
});
diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js
index 5b2b3f46df6..ef20d8f56a4 100644
--- a/spec/frontend/security_configuration/components/training_provider_list_spec.js
+++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js
@@ -19,8 +19,8 @@ import {
TRACK_TOGGLE_TRAINING_PROVIDER_LABEL,
TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION,
TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL,
+ TEMP_PROVIDER_URLS,
} from '~/security_configuration/constants';
-import { TEMP_PROVIDER_URLS } from '~/security_configuration/components/constants';
import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
import { updateSecurityTrainingOptimisticResponse } from '~/security_configuration/graphql/cache_utils';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
@@ -61,10 +61,9 @@ const TEMP_PROVIDER_LOGOS = {
svg: '<svg>Secure Code Warrior</svg>',
},
};
-jest.mock('~/security_configuration/components/constants', () => {
+jest.mock('~/security_configuration/constants', () => {
return {
- TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/components/constants')
- .TEMP_PROVIDER_URLS,
+ TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/constants').TEMP_PROVIDER_URLS,
// NOTE: Jest hoists all mocks to the top so we can't use TEMP_PROVIDER_LOGOS
// here directly.
TEMP_PROVIDER_LOGOS: {
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
index df10d33e2f0..208256afdbd 100644
--- a/spec/frontend/security_configuration/mock_data.js
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -4,7 +4,7 @@ import {
SAST_DESCRIPTION,
SAST_HELP_PATH,
SAST_CONFIG_HELP_PATH,
-} from '~/security_configuration/components/constants';
+} from '~/security_configuration/constants';
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
export const testProjectPath = 'foo/bar';
diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js
index ea04e9e7993..3c6d4baa30f 100644
--- a/spec/frontend/security_configuration/utils_spec.js
+++ b/spec/frontend/security_configuration/utils_spec.js
@@ -1,5 +1,5 @@
import { augmentFeatures, translateScannerNames } from '~/security_configuration/utils';
-import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
+import { SCANNER_NAMES_MAP } from '~/security_configuration/constants';
describe('augmentFeatures', () => {
const mockSecurityFeatures = [
diff --git a/spec/frontend/set_status_modal/set_status_form_spec.js b/spec/frontend/set_status_modal/set_status_form_spec.js
index e24561a9862..5fcbecfa1dc 100644
--- a/spec/frontend/set_status_modal/set_status_form_spec.js
+++ b/spec/frontend/set_status_modal/set_status_form_spec.js
@@ -84,11 +84,11 @@ describe('SetStatusForm', () => {
it('displays time that status will clear', async () => {
await createComponent({
propsData: {
- currentClearStatusAfter: '2022-12-05 11:00:00 UTC',
+ currentClearStatusAfter: '2022-12-05T11:00:00Z',
},
});
- expect(wrapper.findByRole('button', { name: '11:00am' }).exists()).toBe(true);
+ expect(wrapper.findByRole('button', { name: '11:00 AM' }).exists()).toBe(true);
});
});
@@ -96,11 +96,13 @@ describe('SetStatusForm', () => {
it('displays date and time that status will clear', async () => {
await createComponent({
propsData: {
- currentClearStatusAfter: '2022-12-06 11:00:00 UTC',
+ currentClearStatusAfter: '2022-12-06T11:00:00Z',
},
});
- expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true);
+ expect(wrapper.findByRole('button', { name: 'Dec 6, 2022, 11:00 AM' }).exists()).toBe(
+ true,
+ );
});
});
@@ -110,11 +112,11 @@ describe('SetStatusForm', () => {
await createComponent({
propsData: {
clearStatusAfter: thirtyMinutes,
- currentClearStatusAfter: '2022-12-05 11:00:00 UTC',
+ currentClearStatusAfter: '2022-12-05T11:00:00Z',
},
});
- expect(wrapper.findByRole('button', { name: '12:30am' }).exists()).toBe(true);
+ expect(wrapper.findByRole('button', { name: '12:30 AM' }).exists()).toBe(true);
});
});
@@ -123,11 +125,11 @@ describe('SetStatusForm', () => {
await createComponent({
propsData: {
clearStatusAfter: oneDay,
- currentClearStatusAfter: '2022-12-06 11:00:00 UTC',
+ currentClearStatusAfter: '2022-12-06T11:00:00Z',
},
});
- expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 12:00am' }).exists()).toBe(
+ expect(wrapper.findByRole('button', { name: 'Dec 6, 2022, 12:00 AM' }).exists()).toBe(
true,
);
});
diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
index 9c79d564625..7ae2884170e 100644
--- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
+++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
@@ -131,12 +131,12 @@ describe('SetStatusModalWrapper', () => {
beforeEach(async () => {
await initEmojiMock();
- wrapper = createComponent({ currentClearStatusAfter: '2022-12-06 11:00:00 UTC' });
+ wrapper = createComponent({ currentClearStatusAfter: '2022-12-06T11:00:00Z' });
return initModal();
});
it('displays date and time that status will expire in dropdown toggle button', () => {
- expect(wrapper.findByRole('button', { name: 'Dec 6, 2022 11:00am' }).exists()).toBe(true);
+ expect(wrapper.findByRole('button', { name: 'Dec 6, 2022, 11:00 AM' }).exists()).toBe(true);
});
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js
index cd391765dde..bcef99afc46 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js
@@ -14,13 +14,14 @@ Vue.use(Vuex);
describe('DropdownContentsCreateView', () => {
let wrapper;
+ let store;
+
const colors = Object.keys(mockSuggestedColors).map((color) => ({
[color]: mockSuggestedColors[color],
}));
const createComponent = (initialState = mockConfig) => {
- const store = new Vuex.Store(labelSelectModule());
-
+ store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState);
wrapper = shallowMountExtended(DropdownContentsCreateView, {
@@ -47,7 +48,7 @@ describe('DropdownContentsCreateView', () => {
it('returns `true` when `labelCreateInProgress` is true', async () => {
await findColorSelectorInput().vm.$emit('input', '#ff0000');
await findLabelTitleInput().vm.$emit('input', 'Foo');
- wrapper.vm.$store.dispatch('requestCreateLabel');
+ store.dispatch('requestCreateLabel');
await nextTick();
@@ -81,7 +82,6 @@ describe('DropdownContentsCreateView', () => {
describe('getColorName', () => {
it('returns color name from color object', () => {
expect(findAllLinks().at(0).attributes('title')).toBe(Object.values(colors[0]).pop());
- expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop());
});
});
@@ -97,20 +97,17 @@ describe('DropdownContentsCreateView', () => {
describe('handleCreateClick', () => {
it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', async () => {
- jest.spyOn(wrapper.vm, 'createLabel').mockImplementation();
-
+ jest.spyOn(store, 'dispatch').mockImplementation();
await findColorSelectorInput().vm.$emit('input', '#ff0000');
await findLabelTitleInput().vm.$emit('input', 'Foo');
findCreateClickButton().vm.$emit('click');
await nextTick();
- expect(wrapper.vm.createLabel).toHaveBeenCalledWith(
- expect.objectContaining({
- title: 'Foo',
- color: '#ff0000',
- }),
- );
+ expect(store.dispatch).toHaveBeenCalledWith('createLabel', {
+ title: 'Foo',
+ color: '#ff0000',
+ });
});
});
});
@@ -186,7 +183,7 @@ describe('DropdownContentsCreateView', () => {
});
it('shows gl-loading-icon within create button element when `labelCreateInProgress` is `true`', async () => {
- wrapper.vm.$store.dispatch('requestCreateLabel');
+ store.dispatch('requestCreateLabel');
await nextTick();
const loadingIconEl = wrapper.find('.dropdown-actions').findComponent(GlLoadingIcon);
diff --git a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js
index c27afb75375..663bfbb48cc 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js
@@ -254,7 +254,7 @@ describe('LabelsSelect Actions', () => {
describe('updateLabelsSetState', () => {
it('updates labels `set` state to match `selectedLabels`', () => {
- testAction(
+ return testAction(
actions.updateLabelsSetState,
{},
state,
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js
index d70b989b493..21068c2858d 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js
@@ -3,14 +3,15 @@ import { shallowMount } from '@vue/test-utils';
import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue';
-import { mockRegularLabel, mockScopedLabel } from './mock_data';
+import { mockRegularLabel, mockScopedLabel, mockLockedLabel } from './mock_data';
describe('DropdownValue', () => {
let wrapper;
const findAllLabels = () => wrapper.findAllComponents(GlLabel);
- const findRegularLabel = () => findAllLabels().at(1);
+ const findRegularLabel = () => findAllLabels().at(2);
const findScopedLabel = () => findAllLabels().at(0);
+ const findLockedLabel = () => findAllLabels().at(1);
const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]');
const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]');
@@ -18,7 +19,7 @@ describe('DropdownValue', () => {
wrapper = shallowMount(DropdownValue, {
slots,
propsData: {
- selectedLabels: [mockRegularLabel, mockScopedLabel],
+ selectedLabels: [mockLockedLabel, mockRegularLabel, mockScopedLabel],
allowLabelRemove: true,
labelsFilterBasePath: '/gitlab-org/my-project/issues',
labelsFilterParam: 'label_name',
@@ -69,8 +70,8 @@ describe('DropdownValue', () => {
expect(findEmptyPlaceholder().exists()).toBe(false);
});
- it('renders a list of two labels', () => {
- expect(findAllLabels().length).toBe(2);
+ it('renders a list of three labels', () => {
+ expect(findAllLabels().length).toBe(3);
});
it('passes correct props to the regular label', () => {
@@ -96,5 +97,19 @@ describe('DropdownValue', () => {
wrapper.find('.sidebar-collapsed-icon').trigger('click');
expect(wrapper.emitted('onCollapsedValueClick')).toEqual([[]]);
});
+
+ it('does not show close button if label is locked', () => {
+ createComponent({
+ supportsLockOnMerge: true,
+ });
+ expect(findLockedLabel().props('showCloseButton')).toBe(false);
+ });
+
+ it('shows close button if label is not locked', () => {
+ createComponent({
+ supportsLockOnMerge: true,
+ });
+ expect(findRegularLabel().props('showCloseButton')).toBe(true);
+ });
});
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js
index 715dd4e034e..c516dddf0ce 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js
@@ -1,7 +1,7 @@
import { GlLabel } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import EmbeddedLabelsList from '~/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue';
-import { mockRegularLabel, mockScopedLabel } from './mock_data';
+import { mockRegularLabel, mockScopedLabel, mockLockedLabel } from './mock_data';
describe('EmbeddedLabelsList', () => {
let wrapper;
@@ -13,12 +13,13 @@ describe('EmbeddedLabelsList', () => {
.at(0);
const findRegularLabel = () => findLabelByTitle(mockRegularLabel.title);
const findScopedLabel = () => findLabelByTitle(mockScopedLabel.title);
+ const findLockedLabel = () => findLabelByTitle(mockLockedLabel.title);
const createComponent = (props = {}, slots = {}) => {
wrapper = shallowMountExtended(EmbeddedLabelsList, {
slots,
propsData: {
- selectedLabels: [mockRegularLabel, mockScopedLabel],
+ selectedLabels: [mockRegularLabel, mockScopedLabel, mockLockedLabel],
allowLabelRemove: true,
labelsFilterBasePath: '/gitlab-org/my-project/issues',
labelsFilterParam: 'label_name',
@@ -47,8 +48,8 @@ describe('EmbeddedLabelsList', () => {
createComponent();
});
- it('renders a list of two labels', () => {
- expect(findAllLabels()).toHaveLength(2);
+ it('renders a list of three labels', () => {
+ expect(findAllLabels()).toHaveLength(3);
});
it('passes correct props to the regular label', () => {
@@ -69,5 +70,12 @@ describe('EmbeddedLabelsList', () => {
findRegularLabel().vm.$emit('close');
expect(wrapper.emitted('onLabelRemove')).toStrictEqual([[mockRegularLabel.id]]);
});
+
+ it('does not show close button if label is locked', () => {
+ createComponent({
+ supportsLockOnMerge: true,
+ });
+ expect(findLockedLabel().props('showCloseButton')).toBe(false);
+ });
});
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js
index b0b473625bb..5039f00fe4b 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js
@@ -14,6 +14,16 @@ export const mockScopedLabel = {
textColor: '#FFFFFF',
};
+export const mockLockedLabel = {
+ id: 30,
+ title: 'Bar Label',
+ description: 'Bar',
+ color: '#DADA55',
+ textColor: '#FFFFFF',
+ lockOnMerge: true,
+ lock_on_merge: true,
+};
+
export const mockLabels = [
mockRegularLabel,
mockScopedLabel,
diff --git a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js
index a221d28704b..ae31e60254f 100644
--- a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import VueApollo from 'vue-apollo';
@@ -8,8 +8,11 @@ import SidebarReviewers from '~/sidebar/components/reviewers/sidebar_reviewers.v
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
+import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch';
import Mock from '../../mock_data';
+jest.mock('~/super_sidebar/user_counts_fetch');
+
Vue.use(VueApollo);
describe('sidebar reviewers', () => {
@@ -39,7 +42,7 @@ describe('sidebar reviewers', () => {
axiosMock = new AxiosMockAdapter(axios);
mediator = new SidebarMediator(Mock.mediator);
- jest.spyOn(mediator, 'saveReviewers');
+ jest.spyOn(mediator, 'saveReviewers').mockResolvedValue({});
jest.spyOn(mediator, 'addSelfReview');
});
@@ -60,6 +63,17 @@ describe('sidebar reviewers', () => {
expect(mediator.saveReviewers).toHaveBeenCalled();
});
+ it('re-fetches user counts after saving reviewers', async () => {
+ createComponent();
+
+ expect(fetchUserCounts).not.toHaveBeenCalled();
+
+ wrapper.vm.saveReviewers();
+ await nextTick();
+
+ expect(fetchUserCounts).toHaveBeenCalled();
+ });
+
it('calls the mediator when "reviewBySelf" method is called', () => {
createComponent();
diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
index 66bc1f393ae..d7a5e4ba3ba 100644
--- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
@@ -4,7 +4,7 @@ import { TEST_HOST } from 'helpers/test_constants';
import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue';
import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue';
-const userDataMock = ({ approved = false } = {}) => ({
+const userDataMock = ({ approved = false, reviewState = 'UNREVIEWED' } = {}) => ({
id: 1,
name: 'Root',
state: 'active',
@@ -16,6 +16,7 @@ const userDataMock = ({ approved = false } = {}) => ({
canUpdate: true,
reviewed: true,
approved,
+ reviewState,
},
});
@@ -204,4 +205,28 @@ describe('UncollapsedReviewerList component', () => {
);
});
});
+
+ describe('reviewer state icons', () => {
+ it.each`
+ reviewState | approved | icon
+ ${'UNREVIEWED'} | ${false} | ${'dotted-circle'}
+ ${'REVIEWED'} | ${true} | ${'status-success'}
+ ${'REVIEWED'} | ${false} | ${'comment'}
+ ${'REQUESTED_CHANGES'} | ${false} | ${'status-alert'}
+ `(
+ 'renders $icon for reviewState:$reviewState and approved:$approved',
+ ({ reviewState, approved, icon }) => {
+ const user = userDataMock({ approved, reviewState });
+
+ createComponent(
+ {
+ users: [user],
+ },
+ { mrRequestChanges: true },
+ );
+
+ expect(wrapper.find('[data-testid="reviewer-state-icon"]').props('name')).toBe(icon);
+ },
+ );
+ });
});
diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
index c1c3c1fea91..f3709e67037 100644
--- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
+++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
@@ -1,11 +1,11 @@
import { GlLink, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { extendedWrapper } 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 { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -97,59 +97,56 @@ describe('SidebarDropdownWidget', () => {
...requestHandlers,
]);
- wrapper = extendedWrapper(
- mount(SidebarDropdownWidget, {
- provide: { canUpdate: true },
- apolloProvider: mockApollo,
- propsData: {
- workspacePath: mockIssue.projectPath,
- attrWorkspacePath: mockIssue.projectPath,
- iid: mockIssue.iid,
- issuableType: TYPE_ISSUE,
- issuableAttribute: IssuableAttributeType.Milestone,
- },
- attachTo: document.body,
- }),
- );
+ wrapper = mountExtended(SidebarDropdownWidget, {
+ provide: { canUpdate: true },
+ apolloProvider: mockApollo,
+ propsData: {
+ workspacePath: mockIssue.projectPath,
+ attrWorkspacePath: mockIssue.projectPath,
+ iid: mockIssue.iid,
+ issuableType: TYPE_ISSUE,
+ issuableAttribute: IssuableAttributeType.Milestone,
+ },
+ attachTo: document.body,
+ });
await waitForApollo();
};
const createComponent = ({ data = {}, mutationPromise = mutationSuccess, queries = {} } = {}) => {
- wrapper = extendedWrapper(
- shallowMount(SidebarDropdownWidget, {
- provide: { canUpdate: true },
- data() {
- return data;
- },
- propsData: {
- workspacePath: '',
- attrWorkspacePath: '',
- iid: '',
- issuableType: TYPE_ISSUE,
- issuableAttribute: IssuableAttributeType.Milestone,
- },
- mocks: {
- $apollo: {
- mutate: mutationPromise(),
- queries: {
- issuable: { loading: false },
- attributesList: { loading: false },
- ...queries,
- },
+ wrapper = shallowMountExtended(SidebarDropdownWidget, {
+ provide: { canUpdate: true },
+ data() {
+ return data;
+ },
+ propsData: {
+ workspacePath: '',
+ attrWorkspacePath: '',
+ iid: '',
+ issuableType: TYPE_ISSUE,
+ issuableAttribute: IssuableAttributeType.Milestone,
+ },
+ mocks: {
+ $apollo: {
+ mutate: mutationPromise(),
+ queries: {
+ issuable: { loading: false },
+ attributesList: { loading: false },
+ ...queries,
},
},
- directives: {
- GlTooltip: createMockDirective('gl-tooltip'),
- },
- stubs: {
- SidebarEditableItem,
- GlSearchBoxByType,
- },
- }),
- );
-
- wrapper.vm.$refs.dropdown.show = jest.fn();
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ stubs: {
+ SidebarEditableItem,
+ GlSearchBoxByType,
+ SidebarDropdown: stubComponent(SidebarDropdown, {
+ methods: { show: jest.fn() },
+ }),
+ },
+ });
};
describe('when not editing', () => {
diff --git a/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js b/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js
index 657fb52d62c..37d7b3b6781 100644
--- a/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js
+++ b/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js
@@ -6,6 +6,7 @@ import setIssueTimeEstimateWithoutErrors from 'test_fixtures/graphql/issue_set_t
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
import SetTimeEstimateForm from '~/sidebar/components/time_tracking/set_time_estimate_form.vue';
import issueSetTimeEstimateMutation from '~/sidebar/queries/issue_set_time_estimate.mutation.graphql';
@@ -75,10 +76,13 @@ describe('Set Time Estimate Form', () => {
timeTracking,
},
apolloProvider: createMockApollo([[issueSetTimeEstimateMutation, mutationResolverMock]]),
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ methods: { close: modalCloseMock },
+ }),
+ },
});
- wrapper.vm.$refs.modal.close = modalCloseMock;
-
findModal().vm.$emit('show');
await nextTick();
};
diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js
index 9c12088216b..4dc285fc3c8 100644
--- a/spec/frontend/sidebar/sidebar_mediator_spec.js
+++ b/spec/frontend/sidebar/sidebar_mediator_spec.js
@@ -9,7 +9,6 @@ import Mock from './mock_data';
jest.mock('~/alert');
jest.mock('~/vue_shared/plugins/global_toast');
-jest.mock('~/commons/nav/user_merge_requests');
describe('Sidebar mediator', () => {
const { mediator: mediatorMockData } = Mock;
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
index 92511acc4f8..4a42b7168a3 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
@@ -22,7 +22,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
/>
</div>
<div
- class="gfm-form gl-overflow-hidden js-expanded js-vue-markdown-field md-area position-relative"
+ class="gfm-form js-expanded js-vue-markdown-field md-area position-relative"
data-uploads-path=""
>
<markdown-header-stub
diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js
index b699e056576..53993921621 100644
--- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js
@@ -102,10 +102,20 @@ describe('Snippet Blob Edit component', () => {
describe('with unloaded blob and JSON content', () => {
beforeEach(() => {
+ jest.spyOn(axios, 'get');
axiosMock.onGet(TEST_FULL_PATH).reply(HTTP_STATUS_OK, TEST_JSON_CONTENT);
createComponent();
});
+ it('makes an API request for the blob content', () => {
+ const expectedConfig = {
+ transformResponse: [expect.any(Function)],
+ headers: { 'Cache-Control': 'no-cache' },
+ };
+
+ expect(axios.get).toHaveBeenCalledWith(TEST_FULL_PATH, expectedConfig);
+ });
+
// This checks against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/241199
it('emits raw content', async () => {
await waitForPromises();
diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js
index 0a3b57c9244..9e6a30885d4 100644
--- a/spec/frontend/snippets/components/snippet_title_spec.js
+++ b/spec/frontend/snippets/components/snippet_title_spec.js
@@ -1,71 +1,104 @@
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import SnippetTitle from '~/snippets/components/snippet_title.vue';
-describe('Snippet header component', () => {
+describe('Snippet title component', () => {
let wrapper;
const title = 'The property of Thor';
const description = 'Do not touch this hammer';
const descriptionHtml = `<h2>${description}</h2>`;
- const snippet = {
- snippet: {
- title,
- description,
- descriptionHtml,
- },
- };
-
- function createComponent({ props = snippet } = {}) {
- const defaultProps = { ...props };
+ function createComponent({ propsData = {} } = {}) {
wrapper = shallowMount(SnippetTitle, {
propsData: {
- ...defaultProps,
+ snippet: {
+ title,
+ description,
+ descriptionHtml,
+ },
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
},
});
}
- it('renders itself', () => {
- createComponent();
- expect(wrapper.find('.snippet-header').exists()).toBe(true);
- });
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findTooltip = () => getBinding(findIcon().element, 'gl-tooltip');
- it('renders snippets title and description', () => {
- createComponent();
+ describe('default state', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- expect(wrapper.text().trim()).toContain(title);
- expect(wrapper.findComponent(SnippetDescription).props('description')).toBe(descriptionHtml);
- });
+ it('renders itself', () => {
+ expect(wrapper.find('.snippet-header').exists()).toBe(true);
+ });
- it('does not render recent changes time stamp if there were no updates', () => {
- createComponent();
- expect(wrapper.findComponent(GlSprintf).exists()).toBe(false);
- });
+ it('does not render spam icon when author is not banned', () => {
+ expect(findIcon().exists()).toBe(false);
+ });
- it('does not render recent changes time stamp if the time for creation and updates match', () => {
- const props = Object.assign(snippet, {
- snippet: {
- ...snippet.snippet,
- createdAt: '2019-12-16T21:45:36Z',
- updatedAt: '2019-12-16T21:45:36Z',
- },
+ it('renders snippets title and description', () => {
+ expect(wrapper.text().trim()).toContain(title);
+ expect(wrapper.findComponent(SnippetDescription).props('description')).toBe(descriptionHtml);
});
- createComponent({ props });
- expect(wrapper.findComponent(GlSprintf).exists()).toBe(false);
- });
+ it('does not render recent changes time stamp if there were no updates', () => {
+ expect(wrapper.findComponent(GlSprintf).exists()).toBe(false);
+ });
- it('renders translated string with most recent changes timestamp if changes were made', () => {
- const props = Object.assign(snippet, {
- snippet: {
- ...snippet.snippet,
- createdAt: '2019-12-16T21:45:36Z',
- updatedAt: '2019-15-16T21:45:36Z',
- },
+ it('does not render recent changes time stamp if the time for creation and updates match', () => {
+ createComponent({
+ propsData: {
+ snippet: {
+ createdAt: '2019-12-16T21:45:36Z',
+ updatedAt: '2019-12-16T21:45:36Z',
+ },
+ },
+ });
+
+ expect(wrapper.findComponent(GlSprintf).exists()).toBe(false);
+ });
+
+ it('renders translated string with most recent changes timestamp if changes were made', () => {
+ createComponent({
+ propsData: {
+ snippet: {
+ createdAt: '2019-12-16T21:45:36Z',
+ updatedAt: '2019-15-16T21:45:36Z',
+ },
+ },
+ });
+
+ expect(wrapper.findComponent(GlSprintf).exists()).toBe(true);
});
- createComponent({ props });
+ });
+
+ describe('when author is snippet is banned', () => {
+ it('renders spam icon and tooltip when author is banned', () => {
+ createComponent({
+ propsData: {
+ snippet: {
+ hidden: true,
+ },
+ },
+ });
+
+ expect(findIcon().props()).toMatchObject({
+ ariaLabel: 'Hidden',
+ name: 'spam',
+ size: 16,
+ });
- expect(wrapper.findComponent(GlSprintf).exists()).toBe(true);
+ expect(findIcon().attributes('title')).toBe(
+ 'This snippet is hidden because its author has been banned',
+ );
+
+ expect(findTooltip()).toBeDefined();
+ });
});
});
diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js
index 76b03c0aa0d..9d42e9fa26c 100644
--- a/spec/frontend/snippets/test_utils.js
+++ b/spec/frontend/snippets/test_utils.js
@@ -45,6 +45,7 @@ export const createGQLSnippet = () => ({
message: '',
},
},
+ hidden: false,
});
export const createGQLSnippetsQueryResponse = (snippets) => ({
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js
index e63768a03c0..38e1baabf41 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js
@@ -1,14 +1,32 @@
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue';
import FrequentGroups from '~/super_sidebar/components/global_search/components/frequent_groups.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import currentUserFrecentGroupsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
+import { frecentGroupsMock } from '../../../mock_data';
+
+Vue.use(VueApollo);
describe('FrequentlyVisitedGroups', () => {
let wrapper;
const groupsPath = '/mock/group/path';
+ const currentUserFrecentGroupsQueryHandler = jest.fn().mockResolvedValue({
+ data: {
+ frecentGroups: frecentGroupsMock,
+ },
+ });
const createComponent = (options) => {
+ const mockApollo = createMockApollo([
+ [currentUserFrecentGroupsQuery, currentUserFrecentGroupsQueryHandler],
+ ]);
+
wrapper = shallowMount(FrequentGroups, {
+ apolloProvider: mockApollo,
provide: {
groupsPath,
},
@@ -28,19 +46,25 @@ describe('FrequentlyVisitedGroups', () => {
expect(findFrequentItems().props()).toMatchObject({
emptyStateText: 'Groups you visit often will appear here.',
groupName: 'Frequently visited groups',
- maxItems: 3,
- storageKey: null,
viewAllItemsIcon: 'group',
viewAllItemsText: 'View all my groups',
viewAllItemsPath: groupsPath,
});
});
- it('with a user, passes a storage key string to FrequentItems', () => {
- gon.current_username = 'test_user';
+ it('loads frecent groups', () => {
+ createComponent();
+
+ expect(currentUserFrecentGroupsQueryHandler).toHaveBeenCalled();
+ expect(findFrequentItems().props('loading')).toBe(true);
+ });
+
+ it('passes fetched groups to FrequentItems', async () => {
createComponent();
+ await waitForPromises();
- expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-groups');
+ expect(findFrequentItems().props('items')).toEqual(frecentGroupsMock);
+ expect(findFrequentItems().props('loading')).toBe(false);
});
it('passes attrs to FrequentItems', () => {
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js
index aae1fc543f9..b48a9ca6457 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js
@@ -28,7 +28,6 @@ describe('FrequentlyVisitedItem', () => {
};
const findProjectAvatar = () => wrapper.findComponent(ProjectAvatar);
- const findRemoveButton = () => wrapper.findByRole('button');
const findSubtitle = () => wrapper.findByTestId('subtitle');
beforeEach(() => {
@@ -53,46 +52,4 @@ describe('FrequentlyVisitedItem', () => {
await wrapper.setProps({ item: { ...mockItem, subtitle: null } });
expect(findSubtitle().exists()).toBe(false);
});
-
- describe('clicking the remove button', () => {
- const bubbledClickSpy = jest.fn();
- const clickSpy = jest.fn();
-
- beforeEach(() => {
- wrapper.element.addEventListener('click', bubbledClickSpy);
- const button = findRemoveButton();
- button.element.addEventListener('click', clickSpy);
- button.trigger('click');
- });
-
- it('emits a remove event on clicking the remove button', () => {
- expect(wrapper.emitted('remove')).toEqual([[mockItem]]);
- });
-
- it('stops the native event from bubbling and prevents its default behavior', () => {
- expect(bubbledClickSpy).not.toHaveBeenCalled();
- expect(clickSpy.mock.calls[0][0].defaultPrevented).toBe(true);
- });
- });
-
- describe('pressing enter on the remove button', () => {
- const bubbledKeydownSpy = jest.fn();
- const keydownSpy = jest.fn();
-
- beforeEach(() => {
- wrapper.element.addEventListener('keydown', bubbledKeydownSpy);
- const button = findRemoveButton();
- button.element.addEventListener('keydown', keydownSpy);
- button.trigger('keydown.enter');
- });
-
- it('emits a remove event on clicking the remove button', () => {
- expect(wrapper.emitted('remove')).toEqual([[mockItem]]);
- });
-
- it('stops the native event from bubbling and prevents its default behavior', () => {
- expect(bubbledKeydownSpy).not.toHaveBeenCalled();
- expect(keydownSpy.mock.calls[0][0].defaultPrevented).toBe(true);
- });
- });
});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js
index 4700e9c7e10..7876dd92701 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js
@@ -2,28 +2,14 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gi
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GlobalSearchFrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue';
import FrequentItem from '~/super_sidebar/components/global_search/components/frequent_item.vue';
-import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils';
-import { cachedFrequentProjects } from 'jest/super_sidebar/mock_data';
-
-jest.mock('~/super_sidebar/utils', () => {
- const original = jest.requireActual('~/super_sidebar/utils');
-
- return {
- ...original,
- getItemsFromLocalStorage: jest.fn(),
- removeItemFromLocalStorage: jest.fn(),
- };
-});
+import FrequentItemSkeleton from '~/super_sidebar/components/global_search/components/frequent_item_skeleton.vue';
+import { frecentGroupsMock } from 'jest/super_sidebar/mock_data';
describe('FrequentlyVisitedItems', () => {
let wrapper;
- const storageKey = 'mockStorageKey';
- const mockStoredItems = JSON.parse(cachedFrequentProjects);
const mockProps = {
emptyStateText: 'mock empty state text',
groupName: 'mock group name',
- maxItems: 42,
- storageKey,
viewAllItemsText: 'View all items',
viewAllItemsIcon: 'question-o',
viewAllItemsPath: '/mock/all_items',
@@ -42,118 +28,97 @@ describe('FrequentlyVisitedItems', () => {
};
const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem);
+ const findSkeleton = () => wrapper.findComponent(FrequentItemSkeleton);
const findItemRenderer = (root) => root.findComponent(FrequentItem);
- const setStoredItems = (items) => {
- getItemsFromLocalStorage.mockReturnValue(items);
- };
+ describe('common behavior', () => {
+ beforeEach(() => {
+ createComponent({
+ items: frecentGroupsMock,
+ });
+ });
- beforeEach(() => {
- setStoredItems(mockStoredItems);
+ it('renders the group name', () => {
+ expect(wrapper.text()).toContain(mockProps.groupName);
+ });
+
+ it('renders the view all items link', () => {
+ const lastItem = findItems().at(-1);
+ expect(lastItem.props('item')).toMatchObject({
+ text: mockProps.viewAllItemsText,
+ href: mockProps.viewAllItemsPath,
+ });
+
+ const icon = lastItem.findComponent(GlIcon);
+ expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon);
+ });
});
- describe('without a storage key', () => {
+ describe('while items are being fetched', () => {
beforeEach(() => {
- createComponent({ storageKey: null });
+ createComponent({
+ loading: true,
+ });
});
- it('does not render anything', () => {
- expect(wrapper.html()).toBe('');
+ it('shows the loading state', () => {
+ expect(findSkeleton().exists()).toBe(true);
});
- it('emits a nothing-to-render event', () => {
- expect(wrapper.emitted('nothing-to-render')).toEqual([[]]);
+ it('does not show the empty state', () => {
+ expect(wrapper.text()).not.toContain(mockProps.emptyStateText);
});
});
- describe('with a storageKey', () => {
+ describe('when there are no items', () => {
beforeEach(() => {
createComponent();
});
- describe('common behavior', () => {
- it('calls getItemsFromLocalStorage', () => {
- expect(getItemsFromLocalStorage).toHaveBeenCalledWith({
- storageKey,
- maxItems: mockProps.maxItems,
- });
- });
-
- it('renders the group name', () => {
- expect(wrapper.text()).toContain(mockProps.groupName);
- });
-
- it('renders the view all items link', () => {
- const lastItem = findItems().at(-1);
- expect(lastItem.props('item')).toMatchObject({
- text: mockProps.viewAllItemsText,
- href: mockProps.viewAllItemsPath,
- });
-
- const icon = lastItem.findComponent(GlIcon);
- expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon);
- });
+ it('does not show the loading state', () => {
+ expect(findSkeleton().exists()).toBe(false);
});
- describe('with stored items', () => {
- it('renders the items', () => {
- const items = findItems();
-
- mockStoredItems.forEach((storedItem, index) => {
- const dropdownItem = items.at(index);
-
- // Check GlDisclosureDropdownItem's item has the right structure
- expect(dropdownItem.props('item')).toMatchObject({
- text: storedItem.name,
- href: storedItem.webUrl,
- });
-
- // Check FrequentItem's item has the right structure
- expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({
- id: storedItem.id,
- title: storedItem.name,
- subtitle: expect.any(String),
- avatar: storedItem.avatarUrl,
- });
- });
- });
+ it('shows the empty state', () => {
+ expect(wrapper.text()).toContain(mockProps.emptyStateText);
+ });
+ });
- it('does not render the empty state text', () => {
- expect(wrapper.text()).not.toContain('mock empty state text');
+ describe('when there are items', () => {
+ beforeEach(() => {
+ createComponent({
+ items: frecentGroupsMock,
});
+ });
- describe('removing an item', () => {
- let itemToRemove;
+ it('renders the items', () => {
+ const items = findItems();
- beforeEach(() => {
- const itemRenderer = findItemRenderer(findItems().at(0));
- itemToRemove = itemRenderer.props('item');
- itemRenderer.vm.$emit('remove', itemToRemove);
- });
+ frecentGroupsMock.forEach((item, index) => {
+ const dropdownItem = items.at(index);
- it('calls removeItemFromLocalStorage when an item emits a remove event', () => {
- expect(removeItemFromLocalStorage).toHaveBeenCalledWith({
- storageKey,
- item: itemToRemove,
- });
+ // Check GlDisclosureDropdownItem's item has the right structure
+ expect(dropdownItem.props('item')).toMatchObject({
+ text: item.name,
+ href: item.webUrl,
});
- it('no longer renders that item', () => {
- const renderedItemTexts = findItems().wrappers.map((item) => item.props('item').text);
- expect(renderedItemTexts).not.toContain(itemToRemove.text);
+ // Check FrequentItem's item has the right structure
+ expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({
+ id: item.id,
+ title: item.name,
+ subtitle: expect.any(String),
+ avatar: item.avatarUrl,
});
});
});
- });
- describe('with no stored items', () => {
- beforeEach(() => {
- setStoredItems([]);
- createComponent();
+ it('does not show the loading state', () => {
+ expect(findSkeleton().exists()).toBe(false);
});
- it('renders the empty state text', () => {
- expect(wrapper.text()).toContain(mockProps.emptyStateText);
+ it('does not show the empty state', () => {
+ expect(wrapper.text()).not.toContain(mockProps.emptyStateText);
});
});
});
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js
index 7554c123574..b7123f295f7 100644
--- a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js
+++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js
@@ -1,14 +1,32 @@
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue';
import FrequentProjects from '~/super_sidebar/components/global_search/components/frequent_projects.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import currentUserFrecentProjectsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
+import { frecentProjectsMock } from '../../../mock_data';
+
+Vue.use(VueApollo);
describe('FrequentlyVisitedProjects', () => {
let wrapper;
const projectsPath = '/mock/project/path';
+ const currentUserFrecentProjectsQueryHandler = jest.fn().mockResolvedValue({
+ data: {
+ frecentProjects: frecentProjectsMock,
+ },
+ });
const createComponent = (options) => {
+ const mockApollo = createMockApollo([
+ [currentUserFrecentProjectsQuery, currentUserFrecentProjectsQueryHandler],
+ ]);
+
wrapper = shallowMount(FrequentProjects, {
+ apolloProvider: mockApollo,
provide: {
projectsPath,
},
@@ -28,19 +46,25 @@ describe('FrequentlyVisitedProjects', () => {
expect(findFrequentItems().props()).toMatchObject({
emptyStateText: 'Projects you visit often will appear here.',
groupName: 'Frequently visited projects',
- maxItems: 5,
- storageKey: null,
viewAllItemsIcon: 'project',
viewAllItemsText: 'View all my projects',
viewAllItemsPath: projectsPath,
});
});
- it('with a user, passes a storage key string to FrequentItems', () => {
- gon.current_username = 'test_user';
+ it('loads frecent projects', () => {
+ createComponent();
+
+ expect(currentUserFrecentProjectsQueryHandler).toHaveBeenCalled();
+ expect(findFrequentItems().props('loading')).toBe(true);
+ });
+
+ it('passes fetched projects to FrequentItems', async () => {
createComponent();
+ await waitForPromises();
- expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-projects');
+ expect(findFrequentItems().props('items')).toEqual(frecentProjectsMock);
+ expect(findFrequentItems().props('loading')).toBe(false);
});
it('passes attrs to FrequentItems', () => {
diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js
index 39537b65fa5..8e9e3e8ba20 100644
--- a/spec/frontend/super_sidebar/components/help_center_spec.js
+++ b/spec/frontend/super_sidebar/components/help_center_spec.js
@@ -94,7 +94,6 @@ describe('HelpCenter component', () => {
it('passes custom offset to the dropdown', () => {
expect(findDropdown().props('dropdownOffset')).toEqual({
- crossAxis: -4,
mainAxis: 4,
});
});
@@ -169,14 +168,13 @@ describe('HelpCenter component', () => {
describe('showWhatsNew', () => {
beforeEach(() => {
- beforeEach(() => {
- createWrapper({ ...sidebarData, show_version_check: true });
- });
+ createWrapper({ ...sidebarData, show_version_check: true });
+
findButton("What's new 5").click();
});
it('shows the "What\'s new" slideout', () => {
- expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(expect.any(Object));
+ expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(sidebarData.whats_new_version_digest);
});
it('shows the existing "What\'s new" slideout instance on subsequent clicks', () => {
diff --git a/spec/frontend/super_sidebar/components/nav_item_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_link_spec.js
index 5cc1bd01d0f..59fa6d022ae 100644
--- a/spec/frontend/super_sidebar/components/nav_item_link_spec.js
+++ b/spec/frontend/super_sidebar/components/nav_item_link_spec.js
@@ -29,7 +29,7 @@ describe('NavItemLink component', () => {
expect(wrapper.attributes()).toEqual({
href: '/foo',
- class: 'gl-bg-t-gray-a-08',
+ class: 'super-sidebar-nav-item-current',
'aria-current': 'page',
});
});
diff --git a/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js
index a7ca56325fe..dfae5e96cd8 100644
--- a/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js
+++ b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js
@@ -45,7 +45,9 @@ describe('NavItemRouterLink component', () => {
routerLinkSlotProps: { isActive: true },
});
- expect(wrapper.findComponent(RouterLinkStub).props('activeClass')).toBe('gl-bg-t-gray-a-08');
+ expect(wrapper.findComponent(RouterLinkStub).props('activeClass')).toBe(
+ 'super-sidebar-nav-item-current',
+ );
expect(wrapper.attributes()).toEqual({
href: '/foo',
'aria-current': 'page',
diff --git a/spec/frontend/super_sidebar/components/scroll_scrim_spec.js b/spec/frontend/super_sidebar/components/scroll_scrim_spec.js
new file mode 100644
index 00000000000..ff1e9968f9b
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/scroll_scrim_spec.js
@@ -0,0 +1,60 @@
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ScrollScrim from '~/super_sidebar/components/scroll_scrim.vue';
+import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
+
+describe('ScrollScrim', () => {
+ let wrapper;
+ const { trigger: triggerIntersection } = useMockIntersectionObserver();
+
+ const createWrapper = () => {
+ wrapper = shallowMountExtended(ScrollScrim, {});
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ const findTopBoundary = () => wrapper.vm.$refs['top-boundary'];
+ const findBottomBoundary = () => wrapper.vm.$refs['bottom-boundary'];
+
+ describe('top scrim', () => {
+ describe('when top boundary is visible', () => {
+ it('does not show', async () => {
+ triggerIntersection(findTopBoundary(), { entry: { isIntersecting: true } });
+ await nextTick();
+
+ expect(wrapper.classes()).not.toContain('top-scrim-visible');
+ });
+ });
+
+ describe('when top boundary is not visible', () => {
+ it('does show', async () => {
+ triggerIntersection(findTopBoundary(), { entry: { isIntersecting: false } });
+ await nextTick();
+
+ expect(wrapper.classes()).toContain('top-scrim-visible');
+ });
+ });
+ });
+
+ describe('bottom scrim', () => {
+ describe('when bottom boundary is visible', () => {
+ it('does not show', async () => {
+ triggerIntersection(findBottomBoundary(), { entry: { isIntersecting: true } });
+ await nextTick();
+
+ expect(wrapper.classes()).not.toContain('bottom-scrim-visible');
+ });
+ });
+
+ describe('when bottom boundary is not visible', () => {
+ it('does show', async () => {
+ triggerIntersection(findBottomBoundary(), { entry: { isIntersecting: false } });
+ await nextTick();
+
+ expect(wrapper.classes()).toContain('bottom-scrim-visible');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
index 92736b99e14..9718cb7ad15 100644
--- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js
+++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
@@ -1,4 +1,7 @@
import { nextTick } from 'vue';
+import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
+import sidebarEventHub from '~/super_sidebar/event_hub';
+import ExtraInfo from 'jh_else_ce/super_sidebar/components/extra_info.vue';
import { Mousetrap } from '~/lib/mousetrap';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue';
@@ -23,6 +26,7 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { trackContextAccess } from '~/super_sidebar/utils';
import { sidebarData as mockSidebarData, loggedOutSidebarData } from '../mock_data';
+const { lg, xl } = breakpoints;
const initialSidebarState = { ...sidebarState };
jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager');
@@ -56,6 +60,8 @@ describe('SuperSidebar component', () => {
const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId);
const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId);
const findSidebarMenu = () => wrapper.findComponent(SidebarMenu);
+ const findAdminLink = () => wrapper.findByTestId('sidebar-admin-link');
+ const findContextHeader = () => wrapper.findComponent('#super-sidebar-context-header');
let trackingSpy = null;
const createWrapper = ({
@@ -128,6 +134,11 @@ describe('SuperSidebar component', () => {
expect(findHelpCenter().props('sidebarData')).toBe(mockSidebarData);
});
+ it('renders extra info section', () => {
+ createWrapper();
+ expect(wrapper.findComponent(ExtraInfo).exists()).toBe(true);
+ });
+
it('does not render SidebarMenu when items are empty', () => {
createWrapper();
expect(findSidebarMenu().exists()).toBe(false);
@@ -207,6 +218,15 @@ describe('SuperSidebar component', () => {
expect(wrapper.text()).toContain('Your work');
});
+ it('handles event toggle-menu-header correctly', async () => {
+ createWrapper();
+
+ sidebarEventHub.$emit('toggle-menu-header', false);
+
+ await nextTick();
+ expect(findContextHeader().exists()).toBe(false);
+ });
+
describe('item access tracking', () => {
it('does not track anything if logged out', () => {
createWrapper({ sidebarData: loggedOutSidebarData });
@@ -299,8 +319,8 @@ describe('SuperSidebar component', () => {
createWrapper();
});
- it('allows overflow', () => {
- expect(findNavContainer().classes()).toContain('gl-overflow-auto');
+ it('allows overflow with scroll scrim', () => {
+ expect(findNavContainer().element.tagName).toContain('SCROLL-SCRIM');
});
});
@@ -314,4 +334,46 @@ describe('SuperSidebar component', () => {
expect(findTrialStatusPopover().exists()).toBe(true);
});
});
+
+ describe('keyboard interactivity', () => {
+ it('does not bind keydown events on screens xl and above', async () => {
+ jest.spyOn(document, 'addEventListener');
+ jest.spyOn(bp, 'windowWidth').mockReturnValue(xl);
+ createWrapper();
+
+ isCollapsed.mockReturnValue(false);
+ await nextTick();
+
+ expect(document.addEventListener).not.toHaveBeenCalled();
+ });
+
+ it('binds keydown events on screens below xl', () => {
+ jest.spyOn(document, 'addEventListener');
+ jest.spyOn(bp, 'windowWidth').mockReturnValue(lg);
+ createWrapper();
+
+ expect(document.addEventListener).toHaveBeenCalledWith('keydown', wrapper.vm.focusTrap);
+ });
+ });
+
+ describe('link to Admin area', () => {
+ describe('when user is admin', () => {
+ it('renders', () => {
+ createWrapper({
+ sidebarData: {
+ ...mockSidebarData,
+ is_admin: true,
+ },
+ });
+ expect(findAdminLink().attributes('href')).toBe(mockSidebarData.admin_url);
+ });
+ });
+
+ describe('when user is not admin', () => {
+ it('renders', () => {
+ createWrapper();
+ expect(findAdminLink().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js
index 45a60fce00a..4af3247693b 100644
--- a/spec/frontend/super_sidebar/components/user_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/user_menu_spec.js
@@ -1,8 +1,10 @@
import { GlAvatar, GlDisclosureDropdown } from '@gitlab/ui';
+import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import UserMenu from '~/super_sidebar/components/user_menu.vue';
import UserMenuProfileItem from '~/super_sidebar/components/user_menu_profile_item.vue';
+import SetStatusModal from '~/set_status_modal/set_status_modal_wrapper.vue';
import { mockTracking } from 'helpers/tracking_helper';
import PersistentUserCallout from '~/persistent_user_callout';
import { userMenuMockData, userMenuMockStatus, userMenuMockPipelineMinutes } from '../mock_data';
@@ -13,6 +15,7 @@ describe('UserMenu component', () => {
const GlEmoji = { template: '<img/>' };
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findSetStatusModal = () => wrapper.findComponent(SetStatusModal);
const showDropdown = () => findDropdown().vm.$emit('shown');
const closeDropdownSpy = jest.fn();
@@ -28,6 +31,7 @@ describe('UserMenu component', () => {
stubs: {
GlEmoji,
GlAvatar: true,
+ SetStatusModal: stubComponent(SetStatusModal),
...stubs,
},
provide: {
@@ -74,6 +78,20 @@ describe('UserMenu component', () => {
});
});
+ it('updates avatar url on custom avatar update event', async () => {
+ const url = `${userMenuMockData.avatar_url}-new-avatar`;
+
+ document.dispatchEvent(new CustomEvent('userAvatar:update', { detail: { url } }));
+ await nextTick();
+
+ const avatar = toggle.findComponent(GlAvatar);
+ expect(avatar.exists()).toBe(true);
+ expect(avatar.props()).toMatchObject({
+ entityName: userMenuMockData.name,
+ src: url,
+ });
+ });
+
it('renders screen reader text', () => {
expect(toggle.find('.gl-sr-only').text()).toBe(`${userMenuMockData.name} user’s menu`);
});
@@ -91,31 +109,46 @@ describe('UserMenu component', () => {
describe('User status item', () => {
let item;
- const setItem = ({ can_update, busy, customized, stubs } = {}) => {
- createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } }, stubs);
+ const setItem = async ({
+ can_update: canUpdate = false,
+ busy = false,
+ customized = false,
+ stubs,
+ } = {}) => {
+ createWrapper(
+ { status: { ...userMenuMockStatus, can_update: canUpdate, busy, customized } },
+ stubs,
+ );
+ // Mock mounting the modal if we can update
+ if (canUpdate) {
+ expect(wrapper.vm.setStatusModalReady).toEqual(false);
+ findSetStatusModal().vm.$emit('mounted');
+ await nextTick();
+ expect(wrapper.vm.setStatusModalReady).toEqual(true);
+ }
item = wrapper.findByTestId('status-item');
};
describe('When user cannot update the status', () => {
- it('does not render the status menu item', () => {
- setItem();
+ it('does not render the status menu item', async () => {
+ await setItem();
expect(item.exists()).toBe(false);
});
});
describe('When user can update the status', () => {
- it('renders the status menu item', () => {
- setItem({ can_update: true });
+ it('renders the status menu item', async () => {
+ await setItem({ can_update: true });
expect(item.exists()).toBe(true);
+ expect(item.find('button').attributes()).toMatchObject({
+ 'data-track-property': 'nav_user_menu',
+ 'data-track-action': 'click_link',
+ 'data-track-label': 'user_edit_status',
+ });
});
- it('should set the CSS class for triggering status update modal', () => {
- setItem({ can_update: true });
- expect(item.find('.js-set-status-modal-trigger').exists()).toBe(true);
- });
-
- it('should close the dropdown when status modal opened', () => {
- setItem({
+ it('should close the dropdown when status modal opened', async () => {
+ await setItem({
can_update: true,
stubs: {
GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
@@ -139,57 +172,75 @@ describe('UserMenu component', () => {
${true} | ${true} | ${'Edit status'}
`(
'when busy is "$busy" and customized is "$customized" the label is "$label"',
- ({ busy, customized, label }) => {
- setItem({ can_update: true, busy, customized });
+ async ({ busy, customized, label }) => {
+ await setItem({ can_update: true, busy, customized });
expect(item.text()).toBe(label);
},
);
});
+ });
+ });
+
+ describe('set status modal', () => {
+ describe('when the user cannot update the status', () => {
+ it('should not render the modal', () => {
+ createWrapper({
+ status: { ...userMenuMockStatus, can_update: false },
+ });
- describe('Status update modal wrapper', () => {
- const findModalWrapper = () => wrapper.find('.js-set-status-modal-wrapper');
+ expect(findSetStatusModal().exists()).toBe(false);
+ });
+ });
- it('renders the modal wrapper', () => {
- setItem({ can_update: true });
- expect(findModalWrapper().exists()).toBe(true);
+ describe('when the user can update the status', () => {
+ describe.each`
+ busy | customized
+ ${true} | ${true}
+ ${true} | ${false}
+ ${false} | ${true}
+ `('and the status is busy or customized', ({ busy, customized }) => {
+ it('should pass the current status to the modal', () => {
+ createWrapper({
+ status: { ...userMenuMockStatus, can_update: true, busy, customized },
+ });
+
+ expect(findSetStatusModal().exists()).toBe(true);
+ expect(findSetStatusModal().props()).toMatchObject({
+ defaultEmoji: 'speech_balloon',
+ currentEmoji: userMenuMockStatus.emoji,
+ currentMessage: userMenuMockStatus.message,
+ currentAvailability: userMenuMockStatus.availability,
+ currentClearStatusAfter: userMenuMockStatus.clear_after,
+ });
});
- describe('when user cannot update status', () => {
- it('sets default data attributes', () => {
- setItem({ can_update: true });
- expect(findModalWrapper().attributes()).toMatchObject({
- 'data-current-emoji': '',
- 'data-current-message': '',
- 'data-default-emoji': 'speech_balloon',
- });
+ it('casts falsey values to empty strings', () => {
+ createWrapper({
+ status: { can_update: true, busy, customized },
+ });
+
+ expect(findSetStatusModal().exists()).toBe(true);
+ expect(findSetStatusModal().props()).toMatchObject({
+ defaultEmoji: 'speech_balloon',
+ currentEmoji: '',
+ currentMessage: '',
+ currentAvailability: '',
+ currentClearStatusAfter: '',
});
});
+ });
+
+ describe('and the status is neither busy nor customized', () => {
+ it('should pass an empty status to the modal', () => {
+ createWrapper({
+ status: { ...userMenuMockStatus, can_update: true, busy: false, customized: false },
+ });
- describe.each`
- busy | customized
- ${true} | ${true}
- ${true} | ${false}
- ${false} | ${true}
- ${false} | ${false}
- `(`when user can update status`, ({ busy, customized }) => {
- it(`and ${busy ? 'is busy' : 'is not busy'} and status ${
- customized ? 'is' : 'is not'
- } customized sets user status data attributes`, () => {
- setItem({ can_update: true, busy, customized });
- if (busy || customized) {
- expect(findModalWrapper().attributes()).toMatchObject({
- 'data-current-emoji': userMenuMockStatus.emoji,
- 'data-current-message': userMenuMockStatus.message,
- 'data-current-availability': userMenuMockStatus.availability,
- 'data-current-clear-status-after': userMenuMockStatus.clear_after,
- });
- } else {
- expect(findModalWrapper().attributes()).toMatchObject({
- 'data-current-emoji': '',
- 'data-current-message': '',
- 'data-default-emoji': 'speech_balloon',
- });
- }
+ expect(findSetStatusModal().exists()).toBe(true);
+ expect(findSetStatusModal().props()).toMatchObject({
+ defaultEmoji: 'speech_balloon',
+ currentEmoji: '',
+ currentMessage: '',
});
});
});
diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js
index d2d2faedbf8..fc264ad5e0a 100644
--- a/spec/frontend/super_sidebar/mock_data.js
+++ b/spec/frontend/super_sidebar/mock_data.js
@@ -79,6 +79,8 @@ export const contextSwitcherLinks = [
export const sidebarData = {
is_logged_in: true,
+ is_admin: false,
+ admin_url: '/admin',
current_menu_items: [],
current_context: {},
current_context_header: 'Your work',
@@ -188,6 +190,26 @@ export const userMenuMockData = {
canary_toggle_com_url: 'https://next.gitlab.com',
};
+export const frecentGroupsMock = [
+ {
+ id: 'gid://gitlab/Group/1',
+ name: 'Frecent group 1',
+ namespace: 'Frecent Namespace 1',
+ webUrl: '/frecent-namespace-1/frecent-group-1',
+ avatarUrl: '/uploads/-/avatar1.png',
+ },
+];
+
+export const frecentProjectsMock = [
+ {
+ id: 'gid://gitlab/Project/1',
+ name: 'Frecent project 1',
+ namespace: 'Frecent Namespace 1 / Frecent project 1',
+ webUrl: '/frecent-namespace-1/frecent-project-1',
+ avatarUrl: '/uploads/-/avatar1.png',
+ },
+];
+
export const cachedFrequentProjects = JSON.stringify([
{
id: 1,
@@ -283,3 +305,32 @@ export const cachedFrequentGroups = JSON.stringify([
frequency: 3,
},
]);
+
+export const unsortedFrequentItems = [
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+];
+
+/**
+ * This const has a specific order which tests authenticity
+ * of `getTopFrequentItems` method so
+ * DO NOT change order of items in this const.
+ */
+export const sortedFrequentItems = [
+ { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
+ { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
+ { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
+ { id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
+ { id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
+ { id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
+ { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
+ { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
+ { id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
+];
diff --git a/spec/frontend/super_sidebar/user_counts_manager_spec.js b/spec/frontend/super_sidebar/user_counts_manager_spec.js
index b5074620195..3b2ee5b0991 100644
--- a/spec/frontend/super_sidebar/user_counts_manager_spec.js
+++ b/spec/frontend/super_sidebar/user_counts_manager_spec.js
@@ -6,6 +6,7 @@ import {
userCounts,
destroyUserCountsManager,
} from '~/super_sidebar/user_counts_manager';
+import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch';
jest.mock('~/api');
@@ -118,15 +119,30 @@ describe('User Merge Requests', () => {
createUserCountsManager();
});
- it('fetches counts from API, stores and rebroadcasts them', async () => {
- expect(userCounts).toMatchObject(userCountDefaults);
+ describe('manually created event', () => {
+ it('fetches counts from API, stores and rebroadcasts them', async () => {
+ expect(userCounts).toMatchObject(userCountDefaults);
+
+ document.dispatchEvent(new CustomEvent('userCounts:fetch'));
+ await waitForPromises();
+
+ expect(UserApi.getUserCounts).toHaveBeenCalled();
+ expect(userCounts).toMatchObject(userCountUpdate);
+ expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts);
+ });
+ });
+
+ describe('fetchUserCounts helper', () => {
+ it('fetches counts from API, stores and rebroadcasts them', async () => {
+ expect(userCounts).toMatchObject(userCountDefaults);
- document.dispatchEvent(new CustomEvent('userCounts:fetch'));
- await waitForPromises();
+ fetchUserCounts();
+ await waitForPromises();
- expect(UserApi.getUserCounts).toHaveBeenCalled();
- expect(userCounts).toMatchObject(userCountUpdate);
- expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts);
+ expect(UserApi.getUserCounts).toHaveBeenCalled();
+ expect(userCounts).toMatchObject(userCountUpdate);
+ expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts);
+ });
});
});
diff --git a/spec/frontend/super_sidebar/utils_spec.js b/spec/frontend/super_sidebar/utils_spec.js
index 43eb82f5928..a9e4345f9cc 100644
--- a/spec/frontend/super_sidebar/utils_spec.js
+++ b/spec/frontend/super_sidebar/utils_spec.js
@@ -1,20 +1,13 @@
import MockAdapter from 'axios-mock-adapter';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
-import {
- getTopFrequentItems,
- trackContextAccess,
- getItemsFromLocalStorage,
- removeItemFromLocalStorage,
- ariaCurrent,
-} from '~/super_sidebar/utils';
+import { getTopFrequentItems, trackContextAccess, ariaCurrent } from '~/super_sidebar/utils';
import axios from '~/lib/utils/axios_utils';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import AccessorUtilities from '~/lib/utils/accessor';
-import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants';
+import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/super_sidebar/constants';
import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
import waitForPromises from 'helpers/wait_for_promises';
-import { unsortedFrequentItems, sortedFrequentItems } from '../frequent_items/mock_data';
-import { cachedFrequentProjects } from './mock_data';
+import { unsortedFrequentItems, sortedFrequentItems } from './mock_data';
jest.mock('~/sentry/sentry_browser_wrapper');
@@ -24,7 +17,7 @@ describe('Super sidebar utils spec', () => {
describe('getTopFrequentItems', () => {
const maxItems = 3;
- it.each([undefined, null])('returns empty array if `items` is %s', (items) => {
+ it.each([undefined, null, []])('returns empty array if `items` is %s', (items) => {
const result = getTopFrequentItems(items);
expect(result.length).toBe(0);
@@ -224,125 +217,6 @@ describe('Super sidebar utils spec', () => {
});
});
- describe('getItemsFromLocalStorage', () => {
- const storageKey = 'mockStorageKey';
- const maxItems = 5;
- const storedItems = JSON.parse(cachedFrequentProjects);
-
- beforeEach(() => {
- window.localStorage.setItem(storageKey, cachedFrequentProjects);
- });
-
- describe('when localStorage cannot be accessed', () => {
- beforeEach(() => {
- jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false);
- });
-
- it('returns an empty array', () => {
- const items = getItemsFromLocalStorage({ storageKey, maxItems });
- expect(items).toEqual([]);
- });
- });
-
- describe('when localStorage contains parseable data', () => {
- it('returns an array of items limited by max items', () => {
- const items = getItemsFromLocalStorage({ storageKey, maxItems });
- expect(items.length).toEqual(maxItems);
-
- items.forEach((item) => {
- expect(storedItems).toContainEqual(item);
- });
- });
-
- it('returns all items if max items is large', () => {
- const items = getItemsFromLocalStorage({ storageKey, maxItems: 1 });
- expect(items.length).toEqual(1);
-
- expect(storedItems).toContainEqual(items[0]);
- });
- });
-
- describe('when localStorage contains unparseable data', () => {
- let items;
-
- beforeEach(() => {
- window.localStorage.setItem(storageKey, 'unparseable');
- items = getItemsFromLocalStorage({ storageKey, maxItems });
- });
-
- it('logs an error to Sentry', () => {
- expect(Sentry.captureException).toHaveBeenCalled();
- });
-
- it('returns an empty array', () => {
- expect(items).toEqual([]);
- });
- });
- });
-
- describe('removeItemFromLocalStorage', () => {
- const storageKey = 'mockStorageKey';
- const originalStoredItems = JSON.parse(cachedFrequentProjects);
-
- beforeEach(() => {
- window.localStorage.setItem(storageKey, cachedFrequentProjects);
- });
-
- describe('when given an item to delete', () => {
- let items;
- let modifiedStoredItems;
-
- beforeEach(() => {
- items = removeItemFromLocalStorage({ storageKey, item: { id: 3 } });
- modifiedStoredItems = JSON.parse(window.localStorage.getItem(storageKey));
- });
-
- it('removes the item from localStorage', () => {
- expect(modifiedStoredItems.length).toBe(originalStoredItems.length - 1);
- expect(modifiedStoredItems).not.toContainEqual(originalStoredItems[2]);
- });
-
- it('returns the resulting stored structure', () => {
- expect(items).toEqual(modifiedStoredItems);
- });
- });
-
- describe('when given an unknown item to delete', () => {
- let items;
- let modifiedStoredItems;
-
- beforeEach(() => {
- items = removeItemFromLocalStorage({ storageKey, item: { id: 'does-not-exist' } });
- modifiedStoredItems = JSON.parse(window.localStorage.getItem(storageKey));
- });
-
- it('does not change the stored value', () => {
- expect(modifiedStoredItems).toEqual(originalStoredItems);
- });
-
- it('returns the stored structure', () => {
- expect(items).toEqual(originalStoredItems);
- });
- });
-
- describe('when localStorage has unparseable data', () => {
- let items;
-
- beforeEach(() => {
- window.localStorage.setItem(storageKey, 'unparseable');
- items = removeItemFromLocalStorage({ storageKey, item: { id: 3 } });
- });
-
- it('logs an error to Sentry', () => {
- expect(Sentry.captureException).toHaveBeenCalled();
- });
-
- it('returns an empty array', () => {
- expect(items).toEqual([]);
- });
- });
- });
-
describe('ariaCurrent', () => {
it.each`
isActive | expected
diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js
index e79c516a694..605ae028049 100644
--- a/spec/frontend/task_list_spec.js
+++ b/spec/frontend/task_list_spec.js
@@ -126,14 +126,19 @@ describe('TaskList', () => {
});
describe('update', () => {
- it('should disable task list items and make a patch request then enable them again', () => {
- const response = { data: { lock_version: 3 } };
+ const setupTaskListAndMocks = (options) => {
+ taskList = new TaskList(options);
+
jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {});
jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {});
jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {});
jest.spyOn(taskList, 'onSuccess').mockImplementation(() => {});
- jest.spyOn(axios, 'patch').mockReturnValue(Promise.resolve(response));
+ jest.spyOn(axios, 'patch').mockResolvedValue({ data: { lock_version: 3 } });
+
+ return taskList;
+ };
+ const performTest = (options) => {
const value = 'hello world';
const endpoint = '/foo';
const target = $(`<input data-update-url="${endpoint}" value="${value}" />`);
@@ -144,10 +149,11 @@ describe('TaskList', () => {
lineSource: '- [ ] check item',
};
const event = { target, detail };
+ const dataType = options.dataType === 'incident' ? 'issue' : options.dataType;
const patchData = {
- [taskListOptions.dataType]: {
- [taskListOptions.fieldName]: value,
- lock_version: taskListOptions.lockVersion,
+ [dataType]: {
+ [options.fieldName]: value,
+ lock_version: options.lockVersion,
update_task: {
index: detail.index,
checked: detail.checked,
@@ -165,8 +171,42 @@ describe('TaskList', () => {
expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event);
expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData);
expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
- expect(taskList.onSuccess).toHaveBeenCalledWith(response.data);
- expect(taskList.lockVersion).toEqual(response.data.lock_version);
+ expect(taskList.onSuccess).toHaveBeenCalledWith({ lock_version: 3 });
+ expect(taskList.lockVersion).toEqual(3);
+ });
+ };
+
+ it('should disable task list items and make a patch request then enable them again', () => {
+ taskList = setupTaskListAndMocks(taskListOptions);
+
+ return performTest(taskListOptions);
+ });
+
+ describe('for merge requests', () => {
+ it('should wrap the patch request payload in merge_request', () => {
+ const options = {
+ selector: '.task-list',
+ dataType: 'merge_request',
+ fieldName: 'description',
+ lockVersion: 2,
+ };
+ taskList = setupTaskListAndMocks(options);
+
+ return performTest(options);
+ });
+ });
+
+ describe('for incidents', () => {
+ it('should wrap the patch request payload in issue', () => {
+ const options = {
+ selector: '.task-list',
+ dataType: 'incident',
+ fieldName: 'description',
+ lockVersion: 2,
+ };
+ taskList = setupTaskListAndMocks(options);
+
+ return performTest(options);
});
});
});
diff --git a/spec/frontend/tracking/internal_events_spec.js b/spec/frontend/tracking/internal_events_spec.js
index 44a048a4b5f..295b08f4b1c 100644
--- a/spec/frontend/tracking/internal_events_spec.js
+++ b/spec/frontend/tracking/internal_events_spec.js
@@ -1,15 +1,9 @@
import API from '~/api';
-import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import InternalEvents from '~/tracking/internal_events';
-import {
- GITLAB_INTERNAL_EVENT_CATEGORY,
- SERVICE_PING_SCHEMA,
- LOAD_INTERNAL_EVENTS_SELECTOR,
-} from '~/tracking/constants';
+import { LOAD_INTERNAL_EVENTS_SELECTOR } from '~/tracking/constants';
import * as utils from '~/tracking/utils';
import { Tracker } from '~/tracking/tracker';
-import { extraContext } from './mock_data';
jest.mock('~/api', () => ({
trackInternalEvent: jest.fn(),
@@ -41,26 +35,6 @@ describe('InternalEvents', () => {
expect(InternalEvents.trackBrowserSDK).toHaveBeenCalledTimes(1);
expect(InternalEvents.trackBrowserSDK).toHaveBeenCalledWith(event);
});
-
- it('trackEvent calls tracking.event functions with correct arguments', () => {
- const trackingSpy = mockTracking(GITLAB_INTERNAL_EVENT_CATEGORY, undefined, jest.spyOn);
-
- InternalEvents.trackEvent(event, { context: extraContext });
-
- expect(trackingSpy).toHaveBeenCalledTimes(1);
- expect(trackingSpy).toHaveBeenCalledWith(GITLAB_INTERNAL_EVENT_CATEGORY, event, {
- context: [
- {
- schema: SERVICE_PING_SCHEMA,
- data: {
- event_name: event,
- data_source: 'redis_hll',
- },
- },
- extraContext,
- ],
- });
- });
});
describe('mixin', () => {
@@ -68,17 +42,13 @@ describe('InternalEvents', () => {
const Component = {
template: `
<div>
- <button data-testid="button1" @click="handleButton1Click">Button 1</button>
- <button data-testid="button2" @click="handleButton2Click">Button 2</button>
+ <button data-testid="button" @click="handleButton1Click">Button</button>
</div>
`,
methods: {
handleButton1Click() {
this.trackEvent(event);
},
- handleButton2Click() {
- this.trackEvent(event, extraContext);
- },
},
mixins: [InternalEvents.mixin()],
};
@@ -90,20 +60,10 @@ describe('InternalEvents', () => {
it('this.trackEvent function calls InternalEvent`s track function with an event', async () => {
const trackEventSpy = jest.spyOn(InternalEvents, 'trackEvent');
- await wrapper.findByTestId('button1').trigger('click');
-
- expect(trackEventSpy).toHaveBeenCalledTimes(1);
- expect(trackEventSpy).toHaveBeenCalledWith(event, {});
- });
-
- it("this.trackEvent function calls InternalEvent's track function with an event and data", async () => {
- const data = extraContext;
- const trackEventSpy = jest.spyOn(InternalEvents, 'trackEvent');
-
- await wrapper.findByTestId('button2').trigger('click');
+ await wrapper.findByTestId('button').trigger('click');
expect(trackEventSpy).toHaveBeenCalledTimes(1);
- expect(trackEventSpy).toHaveBeenCalledWith(event, data);
+ expect(trackEventSpy).toHaveBeenCalledWith(event);
});
});
diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js
index 0ae01083a09..babefe1dd19 100644
--- a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js
+++ b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js
@@ -16,7 +16,8 @@ import {
NAMESPACE_STORAGE_TYPES,
TOTAL_USAGE_DEFAULT_TEXT,
} from '~/usage_quotas/storage/constants';
-import getProjectStorageStatistics from '~/usage_quotas/storage/queries/project_storage.query.graphql';
+import getCostFactoredProjectStorageStatistics from 'ee_else_ce/usage_quotas/storage/queries/cost_factored_project_storage.query.graphql';
+import getProjectStorageStatistics from 'ee_else_ce/usage_quotas/storage/queries/project_storage.query.graphql';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import {
mockGetProjectStorageStatisticsGraphQLResponse,
@@ -38,7 +39,10 @@ describe('ProjectStorageApp', () => {
response = jest.fn().mockResolvedValue(mockedValue);
}
- const requestHandlers = [[getProjectStorageStatistics, response]];
+ const requestHandlers = [
+ [getProjectStorageStatistics, response],
+ [getCostFactoredProjectStorageStatistics, response],
+ ];
return createMockApollo(requestHandlers);
};
@@ -187,4 +191,30 @@ describe('ProjectStorageApp', () => {
]);
});
});
+
+ describe('when displayCostFactoredStorageSizeOnProjectPages feature flag is enabled', () => {
+ let mockApollo;
+ beforeEach(async () => {
+ mockApollo = createMockApolloProvider({
+ mockedValue: mockGetProjectStorageStatisticsGraphQLResponse,
+ });
+ createComponent({
+ mockApollo,
+ provide: {
+ glFeatures: {
+ displayCostFactoredStorageSizeOnProjectPages: true,
+ },
+ },
+ });
+ await waitForPromises();
+ });
+
+ it('renders correct total usage', () => {
+ const expectedValue = numberToHumanSize(
+ mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics.storageSize,
+ 1,
+ );
+ expect(findUsagePercentage().text()).toBe(expectedValue);
+ });
+ });
});
diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js
index 96e9705f02b..26b33bcd46d 100644
--- a/spec/frontend/user_lists/components/user_lists_table_spec.js
+++ b/spec/frontend/user_lists/components/user_lists_table_spec.js
@@ -5,6 +5,7 @@ import { nextTick } from 'vue';
import { timeagoLanguageCode } from '~/lib/utils/datetime/timeago_utility';
import UserListsTable from '~/user_lists/components/user_lists_table.vue';
import { userList } from 'jest/feature_flags/mock_data';
+import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
jest.mock('timeago.js', () => ({
format: jest.fn().mockReturnValue('2 weeks ago'),
@@ -33,7 +34,7 @@ describe('User Lists Table', () => {
it('should set the title for a tooltip on the created stamp', () => {
expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe(
- 'Feb 4, 2020 8:13am UTC',
+ localeDateFormat.asDateTimeFull.format(userList.created_at),
);
});
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 c81f4328d2a..c3ed131d6e3 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
@@ -1,11 +1,11 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlButton, GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client';
import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json';
-import { visitUrl } from '~/lib/utils/url_utility';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { createAlert } from '~/alert';
@@ -29,11 +29,6 @@ jest.mock('~/alert', () => ({
dismiss: mockAlertDismiss,
})),
}));
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- visitUrl: jest.fn(),
-}));
-
const TEST_HELP_PATH = 'help/path';
const testApprovedBy = () => [1, 7, 10].map((id) => ({ id }));
const testApprovals = () => ({
@@ -53,6 +48,9 @@ describe('MRWidget approvals', () => {
let wrapper;
let service;
let mr;
+ const submitSpy = jest.fn().mockImplementation((e) => {
+ e.preventDefault();
+ });
const createComponent = (options = {}, responses = { query: approvedByCurrentUser }) => {
mockedSubscription = createMockApolloSubscription();
@@ -68,7 +66,7 @@ describe('MRWidget approvals', () => {
apolloProvider.defaultClient.setRequestHandler(query, stream);
});
- wrapper = shallowMount(Approvals, {
+ wrapper = shallowMountExtended(Approvals, {
apolloProvider,
propsData: {
mr,
@@ -78,7 +76,18 @@ describe('MRWidget approvals', () => {
provide,
stubs: {
GlSprintf,
+ GlForm: {
+ data() {
+ return { submitSpy };
+ },
+ // Workaround jsdom not implementing form submit
+ template: '<form @submit="submitSpy"><slot></slot></form>',
+ },
+ GlButton: stubComponent(GlButton, {
+ template: '<button><slot></slot></button>',
+ }),
},
+ attachTo: document.body,
});
};
@@ -257,11 +266,11 @@ describe('MRWidget approvals', () => {
});
describe('when SAML auth is required and user clicks Approve with SAML', () => {
- const fakeGroupSamlPath = '/example_group_saml';
+ const fakeSamlPath = '/example_group_saml';
beforeEach(async () => {
mr.requireSamlAuthToApprove = true;
- mr.samlApprovalPath = fakeGroupSamlPath;
+ mr.samlApprovalPath = fakeSamlPath;
createComponent({}, { query: createCanApproveResponse() });
await waitForPromises();
@@ -269,9 +278,10 @@ describe('MRWidget approvals', () => {
it('redirects the user to the group SAML path', async () => {
const action = findAction();
- action.vm.$emit('click');
- await nextTick();
- expect(visitUrl).toHaveBeenCalledWith(fakeGroupSamlPath);
+
+ await action.trigger('click');
+
+ expect(submitSpy).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js
new file mode 100644
index 00000000000..cc605c8c83d
--- /dev/null
+++ b/spec/frontend/vue_merge_request_widget/components/checks/draft_spec.js
@@ -0,0 +1,196 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+
+import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json';
+
+import { createAlert } from '~/alert';
+
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import MergeRequest from '~/merge_request';
+
+import DraftCheck from '~/vue_merge_request_widget/components/checks/draft.vue';
+import {
+ DRAFT_CHECK_READY,
+ DRAFT_CHECK_ERROR,
+} from '~/vue_merge_request_widget/components/checks/i18n';
+import { FAILURE_REASONS } from '~/vue_merge_request_widget/components/checks/message.vue';
+
+import draftQuery from '~/vue_merge_request_widget/queries/states/draft.query.graphql';
+import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql';
+import removeDraftMutation from '~/vue_merge_request_widget/queries/toggle_draft.mutation.graphql';
+
+Vue.use(VueApollo);
+
+const TEST_PROJECT_ID = getStateQueryResponse.data.project.id;
+const TEST_MR_ID = getStateQueryResponse.data.project.mergeRequest.id;
+const TEST_MR_IID = '23';
+const TEST_MR_TITLE = 'Test MR Title';
+const TEST_PROJECT_PATH = 'lorem/ipsum';
+
+jest.mock('~/alert');
+jest.mock('~/merge_request', () => ({ toggleDraftStatus: jest.fn() }));
+
+describe('~/vue_merge_request_widget/components/checks/draft.vue', () => {
+ let wrapper;
+ let apolloProvider;
+
+ let draftQuerySpy;
+ let removeDraftMutationSpy;
+
+ const findMarkReadyButton = () => wrapper.findByTestId('mark-as-ready-button');
+
+ const createDraftQueryResponse = (canUpdateMergeRequest) => ({
+ data: {
+ project: {
+ __typename: 'Project',
+ id: TEST_PROJECT_ID,
+ mergeRequest: {
+ __typename: 'MergeRequest',
+ id: TEST_MR_ID,
+ draft: true,
+ title: TEST_MR_TITLE,
+ mergeableDiscussionsState: false,
+ userPermissions: {
+ updateMergeRequest: canUpdateMergeRequest,
+ },
+ },
+ },
+ },
+ });
+ const createRemoveDraftMutationResponse = () => ({
+ data: {
+ mergeRequestSetDraft: {
+ __typename: 'MergeRequestSetWipPayload',
+ errors: [],
+ mergeRequest: {
+ __typename: 'MergeRequest',
+ id: TEST_MR_ID,
+ title: TEST_MR_TITLE,
+ draft: false,
+ mergeableDiscussionsState: true,
+ },
+ },
+ },
+ });
+
+ const createComponent = async () => {
+ wrapper = mountExtended(DraftCheck, {
+ apolloProvider,
+ propsData: {
+ mr: {
+ issuableId: TEST_MR_ID,
+ title: TEST_MR_TITLE,
+ iid: TEST_MR_IID,
+ targetProjectFullPath: TEST_PROJECT_PATH,
+ },
+ check: {
+ identifier: 'draft_status',
+ status: 'FAILED',
+ },
+ },
+ });
+
+ await waitForPromises();
+
+ // why: draft.vue has some coupling that this query has been read before
+ // for some reason this has to happen **after** the component has mounted
+ // or apollo throws errors.
+ apolloProvider.defaultClient.cache.writeQuery({
+ query: getStateQuery,
+ variables: {
+ projectPath: TEST_PROJECT_PATH,
+ iid: TEST_MR_IID,
+ },
+ data: getStateQueryResponse.data,
+ });
+ };
+
+ beforeEach(() => {
+ draftQuerySpy = jest.fn().mockResolvedValue(createDraftQueryResponse(true));
+ removeDraftMutationSpy = jest.fn().mockResolvedValue(createRemoveDraftMutationResponse());
+
+ apolloProvider = createMockApollo([
+ [draftQuery, draftQuerySpy],
+ [removeDraftMutation, removeDraftMutationSpy],
+ ]);
+ });
+
+ describe('when user can update MR', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ it('renders text', () => {
+ const message = wrapper.text();
+ expect(message).toContain(FAILURE_REASONS.draft_status);
+ });
+
+ it('renders mark ready button', () => {
+ expect(findMarkReadyButton().text()).toBe(DRAFT_CHECK_READY);
+ });
+
+ it('does not call remove draft mutation', () => {
+ expect(removeDraftMutationSpy).not.toHaveBeenCalled();
+ });
+
+ describe('when mark ready button is clicked', () => {
+ beforeEach(async () => {
+ findMarkReadyButton().vm.$emit('click');
+
+ await waitForPromises();
+ });
+
+ it('calls mutation spy', () => {
+ expect(removeDraftMutationSpy).toHaveBeenCalledWith({
+ draft: false,
+ iid: TEST_MR_IID,
+ projectPath: TEST_PROJECT_PATH,
+ });
+ });
+
+ it('does not create alert', () => {
+ expect(createAlert).not.toHaveBeenCalled();
+ });
+
+ it('calls toggleDraftStatus', () => {
+ expect(MergeRequest.toggleDraftStatus).toHaveBeenCalledWith(TEST_MR_TITLE, true);
+ });
+ });
+
+ describe('when mutation fails and ready button is clicked', () => {
+ beforeEach(async () => {
+ removeDraftMutationSpy.mockRejectedValue(new Error('TEST FAIL'));
+ findMarkReadyButton().vm.$emit('click');
+
+ await waitForPromises();
+ });
+
+ it('creates alert', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: DRAFT_CHECK_ERROR,
+ });
+ });
+
+ it('does not call toggleDraftStatus', () => {
+ expect(MergeRequest.toggleDraftStatus).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when user cannot update MR', () => {
+ beforeEach(async () => {
+ draftQuerySpy.mockResolvedValue(createDraftQueryResponse(false));
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('does not render mark ready button', () => {
+ expect(findMarkReadyButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js
index d6c01aee3b1..d621999337d 100644
--- a/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/checks/rebase_spec.js
@@ -28,7 +28,7 @@ const mockPipelineNodes = [
const mockQueryHandler = ({
rebaseInProgress = false,
targetBranch = '',
- pushToSourceBranch = false,
+ pushToSourceBranch = true,
nodes = mockPipelineNodes,
} = {}) =>
jest.fn().mockResolvedValue({
@@ -279,7 +279,7 @@ describe('Merge request merge checks rebase component', () => {
await waitForPromises();
- expect(findRebaseWithoutCiButton().exists()).toBe(true);
+ expect(findRebaseWithoutCiButton().exists()).toBe(false);
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js
index d39098b27c2..b19095cc686 100644
--- a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js
@@ -138,7 +138,7 @@ describe('Merge request merge checks component', () => {
it.each`
identifier
${'conflict'}
- ${'unresolved_discussions'}
+ ${'discussions_not_resolved'}
${'need_rebase'}
${'default'}
`('renders $identifier merge check', async ({ identifier }) => {
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js
index 8eaed998eb5..5a5d29d3194 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_expandable_section_spec.js
@@ -39,7 +39,7 @@ describe('MrWidgetExpanableSection', () => {
const collapse = findCollapse();
expect(collapse.exists()).toBe(true);
- expect(collapse.attributes('visible')).toBeUndefined();
+ expect(collapse.props('visible')).toBe(false);
});
});
@@ -60,7 +60,7 @@ describe('MrWidgetExpanableSection', () => {
const collapse = findCollapse();
expect(collapse.exists()).toBe(true);
- expect(collapse.attributes('visible')).toBe('true');
+ expect(collapse.props('visible')).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js
index 35b4e222e01..3f0eb946194 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_pipeline_spec.js
@@ -8,6 +8,7 @@ import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import MRWidgetPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue';
import { SUCCESS } from '~/vue_merge_request_widget/constants';
+import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
import mockData from '../mock_data';
describe('MRWidgetPipeline', () => {
@@ -93,7 +94,7 @@ describe('MRWidgetPipeline', () => {
it('should render pipeline finished timestamp', () => {
expect(findPipelineFinishedAt().attributes()).toMatchObject({
- title: 'Apr 7, 2017 2:00pm UTC',
+ title: localeDateFormat.asDateTimeFull.format(mockData.pipeline.details.finished_at),
datetime: mockData.pipeline.details.finished_at,
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js
index b210327aa31..65c4970bc76 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_status_icon_spec.js
@@ -54,5 +54,12 @@ describe('MR widget status icon component', () => {
expect(findIcon().exists()).toBe(true);
expect(findIcon().props().name).toBe('merge-request-close');
});
+
+ it('renders empty status icon', () => {
+ createWrapper({ status: 'empty' });
+
+ expect(findStatusIcon().exists()).toBe(true);
+ expect(findStatusIcon().props().iconName).toBe('neutral');
+ });
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap
index ecf4040cbda..ec0af7c8a7b 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap
+++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap
@@ -8,7 +8,7 @@ exports[`New ready to merge state component renders permission text if canMerge
status="success"
/>
<p
- class="gl-font-weight-bold gl-m-0! gl-text-gray-900! media-body"
+ class="gl-font-weight-bold gl-mb-0! gl-mt-1 gl-text-gray-900! media-body"
>
Ready to merge by members who can write to the target branch.
</p>
@@ -23,7 +23,7 @@ exports[`New ready to merge state component renders permission text if canMerge
status="success"
/>
<p
- class="gl-font-weight-bold gl-m-0! gl-text-gray-900! media-body"
+ class="gl-font-weight-bold gl-mb-0! gl-mt-1 gl-text-gray-900! media-body"
>
Ready to merge!
</p>
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js
index 7f0a171d712..af10d7d5eb7 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_conflicts_spec.js
@@ -1,10 +1,17 @@
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import Vue from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { removeBreakLine } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import userPermissionsQuery from '~/vue_merge_request_widget/queries/permissions.query.graphql';
+import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql';
import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue';
+Vue.use(VueApollo);
+
describe('MRWidgetConflicts', () => {
let wrapper;
const path = '/conflicts';
@@ -20,34 +27,57 @@ describe('MRWidgetConflicts', () => {
const resolveConflictsBtnText = 'Resolve conflicts';
const mergeLocallyBtnText = 'Resolve locally';
- async function createComponent(propsData = {}) {
- wrapper = extendedWrapper(
- mount(ConflictsComponent, {
- propsData,
- data() {
- return {
+ const defaultApolloProvider = (mockData = {}) => {
+ const userData = {
+ data: {
+ project: {
+ id: 234,
+ mergeRequest: {
+ id: 234,
userPermissions: {
- canMerge: propsData.mr.canMerge,
- pushToSourceBranch: propsData.mr.canPushToSourceBranch,
- },
- state: {
- shouldBeRebased: propsData.mr.shouldBeRebased,
- sourceBranchProtected: propsData.mr.sourceBranchProtected,
+ canMerge: mockData.canMerge || false,
+ pushToSourceBranch: mockData.canPushToSourceBranch || false,
},
- };
+ },
},
- mocks: {
- $apollo: {
- queries: {
- userPermissions: { loading: false },
- stateData: { loading: false },
+ },
+ };
+
+ const mrData = {
+ data: {
+ project: {
+ id: 234,
+ mergeRequest: {
+ id: 234,
+ shouldBeRebased: mockData.shouldBeRebased || false,
+ sourceBranchProtected: mockData.sourceBranchProtected || false,
+ userPermissions: {
+ pushToSourceBranch: mockData.canPushToSourceBranch || false,
},
},
},
+ },
+ };
+
+ return createMockApollo([
+ [userPermissionsQuery, jest.fn().mockResolvedValue(userData)],
+ [conflictsStateQuery, jest.fn().mockResolvedValue(mrData)],
+ ]);
+ };
+
+ async function createComponent({
+ propsData,
+ queryData,
+ apolloProvider = defaultApolloProvider(queryData),
+ } = {}) {
+ wrapper = extendedWrapper(
+ mount(ConflictsComponent, {
+ apolloProvider,
+ propsData,
}),
);
- await nextTick();
+ await waitForPromises();
}
// There are two permissions we need to consider:
@@ -62,11 +92,15 @@ describe('MRWidgetConflicts', () => {
describe('when allowed to merge but not allowed to push to source branch', () => {
beforeEach(async () => {
await createComponent({
- mr: {
+ propsData: {
+ mr: {
+ conflictsDocsPath: '',
+ },
+ },
+ queryData: {
canMerge: true,
canPushToSourceBranch: false,
conflictResolutionPath: path,
- conflictsDocsPath: '',
},
});
});
@@ -89,11 +123,15 @@ describe('MRWidgetConflicts', () => {
describe('when not allowed to merge but allowed to push to source branch', () => {
beforeEach(async () => {
await createComponent({
- mr: {
+ propsData: {
+ mr: {
+ conflictResolutionPath: path,
+ conflictsDocsPath: '',
+ },
+ },
+ queryData: {
canMerge: false,
canPushToSourceBranch: true,
- conflictResolutionPath: path,
- conflictsDocsPath: '',
},
});
});
@@ -116,11 +154,15 @@ describe('MRWidgetConflicts', () => {
describe('when allowed to merge and push to source branch', () => {
beforeEach(async () => {
await createComponent({
- mr: {
+ queryData: {
canMerge: true,
canPushToSourceBranch: true,
- conflictResolutionPath: path,
- conflictsDocsPath: '',
+ },
+ propsData: {
+ mr: {
+ conflictResolutionPath: path,
+ conflictsDocsPath: '',
+ },
},
});
});
@@ -144,10 +186,14 @@ describe('MRWidgetConflicts', () => {
describe('when user does not have permission to push to source branch', () => {
it('should show proper message', async () => {
await createComponent({
- mr: {
+ propsData: {
+ mr: {
+ conflictsDocsPath: '',
+ },
+ },
+ queryData: {
canMerge: false,
canPushToSourceBranch: false,
- conflictsDocsPath: '',
},
});
@@ -156,10 +202,14 @@ describe('MRWidgetConflicts', () => {
it('should not have action buttons', async () => {
await createComponent({
- mr: {
+ queryData: {
canMerge: false,
canPushToSourceBranch: false,
- conflictsDocsPath: '',
+ },
+ propsData: {
+ mr: {
+ conflictsDocsPath: '',
+ },
},
});
@@ -169,10 +219,14 @@ describe('MRWidgetConflicts', () => {
it('should not have resolve button when no conflict resolution path', async () => {
await createComponent({
- mr: {
+ propsData: {
+ mr: {
+ conflictResolutionPath: null,
+ conflictsDocsPath: '',
+ },
+ },
+ queryData: {
canMerge: true,
- conflictResolutionPath: null,
- conflictsDocsPath: '',
},
});
@@ -183,9 +237,13 @@ describe('MRWidgetConflicts', () => {
describe('when fast-forward or semi-linear merge enabled', () => {
it('should tell you to rebase locally', async () => {
await createComponent({
- mr: {
+ propsData: {
+ mr: {
+ conflictsDocsPath: '',
+ },
+ },
+ queryData: {
shouldBeRebased: true,
- conflictsDocsPath: '',
},
});
@@ -196,12 +254,16 @@ describe('MRWidgetConflicts', () => {
describe('when source branch protected', () => {
beforeEach(async () => {
await createComponent({
- mr: {
+ propsData: {
+ mr: {
+ conflictResolutionPath: TEST_HOST,
+ conflictsDocsPath: '',
+ },
+ },
+ queryData: {
canMerge: true,
- canPushToSourceBranch: true,
- conflictResolutionPath: TEST_HOST,
sourceBranchProtected: true,
- conflictsDocsPath: '',
+ canPushToSourceBranch: true,
},
});
});
@@ -214,12 +276,16 @@ describe('MRWidgetConflicts', () => {
describe('when source branch not protected', () => {
beforeEach(async () => {
await createComponent({
- mr: {
- canMerge: true,
+ propsData: {
+ mr: {
+ conflictResolutionPath: TEST_HOST,
+ conflictsDocsPath: '',
+ },
+ },
+ queryData: {
canPushToSourceBranch: true,
- conflictResolutionPath: TEST_HOST,
+ canMerge: true,
sourceBranchProtected: false,
- conflictsDocsPath: '',
},
});
});
@@ -229,4 +295,21 @@ describe('MRWidgetConflicts', () => {
expect(findResolveButton().attributes('href')).toEqual(TEST_HOST);
});
});
+
+ describe('error states', () => {
+ it('when project is null due to expired session it does not throw', async () => {
+ const fn = async () => {
+ await createComponent({
+ propsData: { mr: {} },
+ apolloProvider: createMockApollo([
+ [conflictsStateQuery, jest.fn().mockResolvedValue({ data: { project: null } })],
+ [userPermissionsQuery, jest.fn().mockResolvedValue({ data: { project: null } })],
+ ]),
+ });
+ await waitForPromises();
+ };
+
+ await expect(fn()).resolves.not.toThrow();
+ });
+ });
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js
index 85acd5f9a9e..328c0134368 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_merging_spec.js
@@ -1,8 +1,12 @@
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import simplePoll from '~/lib/utils/simple_poll';
import MrWidgetMerging from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue';
import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
+import { STATUS_MERGED } from '~/issues/constants';
+import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch';
+jest.mock('~/super_sidebar/user_counts_fetch');
jest.mock('~/lib/utils/simple_poll', () =>
jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default),
);
@@ -13,7 +17,7 @@ describe('MRWidgetMerging', () => {
const pollMock = jest.fn().mockResolvedValue();
const GlEmoji = { template: '<img />' };
- beforeEach(() => {
+ const createComponent = () => {
wrapper = shallowMount(MrWidgetMerging, {
propsData: {
mr: {
@@ -29,14 +33,18 @@ describe('MRWidgetMerging', () => {
GlEmoji,
},
});
- });
+ };
it('renders information about merge request being merged', () => {
+ createComponent();
+
const message = wrapper.findComponent(BoldText).props('message');
expect(message).toContain('Merging!');
});
describe('initiateMergePolling', () => {
+ beforeEach(createComponent);
+
it('should call simplePoll', () => {
expect(simplePoll).toHaveBeenCalledWith(expect.any(Function), { timeout: 0 });
});
@@ -45,4 +53,15 @@ describe('MRWidgetMerging', () => {
expect(pollMock).toHaveBeenCalled();
});
});
+
+ describe('on successful merge', () => {
+ it('should re-fetch user counts', async () => {
+ pollMock.mockResolvedValueOnce({ data: { state: STATUS_MERGED } });
+ createComponent();
+
+ await nextTick();
+
+ expect(fetchUserCounts).toHaveBeenCalled();
+ });
+ });
});
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 016eac05727..d8eec165395 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
@@ -1,5 +1,6 @@
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { helpPagePath } from '~/helpers/help_page_helper';
import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing_to_merge.vue';
describe('NothingToMerge', () => {
@@ -14,6 +15,7 @@ describe('NothingToMerge', () => {
};
const findNothingToMergeTextBody = () => wrapper.findByTestId('nothing-to-merge-body');
+ const findHelpLink = () => wrapper.findComponent(GlLink);
describe('With Blob link', () => {
beforeEach(() => {
@@ -26,5 +28,9 @@ describe('NothingToMerge', () => {
'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('renders text with link to CI Help Page', () => {
+ expect(findHelpLink().attributes('href')).toBe(helpPagePath('ci/quick_start/index.html'));
+ });
});
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
index 9239807ae71..1b7338744e8 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -1,9 +1,10 @@
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import { GlSprintf } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import produce from 'immer';
+import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client';
import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
@@ -15,13 +16,11 @@ import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squa
import MergeFailedPipelineConfirmationDialog from '~/vue_merge_request_widget/components/states/merge_failed_pipeline_confirmation_dialog.vue';
import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
+import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
jest.mock('~/lib/utils/simple_poll', () =>
jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default),
);
-jest.mock('~/commons/nav/user_merge_requests', () => ({
- refreshUserMergeRequestCounts: jest.fn(),
-}));
const commitMessage = readyToMergeResponse.data.project.mergeRequest.defaultMergeCommitMessage;
const squashCommitMessage =
@@ -82,6 +81,7 @@ Vue.use(VueApollo);
let service;
let wrapper;
let readyToMergeResponseSpy;
+let mockedSubscription;
const createReadyToMergeResponse = (customMr) => {
return produce(readyToMergeResponse, (draft) => {
@@ -90,7 +90,21 @@ const createReadyToMergeResponse = (customMr) => {
};
const createComponent = (customConfig = {}, createState = true) => {
- wrapper = shallowMount(ReadyToMerge, {
+ mockedSubscription = createMockApolloSubscription();
+ const apolloProvider = createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]);
+ const subscriptionResponse = {
+ data: { mergeRequestMergeStatusUpdated: { ...readyToMergeResponse.data.project.mergeRequest } },
+ };
+ subscriptionResponse.data.mergeRequestMergeStatusUpdated.defaultMergeCommitMessage =
+ 'New default merge commit message';
+
+ const subscriptionHandlers = [[readyToMergeSubscription, () => mockedSubscription]];
+
+ subscriptionHandlers.forEach(([query, stream]) => {
+ apolloProvider.defaultClient.setRequestHandler(query, stream);
+ });
+
+ wrapper = shallowMountExtended(ReadyToMerge, {
propsData: {
mr: createTestMr(customConfig),
service,
@@ -112,7 +126,7 @@ const createComponent = (customConfig = {}, createState = true) => {
CommitEdit,
GlSprintf,
},
- apolloProvider: createMockApollo([[readyToMergeQuery, readyToMergeResponseSpy]]),
+ apolloProvider,
});
};
@@ -843,4 +857,60 @@ describe('ReadyToMerge', () => {
expect(wrapper.text()).not.toContain('Auto-merge enabled');
});
});
+
+ describe('commit message', () => {
+ it('updates commit message from subscription', async () => {
+ createComponent({ mr: { id: 1 } });
+
+ await waitForPromises();
+
+ await wrapper.findByTestId('widget_edit_commit_message').vm.$emit('input', true);
+
+ expect(wrapper.findByTestId('merge-commit-message').props('value')).not.toEqual(
+ 'Updated commit message',
+ );
+
+ mockedSubscription.next({
+ data: {
+ mergeRequestMergeStatusUpdated: {
+ ...readyToMergeResponse.data.project.mergeRequest,
+ defaultMergeCommitMessage: 'Updated commit message',
+ },
+ },
+ });
+
+ await waitForPromises();
+
+ expect(wrapper.findByTestId('merge-commit-message').props('value')).toEqual(
+ 'Updated commit message',
+ );
+ });
+
+ it('does not update commit message from subscription if commit message has been manually changed', async () => {
+ createComponent({ mr: { id: 1 } });
+
+ await waitForPromises();
+
+ await wrapper.findByTestId('widget_edit_commit_message').vm.$emit('input', true);
+
+ await wrapper
+ .findByTestId('merge-commit-message')
+ .vm.$emit('input', 'Manually updated commit message');
+
+ mockedSubscription.next({
+ data: {
+ mergeRequestMergeStatusUpdated: {
+ ...readyToMergeResponse.data.project.mergeRequest,
+ defaultMergeCommitMessage: 'Updated commit message',
+ },
+ },
+ });
+
+ await waitForPromises();
+
+ expect(wrapper.findByTestId('merge-commit-message').props('value')).toEqual(
+ 'Manually updated commit message',
+ );
+ });
+ });
});
diff --git a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js
index f46829539a8..f01df2ca419 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/work_in_progress_spec.js
@@ -42,6 +42,9 @@ describe('~/vue_merge_request_widget/components/states/work_in_progress.vue', ()
mergeRequest: {
__typename: 'MergeRequest',
id: TEST_MR_ID,
+ draft: true,
+ title: TEST_MR_TITLE,
+ mergeableDiscussionsState: false,
userPermissions: {
updateMergeRequest: canUpdateMergeRequest,
},
@@ -179,4 +182,17 @@ describe('~/vue_merge_request_widget/components/states/work_in_progress.vue', ()
expect(findWIPButton().exists()).toBe(false);
});
});
+
+ describe('when project is null', () => {
+ beforeEach(async () => {
+ draftQuerySpy.mockResolvedValue({ data: { project: null } });
+ createComponent();
+ await waitForPromises();
+ });
+
+ // This is to mitigate https://gitlab.com/gitlab-org/gitlab/-/issues/413627
+ it('does not throw any error', () => {
+ expect(wrapper.exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap
index d5d3f56e451..f2a66ad2ff2 100644
--- a/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap
+++ b/spec/frontend/vue_merge_request_widget/components/widget/__snapshots__/dynamic_content_spec.js.snap
@@ -49,7 +49,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render
name="MyWidget"
/>
<div
- class="gl-display-flex gl-w-full"
+ class="gl-display-flex gl-flex-direction-column gl-w-full"
>
<div
class="gl-display-flex gl-flex-grow-1"
@@ -88,8 +88,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render
>
<li>
<div
- class="gl-align-items-center gl-display-flex"
- data-qa-selector="child_content"
+ class="gl-align-items-baseline gl-display-flex"
>
<div
class="gl-min-w-0 gl-w-full"
@@ -111,7 +110,7 @@ exports[`~/vue_merge_request_widget/components/widget/dynamic_content.vue render
class="gl-align-items-baseline gl-display-flex"
>
<div
- class="gl-display-flex gl-w-full"
+ class="gl-display-flex gl-flex-direction-column gl-w-full"
>
<div
class="gl-display-flex gl-flex-grow-1"
diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js
deleted file mode 100644
index d5e04c666e0..00000000000
--- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js
+++ /dev/null
@@ -1,224 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import { GlBadge } from '@gitlab/ui';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { trimText } from 'helpers/text_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import axios from '~/lib/utils/axios_utils';
-import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality/index.vue';
-import {
- HTTP_STATUS_INTERNAL_SERVER_ERROR,
- HTTP_STATUS_NO_CONTENT,
- HTTP_STATUS_OK,
-} from '~/lib/utils/http_status';
-import {
- i18n,
- codeQualityPrefixes,
-} from '~/vue_merge_request_widget/extensions/code_quality/constants';
-import {
- codeQualityResponseNewErrors,
- codeQualityResponseResolvedErrors,
- codeQualityResponseResolvedAndNewErrors,
- codeQualityResponseNoErrors,
-} from './mock_data';
-
-describe('Code Quality extension', () => {
- let wrapper;
- let mock;
- const endpoint = '/root/repo/-/merge_requests/4/codequality_reports.json';
-
- const mockApi = (statusCode, data) => {
- mock.onGet(endpoint).reply(statusCode, data);
- };
-
- const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
- const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
- const isCollapsable = () => wrapper.findByTestId('toggle-button').exists();
- const getNeutralIcon = () => wrapper.findByTestId('status-neutral-icon').exists();
- const getAlertIcon = () => wrapper.findByTestId('status-alert-icon').exists();
- const getSuccessIcon = () => wrapper.findByTestId('status-success-icon').exists();
-
- const createComponent = () => {
- wrapper = mountExtended(codeQualityExtension, {
- propsData: {
- mr: {
- codequality: endpoint,
- codequalityReportsPath: endpoint,
- blobPath: {
- head_path: 'example/path',
- base_path: 'example/path',
- },
- },
- },
- });
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('summary', () => {
- it('displays loading text', () => {
- mockApi(HTTP_STATUS_OK, codeQualityResponseNewErrors);
-
- createComponent();
-
- expect(wrapper.text()).toBe(i18n.loading);
- });
-
- it('with a 204 response, continues to display loading state', async () => {
- mockApi(HTTP_STATUS_NO_CONTENT, '');
- createComponent();
-
- await waitForPromises();
-
- expect(wrapper.text()).toBe(i18n.loading);
- });
-
- it('displays failed loading text', async () => {
- mockApi(HTTP_STATUS_INTERNAL_SERVER_ERROR);
-
- createComponent();
-
- await waitForPromises();
-
- expect(wrapper.text()).toBe(i18n.error);
- expect(isCollapsable()).toBe(false);
- });
-
- it('displays new Errors finding', async () => {
- mockApi(HTTP_STATUS_OK, codeQualityResponseNewErrors);
-
- createComponent();
-
- await waitForPromises();
- expect(wrapper.text()).toBe(
- i18n
- .singularCopy(
- i18n.findings(codeQualityResponseNewErrors.new_errors, codeQualityPrefixes.new),
- )
- .replace(/%{strong_start}/g, '')
- .replace(/%{strong_end}/g, ''),
- );
- expect(isCollapsable()).toBe(true);
- expect(getAlertIcon()).toBe(true);
- });
-
- it('displays resolved Errors finding', async () => {
- mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedErrors);
-
- createComponent();
-
- await waitForPromises();
- expect(wrapper.text()).toBe(
- i18n
- .singularCopy(
- i18n.findings(
- codeQualityResponseResolvedErrors.resolved_errors,
- codeQualityPrefixes.fixed,
- ),
- )
- .replace(/%{strong_start}/g, '')
- .replace(/%{strong_end}/g, ''),
- );
- expect(isCollapsable()).toBe(true);
- expect(getSuccessIcon()).toBe(true);
- });
-
- it('displays quality improvement and degradation', async () => {
- mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors);
-
- createComponent();
- await waitForPromises();
-
- // replacing strong tags because they will not be found in the rendered text
- expect(wrapper.text()).toBe(
- i18n
- .improvementAndDegradationCopy(
- i18n.findings(
- codeQualityResponseResolvedAndNewErrors.resolved_errors,
- codeQualityPrefixes.fixed,
- ),
- i18n.findings(
- codeQualityResponseResolvedAndNewErrors.new_errors,
- codeQualityPrefixes.new,
- ),
- )
- .replace(/%{strong_start}/g, '')
- .replace(/%{strong_end}/g, ''),
- );
- expect(isCollapsable()).toBe(true);
- expect(getAlertIcon()).toBe(true);
- });
-
- it('displays no detected errors', async () => {
- mockApi(HTTP_STATUS_OK, codeQualityResponseNoErrors);
-
- createComponent();
-
- await waitForPromises();
-
- expect(wrapper.text()).toBe(i18n.noChanges);
- expect(isCollapsable()).toBe(false);
- expect(getNeutralIcon()).toBe(true);
- });
- });
-
- describe('expanded data', () => {
- beforeEach(async () => {
- mockApi(HTTP_STATUS_OK, codeQualityResponseResolvedAndNewErrors);
-
- createComponent();
-
- await waitForPromises();
-
- findToggleCollapsedButton().trigger('click');
-
- await waitForPromises();
- });
-
- it('displays all report list items in viewport', () => {
- expect(findAllExtensionListItems()).toHaveLength(4);
- });
-
- it('displays report list item formatted', () => {
- const text = {
- newError: trimText(findAllExtensionListItems().at(0).text().replace(/\s+/g, ' ').trim()),
- resolvedError: findAllExtensionListItems().at(2).text().replace(/\s+/g, ' ').trim(),
- };
-
- expect(text.newError).toContain(
- "Minor - Parsing error: 'return' outside of function in index.js:12",
- );
- expect(text.resolvedError).toContain(
- "Minor - Parsing error: 'return' outside of function in index.js:12 Fixed",
- );
- });
-
- it('displays report list item formatted with check_name', () => {
- const text = {
- newError: trimText(findAllExtensionListItems().at(1).text().replace(/\s+/g, ' ').trim()),
- resolvedError: findAllExtensionListItems().at(3).text().replace(/\s+/g, ' ').trim(),
- };
-
- expect(text.newError).toContain(
- 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3',
- );
- expect(text.resolvedError).toContain(
- 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3 Fixed',
- );
- });
-
- it('adds fixed indicator (badge) when error is resolved', () => {
- expect(findAllExtensionListItems().at(3).findComponent(GlBadge).exists()).toBe(true);
- expect(findAllExtensionListItems().at(3).findComponent(GlBadge).text()).toEqual(i18n.fixed);
- });
-
- it('should not add fixed indicator (badge) when error is new', () => {
- expect(findAllExtensionListItems().at(0).findComponent(GlBadge).exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js
deleted file mode 100644
index e66c1521ff5..00000000000
--- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js
+++ /dev/null
@@ -1,101 +0,0 @@
-export const codeQualityResponseNewErrors = {
- status: 'failed',
- new_errors: [
- {
- description: "Parsing error: 'return' outside of function",
- severity: 'minor',
- file_path: 'index.js',
- line: 12,
- },
- {
- description: 'TODO found',
- severity: 'minor',
- file_path: '.gitlab-ci.yml',
- line: 73,
- },
- ],
- resolved_errors: [],
- existing_errors: [],
- summary: {
- total: 12235,
- resolved: 0,
- errored: 12235,
- },
-};
-
-export const codeQualityResponseResolvedErrors = {
- status: 'success',
- new_errors: [],
- resolved_errors: [
- {
- description: "Parsing error: 'return' outside of function",
- severity: 'minor',
- file_path: 'index.js',
- line: 12,
- },
- {
- description: 'TODO found',
- severity: 'minor',
- file_path: '.gitlab-ci.yml',
- line: 73,
- },
- ],
- existing_errors: [],
- summary: {
- total: 12235,
- resolved: 0,
- errored: 12235,
- },
-};
-
-export const codeQualityResponseResolvedAndNewErrors = {
- status: 'failed',
- new_errors: [
- {
- description: "Parsing error: 'return' outside of function",
- severity: 'minor',
- file_path: 'index.js',
- line: 12,
- },
- {
- description: 'Avoid parameter lists longer than 5 parameters. [12/5]',
- check_name: 'Rubocop/Metrics/ParameterLists',
- severity: 'minor',
- file_path: 'main.rb',
- line: 3,
- },
- ],
- resolved_errors: [
- {
- description: "Parsing error: 'return' outside of function",
- severity: 'minor',
- file_path: 'index.js',
- line: 12,
- },
- {
- description: 'Avoid parameter lists longer than 5 parameters. [12/5]',
- check_name: 'Rubocop/Metrics/ParameterLists',
- severity: 'minor',
- file_path: 'main.rb',
- line: 3,
- },
- ],
- existing_errors: [],
- summary: {
- total: 12233,
- resolved: 1,
- errored: 12233,
- },
-};
-
-export const codeQualityResponseNoErrors = {
- status: 'failed',
- new_errors: [],
- resolved_errors: [],
- existing_errors: [],
- summary: {
- total: 12234,
- resolved: 0,
- errored: 12234,
- },
-};
diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
index 6c2b21053f0..d2dfb6ee1bf 100644
--- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
@@ -1,16 +1,19 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import AlertDetails from '~/vue_shared/alert_details/components/alert_details.vue';
import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary_row.vue';
import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants';
import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql';
+import alertQuery from '~/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
import createStore from '~/vue_shared/components/metric_images/store/';
@@ -27,20 +30,57 @@ describe('AlertDetails', () => {
let environmentData = { name: environmentName, path: environmentPath };
let mock;
let wrapper;
+ let requestHandlers;
const projectPath = 'root/alerts';
const projectIssuesPath = 'root/alerts/-/issues';
const projectId = '1';
const $router = { push: jest.fn() };
+ const defaultHandlers = {
+ createIssueMutationMock: jest.fn().mockResolvedValue({
+ data: {
+ createAlertIssue: {
+ errors: [],
+ issue: {
+ id: 'id',
+ iid: 'iid',
+ webUrl: 'webUrl',
+ },
+ },
+ },
+ }),
+ alertQueryMock: jest.fn().mockResolvedValue({
+ data: {
+ project: {
+ id: '1',
+ alertManagementAlerts: {
+ nodes: [],
+ },
+ },
+ },
+ }),
+ };
+
+ const createMockApolloProvider = (handlers) => {
+ Vue.use(VueApollo);
+ requestHandlers = handlers;
+
+ return createMockApollo([
+ [alertQuery, handlers.alertQueryMock],
+ [createIssueMutation, handlers.createIssueMutationMock],
+ ]);
+ };
+
function mountComponent({
data,
- loading = false,
mountMethod = shallowMount,
provide = {},
stubs = {},
+ handlers = defaultHandlers,
} = {}) {
wrapper = extendedWrapper(
mountMethod(AlertDetails, {
+ apolloProvider: createMockApolloProvider(handlers),
provide: {
alertId: 'alertId',
projectPath,
@@ -59,15 +99,6 @@ describe('AlertDetails', () => {
};
},
mocks: {
- $apollo: {
- mutate: jest.fn(),
- queries: {
- alert: {
- loading,
- },
- sidebarStatus: {},
- },
- },
$router,
$route: { params: {} },
},
@@ -139,7 +170,6 @@ describe('AlertDetails', () => {
describe('Metrics tab', () => {
it('should mount without errors', () => {
mountComponent({
- mountMethod: mount,
provide: {
canUpdate: true,
iid: '1',
@@ -216,7 +246,6 @@ describe('AlertDetails', () => {
it('should display "Create incident" button when incident doesn\'t exist yet', async () => {
const issue = null;
mountComponent({
- mountMethod: mount,
data: { alert: { ...mockAlert, issue } },
});
@@ -226,23 +255,16 @@ describe('AlertDetails', () => {
});
it('calls `$apollo.mutate` with `createIssueQuery`', () => {
- const issueIid = '10';
mountComponent({
mountMethod: mount,
data: { alert: { ...mockAlert } },
});
- jest
- .spyOn(wrapper.vm.$apollo, 'mutate')
- .mockResolvedValue({ data: { createAlertIssue: { issue: { iid: issueIid } } } });
findCreateIncidentBtn().trigger('click');
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: createIssueMutation,
- variables: {
- iid: mockAlert.iid,
- projectPath,
- },
+ expect(requestHandlers.createIssueMutationMock).toHaveBeenCalledWith({
+ iid: mockAlert.iid,
+ projectPath,
});
});
@@ -251,25 +273,44 @@ describe('AlertDetails', () => {
mountComponent({
mountMethod: mount,
data: { alert: { ...mockAlert, alertIid: 1 } },
+ handlers: {
+ ...defaultHandlers,
+ createIssueMutationMock: jest.fn().mockRejectedValue(new Error(errorMsg)),
+ },
});
- jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
findCreateIncidentBtn().trigger('click');
await waitForPromises();
- expect(findIncidentCreationAlert().text()).toBe(errorMsg);
+ expect(findIncidentCreationAlert().text()).toBe(`Error: ${errorMsg}`);
});
});
describe('View full alert details', () => {
- beforeEach(() => {
- mountComponent({ data: { alert: mockAlert } });
+ beforeEach(async () => {
+ mountComponent({
+ data: { alert: mockAlert },
+ handlers: {
+ ...defaultHandlers,
+ alertQueryMock: jest.fn().mockResolvedValue({
+ data: {
+ project: {
+ id: '1',
+ alertManagementAlerts: {
+ nodes: [{ id: '1' }],
+ },
+ },
+ },
+ }),
+ },
+ });
+ await waitForPromises();
});
it('should display a table of raw alert details data', () => {
- const details = findDetailsTable();
- expect(details.exists()).toBe(true);
- expect(details.props()).toStrictEqual({
+ expect(findDetailsTable().exists()).toBe(true);
+
+ expect(findDetailsTable().props()).toStrictEqual({
alert: mockAlert,
statuses: PAGE_CONFIG.OPERATIONS.STATUSES,
loading: false,
@@ -279,7 +320,7 @@ describe('AlertDetails', () => {
describe('loading state', () => {
beforeEach(() => {
- mountComponent({ loading: true });
+ mountComponent();
});
it('displays a loading state when loading', () => {
diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon/ci_icon_spec.js
index cbb725bf9e6..792470c8e89 100644
--- a/spec/frontend/vue_shared/components/ci_icon_spec.js
+++ b/spec/frontend/vue_shared/components/ci_icon/ci_icon_spec.js
@@ -1,6 +1,6 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
const mockStatus = {
group: 'success',
diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
index 53218d794c7..b825a578cee 100644
--- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_modal_spec.js
@@ -19,7 +19,7 @@ describe('Confirm Danger Modal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findConfirmationPhrase = () => wrapper.findByTestId('confirm-danger-phrase');
- const findConfirmationInput = () => wrapper.findByTestId('confirm-danger-input');
+ const findConfirmationInput = () => wrapper.findByTestId('confirm-danger-field');
const findDefaultWarning = () => wrapper.findByTestId('confirm-danger-warning');
const findAdditionalMessage = () => wrapper.findByTestId('confirm-danger-message');
const findPrimaryAction = () => findModal().props('actionPrimary');
diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
index 810269257b6..e2c3fc89525 100644
--- a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
@@ -152,7 +152,8 @@ describe('Diff Stats Dropdown', () => {
});
it('focuses the first item when pressing the down key within the search box', () => {
- const spy = jest.spyOn(wrapper.vm, 'focusFirstItem');
+ const { element } = wrapper.find('.gl-new-dropdown-item');
+ const spy = jest.spyOn(element, 'focus');
findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ARROW_DOWN_KEY }));
expect(spy).toHaveBeenCalled();
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
index dd5a05a40c6..1a9a08a9656 100644
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_widget_spec.js
@@ -1,8 +1,8 @@
import { GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-
import { nextTick } from 'vue';
import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue';
+import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component';
describe('DropdownWidget component', () => {
let wrapper;
@@ -27,11 +27,14 @@ describe('DropdownWidget component', () => {
...props,
},
stubs: {
- GlDropdown,
+ GlDropdown: stubComponent(GlDropdown, {
+ methods: {
+ hide: jest.fn(),
+ },
+ template: RENDER_ALL_SLOTS_TEMPLATE,
+ }),
},
});
-
- jest.spyOn(findDropdown().vm, 'hide').mockImplementation();
};
beforeEach(() => {
diff --git a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
index 1376133ec37..02da6079466 100644
--- a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
+++ b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
@@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
describe('EntitySelect', () => {
let wrapper;
let fetchItemsMock;
- let fetchInitialSelectionTextMock;
+ let fetchInitialSelectionMock;
// Mocks
const itemMock = {
@@ -96,16 +96,16 @@ describe('EntitySelect', () => {
});
it("fetches the initially selected value's name", async () => {
- fetchInitialSelectionTextMock = jest.fn().mockImplementation(() => itemMock.text);
+ fetchInitialSelectionMock = jest.fn().mockImplementation(() => itemMock);
createComponent({
props: {
- fetchInitialSelectionText: fetchInitialSelectionTextMock,
+ fetchInitialSelection: fetchInitialSelectionMock,
initialSelection: itemMock.value,
},
});
await nextTick();
- expect(fetchInitialSelectionTextMock).toHaveBeenCalledTimes(1);
+ expect(fetchInitialSelectionMock).toHaveBeenCalledTimes(1);
expect(findListbox().props('toggleText')).toBe(itemMock.text);
});
});
@@ -188,7 +188,7 @@ describe('EntitySelect', () => {
findListbox().vm.$emit('reset');
await nextTick();
- expect(Object.keys(wrapper.emitted('input')[2][0]).length).toBe(0);
+ expect(wrapper.emitted('input')[2][0]).toEqual({});
});
});
});
diff --git a/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js
index ea029ba4f27..6dc38bbd0c6 100644
--- a/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js
+++ b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js
@@ -1,35 +1,35 @@
import VueApollo from 'vue-apollo';
-import Vue, { nextTick } from 'vue';
-import { GlCollapsibleListbox } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Vue from 'vue';
+import { GlCollapsibleListbox, GlAlert } from '@gitlab/ui';
+import { chunk } from 'lodash';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import OrganizationSelect from '~/vue_shared/components/entity_select/organization_select.vue';
import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue';
+import { DEFAULT_PER_PAGE } from '~/api';
import {
ORGANIZATION_TOGGLE_TEXT,
ORGANIZATION_HEADER_TEXT,
FETCH_ORGANIZATIONS_ERROR,
FETCH_ORGANIZATION_ERROR,
} from '~/vue_shared/components/entity_select/constants';
-import resolvers from '~/organizations/shared/graphql/resolvers';
-import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql';
-import { organizations as organizationsMock } from '~/organizations/mock_data';
+import getCurrentUserOrganizationsQuery from '~/organizations/shared/graphql/queries/organizations.query.graphql';
+import getOrganizationQuery from '~/organizations/shared/graphql/queries/organization.query.graphql';
+import { organizations as nodes, pageInfo, pageInfoEmpty } from '~/organizations/mock_data';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
Vue.use(VueApollo);
-jest.useFakeTimers();
-
describe('OrganizationSelect', () => {
let wrapper;
let mockApollo;
// Mocks
- const [organizationMock] = organizationsMock;
-
- // Stubs
- const GlAlert = {
- template: '<div><slot /></div>',
+ const [organization] = nodes;
+ const organizations = {
+ nodes,
+ pageInfo,
};
// Props
@@ -44,23 +44,26 @@ describe('OrganizationSelect', () => {
const findEntitySelect = () => wrapper.findComponent(EntitySelect);
const findAlert = () => wrapper.findComponent(GlAlert);
+ // Mock handlers
const handleInput = jest.fn();
+ const getCurrentUserOrganizationsQueryHandler = jest.fn().mockResolvedValue({
+ data: { currentUser: { id: 'gid://gitlab/User/1', __typename: 'CurrentUser', organizations } },
+ });
+ const getOrganizationQueryHandler = jest.fn().mockResolvedValue({
+ data: { organization },
+ });
// Helpers
- const createComponent = ({ props = {}, mockResolvers = resolvers, handlers } = {}) => {
- mockApollo = createMockApollo(
- handlers || [
- [
- organizationsQuery,
- jest.fn().mockResolvedValueOnce({
- data: { currentUser: { id: 1, organizations: { nodes: organizationsMock } } },
- }),
- ],
- ],
- mockResolvers,
- );
-
- wrapper = shallowMountExtended(OrganizationSelect, {
+ const createComponent = ({
+ props = {},
+ handlers = [
+ [getCurrentUserOrganizationsQuery, getCurrentUserOrganizationsQueryHandler],
+ [getOrganizationQuery, getOrganizationQueryHandler],
+ ],
+ } = {}) => {
+ mockApollo = createMockApollo(handlers);
+
+ wrapper = mountExtended(OrganizationSelect, {
apolloProvider: mockApollo,
propsData: {
label,
@@ -70,10 +73,6 @@ describe('OrganizationSelect', () => {
toggleClass,
...props,
},
- stubs: {
- GlAlert,
- EntitySelect,
- },
listeners: {
input: handleInput,
},
@@ -81,10 +80,6 @@ describe('OrganizationSelect', () => {
};
const openListbox = () => findListbox().vm.$emit('shown');
- afterEach(() => {
- mockApollo = null;
- });
-
describe('entity_select props', () => {
beforeEach(() => {
createComponent();
@@ -107,40 +102,31 @@ describe('OrganizationSelect', () => {
describe('on mount', () => {
it('fetches organizations when the listbox is opened', async () => {
createComponent();
- await nextTick();
- jest.runAllTimers();
- await waitForPromises();
-
openListbox();
- jest.runAllTimers();
await waitForPromises();
- expect(findListbox().props('items')).toEqual([
- { text: organizationsMock[0].name, value: 1 },
- { text: organizationsMock[1].name, value: 2 },
- { text: organizationsMock[2].name, value: 3 },
- ]);
+
+ const expectedItems = nodes.map((node) => ({
+ ...node,
+ text: node.name,
+ value: getIdFromGraphQLId(node.id),
+ }));
+
+ expect(findListbox().props('items')).toEqual(expectedItems);
});
describe('with an initial selection', () => {
it("fetches the initially selected value's name", async () => {
- createComponent({ props: { initialSelection: organizationMock.id } });
- await nextTick();
- jest.runAllTimers();
+ createComponent({ props: { initialSelection: organization.id } });
await waitForPromises();
- expect(findListbox().props('toggleText')).toBe(organizationMock.name);
+ expect(findListbox().props('toggleText')).toBe(organization.name);
});
it('show an error if fetching initially selected fails', async () => {
- const mockResolvers = {
- Query: {
- organization: jest.fn().mockRejectedValueOnce(new Error()),
- },
- };
-
- createComponent({ props: { initialSelection: organizationMock.id }, mockResolvers });
- await nextTick();
- jest.runAllTimers();
+ createComponent({
+ props: { initialSelection: organization.id },
+ handlers: [[getOrganizationQuery, jest.fn().mockRejectedValueOnce()]],
+ });
expect(findAlert().exists()).toBe(false);
@@ -152,18 +138,59 @@ describe('OrganizationSelect', () => {
});
});
+ describe('when listbox bottom is reached and there are more organizations to load', () => {
+ const [firstPage, secondPage] = chunk(nodes, Math.ceil(nodes.length / 2));
+ const getCurrentUserOrganizationsQueryMultiplePagesHandler = jest
+ .fn()
+ .mockResolvedValueOnce({
+ data: {
+ currentUser: {
+ id: 'gid://gitlab/User/1',
+ __typename: 'CurrentUser',
+ organizations: { nodes: firstPage, pageInfo },
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ data: {
+ currentUser: {
+ id: 'gid://gitlab/User/1',
+ __typename: 'CurrentUser',
+ organizations: { nodes: secondPage, pageInfo: pageInfoEmpty },
+ },
+ },
+ });
+
+ beforeEach(async () => {
+ createComponent({
+ handlers: [
+ [getCurrentUserOrganizationsQuery, getCurrentUserOrganizationsQueryMultiplePagesHandler],
+ [getOrganizationQuery, getOrganizationQueryHandler],
+ ],
+ });
+ openListbox();
+ await waitForPromises();
+
+ findListbox().vm.$emit('bottom-reached');
+ await waitForPromises();
+ });
+
+ it('calls graphQL query correct `after` variable', () => {
+ expect(getCurrentUserOrganizationsQueryMultiplePagesHandler).toHaveBeenCalledWith({
+ after: pageInfo.endCursor,
+ first: DEFAULT_PER_PAGE,
+ });
+ expect(findListbox().props('infiniteScroll')).toBe(false);
+ });
+ });
+
it('shows an error when fetching organizations fails', async () => {
createComponent({
- handlers: [[organizationsQuery, jest.fn().mockRejectedValueOnce(new Error())]],
+ handlers: [[getCurrentUserOrganizationsQuery, jest.fn().mockRejectedValueOnce()]],
});
- await nextTick();
- jest.runAllTimers();
- await waitForPromises();
-
openListbox();
expect(findAlert().exists()).toBe(false);
- jest.runAllTimers();
await waitForPromises();
expect(findAlert().exists()).toBe(true);
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 bb612a13209..3a5c7d7729f 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
@@ -1,11 +1,4 @@
-import {
- GlFilteredSearch,
- GlButtonGroup,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlFormCheckbox,
-} from '@gitlab/ui';
+import { GlDropdownItem, GlSorting, GlFilteredSearch, GlFormCheckbox } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
@@ -13,7 +6,6 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import {
FILTERED_SEARCH_TERM,
- SORT_DIRECTION,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
@@ -48,6 +40,7 @@ const createComponent = ({
recentSearchesStorageKey = 'requirements',
tokens = mockAvailableTokens,
sortOptions,
+ initialSortBy,
initialFilterValue = [],
showCheckbox = false,
checkboxChecked = false,
@@ -61,6 +54,7 @@ const createComponent = ({
recentSearchesStorageKey,
tokens,
sortOptions,
+ initialSortBy,
initialFilterValue,
showCheckbox,
checkboxChecked,
@@ -72,34 +66,38 @@ const createComponent = ({
describe('FilteredSearchBarRoot', () => {
let wrapper;
- const findGlButton = () => wrapper.findComponent(GlButton);
- const findGlDropdown = () => wrapper.findComponent(GlDropdown);
+ const findGlSorting = () => wrapper.findComponent(GlSorting);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
- beforeEach(() => {
- wrapper = createComponent({ sortOptions: mockSortOptions });
- });
-
describe('data', () => {
- it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => {
- expect(wrapper.vm.filterValue).toEqual([]);
- expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0]);
- expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.descending);
- expect(wrapper.findComponent(GlButtonGroup).exists()).toBe(true);
- expect(wrapper.findComponent(GlButton).exists()).toBe(true);
- expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
- expect(wrapper.findComponent(GlDropdownItem).exists()).toBe(true);
+ describe('when `sortOptions` are provided', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ sortOptions: mockSortOptions });
+ });
+
+ it('sets a correct initial value for GlFilteredSearch', () => {
+ expect(findGlFilteredSearch().props('value')).toEqual([]);
+ });
+
+ it('emits an event with the selectedSortOption provided by default', async () => {
+ findGlSorting().vm.$emit('sortByChange', mockSortOptions[1].id);
+ await nextTick();
+
+ expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]);
+ });
+
+ it('emits an event with the selectedSortDirection provided by default', async () => {
+ findGlSorting().vm.$emit('sortDirectionChange', true);
+ await nextTick();
+
+ expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]);
+ });
});
- it('does not initialize `selectedSortOption` and `selectedSortDirection` when `sortOptions` is not applied and hides the sort dropdown', () => {
- const wrapperNoSort = createComponent();
+ it('does not initialize the sort dropdown when `sortOptions` are not provided', () => {
+ wrapper = createComponent();
- expect(wrapperNoSort.vm.filterValue).toEqual([]);
- expect(wrapperNoSort.vm.selectedSortOption).toBe(undefined);
- expect(wrapperNoSort.findComponent(GlButtonGroup).exists()).toBe(false);
- expect(wrapperNoSort.findComponent(GlButton).exists()).toBe(false);
- expect(wrapperNoSort.findComponent(GlDropdown).exists()).toBe(false);
- expect(wrapperNoSort.findComponent(GlDropdownItem).exists()).toBe(false);
+ expect(findGlSorting().exists()).toBe(false);
});
});
@@ -125,27 +123,27 @@ describe('FilteredSearchBarRoot', () => {
});
describe('sortDirectionIcon', () => {
- it('renders `sort-highest` descending icon by default', () => {
- expect(findGlButton().props('icon')).toBe('sort-highest');
- expect(findGlButton().attributes()).toMatchObject({
- 'aria-label': 'Sort direction: Descending',
- title: 'Sort direction: Descending',
- });
+ beforeEach(() => {
+ wrapper = createComponent({ sortOptions: mockSortOptions });
+ });
+
+ it('passes isAscending=false to GlSorting by default', () => {
+ expect(findGlSorting().props('isAscending')).toBe(false);
});
it('renders `sort-lowest` ascending icon when the sort button is clicked', async () => {
- findGlButton().vm.$emit('click');
+ findGlSorting().vm.$emit('sortDirectionChange', true);
await nextTick();
- expect(findGlButton().props('icon')).toBe('sort-lowest');
- expect(findGlButton().attributes()).toMatchObject({
- 'aria-label': 'Sort direction: Ascending',
- title: 'Sort direction: Ascending',
- });
+ expect(findGlSorting().props('isAscending')).toBe(true);
});
});
describe('filteredRecentSearches', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
it('returns array of recent searches filtering out any string type (unsupported) items', async () => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
@@ -227,34 +225,37 @@ describe('FilteredSearchBarRoot', () => {
});
});
- describe('handleSortOptionClick', () => {
- it('emits component event `onSort` with selected sort by value', () => {
- wrapper.vm.handleSortOptionClick(mockSortOptions[1]);
+ describe('handleSortOptionChange', () => {
+ it('emits component event `onSort` with selected sort by value', async () => {
+ wrapper = createComponent({ sortOptions: mockSortOptions });
+
+ findGlSorting().vm.$emit('sortByChange', mockSortOptions[1].id);
+ await nextTick();
expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[1]);
expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[1].sortDirection.descending]);
});
});
- describe('handleSortDirectionClick', () => {
+ describe('handleSortDirectionChange', () => {
beforeEach(() => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- selectedSortOption: mockSortOptions[0],
+ wrapper = createComponent({
+ sortOptions: mockSortOptions,
+ initialSortBy: mockSortOptions[0].sortDirection.descending,
});
});
- it('sets `selectedSortDirection` to be opposite of its current value', () => {
- expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.descending);
+ it('sets sort direction to be opposite of its current value', async () => {
+ expect(findGlSorting().props('isAscending')).toBe(false);
- wrapper.vm.handleSortDirectionClick();
+ findGlSorting().vm.$emit('sortDirectionChange', true);
+ await nextTick();
- expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.ascending);
+ expect(findGlSorting().props('isAscending')).toBe(true);
});
it('emits component event `onSort` with opposite of currently selected sort by value', () => {
- wrapper.vm.handleSortDirectionClick();
+ findGlSorting().vm.$emit('sortDirectionChange', true);
expect(wrapper.emitted('onSort')[0]).toEqual([mockSortOptions[0].sortDirection.ascending]);
});
@@ -288,6 +289,8 @@ describe('FilteredSearchBarRoot', () => {
const mockFilters = [tokenValueAuthor, 'foo'];
beforeEach(async () => {
+ wrapper = 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({
@@ -358,19 +361,14 @@ describe('FilteredSearchBarRoot', () => {
});
describe('template', () => {
- beforeEach(async () => {
+ it('renders gl-filtered-search component', async () => {
+ wrapper = 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({
- selectedSortOption: mockSortOptions[0],
- selectedSortDirection: SORT_DIRECTION.descending,
+ await wrapper.setData({
recentSearches: mockHistoryItems,
});
- await nextTick();
- });
-
- it('renders gl-filtered-search component', () => {
const glFilteredSearchEl = wrapper.findComponent(GlFilteredSearch);
expect(glFilteredSearchEl.props('placeholder')).toBe('Filter requirements');
@@ -454,25 +452,28 @@ describe('FilteredSearchBarRoot', () => {
});
it('renders sort dropdown component', () => {
- expect(wrapper.findComponent(GlButtonGroup).exists()).toBe(true);
- expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
- expect(wrapper.findComponent(GlDropdown).props('text')).toBe(mockSortOptions[0].title);
- });
-
- it('renders sort dropdown items', () => {
- const dropdownItemsEl = wrapper.findAllComponents(GlDropdownItem);
+ wrapper = createComponent({ sortOptions: mockSortOptions });
- expect(dropdownItemsEl).toHaveLength(mockSortOptions.length);
- expect(dropdownItemsEl.at(0).text()).toBe(mockSortOptions[0].title);
- expect(dropdownItemsEl.at(0).props('isChecked')).toBe(true);
- expect(dropdownItemsEl.at(1).text()).toBe(mockSortOptions[1].title);
+ expect(findGlSorting().exists()).toBe(true);
});
- it('renders sort direction button', () => {
- const sortButtonEl = wrapper.findComponent(GlButton);
-
- expect(sortButtonEl.attributes('title')).toBe('Sort direction: Descending');
- expect(sortButtonEl.props('icon')).toBe('sort-highest');
+ it('renders sort dropdown items', () => {
+ wrapper = createComponent({ sortOptions: mockSortOptions });
+
+ const { sortOptions, sortBy } = findGlSorting().props();
+
+ expect(sortOptions).toEqual([
+ {
+ value: mockSortOptions[0].id,
+ text: mockSortOptions[0].title,
+ },
+ {
+ value: mockSortOptions[1].id,
+ text: mockSortOptions[1].title,
+ },
+ ]);
+
+ expect(sortBy).toBe(mockSortOptions[0].id);
});
});
@@ -483,6 +484,10 @@ describe('FilteredSearchBarRoot', () => {
value: { data: '' },
};
+ beforeEach(() => {
+ wrapper = createComponent({ sortOptions: mockSortOptions });
+ });
+
it('syncs filter value', async () => {
await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: true });
@@ -498,17 +503,33 @@ describe('FilteredSearchBarRoot', () => {
it('syncs sort values', async () => {
await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: true });
- expect(findGlDropdown().props('text')).toBe('Last updated');
- expect(findGlButton().props('icon')).toBe('sort-lowest');
- expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Ascending');
+ expect(findGlSorting().props()).toMatchObject({
+ sortBy: 2,
+ isAscending: true,
+ });
});
it('does not sync sort values when syncFilterAndSort=false', async () => {
await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: false });
- expect(findGlDropdown().props('text')).toBe('Created date');
- expect(findGlButton().props('icon')).toBe('sort-highest');
- expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Descending');
+ expect(findGlSorting().props()).toMatchObject({
+ sortBy: 1,
+ isAscending: false,
+ });
+ });
+
+ it('does not sync sort values when initialSortBy is unset', async () => {
+ // Give initialSort some value which changes the current sort option...
+ await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: true });
+
+ // ... Read the new sort options...
+ const { sortBy, isAscending } = findGlSorting().props();
+
+ // ... Then *unset* initialSortBy...
+ await wrapper.setProps({ initialSortBy: undefined });
+
+ // ... The sort options should not have changed.
+ expect(findGlSorting().props()).toMatchObject({ sortBy, isAscending });
});
});
});
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 88618de6979..1d6834a5604 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
@@ -156,9 +156,12 @@ describe('BaseToken', () => {
it('uses last item in list when value is an array', () => {
const mockGetActiveTokenValue = jest.fn();
+ const config = { ...mockConfig, multiSelect: true };
+
wrapper = createComponent({
props: {
- value: { data: mockLabels.map((l) => l.title) },
+ config,
+ value: { data: mockLabels.map((l) => l.title), operator: '||' },
suggestions: mockLabels,
getActiveTokenValue: mockGetActiveTokenValue,
},
@@ -409,8 +412,9 @@ describe('BaseToken', () => {
});
it('emits token-selected event when groupMultiSelectTokens: true', () => {
+ const config = { ...mockConfig, multiSelect: true };
wrapper = createComponent({
- props: { suggestions: mockLabels },
+ props: { suggestions: mockLabels, config, value: { operator: '||' } },
groupMultiSelectTokens: true,
});
@@ -419,9 +423,10 @@ describe('BaseToken', () => {
expect(wrapper.emitted('token-selected')).toEqual([[mockTokenValue.title]]);
});
- it('does not emit token-selected event when groupMultiSelectTokens: true', () => {
+ it('does not emit token-selected event when groupMultiSelectTokens: false', () => {
+ const config = { ...mockConfig, multiSelect: true };
wrapper = createComponent({
- props: { suggestions: mockLabels },
+ props: { suggestions: mockLabels, config, value: { operator: '||' } },
groupMultiSelectTokens: false,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js
index 56a59790210..34d0c7f0566 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js
@@ -42,7 +42,7 @@ describe('DateToken', () => {
findDatepicker().vm.$emit('close');
expect(findGlFilteredSearchToken().emitted()).toEqual({
- complete: [[]],
+ complete: [['2014-10-13']],
select: [['2014-10-13']],
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index 36e82b39df4..ee54fb5b941 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -5,15 +5,12 @@ import {
GlDropdownDivider,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
-import axios from '~/lib/utils/axios_utils';
import { sortMilestonesByDueDate } from '~/milestones/utils';
-
import searchMilestonesQuery from '~/issues/list/queries/search_milestones.query.graphql';
import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
@@ -70,7 +67,6 @@ function createComponent(options = {}) {
}
describe('MilestoneToken', () => {
- let mock;
let wrapper;
const findBaseToken = () => wrapper.findComponent(BaseToken);
@@ -80,14 +76,9 @@ describe('MilestoneToken', () => {
};
beforeEach(() => {
- mock = new MockAdapter(axios);
wrapper = createComponent();
});
- afterEach(() => {
- mock.restore();
- });
-
describe('methods', () => {
describe('fetchMilestones', () => {
it('sets loading state', async () => {
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
index 4462d1bfaf5..decf843091e 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
@@ -313,11 +313,11 @@ describe('UserToken', () => {
describe('multiSelect', () => {
it('renders check icons in suggestions when multiSelect is true', async () => {
wrapper = createComponent({
- value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' },
+ value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' },
data: {
users: mockUsers,
},
- config: { ...mockAuthorToken, multiSelect: true, initialUsers: mockUsers },
+ config: { ...mockAuthorToken, multiSelect: true },
active: true,
stubs: { Portal: true },
groupMultiSelectTokens: true,
@@ -327,18 +327,17 @@ describe('UserToken', () => {
const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion);
- expect(findIconAtSuggestion(1).exists()).toBe(false);
- expect(findIconAtSuggestion(2).props('name')).toBe('check');
- expect(findIconAtSuggestion(3).props('name')).toBe('check');
+ expect(findIconAtSuggestion(0).props('name')).toBe('check');
+ expect(findIconAtSuggestion(1).props('name')).toBe('check');
+ expect(findIconAtSuggestion(2).exists()).toBe(false);
// test for left padding on unchecked items (so alignment is correct)
- expect(findIconAtSuggestion(4).exists()).toBe(false);
- expect(suggestions.at(4).find('.gl-pl-6').exists()).toBe(true);
+ expect(suggestions.at(2).find('.gl-pl-6').exists()).toBe(true);
});
it('renders multiple users when multiSelect is true', async () => {
wrapper = createComponent({
- value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' },
+ value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' },
data: {
users: mockUsers,
},
@@ -363,7 +362,7 @@ describe('UserToken', () => {
it('adds new user to multi-select-values', () => {
wrapper = createComponent({
- value: { data: [mockUsers[0].username], operator: '=' },
+ value: { data: [mockUsers[0].username], operator: '||' },
data: {
users: mockUsers,
},
@@ -383,7 +382,7 @@ describe('UserToken', () => {
it('removes existing user from array', () => {
const initialUsers = [mockUsers[0].username, mockUsers[1].username];
wrapper = createComponent({
- value: { data: initialUsers, operator: '=' },
+ value: { data: initialUsers, operator: '||' },
data: {
users: mockUsers,
},
@@ -399,7 +398,7 @@ describe('UserToken', () => {
it('clears input field after token selected', () => {
wrapper = createComponent({
- value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '=' },
+ value: { data: [mockUsers[0].username, mockUsers[1].username], operator: '||' },
data: {
users: mockUsers,
},
@@ -410,7 +409,7 @@ describe('UserToken', () => {
findBaseToken().vm.$emit('token-selected', 'test');
- expect(wrapper.emitted('input')).toEqual([[{ operator: '=', data: '' }]]);
+ expect(wrapper.emitted('input')).toEqual([[{ operator: '||', data: '' }]]);
});
});
diff --git a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js
deleted file mode 100644
index f69a883ee4d..00000000000
--- a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js
+++ /dev/null
@@ -1,118 +0,0 @@
-import { nextTick } from 'vue';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
-
-const SLOT_1 = {
- slotKey: 'slot-1',
- title: 'Hello 1',
-};
-const SLOT_2 = {
- slotKey: 'slot-2',
- title: 'Hello 2',
-};
-
-describe('~/vue_shared/components/keep_alive_slots.vue', () => {
- let wrapper;
-
- const createSlotContent = ({ slotKey, title }) => `
- <div data-testid="slot-child" data-slot-id="${slotKey}">
- <h1>${title}</h1>
- <input type="text" />
- </div>
- `;
- const createComponent = (props = {}) => {
- wrapper = mountExtended(KeepAliveSlots, {
- propsData: props,
- slots: {
- [SLOT_1.slotKey]: createSlotContent(SLOT_1),
- [SLOT_2.slotKey]: createSlotContent(SLOT_2),
- },
- });
- };
-
- const findRenderedSlots = () =>
- wrapper.findAllByTestId('slot-child').wrappers.map((x) => ({
- title: x.find('h1').text(),
- inputValue: x.find('input').element.value,
- isVisible: x.isVisible(),
- }));
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('doesnt show anything', () => {
- expect(findRenderedSlots()).toEqual([]);
- });
-
- describe('when slotKey is changed', () => {
- beforeEach(async () => {
- wrapper.setProps({ slotKey: SLOT_1.slotKey });
- await nextTick();
- });
-
- it('shows slot', () => {
- expect(findRenderedSlots()).toEqual([
- {
- title: SLOT_1.title,
- isVisible: true,
- inputValue: '',
- },
- ]);
- });
-
- it('hides everything when slotKey cannot be found', async () => {
- wrapper.setProps({ slotKey: '' });
- await nextTick();
-
- expect(findRenderedSlots()).toEqual([
- {
- title: SLOT_1.title,
- isVisible: false,
- inputValue: '',
- },
- ]);
- });
-
- describe('when user intreracts then slotKey changes again', () => {
- beforeEach(async () => {
- wrapper.find('input').setValue('TEST');
- wrapper.setProps({ slotKey: SLOT_2.slotKey });
- await nextTick();
- });
-
- it('keeps first slot alive but hidden', () => {
- expect(findRenderedSlots()).toEqual([
- {
- title: SLOT_1.title,
- isVisible: false,
- inputValue: 'TEST',
- },
- {
- title: SLOT_2.title,
- isVisible: true,
- inputValue: '',
- },
- ]);
- });
- });
- });
- });
-
- describe('initialized with slotKey', () => {
- beforeEach(() => {
- createComponent({ slotKey: SLOT_2.slotKey });
- });
-
- it('shows slot', () => {
- expect(findRenderedSlots()).toEqual([
- {
- title: SLOT_2.title,
- isVisible: true,
- inputValue: '',
- },
- ]);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/list_selector/deploy_key_item_spec.js b/spec/frontend/vue_shared/components/list_selector/deploy_key_item_spec.js
new file mode 100644
index 00000000000..96be5b345a1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/list_selector/deploy_key_item_spec.js
@@ -0,0 +1,61 @@
+import { GlIcon, GlButton } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DeployKeyItem from '~/vue_shared/components/list_selector/deploy_key_item.vue';
+
+describe('DeployKeyItem spec', () => {
+ let wrapper;
+
+ const MOCK_DATA = { title: 'Some key', owner: 'root', id: '123' };
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(DeployKeyItem, {
+ propsData: {
+ data: MOCK_DATA,
+ ...props,
+ },
+ });
+ };
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findDeleteButton = () => wrapper.findComponent(GlButton);
+ const findWrapper = () => wrapper.findByTestId('deploy-key-wrapper');
+
+ beforeEach(() => createComponent());
+
+ it('renders a key icon component', () => {
+ expect(findIcon().props('name')).toBe('key');
+ });
+
+ it('renders a title and username', () => {
+ expect(wrapper.text()).toContain('Some key');
+ expect(wrapper.text()).toContain('@root');
+ });
+
+ it('does not render a delete button by default', () => {
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+
+ it('emits a select event when the wrapper is clicked', () => {
+ findWrapper().trigger('click');
+
+ expect(wrapper.emitted('select')).toEqual([[MOCK_DATA.id]]);
+ });
+
+ describe('Delete button', () => {
+ beforeEach(() => createComponent({ canDelete: true }));
+
+ it('renders a delete button', () => {
+ expect(findDeleteButton().exists()).toBe(true);
+ expect(findDeleteButton().props('icon')).toBe('remove');
+ });
+
+ it('emits a delete event if the delete button is clicked', () => {
+ const stopPropagation = jest.fn();
+
+ findDeleteButton().vm.$emit('click', { stopPropagation });
+
+ expect(stopPropagation).toHaveBeenCalled();
+ expect(wrapper.emitted('delete')).toEqual([[MOCK_DATA.id]]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/list_selector/index_spec.js b/spec/frontend/vue_shared/components/list_selector/index_spec.js
index 11e64a91eb0..6de9a77582c 100644
--- a/spec/frontend/vue_shared/components/list_selector/index_spec.js
+++ b/spec/frontend/vue_shared/components/list_selector/index_spec.js
@@ -7,6 +7,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import ListSelector from '~/vue_shared/components/list_selector/index.vue';
import UserItem from '~/vue_shared/components/list_selector/user_item.vue';
import GroupItem from '~/vue_shared/components/list_selector/group_item.vue';
+import DeployKeyItem from '~/vue_shared/components/list_selector/deploy_key_item.vue';
import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -20,18 +21,21 @@ describe('List Selector spec', () => {
let fakeApollo;
const USERS_MOCK_PROPS = {
- title: 'Users',
projectPath: 'some/project/path',
groupPath: 'some/group/path',
type: 'users',
};
const GROUPS_MOCK_PROPS = {
- title: 'Groups',
projectPath: 'some/project/path',
type: 'groups',
};
+ const DEPLOY_KEYS_MOCK_PROPS = {
+ projectPath: 'some/project/path',
+ type: 'deployKeys',
+ };
+
const groupsAutocompleteQuerySuccess = jest.fn().mockResolvedValue(GROUPS_RESPONSE_MOCK);
const createComponent = async (props) => {
@@ -56,6 +60,7 @@ describe('List Selector spec', () => {
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findAllUserComponents = () => wrapper.findAllComponents(UserItem);
const findAllGroupComponents = () => wrapper.findAllComponents(GroupItem);
+ const findAllDeployKeyComponents = () => wrapper.findAllComponents(DeployKeyItem);
beforeEach(() => {
jest.spyOn(Api, 'projectUsers').mockResolvedValue(USERS_RESPONSE_MOCK);
@@ -254,4 +259,46 @@ describe('List Selector spec', () => {
});
});
});
+
+ describe('Deploy keys type', () => {
+ beforeEach(() => createComponent(DEPLOY_KEYS_MOCK_PROPS));
+
+ it('renders a correct title', () => {
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toContain('Deploy keys');
+ });
+
+ it('renders the correct icon', () => {
+ expect(findIcon().props('name')).toBe('key');
+ });
+
+ describe('selected items', () => {
+ const selectedKey = { title: 'MyKey', owner: 'peter', id: '123' };
+ const selectedItems = [selectedKey];
+ beforeEach(() => createComponent({ ...DEPLOY_KEYS_MOCK_PROPS, selectedItems }));
+
+ it('renders a heading with the total selected items', () => {
+ expect(findTitle().text()).toContain('Deploy keys');
+ expect(findTitle().text()).toContain('1');
+ });
+
+ it('renders a deploy key component for each selected item', () => {
+ expect(findAllDeployKeyComponents().length).toBe(selectedItems.length);
+ expect(findAllDeployKeyComponents().at(0).props()).toMatchObject({
+ data: selectedKey,
+ canDelete: true,
+ });
+ });
+
+ it('emits a delete event when a delete event is emitted from the deploy key component', () => {
+ const id = '123';
+ findAllDeployKeyComponents().at(0).vm.$emit('delete', id);
+
+ expect(wrapper.emitted('delete')).toEqual([[id]]);
+ });
+
+ // TODO - add a test for the select event once we have API integration
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/432494
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 40875ed5dbc..57f6d751efd 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -82,6 +82,14 @@ describe('Markdown field header component', () => {
});
});
+ it('attach file button should have data-button-type attribute', () => {
+ const attachButton = findToolbarButtonByProp('icon', 'paperclip');
+
+ // Used for dropzone_input.js as `clickable` property
+ // to prevent triggers upload file by clicking on the edge of textarea
+ expect(attachButton.attributes('data-button-type')).toBe('attach-file');
+ });
+
it('hides markdown preview when previewMarkdown is false', () => {
expect(findPreviewToggle().text()).toBe('Preview');
});
diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
index 544466a22ca..626b1df5474 100644
--- a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
@@ -43,7 +43,7 @@ describe('Metrics tab store actions', () => {
it('should call success action when fetching metric images', () => {
service.getMetricImages.mockImplementation(() => Promise.resolve(fileList));
- testAction(actions.fetchImages, null, state, [
+ return testAction(actions.fetchImages, null, state, [
{ type: types.REQUEST_METRIC_IMAGES },
{
type: types.RECEIVE_METRIC_IMAGES_SUCCESS,
@@ -80,7 +80,7 @@ describe('Metrics tab store actions', () => {
it('should call success action when uploading an image', () => {
service.uploadMetricImage.mockImplementation(() => Promise.resolve(fileList[0]));
- testAction(actions.uploadImage, payload, state, [
+ return testAction(actions.uploadImage, payload, state, [
{ type: types.REQUEST_METRIC_UPLOAD },
{
type: types.RECEIVE_METRIC_UPLOAD_SUCCESS,
@@ -112,7 +112,7 @@ describe('Metrics tab store actions', () => {
it('should call success action when updating an image', () => {
service.updateMetricImage.mockImplementation(() => Promise.resolve());
- testAction(actions.updateImage, payload, state, [
+ return testAction(actions.updateImage, payload, state, [
{ type: types.REQUEST_METRIC_UPLOAD },
{
type: types.RECEIVE_METRIC_UPDATE_SUCCESS,
@@ -140,7 +140,7 @@ describe('Metrics tab store actions', () => {
it('should call success action when deleting an image', () => {
service.deleteMetricImage.mockImplementation(() => Promise.resolve());
- testAction(actions.deleteImage, payload, state, [
+ return testAction(actions.deleteImage, payload, state, [
{
type: types.RECEIVE_METRIC_DELETE_SUCCESS,
payload,
@@ -151,7 +151,7 @@ describe('Metrics tab store actions', () => {
describe('initial data', () => {
it('should set the initial data correctly', () => {
- testAction(actions.setInitialData, initialData, state, [
+ return testAction(actions.setInitialData, initialData, state, [
{ type: types.SET_INITIAL_DATA, payload: initialData },
]);
});
diff --git a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js
index 7efc0e162b8..a67276ac64a 100644
--- a/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/new_resource_dropdown/new_resource_dropdown_spec.js
@@ -11,6 +11,7 @@ import searchProjectsWithinGroupQuery from '~/issues/list/queries/search_project
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import { stubComponent } from 'helpers/stub_component';
import {
emptySearchProjectsQueryResponse,
emptySearchProjectsWithinGroupQueryResponse,
@@ -42,6 +43,7 @@ describe('NewResourceDropdown component', () => {
queryResponse = searchProjectsQueryResponse,
mountFn = shallowMount,
propsData = {},
+ stubs = {},
} = {}) => {
const requestHandlers = [[query, jest.fn().mockResolvedValue(queryResponse)]];
const apolloProvider = createMockApollo(requestHandlers);
@@ -49,6 +51,9 @@ describe('NewResourceDropdown component', () => {
wrapper = mountFn(NewResourceDropdown, {
apolloProvider,
propsData,
+ stubs: {
+ ...stubs,
+ },
});
};
@@ -81,13 +86,18 @@ describe('NewResourceDropdown component', () => {
});
it('focuses on input when dropdown is shown', async () => {
- mountComponent({ mountFn: mount });
-
- const inputSpy = jest.spyOn(findInput().vm, 'focusInput');
+ const inputMock = jest.fn();
+ mountComponent({
+ stubs: {
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ methods: { focusInput: inputMock },
+ }),
+ },
+ });
await showDropdown();
- expect(inputSpy).toHaveBeenCalledTimes(1);
+ expect(inputMock).toHaveBeenCalledTimes(1);
});
describe.each`
diff --git a/spec/frontend/vue_shared/components/number_to_human_size/number_to_human_size_spec.js b/spec/frontend/vue_shared/components/number_to_human_size/number_to_human_size_spec.js
new file mode 100644
index 00000000000..6dd22211c96
--- /dev/null
+++ b/spec/frontend/vue_shared/components/number_to_human_size/number_to_human_size_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount } from '@vue/test-utils';
+import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+
+describe('NumberToHumanSize', () => {
+ /** @type {import('@vue/test-utils').Wrapper} */
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(NumberToHumanSize, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ it('formats the value', () => {
+ const value = 1024;
+ createComponent({ value });
+
+ const expectedValue = numberToHumanSize(value, 1);
+ expect(wrapper.text()).toBe(expectedValue);
+ });
+
+ it('handles number of fraction digits', () => {
+ const value = 1024 + 254;
+ const fractionDigits = 2;
+ createComponent({ value, fractionDigits });
+
+ const expectedValue = numberToHumanSize(value, fractionDigits);
+ expect(wrapper.text()).toBe(expectedValue);
+ });
+
+ describe('plain-zero', () => {
+ it('hides label for zero values', () => {
+ createComponent({ value: 0, plainZero: true });
+ expect(wrapper.text()).toBe('0');
+ });
+
+ it('shows text for non-zero values', () => {
+ const value = 163;
+ const expectedValue = numberToHumanSize(value, 1);
+ createComponent({ value, plainZero: true });
+ expect(wrapper.text()).toBe(expectedValue);
+ });
+ });
+});
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
index c7b2363026a..cd18058abec 100644
--- 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
@@ -62,6 +62,7 @@ describe('Chunk component', () => {
it('renders highlighted content', () => {
expect(findContent().text()).toBe(CHUNK_2.highlightedContent);
+ expect(findContent().attributes('style')).toBe('margin-left: 96px;');
});
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js
index 49e3083f8ed..c84a39274f8 100644
--- a/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/highlight_util_spec.js
@@ -6,6 +6,7 @@ import { LINES_PER_CHUNK, NEWLINE } from '~/vue_shared/components/source_viewer/
jest.mock('highlight.js/lib/core', () => ({
highlight: jest.fn().mockReturnValue({ value: 'highlighted content' }),
registerLanguage: jest.fn(),
+ getLanguage: jest.fn(),
}));
jest.mock('~/vue_shared/components/source_viewer/plugins/index', () => ({
@@ -28,11 +29,37 @@ describe('Highlight utility', () => {
expect(registerPlugins).toHaveBeenCalled();
});
+ describe('sub-languages', () => {
+ const languageDefinition = {
+ subLanguage: 'xml',
+ contains: [{ subLanguage: 'javascript' }, { subLanguage: 'typescript' }],
+ };
+
+ beforeEach(async () => {
+ jest.spyOn(hljs, 'getLanguage').mockReturnValue(languageDefinition);
+ await highlight(fileType, rawContent, language);
+ });
+
+ it('registers the primary sub-language', () => {
+ expect(hljs.registerLanguage).toHaveBeenCalledWith(
+ languageDefinition.subLanguage,
+ expect.any(Function),
+ );
+ });
+
+ it.each(languageDefinition.contains)(
+ 'registers the rest of the sub-languages',
+ ({ subLanguage }) => {
+ expect(hljs.registerLanguage).toHaveBeenCalledWith(subLanguage, expect.any(Function));
+ },
+ );
+ });
+
it('highlights the content', () => {
expect(hljs.highlight).toHaveBeenCalledWith(rawContent, { language });
});
- it('splits the content into chunks', () => {
+ it('splits the content into chunks', async () => {
const contentArray = Array.from({ length: 140 }, () => 'newline'); // simulate 140 lines of code
const chunks = [
@@ -52,7 +79,7 @@ describe('Highlight utility', () => {
},
];
- expect(highlight(fileType, contentArray.join(NEWLINE), language)).toEqual(
+ expect(await highlight(fileType, contentArray.join(NEWLINE), language)).toEqual(
expect.arrayContaining(chunks),
);
});
@@ -71,7 +98,7 @@ describe('unsupported languages', () => {
expect(hljs.highlight).not.toHaveBeenCalled();
});
- it('does not return a result', () => {
- expect(highlight(fileType, rawContent, unsupportedLanguage)).toBe(undefined);
+ it('does not return a result', async () => {
+ expect(await highlight(fileType, rawContent, unsupportedLanguage)).toBe(undefined);
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/mock_data.js
index cfff3a15b77..c98f945fc54 100644
--- a/spec/frontend/vue_shared/components/source_viewer/mock_data.js
+++ b/spec/frontend/vue_shared/components/source_viewer/mock_data.js
@@ -79,6 +79,7 @@ export const BLAME_DATA_QUERY_RESPONSE_MOCK = {
titleHtml: 'Upload New File',
message: 'Upload New File',
authoredDate: '2022-10-31T10:38:30+00:00',
+ authorName: 'Peter',
authorGravatar: 'path/to/gravatar',
webPath: '/commit/1234',
author: {},
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
index ee7164515f6..86dc9afaacc 100644
--- 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
@@ -1,11 +1,15 @@
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { setHTMLFixture } from 'helpers/fixtures';
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 {
+ EVENT_ACTION,
+ EVENT_LABEL_VIEWER,
+ CODEOWNERS_FILE_NAME,
+} from '~/vue_shared/components/source_viewer/constants';
import Tracking from '~/tracking';
import LineHighlighter from '~/blob/line_highlighter';
import addBlobLinksTracking from '~/blob/blob_links_tracking';
@@ -13,6 +17,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import blameDataQuery from '~/vue_shared/components/source_viewer/queries/blame_data.query.graphql';
import Blame from '~/vue_shared/components/source_viewer/components/blame_info.vue';
import * as utils from '~/vue_shared/components/source_viewer/utils';
+import CodeownersValidation from 'ee_component/blob/components/codeowners_validation.vue';
import {
BLOB_DATA_MOCK,
@@ -43,16 +48,17 @@ describe('Source Viewer component', () => {
const blameInfo =
BLAME_DATA_QUERY_RESPONSE_MOCK.data.project.repository.blobs.nodes[0].blame.groups;
- const createComponent = ({ showBlame = true } = {}) => {
+ const createComponent = ({ showBlame = true, blob = {} } = {}) => {
fakeApollo = createMockApollo([[blameDataQuery, blameDataQueryHandlerSuccess]]);
wrapper = shallowMountExtended(SourceViewer, {
apolloProvider: fakeApollo,
mocks: { $route: { hash } },
propsData: {
- blob: BLOB_DATA_MOCK,
+ blob: { ...blob, ...BLOB_DATA_MOCK },
chunks: CHUNKS_MOCK,
projectPath: 'test',
+ currentRef: 'main',
showBlame,
},
});
@@ -111,22 +117,18 @@ describe('Source Viewer component', () => {
});
it('calls the query only once per chunk', async () => {
- jest.spyOn(wrapper.vm.$apollo, 'query');
-
// We trigger the `appear` event multiple times here in order to simulate the user scrolling past the chunk more than once.
// In this scenario we only want to query the backend once.
await triggerChunkAppear();
await triggerChunkAppear();
- expect(wrapper.vm.$apollo.query).toHaveBeenCalledTimes(1);
+ expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(1);
});
it('requests blame information for overlapping chunk', async () => {
- jest.spyOn(wrapper.vm.$apollo, 'query');
-
await triggerChunkAppear(1);
- expect(wrapper.vm.$apollo.query).toHaveBeenCalledTimes(2);
+ expect(blameDataQueryHandlerSuccess).toHaveBeenCalledTimes(2);
expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith(
expect.objectContaining({ fromLine: 71, toLine: 110 }),
);
@@ -156,4 +158,20 @@ describe('Source Viewer component', () => {
expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash);
});
});
+
+ describe('Codeowners validation', () => {
+ const findCodeownersValidation = () => wrapper.findComponent(CodeownersValidation);
+
+ it('does not render codeowners validation when file is not CODEOWNERS', async () => {
+ await createComponent();
+ await nextTick();
+ expect(findCodeownersValidation().exists()).toBe(false);
+ });
+
+ it('renders codeowners validation when file is CODEOWNERS', async () => {
+ await createComponent({ blob: { name: CODEOWNERS_FILE_NAME } });
+ await nextTick();
+ expect(findCodeownersValidation().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
index 41cf1d2b2e8..21c58d662e3 100644
--- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
+++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
@@ -2,9 +2,9 @@ import { shallowMount } from '@vue/test-utils';
import { GlTruncate } from '@gitlab/ui';
import timezoneMock from 'timezone-mock';
-import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
-import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants';
+import { getTimeago } from '~/lib/utils/datetime_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/locale_dateformat';
describe('Time ago with tooltip component', () => {
let vm;
@@ -33,7 +33,7 @@ describe('Time ago with tooltip component', () => {
it('should render timeago with a bootstrap tooltip', () => {
buildVm();
- expect(vm.attributes('title')).toEqual(formatDate(timestamp));
+ expect(vm.attributes('title')).toEqual('May 8, 2017 at 2:57:39 PM GMT');
expect(vm.text()).toEqual(timeAgoTimestamp);
});
diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
deleted file mode 100644
index 95f557b10c1..00000000000
--- a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { mount } from '@vue/test-utils';
-import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
-
-const TestComponent = {
- inject: ['vuexModule'],
- template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `,
-};
-
-const TEST_VUEX_MODULE = 'testVuexModule';
-
-describe('~/vue_shared/components/vuex_module_provider', () => {
- let wrapper;
-
- const findProvidedVuexModule = () => wrapper.find('[data-testid="vuexModule"]').text();
-
- const createComponent = (extraParams = {}) => {
- wrapper = mount(VuexModuleProvider, {
- propsData: {
- vuexModule: TEST_VUEX_MODULE,
- },
- slots: {
- default: TestComponent,
- },
- ...extraParams,
- });
- };
-
- it('provides "vuexModule" set from prop', () => {
- createComponent();
- expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
- });
-
- it('provides "vuexModel" set from "vuex-module" prop when using @vue/compat', () => {
- createComponent({
- propsData: { 'vuex-module': TEST_VUEX_MODULE },
- });
- expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
- });
-});
diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js
index fc69e884258..8b4a68e394a 100644
--- a/spec/frontend/vue_shared/directives/track_event_spec.js
+++ b/spec/frontend/vue_shared/directives/track_event_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import Tracking from '~/tracking';
import TrackEvent from '~/vue_shared/directives/track_event';
@@ -10,34 +10,53 @@ describe('TrackEvent directive', () => {
const clickButton = () => wrapper.find('button').trigger('click');
- const createComponent = (trackingOptions) =>
- Vue.component('DummyElement', {
- directives: {
- TrackEvent,
+ const DummyTrackComponent = Vue.component('DummyTrackComponent', {
+ directives: {
+ TrackEvent,
+ },
+ props: {
+ category: {
+ type: String,
+ required: false,
+ default: '',
},
- data() {
- return {
- trackingOptions,
- };
+ action: {
+ type: String,
+ required: false,
+ default: '',
},
- template: '<button v-track-event="trackingOptions"></button>',
- });
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ template: '<button v-track-event="{ category, action, label }"></button>',
+ });
- const mountComponent = (trackingOptions) => shallowMount(createComponent(trackingOptions));
+ const mountComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMount(DummyTrackComponent, {
+ propsData,
+ });
+ };
it('does not track the event if required arguments are not provided', () => {
- wrapper = mountComponent();
+ mountComponent();
clickButton();
expect(Tracking.event).not.toHaveBeenCalled();
});
- it('tracks event on click if tracking info provided', () => {
- wrapper = mountComponent({
- category: 'Tracking',
- action: 'click_trackable_btn',
- label: 'Trackable Info',
+ it('tracks event on click if tracking info provided', async () => {
+ mountComponent({
+ propsData: {
+ category: 'Tracking',
+ action: 'click_trackable_btn',
+ label: 'Trackable Info',
+ },
});
+
+ await nextTick();
clickButton();
expect(Tracking.event).toHaveBeenCalledWith('Tracking', 'click_trackable_btn', {
diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js
index 1a490359040..94234a03664 100644
--- a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js
+++ b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js
@@ -16,6 +16,7 @@ const fullPath = '/full-path';
const labelsFilterBasePath = '/labels-filter-base-path';
const initialLabels = [];
const issuableType = 'issue';
+const issuableSupportsLockOnMerge = false;
const labelType = WORKSPACE_PROJECT;
const variant = VARIANT_EMBEDDED;
const workspaceType = WORKSPACE_PROJECT;
@@ -36,6 +37,7 @@ describe('IssuableLabelSelector', () => {
labelsFilterBasePath,
initialLabels,
issuableType,
+ issuableSupportsLockOnMerge,
labelType,
variant,
workspaceType,
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
index 47da111b604..98a87ddbcce 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
@@ -6,6 +6,7 @@ import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vu
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
+import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
import { mockIssuable, mockRegularLabel } from '../mock_data';
const createComponent = ({
@@ -168,15 +169,20 @@ describe('IssuableItem', () => {
it('returns timestamp based on `issuable.updatedAt` when the issue is open', () => {
wrapper = createComponent();
- expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
+ expect(findTimestampWrapper().attributes('title')).toBe(
+ localeDateFormat.asDateTimeFull.format(mockIssuable.updatedAt),
+ );
});
it('returns timestamp based on `issuable.closedAt` when the issue is closed', () => {
+ const closedAt = '2020-06-18T11:30:00Z';
wrapper = createComponent({
- issuable: { ...mockIssuable, closedAt: '2020-06-18T11:30:00Z', state: 'closed' },
+ issuable: { ...mockIssuable, closedAt, state: 'closed' },
});
- expect(findTimestampWrapper().attributes('title')).toBe('Jun 18, 2020 11:30am UTC');
+ expect(findTimestampWrapper().attributes('title')).toBe(
+ localeDateFormat.asDateTimeFull.format(closedAt),
+ );
});
it('returns timestamp based on `issuable.updatedAt` when the issue is closed but `issuable.closedAt` is undefined', () => {
@@ -184,7 +190,9 @@ describe('IssuableItem', () => {
issuable: { ...mockIssuable, closedAt: undefined, state: 'closed' },
});
- expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
+ expect(findTimestampWrapper().attributes('title')).toBe(
+ localeDateFormat.asDateTimeFull.format(mockIssuable.updatedAt),
+ );
});
});
@@ -409,7 +417,9 @@ describe('IssuableItem', () => {
const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]');
expect(createdAtEl.exists()).toBe(true);
- expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm UTC');
+ expect(createdAtEl.attributes('title')).toBe(
+ localeDateFormat.asDateTimeFull.format(mockIssuable.createdAt),
+ );
expect(createdAtEl.text()).toBe(wrapper.vm.createdAt);
});
@@ -535,7 +545,9 @@ describe('IssuableItem', () => {
const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]');
- expect(timestampEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
+ expect(timestampEl.attributes('title')).toBe(
+ localeDateFormat.asDateTimeFull.format(mockIssuable.updatedAt),
+ );
expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp);
});
@@ -549,13 +561,16 @@ describe('IssuableItem', () => {
});
it('renders issuable closedAt info and does not render updatedAt info', () => {
+ const closedAt = '2022-06-18T11:30:00Z';
wrapper = createComponent({
- issuable: { ...mockIssuable, closedAt: '2022-06-18T11:30:00Z', state: 'closed' },
+ issuable: { ...mockIssuable, closedAt, state: 'closed' },
});
const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]');
- expect(timestampEl.attributes('title')).toBe('Jun 18, 2022 11:30am UTC');
+ expect(timestampEl.attributes('title')).toBe(
+ localeDateFormat.asDateTimeFull.format(closedAt),
+ );
expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp);
});
});
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 51aae9b4512..a2a059d5b18 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
@@ -4,6 +4,7 @@ import VueDraggable from 'vuedraggable';
import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
+import { DRAG_DELAY } from '~/sortable/constants';
import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue';
import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
@@ -476,6 +477,11 @@ describe('IssuableListRoot', () => {
expect(findIssuableItem().classes()).toContain('gl-cursor-grab');
});
+ it('sets delay and delayOnTouchOnly attributes on list', () => {
+ expect(findVueDraggable().vm.$attrs.delay).toBe(DRAG_DELAY);
+ expect(findVueDraggable().vm.$attrs.delayOnTouchOnly).toBe(true);
+ });
+
it('emits a "reorder" event when user updates the issue order', () => {
const oldIndex = 4;
const newIndex = 6;
diff --git a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
index f2509aead77..d5c6ece8cb5 100644
--- a/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
@@ -1,3 +1,4 @@
+import { GlButton, GlIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { nextTick } from 'vue';
import Cookies from '~/lib/utils/cookies';
@@ -18,6 +19,10 @@ const createComponent = () => {
<button class="js-todo">Todo</button>
`,
},
+ stubs: {
+ GlButton,
+ GlIcon,
+ },
});
};
@@ -62,9 +67,8 @@ describe('IssuableSidebarRoot', () => {
const buttonEl = findToggleSidebarButton();
expect(buttonEl.exists()).toBe(true);
- expect(buttonEl.attributes('title')).toBe('Toggle sidebar');
- expect(buttonEl.find('span').text()).toBe('Collapse sidebar');
- expect(wrapper.findByTestId('icon-collapse').isVisible()).toBe(true);
+ expect(buttonEl.attributes('title')).toBe('Collapse sidebar');
+ expect(wrapper.findByTestId('chevron-double-lg-right-icon').isVisible()).toBe(true);
});
describe('when collapsing the sidebar', () => {
@@ -116,12 +120,12 @@ describe('IssuableSidebarRoot', () => {
assertPageLayoutClasses({ isExpanded: false });
});
- it('renders sidebar toggle button with text and icon', () => {
+ it('renders sidebar toggle button with title and icon', () => {
const buttonEl = findToggleSidebarButton();
expect(buttonEl.exists()).toBe(true);
- expect(buttonEl.attributes('title')).toBe('Toggle sidebar');
- expect(wrapper.findByTestId('icon-expand').isVisible()).toBe(true);
+ expect(buttonEl.attributes('title')).toBe('Expand sidebar');
+ expect(wrapper.findByTestId('chevron-double-lg-left-icon').isVisible()).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 109b7732539..716de45f4b4 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
@@ -116,21 +116,23 @@ describe('Experimental new namespace creation app', () => {
expect(findLegacyContainer().exists()).toBe(true);
});
- describe.each`
- featureFlag | isSuperSidebarCollapsed | isToggleVisible
- ${true} | ${true} | ${true}
- ${true} | ${false} | ${false}
- ${false} | ${true} | ${false}
- ${false} | ${false} | ${false}
- `('Super sidebar toggle', ({ featureFlag, isSuperSidebarCollapsed, isToggleVisible }) => {
- beforeEach(() => {
- sidebarState.isCollapsed = isSuperSidebarCollapsed;
- gon.use_new_navigation = featureFlag;
- createComponent();
+ describe('SuperSidebarToggle', () => {
+ describe('when collapsed', () => {
+ it('shows sidebar toggle', () => {
+ sidebarState.isCollapsed = true;
+ createComponent();
+
+ expect(findSuperSidebarToggle().exists()).toBe(true);
+ });
});
- it(`${isToggleVisible ? 'is visible' : 'is not visible'}`, () => {
- expect(findSuperSidebarToggle().exists()).toBe(isToggleVisible);
+ describe('when not collapsed', () => {
+ it('does not show sidebar toggle', () => {
+ sidebarState.isCollapsed = false;
+ createComponent();
+
+ expect(findSuperSidebarToggle().exists()).toBe(false);
+ });
});
});
@@ -170,17 +172,10 @@ describe('Experimental new namespace creation app', () => {
});
describe('top bar', () => {
- it('adds "top-bar-fixed" and "container-fluid" classes when new navigation enabled', () => {
- gon.use_new_navigation = true;
+ it('has "top-bar-fixed" and "container-fluid" classes', () => {
createComponent();
expect(findTopBar().classes()).toEqual(['top-bar-fixed', 'container-fluid']);
});
-
- it('does not add classes when new navigation is not enabled', () => {
- createComponent();
-
- expect(findTopBar().classes()).toEqual([]);
- });
});
});
diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
index f3d0d66cdd1..2b36344cfa8 100644
--- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
+++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
@@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
+import { featureToMutationMap } from 'ee_else_ce/security_configuration/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
diff --git a/spec/frontend/webhooks/components/form_url_app_spec.js b/spec/frontend/webhooks/components/form_url_app_spec.js
index cbeff184e9d..fe8bba68610 100644
--- a/spec/frontend/webhooks/components/form_url_app_spec.js
+++ b/spec/frontend/webhooks/components/form_url_app_spec.js
@@ -1,5 +1,5 @@
import { nextTick } from 'vue';
-import { GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
+import { GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink, GlAlert } from '@gitlab/ui';
import { scrollToElement } from '~/lib/utils/common_utils';
import FormUrlApp from '~/webhooks/components/form_url_app.vue';
@@ -30,6 +30,7 @@ describe('FormUrlApp', () => {
const findFormUrlPreview = () => wrapper.findByTestId('form-url-preview');
const findUrlMaskSection = () => wrapper.findByTestId('url-mask-section');
const findFormEl = () => document.querySelector('.js-webhook-form');
+ const findAlert = () => wrapper.findComponent(GlAlert);
const submitForm = () => findFormEl().dispatchEvent(new Event('submit'));
describe('template', () => {
@@ -156,6 +157,23 @@ describe('FormUrlApp', () => {
});
});
+ describe('token will be cleared warning', () => {
+ beforeEach(() => {
+ createComponent({ initialUrl: 'url' });
+ });
+
+ it('is hidden when URL has not changed', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('is displayed when URL has changed', async () => {
+ findFormUrl().vm.$emit('input', 'another_url');
+ await nextTick();
+
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+
describe('validations', () => {
const inputRequiredText = FormUrlApp.i18n.inputRequired;
diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js
index 5f5e4e53be2..908aa3aaeea 100644
--- a/spec/frontend/whats_new/store/actions_spec.js
+++ b/spec/frontend/whats_new/store/actions_spec.js
@@ -11,8 +11,8 @@ describe('whats new actions', () => {
describe('openDrawer', () => {
useLocalStorageSpy();
- it('should commit openDrawer', () => {
- testAction(actions.openDrawer, 'digest-hash', {}, [{ type: types.OPEN_DRAWER }]);
+ it('should commit openDrawer', async () => {
+ await testAction(actions.openDrawer, 'digest-hash', {}, [{ type: types.OPEN_DRAWER }]);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
'display-whats-new-notification',
@@ -23,7 +23,7 @@ describe('whats new actions', () => {
describe('closeDrawer', () => {
it('should commit closeDrawer', () => {
- testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]);
+ return testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]);
});
});
@@ -52,7 +52,7 @@ describe('whats new actions', () => {
.onGet('/-/whats_new', { params: { page: undefined, v: undefined } })
.replyOnce(HTTP_STATUS_OK, [{ title: 'GitLab Stories' }]);
- testAction(
+ return testAction(
actions.fetchItems,
{},
{},
@@ -69,7 +69,7 @@ describe('whats new actions', () => {
.onGet('/-/whats_new', { params: { page: 8, v: 42 } })
.replyOnce(HTTP_STATUS_OK, [{ title: 'GitLab Stories' }]);
- testAction(
+ return testAction(
actions.fetchItems,
{ page: 8, versionDigest: 42 },
{},
@@ -80,11 +80,11 @@ describe('whats new actions', () => {
});
it('if already fetching, does not fetch', () => {
- testAction(actions.fetchItems, {}, { fetching: true }, []);
+ return testAction(actions.fetchItems, {}, { fetching: true }, []);
});
it('should commit fetching, setFeatures and setPagination', () => {
- testAction(actions.fetchItems, {}, {}, [
+ return testAction(actions.fetchItems, {}, {}, [
{ type: types.SET_FETCHING, payload: true },
{ type: types.ADD_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] },
{ type: types.SET_PAGE_INFO, payload: { nextPage: 2 } },
@@ -94,8 +94,10 @@ describe('whats new actions', () => {
});
describe('setDrawerBodyHeight', () => {
- testAction(actions.setDrawerBodyHeight, 42, {}, [
- { type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 },
- ]);
+ it('should commit setDrawerBodyHeight', () => {
+ return testAction(actions.setDrawerBodyHeight, 42, {}, [
+ { type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 },
+ ]);
+ });
});
});
diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js
deleted file mode 100644
index 020d833c578..00000000000
--- a/spec/frontend/whats_new/utils/notification_spec.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import htmlWhatsNewNotification from 'test_fixtures_static/whats_new_notification.html';
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import { setNotification, getVersionDigest } from '~/whats_new/utils/notification';
-
-describe('~/whats_new/utils/notification', () => {
- useLocalStorageSpy();
-
- let wrapper;
-
- const findNotificationEl = () => wrapper.querySelector('.header-help');
- const findNotificationCountEl = () => wrapper.querySelector('.js-whats-new-notification-count');
- const getAppEl = () => wrapper.querySelector('.app');
-
- beforeEach(() => {
- setHTMLFixture(htmlWhatsNewNotification);
- wrapper = document.querySelector('.whats-new-notification-fixture-root');
- });
-
- afterEach(() => {
- wrapper.remove();
- resetHTMLFixture();
- });
-
- describe('setNotification', () => {
- const subject = () => setNotification(getAppEl());
-
- it("when storage key doesn't exist it adds notifications class", () => {
- const notificationEl = findNotificationEl();
-
- expect(notificationEl.classList).not.toContain('with-notifications');
-
- subject();
-
- expect(findNotificationCountEl()).not.toBe(null);
- expect(notificationEl.classList).toContain('with-notifications');
- });
-
- 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');
-
- expect(findNotificationCountEl()).not.toBe(null);
-
- subject();
-
- 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', () => {
- it('retrieves the storage key data attribute from the el', () => {
- expect(getVersionDigest(getAppEl())).toBe('version-digest');
- });
- });
-});
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
index 3a84ba4bd5e..660ff671a80 100644
--- a/spec/frontend/work_items/components/item_title_spec.js
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -2,11 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import { escape } from 'lodash';
import ItemTitle from '~/work_items/components/item_title.vue';
-const createComponent = ({ title = 'Sample title', disabled = false } = {}) =>
+const createComponent = ({ title = 'Sample title', disabled = false, useH1 = false } = {}) =>
shallowMount(ItemTitle, {
propsData: {
title,
disabled,
+ useH1,
},
});
@@ -27,6 +28,12 @@ describe('ItemTitle', () => {
expect(findInputEl().text()).toBe('Sample title');
});
+ it('renders H1 if useH1 is true, otherwise renders H2', () => {
+ expect(wrapper.element.tagName).toBe('H2');
+ wrapper = createComponent({ useH1: true });
+ expect(wrapper.element.tagName).toBe('H1');
+ });
+
it('renders title contents with editing disabled', () => {
wrapper = createComponent({
disabled: true,
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 596283a9590..97aed1d548e 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,4 +1,4 @@
-import { GlButton, GlDisclosureDropdown } from '@gitlab/ui';
+import { GlDisclosureDropdown } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -17,7 +17,7 @@ describe('Work Item Note Actions', () => {
const showSpy = jest.fn();
const findReplyButton = () => wrapper.findComponent(ReplyButton);
- const findEditButton = () => wrapper.findComponent(GlButton);
+ const findEditButton = () => wrapper.findByTestId('note-actions-edit');
const findEmojiButton = () => wrapper.findByTestId('note-emoji-button');
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findDeleteNoteButton = () => wrapper.findByTestId('delete-note-action');
@@ -64,6 +64,7 @@ describe('Work Item Note Actions', () => {
projectName,
},
provide: {
+ isGroup: false,
glFeatures: {
workItemsMvc2: true,
},
diff --git a/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js
index ce915635946..6ce4c09329f 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_awards_list_spec.js
@@ -9,6 +9,7 @@ import AwardsList from '~/vue_shared/components/awards_list.vue';
import WorkItemNoteAwardsList from '~/work_items/components/notes/work_item_note_awards_list.vue';
import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql';
+import groupWorkItemNotesByIidQuery from '~/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql';
import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql';
import {
mockWorkItemNotesResponseWithComments,
@@ -45,7 +46,9 @@ describe('Work Item Note Awards List', () => {
const findAwardsList = () => wrapper.findComponent(AwardsList);
const createComponent = ({
+ isGroup = false,
note = firstNote,
+ query = workItemNotesByIidQuery,
addAwardEmojiMutationHandler = addAwardEmojiMutationSuccessHandler,
removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler,
} = {}) => {
@@ -55,12 +58,15 @@ describe('Work Item Note Awards List', () => {
]);
apolloProvider.clients.defaultClient.writeQuery({
- query: workItemNotesByIidQuery,
+ query,
variables: { fullPath, iid: workItemIid },
...mockWorkItemNotesResponseWithComments,
});
wrapper = shallowMount(WorkItemNoteAwardsList, {
+ provide: {
+ isGroup,
+ },
propsData: {
fullPath,
workItemIid,
@@ -89,54 +95,58 @@ describe('Work Item Note Awards List', () => {
expect(findAwardsList().props('canAwardEmoji')).toBe(hasAwardEmojiPermission);
});
- it('adds award if not already awarded', async () => {
- createComponent();
- await waitForPromises();
-
- findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
-
- expect(addAwardEmojiMutationSuccessHandler).toHaveBeenCalledWith({
- awardableId: firstNote.id,
- name: EMOJI_THUMBSUP,
- });
- });
+ it.each`
+ isGroup | query
+ ${true} | ${groupWorkItemNotesByIidQuery}
+ ${false} | ${workItemNotesByIidQuery}
+ `(
+ 'adds award if not already awarded in both group and project contexts',
+ async ({ isGroup, query }) => {
+ createComponent({ isGroup, query });
+ await waitForPromises();
+
+ findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
+
+ expect(addAwardEmojiMutationSuccessHandler).toHaveBeenCalledWith({
+ awardableId: firstNote.id,
+ name: EMOJI_THUMBSUP,
+ });
+ },
+ );
it('emits error if awarding emoji fails', async () => {
- createComponent({
- addAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no'),
- });
- await waitForPromises();
+ createComponent({ addAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no') });
findAwardsList().vm.$emit('award', EMOJI_THUMBSUP);
-
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[__('Failed to add emoji. Please try again')]]);
});
- it('removes award if already awarded', async () => {
- const removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler;
-
- createComponent({ removeAwardEmojiMutationHandler });
-
- findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN);
-
- await waitForPromises();
-
- expect(removeAwardEmojiMutationHandler).toHaveBeenCalledWith({
- awardableId: firstNote.id,
- name: EMOJI_THUMBSDOWN,
- });
- });
+ it.each`
+ isGroup | query
+ ${true} | ${groupWorkItemNotesByIidQuery}
+ ${false} | ${workItemNotesByIidQuery}
+ `(
+ 'removes award if already awarded in both group and project contexts',
+ async ({ isGroup, query }) => {
+ const removeAwardEmojiMutationHandler = removeAwardEmojiMutationSuccessHandler;
+ createComponent({ isGroup, query, removeAwardEmojiMutationHandler });
+
+ findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN);
+ await waitForPromises();
+
+ expect(removeAwardEmojiMutationHandler).toHaveBeenCalledWith({
+ awardableId: firstNote.id,
+ name: EMOJI_THUMBSDOWN,
+ });
+ },
+ );
it('restores award if remove fails', async () => {
- createComponent({
- removeAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no'),
- });
- await waitForPromises();
+ createComponent({ removeAwardEmojiMutationHandler: jest.fn().mockRejectedValue('oh no') });
findAwardsList().vm.$emit('award', EMOJI_THUMBSDOWN);
-
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[__('Failed to remove emoji. Please try again')]]);
diff --git a/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js b/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js
index daf74f7a93b..dff54fef9fe 100644
--- a/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_notes_activity_header_spec.js
@@ -9,7 +9,8 @@ import {
describe('Work Item Note Activity Header', () => {
let wrapper;
- const findActivityLabelHeading = () => wrapper.find('h3');
+ const findActivityLabelH2Heading = () => wrapper.find('h2');
+ const findActivityLabelH3Heading = () => wrapper.find('h3');
const findActivityFilterDropdown = () => wrapper.findByTestId('work-item-filter');
const findActivitySortDropdown = () => wrapper.findByTestId('work-item-sort');
@@ -18,6 +19,7 @@ describe('Work Item Note Activity Header', () => {
sortOrder = ASC,
workItemType = 'Task',
discussionFilter = WORK_ITEM_NOTES_FILTER_ALL_NOTES,
+ useH2 = false,
} = {}) => {
wrapper = shallowMountExtended(WorkItemNotesActivityHeader, {
propsData: {
@@ -25,6 +27,7 @@ describe('Work Item Note Activity Header', () => {
sortOrder,
workItemType,
discussionFilter,
+ useH2,
},
});
};
@@ -34,7 +37,18 @@ describe('Work Item Note Activity Header', () => {
});
it('Should have the Activity label', () => {
- expect(findActivityLabelHeading().text()).toBe(WorkItemNotesActivityHeader.i18n.activityLabel);
+ expect(findActivityLabelH3Heading().text()).toBe(
+ WorkItemNotesActivityHeader.i18n.activityLabel,
+ );
+ });
+
+ it('Should render an H2 instead of an H3 if useH2 is true', () => {
+ createComponent();
+ expect(findActivityLabelH3Heading().exists()).toBe(true);
+ expect(findActivityLabelH2Heading().exists()).toBe(false);
+ createComponent({ useH2: true });
+ expect(findActivityLabelH2Heading().exists()).toBe(true);
+ expect(findActivityLabelH3Heading().exists()).toBe(false);
});
it('Should have Activity filtering dropdown', () => {
diff --git a/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_item_spec.js b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_item_spec.js
new file mode 100644
index 00000000000..2cfe61654ad
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_item_spec.js
@@ -0,0 +1,53 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import DisclosureHierarchyItem from '~/work_items/components/work_item_ancestors/disclosure_hierarchy_item.vue';
+import { mockDisclosureHierarchyItems } from './mock_data';
+
+describe('DisclosurePathItem', () => {
+ let wrapper;
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+
+ const createComponent = (props = {}, options = {}) => {
+ return shallowMount(DisclosureHierarchyItem, {
+ propsData: {
+ item: mockDisclosureHierarchyItems[0],
+ ...props,
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ describe('renders the item', () => {
+ it('renders the inline icon', () => {
+ expect(findIcon().exists()).toBe(true);
+ expect(findIcon().props('name')).toBe(mockDisclosureHierarchyItems[0].icon);
+ });
+ });
+
+ describe('item slot', () => {
+ beforeEach(() => {
+ wrapper = createComponent(null, {
+ scopedSlots: {
+ default: `
+ <div
+ data-testid="item-slot-content">
+ {{ props.item.title }}
+ </div>
+ `,
+ },
+ });
+ });
+
+ it('contains all elements passed into the additional slot', () => {
+ const item = wrapper.find('[data-testid="item-slot-content"]');
+
+ expect(item.text()).toBe(mockDisclosureHierarchyItems[0].title);
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_spec.js b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_spec.js
new file mode 100644
index 00000000000..b808c13c3e7
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_ancestors/disclosure_hierarchy_spec.js
@@ -0,0 +1,99 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui';
+import DisclosureHierarchy from '~/work_items/components/work_item_ancestors//disclosure_hierarchy.vue';
+import DisclosureHierarchyItem from '~/work_items/components/work_item_ancestors/disclosure_hierarchy_item.vue';
+import { mockDisclosureHierarchyItems } from './mock_data';
+
+describe('DisclosurePath', () => {
+ let wrapper;
+
+ const createComponent = (props = {}, options = {}) => {
+ return shallowMount(DisclosureHierarchy, {
+ propsData: {
+ items: mockDisclosureHierarchyItems,
+ ...props,
+ },
+ ...options,
+ });
+ };
+
+ const listItems = () => wrapper.findAllComponents(DisclosureHierarchyItem);
+ const itemAt = (index) => listItems().at(index);
+ const itemTextAt = (index) => itemAt(index).props('item').title;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ describe('renders the list of items', () => {
+ it('renders the correct number of items', () => {
+ expect(listItems().length).toBe(mockDisclosureHierarchyItems.length);
+ });
+
+ it('renders the items in the correct order', () => {
+ expect(itemTextAt(0)).toContain(mockDisclosureHierarchyItems[0].title);
+ expect(itemTextAt(4)).toContain(mockDisclosureHierarchyItems[4].title);
+ expect(itemTextAt(9)).toContain(mockDisclosureHierarchyItems[9].title);
+ });
+ });
+
+ describe('slots', () => {
+ beforeEach(() => {
+ wrapper = createComponent(null, {
+ scopedSlots: {
+ default: `
+ <div
+ :data-itemid="props.itemId"
+ data-testid="item-slot-content">
+ {{ props.item.title }}
+ </div>
+ `,
+ },
+ });
+ });
+
+ it('contains all elements passed into the default slot', () => {
+ mockDisclosureHierarchyItems.forEach((item, index) => {
+ const disclosureItem = wrapper.findAll('[data-testid="item-slot-content"]').at(index);
+
+ expect(disclosureItem.text()).toBe(item.title);
+ expect(disclosureItem.attributes('data-itemid')).toContain('disclosure-');
+ });
+ });
+ });
+
+ describe('with ellipsis', () => {
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
+ const findTooltipText = () => findTooltip().text();
+ const tooltipText = 'Display more items';
+
+ beforeEach(() => {
+ wrapper = createComponent({ withEllipsis: true, ellipsisTooltipLabel: tooltipText });
+ });
+
+ describe('renders items and dropdown', () => {
+ it('renders 2 items', () => {
+ expect(listItems().length).toBe(2);
+ });
+
+ it('renders first and last items', () => {
+ expect(itemTextAt(0)).toContain(mockDisclosureHierarchyItems[0].title);
+ expect(itemTextAt(1)).toContain(
+ mockDisclosureHierarchyItems[mockDisclosureHierarchyItems.length - 1].title,
+ );
+ });
+
+ it('renders dropdown with the rest of the items passed down', () => {
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDropdown().props('items').length).toBe(mockDisclosureHierarchyItems.length - 2);
+ });
+
+ it('renders tooltip with text passed as prop', () => {
+ expect(findTooltip().exists()).toBe(true);
+ expect(findTooltipText()).toBe(tooltipText);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_ancestors/mock_data.js b/spec/frontend/work_items/components/work_item_ancestors/mock_data.js
new file mode 100644
index 00000000000..8e7f99658de
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_ancestors/mock_data.js
@@ -0,0 +1,197 @@
+export const mockDisclosureHierarchyItems = [
+ {
+ title: 'First',
+ icon: 'epic',
+ href: '#',
+ },
+ {
+ title: 'Second',
+ icon: 'epic',
+ href: '#',
+ },
+ {
+ title: 'Third',
+ icon: 'epic',
+ href: '#',
+ },
+ {
+ title: 'Fourth',
+ icon: 'epic',
+ href: '#',
+ },
+ {
+ title: 'Fifth',
+ icon: 'issues',
+ href: '#',
+ },
+ {
+ title: 'Sixth',
+ icon: 'issues',
+ href: '#',
+ },
+ {
+ title: 'Seventh',
+ icon: 'issues',
+ href: '#',
+ },
+ {
+ title: 'Eighth',
+ icon: 'issue-type-task',
+ href: '#',
+ disabled: true,
+ },
+ {
+ title: 'Ninth',
+ icon: 'issue-type-task',
+ href: '#',
+ },
+ {
+ title: 'Tenth',
+ icon: 'issue-type-task',
+ href: '#',
+ },
+];
+
+export const workItemAncestorsQueryResponse = {
+ data: {
+ workItem: {
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1',
+ title: 'Test',
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ parent: {
+ id: 'gid://gitlab/Issue/1',
+ },
+ ancestors: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/444',
+ iid: '4',
+ reference: '#40',
+ createdAt: '2022-08-03T12:41:54Z',
+ closedAt: null,
+ confidential: false,
+ title: '123',
+ state: 'OPEN',
+ webUrl: '/gitlab-org/gitlab-test/-/work_items/4',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/2',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+};
+
+export const workItemThreeAncestorsQueryResponse = {
+ data: {
+ workItem: {
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1',
+ title: 'Test',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
+ iconName: 'issue-type-task',
+ },
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ parent: {
+ id: 'gid://gitlab/Issue/1',
+ },
+ ancestors: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/444',
+ iid: '4',
+ reference: '#40',
+ createdAt: '2022-08-03T12:41:54Z',
+ closedAt: null,
+ confidential: false,
+ title: '123',
+ state: 'OPEN',
+ webUrl: '/gitlab-org/gitlab-test/-/work_items/4',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/2',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
+ },
+ },
+ {
+ id: 'gid://gitlab/WorkItem/445',
+ iid: '5',
+ reference: '#41',
+ createdAt: '2022-08-03T12:41:54Z',
+ closedAt: null,
+ confidential: false,
+ title: '1234',
+ state: 'OPEN',
+ webUrl: '/gitlab-org/gitlab-test/-/work_items/5',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/2',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
+ },
+ },
+ {
+ id: 'gid://gitlab/WorkItem/446',
+ iid: '6',
+ reference: '#42',
+ createdAt: '2022-08-03T12:41:54Z',
+ closedAt: null,
+ confidential: false,
+ title: '12345',
+ state: 'OPEN',
+ webUrl: '/gitlab-org/gitlab-test/-/work_items/6',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/2',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+};
+
+export const workItemEmptyAncestorsQueryResponse = {
+ data: {
+ workItem: {
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1',
+ title: 'Test',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
+ iconName: 'issue-type-task',
+ },
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ parent: {
+ id: null,
+ },
+ ancestors: {
+ nodes: [],
+ },
+ },
+ ],
+ },
+ },
+};
diff --git a/spec/frontend/work_items/components/work_item_ancestors/work_item_ancestors_spec.js b/spec/frontend/work_items/components/work_item_ancestors/work_item_ancestors_spec.js
new file mode 100644
index 00000000000..a9f66b20f06
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_ancestors/work_item_ancestors_spec.js
@@ -0,0 +1,117 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlPopover } 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 { createAlert } from '~/alert';
+import DisclosureHierarchy from '~/work_items/components/work_item_ancestors/disclosure_hierarchy.vue';
+import WorkItemAncestors from '~/work_items/components/work_item_ancestors/work_item_ancestors.vue';
+import workItemAncestorsQuery from '~/work_items/graphql/work_item_ancestors.query.graphql';
+import { formatAncestors } from '~/work_items/utils';
+
+import { workItemTask } from '../../mock_data';
+import {
+ workItemAncestorsQueryResponse,
+ workItemEmptyAncestorsQueryResponse,
+ workItemThreeAncestorsQueryResponse,
+} from './mock_data';
+
+Vue.use(VueApollo);
+jest.mock('~/alert');
+
+describe('WorkItemAncestors', () => {
+ let wrapper;
+ let mockApollo;
+
+ const workItemAncestorsQueryHandler = jest.fn().mockResolvedValue(workItemAncestorsQueryResponse);
+ const workItemEmptyAncestorsQueryHandler = jest
+ .fn()
+ .mockResolvedValue(workItemEmptyAncestorsQueryResponse);
+ const workItemThreeAncestorsQueryHandler = jest
+ .fn()
+ .mockResolvedValue(workItemThreeAncestorsQueryResponse);
+ const workItemAncestorsFailureHandler = jest.fn().mockRejectedValue(new Error());
+
+ const findDisclosureHierarchy = () => wrapper.findComponent(DisclosureHierarchy);
+ const findPopover = () => wrapper.findComponent(GlPopover);
+
+ const createComponent = ({
+ props = {},
+ options = {},
+ ancestorsQueryHandler = workItemAncestorsQueryHandler,
+ } = {}) => {
+ mockApollo = createMockApollo([[workItemAncestorsQuery, ancestorsQueryHandler]]);
+ return mountExtended(WorkItemAncestors, {
+ apolloProvider: mockApollo,
+ propsData: {
+ workItem: workItemTask,
+ ...props,
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(async () => {
+ createAlert.mockClear();
+ wrapper = createComponent();
+ await waitForPromises();
+ });
+
+ it('fetches work item ancestors', () => {
+ expect(workItemAncestorsQueryHandler).toHaveBeenCalled();
+ });
+
+ it('displays DisclosureHierarchy component with ancestors when work item has at least one ancestor', () => {
+ expect(findDisclosureHierarchy().exists()).toBe(true);
+ expect(findDisclosureHierarchy().props('items')).toEqual(
+ expect.objectContaining(formatAncestors(workItemAncestorsQueryResponse.data.workItem)),
+ );
+ });
+
+ it('does not display DisclosureHierarchy component when work item has no ancestor', async () => {
+ wrapper = createComponent({ ancestorsQueryHandler: workItemEmptyAncestorsQueryHandler });
+ await waitForPromises();
+
+ expect(findDisclosureHierarchy().exists()).toBe(false);
+ });
+
+ it('displays work item info in popover on hover and focus', () => {
+ expect(findPopover().exists()).toBe(true);
+ expect(findPopover().props('triggers')).toBe('hover focus');
+
+ const ancestor = findDisclosureHierarchy().props('items')[0];
+
+ expect(findPopover().text()).toContain(ancestor.title);
+ expect(findPopover().text()).toContain(ancestor.reference);
+ });
+
+ describe('when work item has less than 3 ancestors', () => {
+ it('does not activate ellipsis option for DisclosureHierarchy component', () => {
+ expect(findDisclosureHierarchy().props('withEllipsis')).toBe(false);
+ });
+ });
+
+ describe('when work item has at least 3 ancestors', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ ancestorsQueryHandler: workItemThreeAncestorsQueryHandler });
+ await waitForPromises();
+ });
+
+ it('activates ellipsis option for DisclosureHierarchy component', () => {
+ expect(findDisclosureHierarchy().props('withEllipsis')).toBe(true);
+ });
+ });
+
+ it('creates alert when the query fails', async () => {
+ createComponent({ ancestorsQueryHandler: workItemAncestorsFailureHandler });
+ await waitForPromises();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ captureError: true,
+ error: expect.any(Object),
+ message: 'Something went wrong while fetching ancestors.',
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
index 123cf647674..48ec84ceb85 100644
--- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
+++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
@@ -1,11 +1,20 @@
+import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue';
import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
-
+import WorkItemParentInline from '~/work_items/components/work_item_parent_inline.vue';
+import WorkItemParent from '~/work_items/components/work_item_parent_with_edit.vue';
+import waitForPromises from 'helpers/wait_for_promises';
import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue';
-import { workItemResponseFactory } from '../mock_data';
+import {
+ workItemResponseFactory,
+ taskType,
+ issueType,
+ objectiveType,
+ keyResultType,
+} from '../mock_data';
describe('WorkItemAttributesWrapper component', () => {
let wrapper;
@@ -16,8 +25,13 @@ describe('WorkItemAttributesWrapper component', () => {
const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone);
+ const findWorkItemParentInline = () => wrapper.findComponent(WorkItemParentInline);
+ const findWorkItemParent = () => wrapper.findComponent(WorkItemParent);
- const createComponent = ({ workItem = workItemQueryResponse.data.workItem } = {}) => {
+ const createComponent = ({
+ workItem = workItemQueryResponse.data.workItem,
+ workItemsMvc2 = true,
+ } = {}) => {
wrapper = shallowMount(WorkItemAttributesWrapper, {
propsData: {
fullPath: 'group/project',
@@ -29,6 +43,9 @@ describe('WorkItemAttributesWrapper component', () => {
hasOkrsFeature: true,
hasIssuableHealthStatusFeature: true,
projectNamespace: 'namespace',
+ glFeatures: {
+ workItemsMvc2,
+ },
},
stubs: {
WorkItemWeight: true,
@@ -94,4 +111,54 @@ describe('WorkItemAttributesWrapper component', () => {
expect(findWorkItemMilestone().exists()).toBe(exists);
});
});
+
+ describe('parent widget', () => {
+ describe.each`
+ description | workItemType | exists
+ ${'when work item type is task'} | ${taskType} | ${true}
+ ${'when work item type is objective'} | ${objectiveType} | ${true}
+ ${'when work item type is keyresult'} | ${keyResultType} | ${true}
+ ${'when work item type is issue'} | ${issueType} | ${false}
+ `('$description', ({ workItemType, exists }) => {
+ it(`${exists ? 'renders' : 'does not render'} parent component`, async () => {
+ const response = workItemResponseFactory({ workItemType });
+ createComponent({ workItem: response.data.workItem });
+
+ await waitForPromises();
+
+ expect(findWorkItemParent().exists()).toBe(exists);
+ });
+ });
+
+ it('renders WorkItemParent when workItemsMvc2 enabled', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findWorkItemParent().exists()).toBe(true);
+ expect(findWorkItemParentInline().exists()).toBe(false);
+ });
+
+ it('renders WorkItemParentInline when workItemsMvc2 disabled', async () => {
+ createComponent({ workItemsMvc2: false });
+
+ await waitForPromises();
+
+ expect(findWorkItemParent().exists()).toBe(false);
+ expect(findWorkItemParentInline().exists()).toBe(true);
+ });
+
+ it('emits an error event to the wrapper', async () => {
+ const response = workItemResponseFactory({ parentWidgetPresent: true });
+ createComponent({ workItem: response.data.workItem });
+ const updateError = 'Failed to update';
+
+ await waitForPromises();
+
+ findWorkItemParent().vm.$emit('error', updateError);
+ await nextTick();
+
+ expect(wrapper.emitted('error')).toEqual([[updateError]]);
+ });
+ });
});
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 6fa3a70c3eb..f77d6c89035 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
@@ -61,7 +61,6 @@ describe('WorkItemDetailModal component', () => {
expect(findWorkItemDetail().props()).toEqual({
isModal: true,
workItemIid: '1',
- workItemParentId: null,
});
});
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 acfe4571cd2..d63bb94c3f0 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -1,10 +1,4 @@
-import {
- GlAlert,
- GlSkeletonLoader,
- GlButton,
- GlEmptyState,
- GlIntersectionObserver,
-} from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader, GlEmptyState } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -15,6 +9,7 @@ import setWindowLocation from 'helpers/set_window_location_helper';
import { stubComponent } from 'helpers/stub_component';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
+import WorkItemAncestors from '~/work_items/components/work_item_ancestors/work_item_ancestors.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemCreatedUpdated from '~/work_items/components/work_item_created_updated.vue';
import WorkItemAttributesWrapper from '~/work_items/components/work_item_attributes_wrapper.vue';
@@ -23,13 +18,13 @@ import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree
import WorkItemRelationships from '~/work_items/components/work_item_relationships/work_item_relationships.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import WorkItemStickyHeader from '~/work_items/components/work_item_sticky_header.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import WorkItemTodos from '~/work_items/components/work_item_todos.vue';
import { i18n } from '~/work_items/constants';
import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
-import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
import workItemUpdatedSubscription from '~/work_items/graphql/work_item_updated.subscription.graphql';
import {
@@ -74,8 +69,7 @@ describe('WorkItemDetail component', () => {
const findCreatedUpdated = () => wrapper.findComponent(WorkItemCreatedUpdated);
const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription);
const findWorkItemAttributesWrapper = () => wrapper.findComponent(WorkItemAttributesWrapper);
- const findParent = () => wrapper.findByTestId('work-item-parent');
- const findParentButton = () => findParent().findComponent(GlButton);
+ const findAncestors = () => wrapper.findComponent(WorkItemAncestors);
const findCloseButton = () => wrapper.findByTestId('work-item-close');
const findWorkItemType = () => wrapper.findByTestId('work-item-type');
const findHierarchyTree = () => wrapper.findComponent(WorkItemTree);
@@ -84,11 +78,9 @@ describe('WorkItemDetail component', () => {
const findModal = () => wrapper.findComponent(WorkItemDetailModal);
const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
const findWorkItemTodos = () => wrapper.findComponent(WorkItemTodos);
- const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
- const findStickyHeader = () => wrapper.findByTestId('work-item-sticky-header');
+ const findStickyHeader = () => wrapper.findComponent(WorkItemStickyHeader);
const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview');
const findRightSidebar = () => wrapper.findByTestId('work-item-overview-right-sidebar');
- const triggerPageScroll = () => findIntersectionObserver().vm.$emit('disappear');
const createComponent = ({
isGroup = false,
@@ -96,7 +88,7 @@ describe('WorkItemDetail component', () => {
updateInProgress = false,
workItemIid = '1',
handler = successHandler,
- confidentialityMock = [updateWorkItemMutation, jest.fn()],
+ mutationHandler,
error = undefined,
workItemsMvc2Enabled = false,
linkedWorkItemsEnabled = false,
@@ -105,8 +97,8 @@ describe('WorkItemDetail component', () => {
apolloProvider: createMockApollo([
[workItemByIidQuery, handler],
[groupWorkItemByIidQuery, groupSuccessHandler],
+ [updateWorkItemMutation, mutationHandler],
[workItemUpdatedSubscription, workItemUpdatedSubscriptionHandler],
- confidentialityMock,
]),
isLoggedIn: isLoggedIn(),
propsData: {
@@ -134,6 +126,7 @@ describe('WorkItemDetail component', () => {
reportAbusePath: '/report/abuse/path',
},
stubs: {
+ WorkItemAncestors: true,
WorkItemWeight: true,
WorkItemIteration: true,
WorkItemHealthStatus: true,
@@ -236,119 +229,52 @@ describe('WorkItemDetail component', () => {
describe('confidentiality', () => {
const errorMessage = 'Mutation failed';
- const confidentialWorkItem = workItemByIidResponseFactory({
- confidential: true,
- });
- const workItem = confidentialWorkItem.data.workspace.workItems.nodes[0];
-
- // Mocks for work item without parent
- const withoutParentExpectedInputVars = { id, confidential: true };
- const toggleConfidentialityWithoutParentHandler = jest.fn().mockResolvedValue({
- data: {
- workItemUpdate: {
- workItem,
- errors: [],
- },
- },
- });
- const withoutParentHandlerMock = jest
- .fn()
- .mockResolvedValue(workItemQueryResponseWithoutParent);
- const confidentialityWithoutParentMock = [
- updateWorkItemMutation,
- toggleConfidentialityWithoutParentHandler,
- ];
- const confidentialityWithoutParentFailureMock = [
- updateWorkItemMutation,
- jest.fn().mockRejectedValue(new Error(errorMessage)),
- ];
-
- // Mocks for work item with parent
- const withParentExpectedInputVars = {
- id: mockParent.parent.id,
- taskData: { id, confidential: true },
- };
- const toggleConfidentialityWithParentHandler = jest.fn().mockResolvedValue({
+ const confidentialWorkItem = workItemByIidResponseFactory({ confidential: true });
+ const mutationHandler = jest.fn().mockResolvedValue({
data: {
workItemUpdate: {
- workItem: {
- id: workItem.id,
- descriptionHtml: workItem.description,
- },
- task: {
- workItem,
- confidential: true,
- },
+ workItem: confidentialWorkItem.data.workspace.workItems.nodes[0],
errors: [],
},
},
});
- const confidentialityWithParentMock = [
- updateWorkItemTaskMutation,
- toggleConfidentialityWithParentHandler,
- ];
- const confidentialityWithParentFailureMock = [
- updateWorkItemTaskMutation,
- jest.fn().mockRejectedValue(new Error(errorMessage)),
- ];
-
- describe.each`
- context | handlerMock | confidentialityMock | confidentialityFailureMock | inputVariables
- ${'no parent'} | ${withoutParentHandlerMock} | ${confidentialityWithoutParentMock} | ${confidentialityWithoutParentFailureMock} | ${withoutParentExpectedInputVars}
- ${'parent'} | ${successHandler} | ${confidentialityWithParentMock} | ${confidentialityWithParentFailureMock} | ${withParentExpectedInputVars}
- `(
- 'when work item has $context',
- ({ handlerMock, confidentialityMock, confidentialityFailureMock, inputVariables }) => {
- it('sends updateInProgress props to child component', async () => {
- createComponent({
- handler: handlerMock,
- confidentialityMock,
- });
-
- await waitForPromises();
-
- findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
- await nextTick();
-
- expect(findCreatedUpdated().props('updateInProgress')).toBe(true);
- });
+ it('sends updateInProgress props to child component', async () => {
+ createComponent({ mutationHandler });
+ await waitForPromises();
- it('emits workItemUpdated when mutation is successful', async () => {
- createComponent({
- handler: handlerMock,
- confidentialityMock,
- });
+ findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
+ await nextTick();
- await waitForPromises();
+ expect(findCreatedUpdated().props('updateInProgress')).toBe(true);
+ });
- findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
- await waitForPromises();
+ it('emits workItemUpdated when mutation is successful', async () => {
+ createComponent({ mutationHandler });
+ await waitForPromises();
- expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]);
- expect(confidentialityMock[1]).toHaveBeenCalledWith({
- input: inputVariables,
- });
- });
+ findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
+ await waitForPromises();
- it('shows an alert when mutation fails', async () => {
- createComponent({
- handler: handlerMock,
- confidentialityMock: confidentialityFailureMock,
- });
+ expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]);
+ expect(mutationHandler).toHaveBeenCalledWith({
+ input: {
+ id: 'gid://gitlab/WorkItem/1',
+ confidential: true,
+ },
+ });
+ });
- await waitForPromises();
- findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
- await waitForPromises();
- expect(wrapper.emitted('workItemUpdated')).toBeUndefined();
+ it('shows an alert when mutation fails', async () => {
+ createComponent({ mutationHandler: jest.fn().mockRejectedValue(new Error(errorMessage)) });
+ await waitForPromises();
- await nextTick();
+ findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
+ await waitForPromises();
- expect(findAlert().exists()).toBe(true);
- expect(findAlert().text()).toBe(errorMessage);
- });
- },
- );
+ expect(wrapper.emitted('workItemUpdated')).toBeUndefined();
+ expect(findAlert().text()).toBe(errorMessage);
+ });
});
describe('description', () => {
@@ -366,19 +292,19 @@ describe('WorkItemDetail component', () => {
});
});
- describe('secondary breadcrumbs', () => {
- it('does not show secondary breadcrumbs by default', () => {
+ describe('ancestors widget', () => {
+ it('does not show ancestors widget by default', () => {
createComponent();
- expect(findParent().exists()).toBe(false);
+ expect(findAncestors().exists()).toBe(false);
});
- it('does not show secondary breadcrumbs if there is not a parent', async () => {
+ it('does not show ancestors widget if there is not a parent', async () => {
createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) });
await waitForPromises();
- expect(findParent().exists()).toBe(false);
+ expect(findAncestors().exists()).toBe(false);
});
it('shows title in the header when there is no parent', async () => {
@@ -396,45 +322,8 @@ describe('WorkItemDetail component', () => {
return waitForPromises();
});
- it('shows secondary breadcrumbs if there is a parent', () => {
- expect(findParent().exists()).toBe(true);
- });
-
- it('shows parent breadcrumb icon', () => {
- expect(findParentButton().props('icon')).toBe(mockParent.parent.workItemType.iconName);
- });
-
- it('shows parent title and iid', () => {
- expect(findParentButton().text()).toBe(
- `${mockParent.parent.title} #${mockParent.parent.iid}`,
- );
- });
-
- it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => {
- expect(findParentButton().attributes().href).toBe('../../-/issues/5');
- });
-
- it('sets the parent breadcrumb URL based on parent webUrl when parent type is not `Issue`', async () => {
- const mockParentObjective = {
- parent: {
- ...mockParent.parent,
- workItemType: {
- id: mockParent.parent.workItemType.id,
- name: 'Objective',
- iconName: 'issue-type-objective',
- },
- },
- };
- const parentResponse = workItemByIidResponseFactory(mockParentObjective);
- createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) });
- await waitForPromises();
-
- expect(findParentButton().attributes().href).toBe(mockParentObjective.parent.webUrl);
- });
-
- it('shows work item type and iid', () => {
- const { iid } = workItemQueryResponse.data.workspace.workItems.nodes[0];
- expect(findParent().text()).toContain(`#${iid}`);
+ it('shows ancestors widget if there is a parent', () => {
+ expect(findAncestors().exists()).toBe(true);
});
it('does not show title in the header when parent exists', () => {
@@ -769,8 +658,7 @@ describe('WorkItemDetail component', () => {
expect(findWorkItemTwoColumnViewContainer().classes()).not.toContain('work-item-overview');
});
- it('does not have sticky header', () => {
- expect(findIntersectionObserver().exists()).toBe(false);
+ it('does not have sticky header component', () => {
expect(findStickyHeader().exists()).toBe(false);
});
@@ -789,18 +677,7 @@ describe('WorkItemDetail component', () => {
expect(findWorkItemTwoColumnViewContainer().classes()).toContain('work-item-overview');
});
- it('does not show sticky header by default', () => {
- expect(findStickyHeader().exists()).toBe(false);
- });
-
- it('has the sticky header when the page is scrolled', async () => {
- expect(findIntersectionObserver().exists()).toBe(true);
-
- global.pageYOffset = 100;
- triggerPageScroll();
-
- await nextTick();
-
+ it('renders the work item sticky header component', () => {
expect(findStickyHeader().exists()).toBe(true);
});
diff --git a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_actions_split_button_spec.js
index 55d5b34ae70..630ffa1a699 100644
--- a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_actions_split_button_spec.js
@@ -1,12 +1,40 @@
import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue';
+import WorkItemActionsSplitButton from '~/work_items/components/work_item_links/work_item_actions_split_button.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+const okrActions = [
+ {
+ name: 'Objective',
+ items: [
+ {
+ text: 'New objective',
+ },
+ {
+ text: 'Existing objective',
+ },
+ ],
+ },
+ {
+ name: 'Key result',
+ items: [
+ {
+ text: 'New key result',
+ },
+ {
+ text: 'Existing key result',
+ },
+ ],
+ },
+];
+
const createComponent = () => {
return extendedWrapper(
- shallowMount(OkrActionsSplitButton, {
+ shallowMount(WorkItemActionsSplitButton, {
+ propsData: {
+ actions: okrActions,
+ },
stubs: {
GlDisclosureDropdown,
},
@@ -21,7 +49,7 @@ describe('RelatedItemsTree', () => {
wrapper = createComponent();
});
- describe('OkrActionsSplitButton', () => {
+ describe('WorkItemActionsSplitButton', () => {
describe('template', () => {
it('renders objective and key results sections', () => {
expect(wrapper.findAllComponents(GlDisclosureDropdownGroup).at(0).props('group').name).toBe(
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 6c1d1035c3d..49a674e73c8 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,28 +1,36 @@
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { GlToggle } 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 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';
-import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue';
+import WorkItemActionsSplitButton from '~/work_items/components/work_item_links/work_item_actions_split_button.vue';
+import getAllowedWorkItemChildTypes from '~/work_items//graphql/work_item_allowed_children.query.graphql';
import {
FORM_TYPES,
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
} from '~/work_items/constants';
-import { childrenWorkItems } from '../../mock_data';
+import { childrenWorkItems, allowedChildrenTypesResponse } from '../../mock_data';
+
+Vue.use(VueApollo);
describe('WorkItemTree', () => {
let wrapper;
const findEmptyState = () => wrapper.findByTestId('tree-empty');
- const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton);
+ const findToggleFormSplitButton = () => wrapper.findComponent(WorkItemActionsSplitButton);
const findForm = () => wrapper.findComponent(WorkItemLinksForm);
const findWidgetWrapper = () => wrapper.findComponent(WidgetWrapper);
const findWorkItemLinkChildrenWrapper = () => wrapper.findComponent(WorkItemChildrenWrapper);
const findShowLabelsToggle = () => wrapper.findComponent(GlToggle);
+ const allowedChildrenTypesHandler = jest.fn().mockResolvedValue(allowedChildrenTypesResponse);
+
const createComponent = ({
workItemType = 'Objective',
parentWorkItemType = 'Objective',
@@ -31,6 +39,9 @@ describe('WorkItemTree', () => {
canUpdate = true,
} = {}) => {
wrapper = shallowMountExtended(WorkItemTree, {
+ apolloProvider: createMockApollo([
+ [getAllowedWorkItemChildTypes, allowedChildrenTypesHandler],
+ ]),
propsData: {
fullPath: 'test/project',
workItemType,
@@ -79,18 +90,25 @@ describe('WorkItemTree', () => {
expect(findWidgetWrapper().props('error')).toBe(errorMessage);
});
+ it('fetches allowed children types for current work item', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(allowedChildrenTypesHandler).toHaveBeenCalled();
+ });
+
it.each`
- option | event | formType | childType
- ${'New objective'} | ${'showCreateObjectiveForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE}
- ${'Existing objective'} | ${'showAddObjectiveForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE}
- ${'New key result'} | ${'showCreateKeyResultForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT}
- ${'Existing key result'} | ${'showAddKeyResultForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT}
+ option | formType | childType
+ ${'New objective'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE}
+ ${'Existing objective'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE}
+ ${'New key result'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT}
+ ${'Existing key result'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT}
`(
- 'when selecting $option from split button, renders the form passing $formType and $childType',
- async ({ event, formType, childType }) => {
+ 'when triggering action $option, renders the form passing $formType and $childType',
+ async ({ formType, childType }) => {
createComponent();
- findToggleFormSplitButton().vm.$emit(event);
+ wrapper.vm.showAddForm(formType, childType);
await nextTick();
expect(findForm().exists()).toBe(true);
@@ -122,7 +140,7 @@ describe('WorkItemTree', () => {
it('emits `addChild` event when form emits `addChild` event', async () => {
createComponent();
- findToggleFormSplitButton().vm.$emit('showCreateObjectiveForm');
+ wrapper.vm.showAddForm(FORM_TYPES.create, WORK_ITEM_TYPE_ENUM_OBJECTIVE);
await nextTick();
findForm().vm.$emit('addChild');
diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js
index 9e02e0708d4..2620242000e 100644
--- a/spec/frontend/work_items/components/work_item_notes_spec.js
+++ b/spec/frontend/work_items/components/work_item_notes_spec.js
@@ -10,6 +10,7 @@ import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
import WorkItemAddNote from '~/work_items/components/notes/work_item_add_note.vue';
import WorkItemNotesActivityHeader from '~/work_items/components/notes/work_item_notes_activity_header.vue';
+import groupWorkItemNotesByIidQuery from '~/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql';
import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql';
import deleteWorkItemNoteMutation from '~/work_items/graphql/notes/delete_work_item_notes.mutation.graphql';
import workItemNoteCreatedSubscription from '~/work_items/graphql/notes/work_item_note_created.subscription.graphql';
@@ -63,6 +64,9 @@ describe('WorkItemNotes component', () => {
const findWorkItemCommentNoteAtIndex = (index) => findAllWorkItemCommentNotes().at(index);
const findDeleteNoteModal = () => wrapper.findComponent(GlModal);
+ const groupWorkItemNotesQueryHandler = jest
+ .fn()
+ .mockResolvedValue(mockWorkItemNotesByIidResponse);
const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesByIidResponse);
const workItemMoreNotesQueryHandler = jest.fn().mockResolvedValue(mockMoreWorkItemNotesResponse);
const workItemNotesWithCommentsQueryHandler = jest
@@ -87,17 +91,22 @@ describe('WorkItemNotes component', () => {
workItemIid = mockWorkItemIid,
defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler,
deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler,
+ isGroup = false,
isModal = false,
isWorkItemConfidential = false,
} = {}) => {
wrapper = shallowMount(WorkItemNotes, {
apolloProvider: createMockApollo([
[workItemNotesByIidQuery, defaultWorkItemNotesQueryHandler],
+ [groupWorkItemNotesByIidQuery, groupWorkItemNotesQueryHandler],
[deleteWorkItemNoteMutation, deleteWINoteMutationHandler],
[workItemNoteCreatedSubscription, notesCreateSubscriptionHandler],
[workItemNoteUpdatedSubscription, notesUpdateSubscriptionHandler],
[workItemNoteDeletedSubscription, notesDeleteSubscriptionHandler],
]),
+ provide: {
+ isGroup,
+ },
propsData: {
fullPath: 'test-path',
workItemId,
@@ -354,4 +363,22 @@ describe('WorkItemNotes component', () => {
expect(findWorkItemCommentNoteAtIndex(0).props('isWorkItemConfidential')).toBe(true);
});
+
+ describe('when project context', () => {
+ it('calls the project work item query', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(workItemNotesQueryHandler).toHaveBeenCalled();
+ });
+ });
+
+ describe('when group context', () => {
+ it('calls the group work item query', async () => {
+ createComponent({ isGroup: true });
+ await waitForPromises();
+
+ expect(groupWorkItemNotesQueryHandler).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_parent_spec.js b/spec/frontend/work_items/components/work_item_parent_inline_spec.js
index 11fe6dffbfa..3e4f99d5935 100644
--- a/spec/frontend/work_items/components/work_item_parent_spec.js
+++ b/spec/frontend/work_items/components/work_item_parent_inline_spec.js
@@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
-import WorkItemParent from '~/work_items/components/work_item_parent.vue';
+import WorkItemParentInline from '~/work_items/components/work_item_parent_inline.vue';
import { removeHierarchyChild } from '~/work_items/graphql/cache_utils';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import groupWorkItemsQuery from '~/work_items/graphql/group_work_items.query.graphql';
@@ -26,7 +26,7 @@ jest.mock('~/work_items/graphql/cache_utils', () => ({
removeHierarchyChild: jest.fn(),
}));
-describe('WorkItemParent component', () => {
+describe('WorkItemParentInline component', () => {
Vue.use(VueApollo);
let wrapper;
@@ -50,7 +50,7 @@ describe('WorkItemParent component', () => {
mutationHandler = successUpdateWorkItemMutationHandler,
isGroup = false,
} = {}) => {
- wrapper = shallowMountExtended(WorkItemParent, {
+ wrapper = shallowMountExtended(WorkItemParentInline, {
apolloProvider: createMockApollo([
[projectWorkItemsQuery, searchQueryHandler],
[groupWorkItemsQuery, groupWorkItemsSuccessHandler],
diff --git a/spec/frontend/work_items/components/work_item_parent_with_edit_spec.js b/spec/frontend/work_items/components/work_item_parent_with_edit_spec.js
new file mode 100644
index 00000000000..61e43456479
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_parent_with_edit_spec.js
@@ -0,0 +1,409 @@
+import { GlForm, GlCollapsibleListbox } from '@gitlab/ui';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __ } from '~/locale';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import WorkItemParent from '~/work_items/components/work_item_parent_with_edit.vue';
+import { removeHierarchyChild } from '~/work_items/graphql/cache_utils';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import groupWorkItemsQuery from '~/work_items/graphql/group_work_items.query.graphql';
+import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql';
+import { WORK_ITEM_TYPE_ENUM_OBJECTIVE } from '~/work_items/constants';
+
+import {
+ availableObjectivesResponse,
+ mockParentWidgetResponse,
+ updateWorkItemMutationResponseFactory,
+ searchedObjectiveResponse,
+ updateWorkItemMutationErrorResponse,
+} from '../mock_data';
+
+jest.mock('~/sentry/sentry_browser_wrapper');
+jest.mock('~/work_items/graphql/cache_utils', () => ({
+ removeHierarchyChild: jest.fn(),
+}));
+
+describe('WorkItemParent component', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+
+ const workItemId = 'gid://gitlab/WorkItem/1';
+ const workItemType = 'Objective';
+ const mockFullPath = 'full-path';
+
+ const groupWorkItemsSuccessHandler = jest.fn().mockResolvedValue(availableObjectivesResponse);
+ const availableWorkItemsSuccessHandler = jest.fn().mockResolvedValue(availableObjectivesResponse);
+ const availableWorkItemsFailureHandler = jest.fn().mockRejectedValue(new Error());
+
+ const findHeader = () => wrapper.find('h3');
+ const findEditButton = () => wrapper.find('[data-testid="edit-parent"]');
+ const findApplyButton = () => wrapper.find('[data-testid="apply-parent"]');
+
+ const findLoadingIcon = () => wrapper.find('[data-testid="loading-icon-parent"]');
+ const findLabel = () => wrapper.find('label');
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+
+ const successUpdateWorkItemMutationHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponseFactory({ parent: mockParentWidgetResponse }));
+
+ const createComponent = ({
+ canUpdate = true,
+ parent = null,
+ searchQueryHandler = availableWorkItemsSuccessHandler,
+ mutationHandler = successUpdateWorkItemMutationHandler,
+ isEditing = false,
+ isGroup = false,
+ } = {}) => {
+ wrapper = mountExtended(WorkItemParent, {
+ apolloProvider: createMockApollo([
+ [projectWorkItemsQuery, searchQueryHandler],
+ [groupWorkItemsQuery, groupWorkItemsSuccessHandler],
+ [updateWorkItemMutation, mutationHandler],
+ ]),
+ provide: {
+ fullPath: mockFullPath,
+ isGroup,
+ },
+ propsData: {
+ canUpdate,
+ parent,
+ workItemId,
+ workItemType,
+ },
+ });
+
+ if (isEditing) {
+ findEditButton().trigger('click');
+ }
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('label', () => {
+ it('shows header when not editing', () => {
+ createComponent();
+
+ expect(findHeader().exists()).toBe(true);
+ expect(findHeader().classes('gl-sr-only')).toBe(false);
+ expect(findLabel().exists()).toBe(false);
+ });
+
+ it('shows label and hides header while editing', async () => {
+ createComponent({ isEditing: true });
+
+ await nextTick();
+
+ expect(findLabel().exists()).toBe(true);
+ expect(findHeader().classes('gl-sr-only')).toBe(true);
+ });
+ });
+
+ describe('edit button', () => {
+ it('is not shown if user cannot edit', () => {
+ createComponent({ canUpdate: false });
+
+ expect(findEditButton().exists()).toBe(false);
+ });
+
+ it('is shown if user can edit', () => {
+ createComponent({ canUpdate: true });
+
+ expect(findEditButton().exists()).toBe(true);
+ });
+
+ it('triggers edit mode on click', async () => {
+ createComponent();
+
+ findEditButton().trigger('click');
+
+ await nextTick();
+
+ expect(findLabel().exists()).toBe(true);
+ expect(findForm().exists()).toBe(true);
+ });
+
+ it('is replaced by Apply button while editing', async () => {
+ createComponent();
+
+ findEditButton().trigger('click');
+
+ await nextTick();
+
+ expect(findEditButton().exists()).toBe(false);
+ expect(findApplyButton().exists()).toBe(true);
+ });
+ });
+
+ describe('loading icon', () => {
+ const selectWorkItem = async (workItem) => {
+ await findCollapsibleListbox().vm.$emit('select', workItem);
+ };
+
+ it('shows loading icon while update is in progress', async () => {
+ createComponent();
+ findEditButton().trigger('click');
+
+ await nextTick();
+
+ selectWorkItem('gid://gitlab/WorkItem/716');
+
+ await nextTick();
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('shows loading icon when unassign is clicked', async () => {
+ createComponent({ parent: mockParentWidgetResponse });
+ findEditButton().trigger('click');
+
+ await nextTick();
+
+ findCollapsibleListbox().vm.$emit('reset');
+
+ await nextTick();
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('value', () => {
+ it('shows None when no parent is set', () => {
+ createComponent();
+
+ expect(wrapper.text()).toContain(__('None'));
+ });
+
+ it('shows parent when parent is set', () => {
+ createComponent({ parent: mockParentWidgetResponse });
+
+ expect(wrapper.text()).not.toContain(__('None'));
+ expect(wrapper.text()).toContain(mockParentWidgetResponse.title);
+ });
+ });
+
+ describe('form', () => {
+ it('is not shown while not editing', async () => {
+ await createComponent();
+
+ expect(findForm().exists()).toBe(false);
+ });
+
+ it('is shown while editing', async () => {
+ await createComponent({ isEditing: true });
+
+ expect(findForm().exists()).toBe(true);
+ });
+ });
+
+ describe('Parent Input', () => {
+ it('is not shown while not editing', async () => {
+ await createComponent();
+
+ expect(findCollapsibleListbox().exists()).toBe(false);
+ });
+
+ it('renders the collapsible listbox with required props', async () => {
+ await createComponent({ isEditing: true });
+
+ expect(findCollapsibleListbox().exists()).toBe(true);
+ expect(findCollapsibleListbox().props()).toMatchObject({
+ items: [],
+ headerText: 'Assign parent',
+ category: 'primary',
+ loading: false,
+ isCheckCentered: true,
+ searchable: true,
+ searching: false,
+ infiniteScroll: false,
+ noResultsText: 'No matching results',
+ toggleText: 'None',
+ searchPlaceholder: 'Search',
+ resetButtonLabel: 'Unassign',
+ });
+ });
+ it('shows loading while searching', async () => {
+ await createComponent({ isEditing: true });
+
+ await findCollapsibleListbox().vm.$emit('shown');
+ expect(findCollapsibleListbox().props('searching')).toBe(true);
+ });
+ });
+
+ describe('work items query', () => {
+ it('loads work items in the listbox', async () => {
+ await createComponent({ isEditing: true });
+ await findCollapsibleListbox().vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(findCollapsibleListbox().props('searching')).toBe(false);
+ expect(findCollapsibleListbox().props('items')).toStrictEqual([
+ { text: 'Objective 101', value: 'gid://gitlab/WorkItem/716' },
+ { text: 'Objective 103', value: 'gid://gitlab/WorkItem/712' },
+ { text: 'Objective 102', value: 'gid://gitlab/WorkItem/711' },
+ ]);
+ expect(availableWorkItemsSuccessHandler).toHaveBeenCalled();
+ });
+
+ it('emits error when the query fails', async () => {
+ await createComponent({
+ searchQueryHandler: availableWorkItemsFailureHandler,
+ isEditing: true,
+ });
+
+ await findCollapsibleListbox().vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([
+ ['Something went wrong while fetching items. Please try again.'],
+ ]);
+ });
+
+ it('searches item when input data is entered', async () => {
+ const searchedItemQueryHandler = jest.fn().mockResolvedValue(searchedObjectiveResponse);
+ await createComponent({
+ searchQueryHandler: searchedItemQueryHandler,
+ isEditing: true,
+ });
+
+ await findCollapsibleListbox().vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(searchedItemQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'full-path',
+ searchTerm: '',
+ types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE],
+ in: undefined,
+ iid: null,
+ isNumber: false,
+ });
+
+ await findCollapsibleListbox().vm.$emit('search', 'Objective 101');
+
+ expect(searchedItemQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'full-path',
+ searchTerm: 'Objective 101',
+ types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE],
+ in: 'TITLE',
+ iid: null,
+ isNumber: false,
+ });
+
+ await nextTick();
+
+ expect(findCollapsibleListbox().props('items')).toStrictEqual([
+ { text: 'Objective 101', value: 'gid://gitlab/WorkItem/716' },
+ ]);
+ });
+ });
+
+ describe('listbox', () => {
+ const selectWorkItem = async (workItem) => {
+ await findCollapsibleListbox().vm.$emit('select', workItem);
+ };
+
+ it('calls mutation when item is selected', async () => {
+ await createComponent({ isEditing: true });
+ selectWorkItem('gid://gitlab/WorkItem/716');
+
+ await waitForPromises();
+
+ expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({
+ input: {
+ id: 'gid://gitlab/WorkItem/1',
+ hierarchyWidget: {
+ parentId: 'gid://gitlab/WorkItem/716',
+ },
+ },
+ });
+
+ expect(removeHierarchyChild).toHaveBeenCalledWith({
+ cache: expect.anything(Object),
+ fullPath: mockFullPath,
+ iid: undefined,
+ isGroup: false,
+ workItem: { id: 'gid://gitlab/WorkItem/1' },
+ });
+ });
+
+ it('calls mutation when item is unassigned', async () => {
+ const unAssignParentWorkItemMutationHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponseFactory({ parent: null }));
+ await createComponent({
+ parent: {
+ iid: '1',
+ },
+ mutationHandler: unAssignParentWorkItemMutationHandler,
+ });
+
+ findEditButton().trigger('click');
+
+ await nextTick();
+
+ findCollapsibleListbox().vm.$emit('reset');
+
+ await waitForPromises();
+
+ expect(unAssignParentWorkItemMutationHandler).toHaveBeenCalledWith({
+ input: {
+ id: 'gid://gitlab/WorkItem/1',
+ hierarchyWidget: {
+ parentId: null,
+ },
+ },
+ });
+ expect(removeHierarchyChild).toHaveBeenCalledWith({
+ cache: expect.anything(Object),
+ fullPath: mockFullPath,
+ iid: '1',
+ isGroup: false,
+ workItem: { id: 'gid://gitlab/WorkItem/1' },
+ });
+ });
+
+ it('emits error when mutation fails', async () => {
+ await createComponent({
+ mutationHandler: jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse),
+ isEditing: true,
+ });
+
+ selectWorkItem('gid://gitlab/WorkItem/716');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([['Error!']]);
+ });
+
+ it('emits error and captures exception in sentry when network request fails', async () => {
+ const error = new Error('error');
+ await createComponent({
+ mutationHandler: jest.fn().mockRejectedValue(error),
+ isEditing: true,
+ });
+
+ selectWorkItem('gid://gitlab/WorkItem/716');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([
+ ['Something went wrong while updating the objective. Please try again.'],
+ ]);
+ expect(Sentry.captureException).toHaveBeenCalledWith(error);
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js b/spec/frontend/work_items/components/work_item_state_toggle_spec.js
index a210bd50422..a210bd50422 100644
--- a/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js
+++ b/spec/frontend/work_items/components/work_item_state_toggle_spec.js
diff --git a/spec/frontend/work_items/components/work_item_sticky_header_spec.js b/spec/frontend/work_items/components/work_item_sticky_header_spec.js
new file mode 100644
index 00000000000..4b7818044b1
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_sticky_header_spec.js
@@ -0,0 +1,59 @@
+import { GlIntersectionObserver } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { STATE_OPEN } from '~/work_items/constants';
+import { workItemResponseFactory } from 'jest/work_items/mock_data';
+import WorkItemStickyHeader from '~/work_items/components/work_item_sticky_header.vue';
+import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
+import WorkItemActions from '~/work_items/components/work_item_actions.vue';
+import WorkItemTodos from '~/work_items/components/work_item_todos.vue';
+
+describe('WorkItemStickyHeader', () => {
+ let wrapper;
+
+ const workItemResponse = workItemResponseFactory({ canUpdate: true, confidential: true }).data
+ .workItem;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(WorkItemStickyHeader, {
+ propsData: {
+ workItem: workItemResponse,
+ fullPath: '/test',
+ isStickyHeaderShowing: true,
+ workItemNotificationsSubscribed: true,
+ updateInProgress: false,
+ parentWorkItemConfidentiality: false,
+ showWorkItemCurrentUserTodos: true,
+ isModal: false,
+ currentUserTodos: [],
+ workItemState: STATE_OPEN,
+ },
+ });
+ };
+ const findStickyHeader = () => wrapper.findByTestId('work-item-sticky-header');
+ const findConfidentialityBadge = () => wrapper.findComponent(ConfidentialityBadge);
+ const findWorkItemActions = () => wrapper.findComponent(WorkItemActions);
+ const findWorkItemTodos = () => wrapper.findComponent(WorkItemTodos);
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
+ const triggerPageScroll = () => findIntersectionObserver().vm.$emit('disappear');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has the sticky header when the page is scrolled', async () => {
+ global.pageYOffset = 100;
+ triggerPageScroll();
+
+ await nextTick();
+
+ expect(findStickyHeader().exists()).toBe(true);
+ });
+
+ it('has the components of confidentiality, actions, todos and title', () => {
+ expect(findConfidentialityBadge().exists()).toBe(true);
+ expect(findWorkItemActions().exists()).toBe(true);
+ expect(findWorkItemTodos().exists()).toBe(true);
+ expect(wrapper.findByText(workItemResponse.title).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js
index 0f466bcf691..de740e5fbc5 100644
--- a/spec/frontend/work_items/components/work_item_title_spec.js
+++ b/spec/frontend/work_items/components/work_item_title_spec.js
@@ -8,7 +8,6 @@ import ItemTitle from '~/work_items/components/item_title.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
-import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data';
describe('WorkItemTitle component', () => {
@@ -20,22 +19,14 @@ describe('WorkItemTitle component', () => {
const findItemTitle = () => wrapper.findComponent(ItemTitle);
- const createComponent = ({
- workItemParentId,
- mutationHandler = mutationSuccessHandler,
- canUpdate = true,
- } = {}) => {
+ const createComponent = ({ mutationHandler = mutationSuccessHandler, canUpdate = true } = {}) => {
const { id, title, workItemType } = workItemQueryResponse.data.workItem;
wrapper = shallowMount(WorkItemTitle, {
- apolloProvider: createMockApollo([
- [updateWorkItemMutation, mutationHandler],
- [updateWorkItemTaskMutation, mutationHandler],
- ]),
+ apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
propsData: {
workItemId: id,
workItemTitle: title,
workItemType: workItemType.name,
- workItemParentId,
canUpdate,
},
});
@@ -77,27 +68,6 @@ describe('WorkItemTitle component', () => {
});
});
- it('calls WorkItemTaskUpdate if passed workItemParentId prop', () => {
- const title = 'new title!';
- const workItemParentId = '1234';
-
- createComponent({
- workItemParentId,
- });
-
- findItemTitle().vm.$emit('title-changed', title);
-
- expect(mutationSuccessHandler).toHaveBeenCalledWith({
- input: {
- id: workItemParentId,
- taskData: {
- id: workItemQueryResponse.data.workItem.id,
- title,
- },
- },
- });
- });
-
it('does not call a mutation when the title has not changed', () => {
createComponent();
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 8df46403b90..9d4606eb95a 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -445,7 +445,7 @@ export const descriptionHtmlWithCheckboxes = `
</ul>
`;
-const taskType = {
+export const taskType = {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
@@ -459,6 +459,20 @@ export const objectiveType = {
iconName: 'issue-type-objective',
};
+export const keyResultType = {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/2411',
+ name: 'Key Result',
+ iconName: 'issue-type-keyresult',
+};
+
+export const issueType = {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/2411',
+ name: 'Issue',
+ iconName: 'issue-type-issue',
+};
+
export const mockEmptyLinkedItems = {
type: WIDGET_TYPE_LINKED_ITEMS,
blocked: false,
@@ -3703,5 +3717,40 @@ export const updateWorkItemNotificationsMutationResponse = (subscribed) => ({
},
});
+export const allowedChildrenTypesResponse = {
+ data: {
+ workItem: {
+ id: 'gid://gitlab/WorkItem/634',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/6',
+ name: 'Objective',
+ widgetDefinitions: [
+ {
+ type: 'HIERARCHY',
+ allowedChildTypes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItems::Type/7',
+ name: 'Key Result',
+ __typename: 'WorkItemType',
+ },
+ {
+ id: 'gid://gitlab/WorkItems::Type/6',
+ name: 'Objective',
+ __typename: 'WorkItemType',
+ },
+ ],
+ __typename: 'WorkItemTypeConnection',
+ },
+ __typename: 'WorkItemWidgetDefinitionHierarchy',
+ },
+ ],
+ __typename: 'WorkItemType',
+ },
+ __typename: 'WorkItem',
+ },
+ },
+};
+
export const generateWorkItemsListWithId = (count) =>
Array.from({ length: count }, (_, i) => ({ id: `gid://gitlab/WorkItem/${i + 1}` }));
diff --git a/spec/frontend/work_items/notes/award_utils_spec.js b/spec/frontend/work_items/notes/award_utils_spec.js
index 8ae32ce5f40..43eceb13b67 100644
--- a/spec/frontend/work_items/notes/award_utils_spec.js
+++ b/spec/frontend/work_items/notes/award_utils_spec.js
@@ -2,6 +2,7 @@ import { getMutation, optimisticAwardUpdate } from '~/work_items/notes/award_uti
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import mockApollo from 'helpers/mock_apollo_helper';
import { __ } from '~/locale';
+import groupWorkItemNotesByIidQuery from '~/work_items/graphql/notes/group_work_item_notes_by_iid.query.graphql';
import workItemNotesByIidQuery from '~/work_items/graphql/notes/work_item_notes_by_iid.query.graphql';
import addAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_add_award_emoji.mutation.graphql';
import removeAwardEmojiMutation from '~/work_items/graphql/notes/work_item_note_remove_award_emoji.mutation.graphql';
@@ -105,5 +106,22 @@ describe('Work item note award utils', () => {
expect(updatedNote.awardEmoji.nodes).toEqual([]);
});
+
+ it.each`
+ description | isGroup | query
+ ${'calls project query when in project context'} | ${false} | ${workItemNotesByIidQuery}
+ ${'calls group query when in group context'} | ${true} | ${groupWorkItemNotesByIidQuery}
+ `('$description', ({ isGroup, query }) => {
+ const note = firstNote;
+ const { name } = mockAwardEmojiThumbsUp;
+ const cacheSpy = { updateQuery: jest.fn() };
+
+ optimisticAwardUpdate({ note, name, fullPath, isGroup, workItemIid })(cacheSpy);
+
+ expect(cacheSpy.updateQuery).toHaveBeenCalledWith(
+ { query, variables: { fullPath, iid: workItemIid } },
+ expect.any(Function),
+ );
+ });
});
});
diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js
index 527f5890338..2c898f97ee9 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -8,7 +8,6 @@ import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import ItemTitle from '~/work_items/components/item_title.vue';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
-import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data';
jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] }));
@@ -42,7 +41,6 @@ describe('Create work item component', () => {
[
[projectWorkItemTypesQuery, queryHandler],
[createWorkItemMutation, mutationHandler],
- [createWorkItemFromTaskMutation, mutationHandler],
],
{},
{ typePolicies: { Project: { merge: true } } },
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 84b10f30418..4854b5bfb77 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -49,7 +49,6 @@ describe('Work items root component', () => {
expect(findWorkItemDetail().props()).toEqual({
isModal: false,
- workItemParentId: null,
workItemIid: '1',
});
});