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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2024-01-16 13:42:19 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2024-01-16 13:42:19 +0300
commit84d1bd786125c1c14a3ba5f63e38a4cc736a9027 (patch)
treef550fa965f507077e20dbb6d61a8269a99ef7107 /spec/frontend
parent3a105e36e689f7b75482236712f1a47fd5a76814 (diff)
Add latest changes from gitlab-org/gitlab@16-8-stable-eev16.8.0-rc42
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/admin/abuse_report/components/user_details_spec.js67
-rw-r--r--spec/frontend/admin/abuse_report/mock_data.js6
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_form_spec.js250
-rw-r--r--spec/frontend/behaviors/secret_values_spec.js230
-rw-r--r--spec/frontend/blob/openapi/index_spec.js13
-rw-r--r--spec/frontend/boards/board_list_helper.js1
-rw-r--r--spec/frontend/boards/board_list_spec.js83
-rw-r--r--spec/frontend/boards/components/board_add_new_column_form_spec.js22
-rw-r--r--spec/frontend/boards/components/board_add_new_column_trigger_spec.js7
-rw-r--r--spec/frontend/boards/components/board_app_spec.js8
-rw-r--r--spec/frontend/boards/components/board_card_move_to_position_spec.js12
-rw-r--r--spec/frontend/boards/components/board_card_spec.js1
-rw-r--r--spec/frontend/boards/components/board_content_spec.js2
-rw-r--r--spec/frontend/boards/components/board_top_bar_spec.js1
-rw-r--r--spec/frontend/boards/components/boards_selector_spec.js1
-rw-r--r--spec/frontend/boards/components/config_toggle_spec.js7
-rw-r--r--spec/frontend/boards/mock_data.js78
-rw-r--r--spec/frontend/boards/project_select_spec.js6
-rw-r--r--spec/frontend/boards/stores/actions_spec.js2098
-rw-r--r--spec/frontend/boards/stores/getters_spec.js203
-rw-r--r--spec/frontend/boards/stores/state_spec.js11
-rw-r--r--spec/frontend/captcha/captcha_modal_spec.js63
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js4
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_components_spec.js4
-rw-r--r--spec/frontend/ci/catalog/components/details/ci_resource_header_spec.js2
-rw-r--r--spec/frontend/ci/catalog/components/list/catalog_search_spec.js13
-rw-r--r--spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js71
-rw-r--r--spec/frontend/ci/catalog/components/list/ci_resources_list_item_spec.js4
-rw-r--r--spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js97
-rw-r--r--spec/frontend/ci/catalog/components/pages/ci_resources_page_spec.js68
-rw-r--r--spec/frontend/ci/catalog/mock.js34
-rw-r--r--spec/frontend/ci/ci_environments_dropdown/ci_environments_dropdown_spec.js (renamed from spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js)65
-rw-r--r--spec/frontend/ci/ci_environments_dropdown/utils_spec.js (renamed from spec/frontend/ci/ci_variable_list/utils_spec.js)14
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js2
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js2
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js8
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js4
-rw-r--r--spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js6
-rw-r--r--spec/frontend/ci/ci_variable_list/mocks.js2
-rw-r--r--spec/frontend/ci/pipeline_details/test_reports/mock_data.js8
-rw-r--r--spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js44
-rw-r--r--spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js10
-rw-r--r--spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js23
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js3
-rw-r--r--spec/frontend/ci/runner/components/runner_job_status_badge_spec.js3
-rw-r--r--spec/frontend/clusters/agents/components/show_spec.js20
-rw-r--r--spec/frontend/comment_templates/components/form_spec.js14
-rw-r--r--spec/frontend/commit/components/signature_badge_spec.js1
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js42
-rw-r--r--spec/frontend/content_editor/extensions/copy_paste_spec.js13
-rw-r--r--spec/frontend/content_editor/extensions/task_item_spec.js115
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js50
-rw-r--r--spec/frontend/content_editor/services/markdown_sourcemap_spec.js33
-rw-r--r--spec/frontend/custom_emoji/components/__snapshots__/list_spec.js.snap2
-rw-r--r--spec/frontend/custom_emoji/components/list_spec.js3
-rw-r--r--spec/frontend/deploy_keys/components/action_btn_spec.js43
-rw-r--r--spec/frontend/deploy_keys/components/app_spec.js244
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js154
-rw-r--r--spec/frontend/deploy_keys/components/keys_panel_spec.js13
-rw-r--r--spec/frontend/deploy_keys/graphql/resolvers_spec.js7
-rw-r--r--spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap160
-rw-r--r--spec/frontend/diffs/components/app_spec.js39
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js27
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js146
-rw-r--r--spec/frontend/diffs/components/diff_row_utils_spec.js39
-rw-r--r--spec/frontend/diffs/components/tree_list_spec.js103
-rw-r--r--spec/frontend/diffs/store/actions_spec.js137
-rw-r--r--spec/frontend/diffs/store/getters_spec.js32
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js57
-rw-r--r--spec/frontend/diffs/store/utils_spec.js11
-rw-r--r--spec/frontend/editor/schema/ci/ci_schema_spec.js22
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml11
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml18
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/services.yml14
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_job_failure.yml (renamed from spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml)1
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_new_commit.yml3
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_job_failure.yml7
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_new_commit.yml7
-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.yml13
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml29
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/services.yml15
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_job_failure.yml (renamed from spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml)1
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_new_commit.yml3
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_job_failure.yml7
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_new_commit.yml7
-rw-r--r--spec/frontend/emoji/components/emoji_group_spec.js1
-rw-r--r--spec/frontend/environments/helpers/k8s_integration_helper_spec.js30
-rw-r--r--spec/frontend/environments/kubernetes_status_bar_spec.js53
-rw-r--r--spec/frontend/error_tracking/components/error_details_info_spec.js7
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js22
-rw-r--r--spec/frontend/fixtures/static/oauth_remember_me.html8
-rw-r--r--spec/frontend/groups/components/app_spec.js3
-rw-r--r--spec/frontend/groups/components/group_item_spec.js61
-rw-r--r--spec/frontend/groups/components/group_name_and_path_spec.js17
-rw-r--r--spec/frontend/groups/components/overview_tabs_spec.js16
-rw-r--r--spec/frontend/groups_projects/components/more_actions_dropdown_spec.js30
-rw-r--r--spec/frontend/ide/lib/alerts/environment_spec.js21
-rw-r--r--spec/frontend/ide/services/index_spec.js33
-rw-r--r--spec/frontend/ide/stores/actions/alert_spec.js46
-rw-r--r--spec/frontend/ide/stores/getters/alert_spec.js46
-rw-r--r--spec/frontend/ide/stores/mutations/alert_spec.js26
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_status_spec.js1
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js17
-rw-r--r--spec/frontend/invite_members/utils/member_utils_spec.js16
-rw-r--r--spec/frontend/jira_connect/branches/components/project_dropdown_spec.js78
-rw-r--r--spec/frontend/jira_connect/branches/mock_data.js30
-rw-r--r--spec/frontend/kubernetes_dashboard/components/workload_table_spec.js11
-rw-r--r--spec/frontend/kubernetes_dashboard/graphql/mock_data.js246
-rw-r--r--spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js254
-rw-r--r--spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js80
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/cron_jobs_page_spec.js102
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/jobs_page_spec.js102
-rw-r--r--spec/frontend/kubernetes_dashboard/pages/services_page_spec.js104
-rw-r--r--spec/frontend/lib/utils/number_utils_spec.js (renamed from spec/frontend/lib/utils/number_utility_spec.js)22
-rw-r--r--spec/frontend/lib/utils/secret_detection_spec.js5
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js8
-rw-r--r--spec/frontend/logo_spec.js8
-rw-r--r--spec/frontend/ml/model_registry/apps/index_ml_models_spec.js45
-rw-r--r--spec/frontend/ml/model_registry/apps/new_ml_model_spec.js119
-rw-r--r--spec/frontend/ml/model_registry/apps/show_ml_model_spec.js11
-rw-r--r--spec/frontend/ml/model_registry/components/actions_dropdown_spec.js39
-rw-r--r--spec/frontend/ml/model_registry/components/candidate_list_spec.js94
-rw-r--r--spec/frontend/ml/model_registry/components/model_version_list_spec.js90
-rw-r--r--spec/frontend/ml/model_registry/components/searchable_list_spec.js170
-rw-r--r--spec/frontend/ml/model_registry/graphql_mock_data.js24
-rw-r--r--spec/frontend/ml/model_registry/mock_data.js1
-rw-r--r--spec/frontend/oauth_remember_me_spec.js8
-rw-r--r--spec/frontend/observability/client_spec.js32
-rw-r--r--spec/frontend/organizations/new/components/app_spec.js27
-rw-r--r--spec/frontend/organizations/settings/general/components/organization_settings_spec.js71
-rw-r--r--spec/frontend/organizations/shared/components/groups_view_spec.js10
-rw-r--r--spec/frontend/organizations/shared/components/new_edit_form_spec.js71
-rw-r--r--spec/frontend/organizations/shared/components/projects_view_spec.js10
-rw-r--r--spec/frontend/organizations/show/components/app_spec.js7
-rw-r--r--spec/frontend/organizations/show/components/organization_description_spec.js46
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js1
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap8
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js14
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js97
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js19
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/mock_data.js33
-rw-r--r--spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js112
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/ci_catalog_settings_spec.js6
-rw-r--r--spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js6
-rw-r--r--spec/frontend/performance_bar/components/performance_bar_app_spec.js3
-rw-r--r--spec/frontend/performance_bar/components/request_warning_spec.js23
-rw-r--r--spec/frontend/profile/preferences/components/profile_preferences_spec.js15
-rw-r--r--spec/frontend/projects/commit/components/commit_comments_button_spec.js42
-rw-r--r--spec/frontend/projects/new/components/new_project_url_select_spec.js69
-rw-r--r--spec/frontend/projects/settings/components/new_access_dropdown_spec.js23
-rw-r--r--spec/frontend/projects/settings/repository/branch_rules/app_spec.js123
-rw-r--r--spec/frontend/projects/settings/repository/branch_rules/mock_data.js14
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap8
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js19
-rw-r--r--spec/frontend/releases/components/app_index_spec.js44
-rw-r--r--spec/frontend/releases/mock_data.js11
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js52
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js15
-rw-r--r--spec/frontend/search/store/actions_spec.js25
-rw-r--r--spec/frontend/security_configuration/components/feature_card_spec.js2
-rw-r--r--spec/frontend/security_configuration/mock_data.js79
-rw-r--r--spec/frontend/security_configuration/utils_spec.js109
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js45
-rw-r--r--spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js24
-rw-r--r--spec/frontend/sidebar/components/mock_data.js24
-rw-r--r--spec/frontend/sidebar/components/sidebar_color_picker_spec.js58
-rw-r--r--spec/frontend/super_sidebar/components/create_menu_spec.js1
-rw-r--r--spec/frontend/super_sidebar/components/user_menu_spec.js58
-rw-r--r--spec/frontend/super_sidebar/mock_data.js101
-rw-r--r--spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js51
-rw-r--r--spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js44
-rw-r--r--spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js43
-rw-r--r--spec/frontend/usage_quotas/storage/mock_data.js2
-rw-r--r--spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js16
-rw-r--r--spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js49
-rw-r--r--spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/file_row_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/daterange_token_spec.js170
-rw-r--r--spec/frontend/vue_shared/components/gl_countdown_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/groups_list/groups_list_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/help_page_link/help_page_link_spec.js51
-rw-r--r--spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/projects_list/projects_list_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/segmented_control_button_group_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_new_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/upload_dropzone/avatar_upload_dropzone_spec.js116
-rw-r--r--spec/frontend/vue_shared/components/user_select_spec.js45
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js79
-rw-r--r--spec/frontend/work_items/components/notes/work_item_comment_form_spec.js25
-rw-r--r--spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js161
-rw-r--r--spec/frontend/work_items/components/shared/work_item_token_input_spec.js238
-rw-r--r--spec/frontend/work_items/components/work_item_assignees_spec.js3
-rw-r--r--spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js26
-rw-r--r--spec/frontend/work_items/components/work_item_description_rendered_spec.js15
-rw-r--r--spec/frontend/work_items/components/work_item_description_spec.js36
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js84
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js26
-rw-r--r--spec/frontend/work_items/components/work_item_milestone_inline_spec.js (renamed from spec/frontend/work_items/components/work_item_milestone_spec.js)20
-rw-r--r--spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js209
-rw-r--r--spec/frontend/work_items/components/work_item_parent_inline_spec.js6
-rw-r--r--spec/frontend/work_items/components/work_item_parent_with_edit_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_state_toggle_spec.js28
-rw-r--r--spec/frontend/work_items/components/work_item_title_with_edit_spec.js59
-rw-r--r--spec/frontend/work_items/mock_data.js72
-rw-r--r--spec/frontend/work_items/utils_spec.js24
211 files changed, 6578 insertions, 4234 deletions
diff --git a/spec/frontend/admin/abuse_report/components/user_details_spec.js b/spec/frontend/admin/abuse_report/components/user_details_spec.js
index 24ec0cdb1b2..42c219b1b11 100644
--- a/spec/frontend/admin/abuse_report/components/user_details_spec.js
+++ b/spec/frontend/admin/abuse_report/components/user_details_spec.js
@@ -1,6 +1,5 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { sprintf } from '~/locale';
import UserDetails from '~/admin/abuse_report/components/user_details.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { USER_DETAILS_I18N } from '~/admin/abuse_report/constants';
@@ -61,7 +60,7 @@ describe('UserDetails', () => {
describe('verification', () => {
it('renders the users verification with the correct label', () => {
expect(findUserDetailLabel('verification')).toBe(USER_DETAILS_I18N.verification);
- expect(findUserDetailValue('verification')).toBe('Email, Credit card');
+ expect(findUserDetailValue('verification')).toBe('Email, Phone, Credit card');
});
});
@@ -73,7 +72,7 @@ describe('UserDetails', () => {
describe('similar credit cards', () => {
it('renders the number of similar records', () => {
expect(findUserDetail('credit-card-verification').text()).toContain(
- sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }),
+ `Card matches ${user.creditCard.similarRecordsCount} accounts`,
);
});
@@ -83,7 +82,7 @@ describe('UserDetails', () => {
);
expect(findLinkFor('credit-card-verification').text()).toBe(
- sprintf('%{similarRecordsCount} accounts', { ...user.creditCard }),
+ `${user.creditCard.similarRecordsCount} accounts`,
);
expect(findLinkFor('credit-card-verification').text()).toContain(
@@ -100,7 +99,7 @@ describe('UserDetails', () => {
it('does not render the number of similar records', () => {
expect(findUserDetail('credit-card-verification').text()).not.toContain(
- sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }),
+ `Card matches ${user.creditCard.similarRecordsCount} accounts`,
);
});
@@ -123,6 +122,60 @@ describe('UserDetails', () => {
});
});
+ describe('phoneNumber', () => {
+ it('renders the correct label', () => {
+ expect(findUserDetailLabel('phone-number-verification')).toBe(USER_DETAILS_I18N.phoneNumber);
+ });
+
+ describe('similar phone numbers', () => {
+ it('renders the number of similar records', () => {
+ expect(findUserDetail('phone-number-verification').text()).toContain(
+ `Phone matches ${user.phoneNumber.similarRecordsCount} accounts`,
+ );
+ });
+
+ it('renders a link to the matching phone numbers', () => {
+ expect(findLinkFor('phone-number-verification').attributes('href')).toBe(
+ user.phoneNumber.phoneMatchesLink,
+ );
+
+ expect(findLinkFor('phone-number-verification').text()).toBe(
+ `${user.phoneNumber.similarRecordsCount} accounts`,
+ );
+ });
+
+ describe('when the number of similar phone numbers is less than 2', () => {
+ beforeEach(() => {
+ createComponent({
+ user: { ...user, phoneNumber: { ...user.phoneNumber, similarRecordsCount: 1 } },
+ });
+ });
+
+ it('does not render the number of similar records', () => {
+ expect(findUserDetail('phone-number-verification').text()).not.toContain(
+ `Phone matches ${user.phoneNumber.similarRecordsCount} accounts`,
+ );
+ });
+
+ it('does not render a link to the matching phone numbers', () => {
+ expect(findLinkFor('phone-number-verification').exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when the users phoneNumber is blank', () => {
+ beforeEach(() => {
+ createComponent({
+ user: { ...user, phoneNumber: undefined },
+ });
+ });
+
+ it('does not render the users phoneNumber', () => {
+ expect(findUserDetail('phone-number-verification').exists()).toBe(false);
+ });
+ });
+ });
+
describe('otherReports', () => {
it('renders the correct label', () => {
expect(findUserDetailLabel('past-closed-reports')).toBe(USER_DETAILS_I18N.pastReports);
@@ -132,9 +185,7 @@ describe('UserDetails', () => {
const index = user.pastClosedReports.indexOf(pastReport);
it('renders the category', () => {
- expect(findPastReport(index).text()).toContain(
- sprintf('Reported for %{category}', { ...pastReport }),
- );
+ expect(findPastReport(index).text()).toContain(`Reported for ${pastReport.category}`);
});
it('renders a link to the report', () => {
diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js
index 9790b44c976..f02986fb5bb 100644
--- a/spec/frontend/admin/abuse_report/mock_data.js
+++ b/spec/frontend/admin/abuse_report/mock_data.js
@@ -9,12 +9,16 @@ export const mockAbuseReport = {
path: '/spamuser417',
adminPath: '/admin/users/spamuser417',
plan: 'Free',
- verificationState: { email: true, phone: false, creditCard: true },
+ verificationState: { email: true, phone: true, creditCard: true },
creditCard: {
name: 'S. User',
similarRecordsCount: 2,
cardMatchesLink: '/admin/users/spamuser417/card_match',
},
+ phoneNumber: {
+ similarRecordsCount: 2,
+ phoneMatchesLink: '/admin/users/spamuser417/phone_match',
+ },
pastClosedReports: [
{
category: 'offensive',
diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
index e6b38a1e824..41690e1b5be 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
@@ -6,30 +6,48 @@ import {
GlFormTextarea,
GlTab,
GlLink,
+ GlModal,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue';
import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue';
import { typeSet } from '~/alerts_settings/constants';
-import alertFields from '../mocks/alert_fields.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
import parsedMapping from '../mocks/parsed_mapping.json';
+import alertFields from '../mocks/alert_fields.json';
const scrollIntoViewMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
+Vue.use(VueApollo);
+
describe('AlertsSettingsForm', () => {
let wrapper;
const mockToastShow = jest.fn();
+ let apolloProvider;
+
+ const createComponent = async ({
+ props = {},
+ multiIntegrations = true,
+ currentIntegration = null,
+ } = {}) => {
+ const mockResolvers = {
+ Query: {
+ currentIntegration() {
+ return currentIntegration;
+ },
+ },
+ };
+
+ apolloProvider = createMockApollo([], mockResolvers);
- const createComponent = ({ data = {}, props = {}, multiIntegrations = true } = {}) => {
wrapper = extendedWrapper(
mount(AlertsSettingsForm, {
- data() {
- return { ...data };
- },
+ apolloProvider,
propsData: {
loading: false,
canAddIntegration: true,
@@ -39,15 +57,14 @@ describe('AlertsSettingsForm', () => {
multiIntegrations,
},
mocks: {
- $apollo: {
- query: jest.fn(),
- },
$toast: {
show: mockToastShow,
},
},
}),
);
+
+ await waitForPromises();
};
const findForm = () => wrapper.findComponent(GlForm);
@@ -55,6 +72,7 @@ describe('AlertsSettingsForm', () => {
const findFormFields = () => wrapper.findAllComponents(GlFormInput);
const findFormToggle = () => wrapper.findComponent(GlToggle);
const findSamplePayloadSection = () => wrapper.findByTestId('sample-payload-section');
+ const findResetPayloadModal = () => wrapper.findComponent(GlModal);
const findMappingBuilder = () => wrapper.findComponent(MappingBuilder);
const findSubmitButton = () => wrapper.findByTestId('integration-form-submit');
const findMultiSupportText = () => wrapper.findByTestId('multi-integrations-not-supported');
@@ -76,9 +94,13 @@ describe('AlertsSettingsForm', () => {
findFormToggle().vm.$emit('change', true);
};
+ afterEach(() => {
+ apolloProvider = null;
+ });
+
describe('with default values', () => {
- beforeEach(() => {
- createComponent();
+ beforeEach(async () => {
+ await createComponent();
});
it('render the initial form with only an integration type dropdown', () => {
@@ -94,21 +116,23 @@ describe('AlertsSettingsForm', () => {
expect(findFormFields().at(0).isVisible()).toBe(true);
});
- it('disables the dropdown and shows help text when multi integrations are not supported', () => {
- createComponent({ props: { canAddIntegration: false } });
+ it('disables the dropdown and shows help text when multi integrations are not supported', async () => {
+ await createComponent({ props: { canAddIntegration: false } });
+
expect(findSelect().attributes('disabled')).toBeDefined();
expect(findMultiSupportText().exists()).toBe(true);
});
it('hides the name input when the selected value is prometheus', async () => {
- createComponent();
+ await createComponent();
await selectOptionAtIndex(2);
expect(findFormFields()).toHaveLength(0);
});
- it('verify pricing link url', () => {
- createComponent({ props: { canAddIntegration: false } });
+ it('verify pricing link url', async () => {
+ await createComponent({ props: { canAddIntegration: false } });
+
const link = findMultiSupportText().findComponent(GlLink);
expect(link.attributes('href')).toMatch(/https:\/\/about.gitlab.(com|cn)\/pricing/);
});
@@ -118,24 +142,19 @@ describe('AlertsSettingsForm', () => {
expect(findTabs()).toHaveLength(3);
});
- it('only first tab is enabled on integration create', () => {
- createComponent({
- data: {
- currentIntegration: null,
- },
- });
+ it('only first tab is enabled on integration create', async () => {
+ await createComponent();
+
const tabs = findTabs();
expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false);
expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(true);
expect(tabs.at(2).find('[role="tabpanel"]').classes('disabled')).toBe(true);
});
- it('all tabs are enabled on integration edit', () => {
- createComponent({
- data: {
- currentIntegration: { id: 1 },
- },
- });
+ it('all tabs are enabled on integration edit', async () => {
+ const currentIntegration = { id: 1 };
+ await createComponent({ currentIntegration });
+
const tabs = findTabs();
expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false);
expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(false);
@@ -147,10 +166,7 @@ describe('AlertsSettingsForm', () => {
describe('submitting integration form', () => {
describe('HTTP', () => {
it('create with custom mapping', async () => {
- createComponent({
- multiIntegrations: true,
- props: { alertFields },
- });
+ await createComponent({ props: { alertFields } });
const integrationName = 'Test integration';
await selectOptionAtIndex(1);
@@ -172,25 +188,23 @@ describe('AlertsSettingsForm', () => {
});
});
- it('update', () => {
- createComponent({
- data: {
- integrationForm: { id: '1', name: 'Test integration pre', type: typeSet.http },
- currentIntegration: { id: '1' },
- },
- props: {
- loading: false,
- },
- });
+ it('update', async () => {
+ const currentIntegration = {
+ id: '1',
+ name: 'Test integration pre',
+ type: typeSet.http,
+ };
+ await createComponent({ currentIntegration });
const updatedIntegrationName = 'Test integration post';
enableIntegration(0, updatedIntegrationName);
- const submitBtn = findSubmitButton();
- expect(submitBtn.exists()).toBe(true);
- expect(submitBtn.text()).toBe('Save integration');
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe('Save integration');
+
+ await nextTick();
+ await findSubmitButton().trigger('click');
- submitBtn.trigger('click');
expect(wrapper.emitted('update-integration')[0][0]).toMatchObject({
type: typeSet.http,
variables: {
@@ -205,13 +219,12 @@ describe('AlertsSettingsForm', () => {
describe('PROMETHEUS', () => {
it('create', async () => {
- createComponent();
+ await createComponent();
await selectOptionAtIndex(2);
enableIntegration(0);
- const submitBtn = findSubmitButton();
- expect(submitBtn.exists()).toBe(true);
- expect(submitBtn.text()).toBe('Save integration');
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe('Save integration');
findForm().trigger('submit');
@@ -221,22 +234,17 @@ describe('AlertsSettingsForm', () => {
});
});
- it('update', () => {
- createComponent({
- data: {
- integrationForm: { id: '1', type: typeSet.prometheus },
- currentIntegration: { id: '1' },
- },
- props: {
- loading: false,
- },
- });
+ it('update', async () => {
+ const currentIntegration = {
+ id: '1',
+ type: typeSet.prometheus,
+ };
+ await createComponent({ currentIntegration });
enableIntegration(0);
- const submitBtn = findSubmitButton();
- expect(submitBtn.exists()).toBe(true);
- expect(submitBtn.text()).toBe('Save integration');
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe('Save integration');
findForm().trigger('submit');
@@ -249,16 +257,9 @@ describe('AlertsSettingsForm', () => {
});
describe('submitting the integration with a JSON test payload', () => {
- beforeEach(() => {
- createComponent({
- data: {
- currentIntegration: { id: '1', name: 'Test' },
- active: true,
- },
- props: {
- loading: false,
- },
- });
+ beforeEach(async () => {
+ const currentIntegration = { id: '1', name: 'Test' };
+ await createComponent({ currentIntegration });
});
it('should not allow a user to test invalid JSON', async () => {
@@ -285,17 +286,18 @@ describe('AlertsSettingsForm', () => {
describe('Test payload section for HTTP integration', () => {
const validSamplePayload = JSON.stringify(alertFields);
const emptySamplePayload = '{}';
- beforeEach(() => {
- createComponent({
- multiIntegrations: true,
- data: {
- integrationForm: { type: typeSet.http },
- currentIntegration: {
- payloadExample: emptySamplePayload,
- },
- active: false,
- resetPayloadAndMappingConfirmed: false,
- },
+
+ beforeEach(async () => {
+ const currentIntegration = {
+ id: '1',
+ name: 'Test',
+ type: typeSet.http,
+ payloadExample: emptySamplePayload,
+ payloadAttributeMappings: [],
+ };
+
+ await createComponent({
+ currentIntegration,
props: { alertFields },
});
});
@@ -314,14 +316,25 @@ describe('AlertsSettingsForm', () => {
const validPayloadMsg = payload === emptySamplePayload ? 'not valid' : 'valid';
it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and payload is ${validPayloadMsg}`, async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentIntegration: { payloadExample: payload },
- resetPayloadAndMappingConfirmed,
+ const currentIntegration = {
+ id: '1',
+ name: 'Test',
+ type: typeSet.http,
+ payloadExample: payload,
+ payloadAttributeMappings: [],
+ };
+
+ await createComponent({
+ currentIntegration,
+ props: { alertFields },
});
+ if (resetPayloadAndMappingConfirmed) {
+ findResetPayloadModal().vm.$emit('ok');
+ }
+
await nextTick();
+
expect(
findSamplePayloadSection().findComponent(GlFormTextarea).attributes('disabled'),
).toBe(disabled);
@@ -342,14 +355,21 @@ describe('AlertsSettingsForm', () => {
: 'was not confirmed';
it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentIntegration: {
- payloadExample,
- },
- resetPayloadAndMappingConfirmed,
+ const currentIntegration = {
+ type: typeSet.http,
+ payloadExample,
+ payloadAttributeMappings: [],
+ };
+
+ await createComponent({
+ currentIntegration,
+ props: { alertFields },
});
+
+ if (resetPayloadAndMappingConfirmed) {
+ findResetPayloadModal().vm.$emit('ok');
+ }
+
await nextTick();
expect(findActionBtn().text()).toBe(caption);
});
@@ -357,14 +377,6 @@ describe('AlertsSettingsForm', () => {
});
describe('Parsing payload', () => {
- 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({
- resetPayloadAndMappingConfirmed: true,
- });
- });
-
it('displays a toast message on successful parse', async () => {
jest.spyOn(wrapper.vm.$apollo, 'query').mockResolvedValue({
data: {
@@ -408,7 +420,7 @@ describe('AlertsSettingsForm', () => {
const multiIntegrationsEnabled = multiIntegrations ? 'enabled' : 'not enabled';
it(`is ${visibleMsg} when multiIntegrations are ${multiIntegrationsEnabled}, integration type is ${integrationType} and alert fields are ${alertFieldsMsg}`, async () => {
- createComponent({
+ await createComponent({
multiIntegrations,
props: {
alertFields: alertFieldsProvided ? alertFields : [],
@@ -423,8 +435,8 @@ describe('AlertsSettingsForm', () => {
});
describe('Form validation', () => {
- beforeEach(() => {
- createComponent();
+ beforeEach(async () => {
+ await createComponent();
});
it('should not be able to submit when no integration type is selected', async () => {
@@ -452,39 +464,29 @@ describe('AlertsSettingsForm', () => {
});
it('should be able to submit when form is dirty', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentIntegration: { type: typeSet.http, name: 'Existing integration' },
- });
- await nextTick();
- await findFormFields().at(0).vm.$emit('input', 'Updated name');
+ const currentIntegration = { type: typeSet.http, name: 'Existing integration' };
+ await createComponent({ currentIntegration });
+ await findFormFields().at(0).vm.$emit('input', 'Updated name');
expect(findSubmitButton().attributes('disabled')).toBe(undefined);
});
it('should not be able to submit when form is pristine', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- currentIntegration: { type: typeSet.http, name: 'Existing integration' },
- });
- await nextTick();
-
+ const currentIntegration = { type: typeSet.http, name: 'Existing integration' };
+ await createComponent({ currentIntegration });
expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('should disable submit button after click on validation failure', async () => {
await selectOptionAtIndex(1);
- findSubmitButton().trigger('click');
- await nextTick();
+ await findSubmitButton().trigger('click');
expect(findSubmitButton().attributes('disabled')).toBeDefined();
});
it('should scroll to invalid field on validation failure', async () => {
await selectOptionAtIndex(1);
- findSubmitButton().trigger('click');
+ await findSubmitButton().trigger('click');
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
});
diff --git a/spec/frontend/behaviors/secret_values_spec.js b/spec/frontend/behaviors/secret_values_spec.js
deleted file mode 100644
index 06155017dd1..00000000000
--- a/spec/frontend/behaviors/secret_values_spec.js
+++ /dev/null
@@ -1,230 +0,0 @@
-import SecretValues from '~/behaviors/secret_values';
-
-function generateValueMarkup(
- secret,
- valueClass = 'js-secret-value',
- placeholderClass = 'js-secret-value-placeholder',
-) {
- return `
- <div class="${placeholderClass}">
- ***
- </div>
- <div class="hidden ${valueClass}">
- ${secret}
- </div>
- `;
-}
-
-function generateFixtureMarkup(secrets, isRevealed, valueClass, placeholderClass) {
- return `
- <div class="js-secret-container">
- ${secrets.map((secret) => generateValueMarkup(secret, valueClass, placeholderClass)).join('')}
- <button
- class="js-secret-value-reveal-button"
- data-secret-reveal-status="${isRevealed}"
- >
- ...
- </button>
- </div>
- `;
-}
-
-function setupSecretFixture(
- secrets,
- isRevealed,
- valueClass = 'js-secret-value',
- placeholderClass = 'js-secret-value-placeholder',
-) {
- const wrapper = document.createElement('div');
- wrapper.innerHTML = generateFixtureMarkup(secrets, isRevealed, valueClass, placeholderClass);
-
- const secretValues = new SecretValues({
- container: wrapper.querySelector('.js-secret-container'),
- valueSelector: `.${valueClass}`,
- placeholderSelector: `.${placeholderClass}`,
- });
- secretValues.init();
-
- return wrapper;
-}
-
-describe('setupSecretValues', () => {
- describe('with a single secret', () => {
- const secrets = ['mysecret123'];
-
- it('should have correct "Reveal" label when values are hidden', () => {
- const wrapper = setupSecretFixture(secrets, false);
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
-
- expect(revealButton.textContent).toEqual('Reveal value');
- });
-
- it('should have correct "Hide" label when values are shown', () => {
- const wrapper = setupSecretFixture(secrets, true);
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
-
- expect(revealButton.textContent).toEqual('Hide value');
- });
-
- it('should have value hidden initially', () => {
- const wrapper = setupSecretFixture(secrets, false);
- const values = wrapper.querySelectorAll('.js-secret-value');
- const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
-
- expect(values.length).toEqual(1);
- expect(values[0].classList.contains('hide')).toEqual(true);
- expect(placeholders.length).toEqual(1);
- expect(placeholders[0].classList.contains('hide')).toEqual(false);
- });
-
- it('should toggle value and placeholder', () => {
- const wrapper = setupSecretFixture(secrets, false);
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
- const values = wrapper.querySelectorAll('.js-secret-value');
- const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
-
- revealButton.click();
-
- expect(values.length).toEqual(1);
- expect(values[0].classList.contains('hide')).toEqual(false);
- expect(placeholders.length).toEqual(1);
- expect(placeholders[0].classList.contains('hide')).toEqual(true);
-
- revealButton.click();
-
- expect(values.length).toEqual(1);
- expect(values[0].classList.contains('hide')).toEqual(true);
- expect(placeholders.length).toEqual(1);
- expect(placeholders[0].classList.contains('hide')).toEqual(false);
- });
- });
-
- describe('with a multiple secrets', () => {
- const secrets = ['mysecret123', 'happygoat456', 'tanuki789'];
-
- it('should have correct "Reveal" label when values are hidden', () => {
- const wrapper = setupSecretFixture(secrets, false);
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
-
- expect(revealButton.textContent).toEqual('Reveal values');
- });
-
- it('should have correct "Hide" label when values are shown', () => {
- const wrapper = setupSecretFixture(secrets, true);
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
-
- expect(revealButton.textContent).toEqual('Hide values');
- });
-
- it('should have all values hidden initially', () => {
- const wrapper = setupSecretFixture(secrets, false);
- const values = wrapper.querySelectorAll('.js-secret-value');
- const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
-
- expect(values.length).toEqual(3);
- values.forEach((value) => {
- expect(value.classList.contains('hide')).toEqual(true);
- });
-
- expect(placeholders.length).toEqual(3);
- placeholders.forEach((placeholder) => {
- expect(placeholder.classList.contains('hide')).toEqual(false);
- });
- });
-
- it('should toggle values and placeholders', () => {
- const wrapper = setupSecretFixture(secrets, false);
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
- const values = wrapper.querySelectorAll('.js-secret-value');
- const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
-
- revealButton.click();
-
- expect(values.length).toEqual(3);
- values.forEach((value) => {
- expect(value.classList.contains('hide')).toEqual(false);
- });
-
- expect(placeholders.length).toEqual(3);
- placeholders.forEach((placeholder) => {
- expect(placeholder.classList.contains('hide')).toEqual(true);
- });
-
- revealButton.click();
-
- expect(values.length).toEqual(3);
- values.forEach((value) => {
- expect(value.classList.contains('hide')).toEqual(true);
- });
-
- expect(placeholders.length).toEqual(3);
- placeholders.forEach((placeholder) => {
- expect(placeholder.classList.contains('hide')).toEqual(false);
- });
- });
- });
-
- describe('with dynamic secrets', () => {
- const secrets = ['mysecret123', 'happygoat456', 'tanuki789'];
-
- it('should toggle values and placeholders', () => {
- const wrapper = setupSecretFixture(secrets, false);
- // Insert the new dynamic row
- wrapper
- .querySelector('.js-secret-container')
- .insertAdjacentHTML('afterbegin', generateValueMarkup('foobarbazdynamic'));
-
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
- const values = wrapper.querySelectorAll('.js-secret-value');
- const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
-
- revealButton.click();
-
- expect(values.length).toEqual(4);
- values.forEach((value) => {
- expect(value.classList.contains('hide')).toEqual(false);
- });
-
- expect(placeholders.length).toEqual(4);
- placeholders.forEach((placeholder) => {
- expect(placeholder.classList.contains('hide')).toEqual(true);
- });
-
- revealButton.click();
-
- expect(values.length).toEqual(4);
- values.forEach((value) => {
- expect(value.classList.contains('hide')).toEqual(true);
- });
-
- expect(placeholders.length).toEqual(4);
- placeholders.forEach((placeholder) => {
- expect(placeholder.classList.contains('hide')).toEqual(false);
- });
- });
- });
-
- describe('selector options', () => {
- const secrets = ['mysecret123'];
-
- it('should respect `valueSelector` and `placeholderSelector` options', () => {
- const valueClass = 'js-some-custom-placeholder-selector';
- const placeholderClass = 'js-some-custom-value-selector';
-
- const wrapper = setupSecretFixture(secrets, false, valueClass, placeholderClass);
- const values = wrapper.querySelectorAll(`.${valueClass}`);
- const placeholders = wrapper.querySelectorAll(`.${placeholderClass}`);
- const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
-
- expect(values.length).toEqual(1);
- expect(placeholders.length).toEqual(1);
-
- revealButton.click();
-
- expect(values.length).toEqual(1);
- expect(values[0].classList.contains('hide')).toEqual(false);
- expect(placeholders.length).toEqual(1);
- expect(placeholders[0].classList.contains('hide')).toEqual(true);
- });
- });
-});
diff --git a/spec/frontend/blob/openapi/index_spec.js b/spec/frontend/blob/openapi/index_spec.js
index c96a021550d..fe98f46d013 100644
--- a/spec/frontend/blob/openapi/index_spec.js
+++ b/spec/frontend/blob/openapi/index_spec.js
@@ -1,24 +1,25 @@
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
+import SwaggerClient from 'swagger-client';
import { TEST_HOST } from 'helpers/test_constants';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import renderOpenApi from '~/blob/openapi';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import setWindowLocation from 'helpers/set_window_location_helper';
describe('OpenAPI blob viewer', () => {
const id = 'js-openapi-viewer';
const mockEndpoint = 'some/endpoint';
- let mock;
beforeEach(() => {
+ jest.spyOn(SwaggerClient, 'resolve').mockReturnValue(Promise.resolve({ spec: 'some spec' }));
setHTMLFixture(`<div id="${id}" data-endpoint="${mockEndpoint}"></div>`);
- mock = new MockAdapter(axios).onGet().reply(HTTP_STATUS_OK);
});
afterEach(() => {
resetHTMLFixture();
- mock.restore();
+ });
+
+ it('bundles the spec file', async () => {
+ await renderOpenApi();
+ expect(SwaggerClient.resolve).toHaveBeenCalledWith({ url: mockEndpoint });
});
describe('without config options', () => {
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index e3afd2dec2f..1ee4a7353ce 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -64,7 +64,6 @@ export default function createComponent({
disabled: false,
boardType: 'group',
issuableType: 'issue',
- isApolloBoard: true,
...provide,
},
stubs,
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 8d59cb2692e..ad5804f6eb7 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -5,6 +5,7 @@ import { DraggableItemTypes, ListType } from 'ee_else_ce/boards/constants';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import waitForPromises from 'helpers/wait_for_promises';
import createComponent from 'jest/boards/board_list_helper';
+import { ESC_KEY_CODE } from '~/lib/utils/keycodes';
import BoardCard from '~/boards/components/board_card.vue';
import eventHub from '~/boards/eventhub';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
@@ -203,9 +204,38 @@ describe('Board list component', () => {
expect(document.body.classList.contains('is-dragging')).toBe(true);
});
+
+ it('attaches `keyup` event listener on document', async () => {
+ jest.spyOn(document, 'addEventListener');
+ findDraggable().vm.$emit('start', {
+ item: {
+ dataset: {
+ draggableItemType: DraggableItemTypes.card,
+ },
+ },
+ });
+ await nextTick();
+
+ expect(document.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function));
+ });
});
describe('handleDragOnEnd', () => {
+ const getDragEndParam = (draggableItemType) => ({
+ oldIndex: 1,
+ newIndex: 0,
+ item: {
+ dataset: {
+ draggableItemType,
+ itemId: mockIssues[0].id,
+ itemIid: mockIssues[0].iid,
+ itemPath: mockIssues[0].referencePath,
+ },
+ },
+ to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
+ from: { dataset: { listId: 'gid://gitlab/List/2' } },
+ });
+
beforeEach(() => {
startDrag();
});
@@ -213,42 +243,39 @@ describe('Board list component', () => {
it('removes class `is-dragging` from document body', () => {
document.body.classList.add('is-dragging');
- endDrag({
- oldIndex: 1,
- newIndex: 0,
- item: {
- dataset: {
- draggableItemType: DraggableItemTypes.card,
- itemId: mockIssues[0].id,
- itemIid: mockIssues[0].iid,
- itemPath: mockIssues[0].referencePath,
- },
- },
- to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
- from: { dataset: { listId: 'gid://gitlab/List/2' } },
- });
+ endDrag(getDragEndParam(DraggableItemTypes.card));
expect(document.body.classList.contains('is-dragging')).toBe(false);
});
it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.card}"`, () => {
- endDrag({
- oldIndex: 1,
- newIndex: 0,
- item: {
- dataset: {
- draggableItemType: DraggableItemTypes.list,
- itemId: mockIssues[0].id,
- itemIid: mockIssues[0].iid,
- itemPath: mockIssues[0].referencePath,
- },
- },
- to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
- from: { dataset: { listId: 'gid://gitlab/List/2' } },
- });
+ endDrag(getDragEndParam(DraggableItemTypes.list));
expect(document.body.classList.contains('is-dragging')).toBe(true);
});
+
+ it('detaches `keyup` event listener on document', async () => {
+ jest.spyOn(document, 'removeEventListener');
+
+ findDraggable().vm.$emit('end', getDragEndParam(DraggableItemTypes.card));
+ await nextTick();
+
+ expect(document.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function));
+ });
+ });
+
+ describe('handleKeyUp', () => {
+ it('dispatches `mouseup` event when Escape key is pressed', () => {
+ jest.spyOn(document, 'dispatchEvent');
+
+ document.dispatchEvent(
+ new Event('keyup', {
+ keyCode: ESC_KEY_CODE,
+ }),
+ );
+
+ expect(document.dispatchEvent).toHaveBeenCalledWith(new Event('mouseup'));
+ });
});
});
diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js
index 719e36629c2..406ce007088 100644
--- a/spec/frontend/boards/components/board_add_new_column_form_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js
@@ -1,37 +1,17 @@
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
-import defaultState from '~/boards/stores/state';
import { mockLabelList } from '../mock_data';
-Vue.use(Vuex);
-
describe('BoardAddNewColumnForm', () => {
let wrapper;
- const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
- return new Vuex.Store({
- state: {
- ...defaultState,
- ...state,
- },
- actions,
- getters,
- });
- };
-
- const mountComponent = ({ searchLabel = '', selectedIdValid = true, actions, slots } = {}) => {
+ const mountComponent = ({ searchLabel = '', selectedIdValid = true, slots } = {}) => {
wrapper = shallowMountExtended(BoardAddNewColumnForm, {
propsData: {
searchLabel,
selectedIdValid,
},
slots,
- store: createStore({
- actions,
- }),
});
};
diff --git a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
index 396ec7d67cd..f536a1e6c64 100644
--- a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js
@@ -1,14 +1,8 @@
import { GlButton } 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 BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
-import { createStore } from '~/boards/stores';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-Vue.use(Vuex);
-
describe('BoardAddNewColumnTrigger', () => {
let wrapper;
@@ -24,7 +18,6 @@ describe('BoardAddNewColumnTrigger', () => {
propsData: {
isNewListShowing,
},
- store: createStore(),
});
};
diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js
index 157c76b4fff..9452e3e10c9 100644
--- a/spec/frontend/boards/components/board_app_spec.js
+++ b/spec/frontend/boards/components/board_app_spec.js
@@ -47,7 +47,7 @@ describe('BoardApp', () => {
beforeEach(async () => {
cacheUpdates.setError = jest.fn();
- createComponent({ isApolloBoard: true });
+ createComponent();
await nextTick();
});
@@ -60,7 +60,7 @@ describe('BoardApp', () => {
});
it('should not have is-compact class when no card is selected', async () => {
- createComponent({ isApolloBoard: true, issue: {} });
+ createComponent({ issue: {} });
await nextTick();
expect(wrapper.classes()).not.toContain('is-compact');
@@ -69,14 +69,14 @@ describe('BoardApp', () => {
it('refetches lists when updateBoard event is received', async () => {
jest.spyOn(eventHub, '$on').mockImplementation(() => {});
- createComponent({ isApolloBoard: true });
+ createComponent();
await waitForPromises();
expect(eventHub.$on).toHaveBeenCalledWith('updateBoard', wrapper.vm.refetchLists);
});
it('sets error on fetch lists failure', async () => {
- createComponent({ isApolloBoard: true, handler: boardListQueryHandlerFailure });
+ createComponent({ handler: boardListQueryHandlerFailure });
await waitForPromises();
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 d3c43a4e054..27cb575c067 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
@@ -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 { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
import {
BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION,
@@ -11,8 +8,6 @@ import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_posi
import { mockList, mockIssue2 } from 'jest/boards/mock_data';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-Vue.use(Vuex);
-
const dropdownOptions = [
{
text: BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION,
@@ -27,15 +22,10 @@ const dropdownOptions = [
describe('Board Card Move to position', () => {
let wrapper;
let trackingSpy;
- let store;
const itemIndex = 1;
- const createComponent = (propsData, isApolloBoard = false) => {
+ const createComponent = (propsData) => {
wrapper = shallowMount(BoardCardMoveToPosition, {
- store,
- provide: {
- isApolloBoard,
- },
propsData: {
item: mockIssue2,
list: mockList,
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index dae0db27104..1781c58c11f 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -76,7 +76,6 @@ describe('Board card', () => {
isGroupBoard: true,
disabled: false,
allowSubEpics: false,
- isApolloBoard: true,
...provide,
},
});
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index 706f84ad319..3b02a33bf7d 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -147,7 +147,7 @@ describe('BoardContent', () => {
describe('when error is passed', () => {
beforeEach(async () => {
- createComponent({ props: { apolloError: 'Error' } });
+ createComponent({ props: { error: 'Error' } });
await waitForPromises();
});
diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js
index 03526600114..477c504ecba 100644
--- a/spec/frontend/boards/components/board_top_bar_spec.js
+++ b/spec/frontend/boards/components/board_top_bar_spec.js
@@ -56,7 +56,6 @@ describe('BoardTopBar', () => {
isIssueBoard: true,
isEpicBoard: false,
isGroupBoard: true,
- // isApolloBoard: false,
...provide,
},
stubs: { IssueBoardFilteredSearch },
diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js
index 8766b1c25f2..db5243732c6 100644
--- a/spec/frontend/boards/components/boards_selector_spec.js
+++ b/spec/frontend/boards/components/boards_selector_spec.js
@@ -94,7 +94,6 @@ describe('BoardsSelector', () => {
boardType: isGroupBoard ? 'group' : 'project',
isGroupBoard,
isProjectBoard,
- // isApolloBoard: false,
...provide,
},
});
diff --git a/spec/frontend/boards/components/config_toggle_spec.js b/spec/frontend/boards/components/config_toggle_spec.js
index 3d505038331..915dafc8a89 100644
--- a/spec/frontend/boards/components/config_toggle_spec.js
+++ b/spec/frontend/boards/components/config_toggle_spec.js
@@ -1,22 +1,15 @@
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import ConfigToggle from '~/boards/components/config_toggle.vue';
import eventHub from '~/boards/eventhub';
-import store from '~/boards/stores';
import { mockTracking } from 'helpers/tracking_helper';
describe('ConfigToggle', () => {
let wrapper;
- Vue.use(Vuex);
-
const createComponent = (provide = {}, props = {}) =>
shallowMount(ConfigToggle, {
- store,
provide: {
canAdminList: true,
...provide,
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 3a5e108ac07..c2587b17409 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -1,6 +1,5 @@
import { GlFilteredSearchToken } from '@gitlab/ui';
import { keyBy } from 'lodash';
-import { ListType } from '~/boards/constants';
import {
OPERATORS_IS,
OPERATORS_IS_NOT,
@@ -70,19 +69,6 @@ export const mockGroupBoardResponse = {
},
};
-export const mockBoardConfig = {
- milestoneId: 'gid://gitlab/Milestone/114',
- milestoneTitle: '14.9',
- iterationId: 'gid://gitlab/Iteration/124',
- iterationTitle: 'Iteration 9',
- iterationCadenceId: 'gid://gitlab/Iteration::Cadence/134',
- assigneeId: 'gid://gitlab/User/1',
- assigneeUsername: 'admin',
- labels: [{ id: 'gid://gitlab/Label/32', title: 'Deliverable' }],
- labelIds: ['gid://gitlab/Label/32'],
- weight: 2,
-};
-
export const boardObj = {
id: 1,
name: 'test',
@@ -238,17 +224,6 @@ export const mockMilestone = {
due_date: '2019-12-31',
};
-export const mockMilestones = [
- {
- id: 'gid://gitlab/Milestone/1',
- title: 'Milestone 1',
- },
- {
- id: 'gid://gitlab/Milestone/2',
- title: 'Milestone 2',
- },
-];
-
export const assignees = [
{
id: 'gid://gitlab/User/2',
@@ -405,14 +380,6 @@ export const mockEpic = {
},
};
-export const mockActiveIssue = {
- ...mockIssue,
- id: 'gid://gitlab/Issue/436',
- iid: '27',
- subscribed: false,
- emailsDisabled: false,
-};
-
export const mockIssue2 = {
...rawIssue,
id: 'gid://gitlab/Issue/437',
@@ -588,11 +555,6 @@ export const mockLists = [mockList, mockLabelList];
export const mockListsById = keyBy(mockLists, 'id');
-export const mockIssuesByListId = {
- 'gid://gitlab/List/1': [mockIssue.id, mockIssue3.id, mockIssue4.id],
- 'gid://gitlab/List/2': mockIssues.map(({ id }) => id),
-};
-
export const participants = [
{
id: '1',
@@ -633,21 +595,8 @@ export const mockGroupProject2 = {
archived: false,
};
-export const mockArchivedGroupProject = {
- id: 2,
- name: 'Archived Project',
- nameWithNamespace: 'Awesome Group / Archived Project',
- fullPath: 'awesome-group/archived-project',
- archived: true,
-};
-
export const mockGroupProjects = [mockGroupProject1, mockGroupProject2];
-export const mockActiveGroupProjects = [
- { ...mockGroupProject1, archived: false },
- { ...mockGroupProject2, archived: false },
-];
-
export const mockIssueGroupPath = 'gitlab-org';
export const mockIssueProjectPath = `${mockIssueGroupPath}/gitlab-test`;
@@ -778,33 +727,6 @@ export const mockMoveIssueParams = {
moveAfterId: undefined,
};
-export const mockMoveState = {
- boardLists: {
- 'gid://gitlab/List/1': {
- listType: ListType.backlog,
- },
- 'gid://gitlab/List/2': {
- listType: ListType.closed,
- },
- },
- boardItems: {
- [mockMoveIssueParams.itemId]: { foo: 'bar' },
- },
- boardItemsByListId: {
- [mockMoveIssueParams.fromListId]: [mockMoveIssueParams.itemId],
- [mockMoveIssueParams.toListId]: [],
- },
-};
-
-export const mockMoveData = {
- reordering: false,
- shouldClone: false,
- itemNotInToList: true,
- originalIndex: 0,
- originalIssue: { foo: 'bar' },
- ...mockMoveIssueParams,
-};
-
export const mockEmojiToken = {
type: TOKEN_TYPE_MY_REACTION,
icon: 'thumb-up',
diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js
index f1daccfadda..6d2db10d7b8 100644
--- a/spec/frontend/boards/project_select_spec.js
+++ b/spec/frontend/boards/project_select_spec.js
@@ -69,6 +69,12 @@ describe('ProjectSelect component', () => {
expect(findGlCollapsibleListBox().exists()).toBe(true);
expect(findGlCollapsibleListBox().text()).toContain('Select a project');
});
+
+ it('passes down non archived projects to dropdown', async () => {
+ findGlCollapsibleListBox().vm.$emit('shown');
+ await nextTick();
+ expect(findGlCollapsibleListBox().props('items').length).toEqual(mockProjects.length - 1);
+ });
});
describe('when dropdown menu is open', () => {
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
deleted file mode 100644
index 616bb083211..00000000000
--- a/spec/frontend/boards/stores/actions_spec.js
+++ /dev/null
@@ -1,2098 +0,0 @@
-import { cloneDeep } from 'lodash';
-import Vue from 'vue';
-// eslint-disable-next-line no-restricted-imports
-import Vuex from 'vuex';
-import * as Sentry from '~/sentry/sentry_browser_wrapper';
-import { inactiveId, ISSUABLE, ListType, DraggableItemTypes } from 'ee_else_ce/boards/constants';
-import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
-import testAction from 'helpers/vuex_action_helper';
-import {
- formatListIssues,
- formatBoardLists,
- formatIssueInput,
- formatIssue,
- getMoveData,
- updateListPosition,
-} from 'ee_else_ce/boards/boards_util';
-import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
-import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
-import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
-import actions from '~/boards/stores/actions';
-import * as types from '~/boards/stores/mutation_types';
-import mutations from '~/boards/stores/mutations';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE, WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants';
-
-import projectBoardMilestones from '~/boards/graphql/project_board_milestones.query.graphql';
-import groupBoardMilestones from '~/boards/graphql/group_board_milestones.query.graphql';
-import {
- mockBoard,
- mockBoardConfig,
- mockLists,
- mockListsById,
- mockIssue,
- mockIssue2,
- rawIssue,
- mockIssues,
- labels,
- mockActiveIssue,
- mockGroupProjects,
- mockMoveIssueParams,
- mockMoveState,
- mockMoveData,
- mockList,
- mockMilestones,
-} from '../mock_data';
-
-jest.mock('~/alert');
-
-// We need this helper to make sure projectPath is including
-// subgroups when the movIssue action is called.
-const getProjectPath = (path) => path.split('#')[0];
-
-Vue.use(Vuex);
-
-beforeEach(() => {
- window.gon = { features: {} };
-});
-
-describe('fetchBoard', () => {
- const payload = {
- fullPath: 'gitlab-org',
- fullBoardId: 'gid://gitlab/Board/1',
- boardType: 'project',
- };
-
- const queryResponse = {
- data: {
- workspace: {
- board: mockBoard,
- },
- },
- };
-
- it('should commit mutation REQUEST_CURRENT_BOARD and dispatch setBoard on success', async () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- await testAction({
- action: actions.fetchBoard,
- payload,
- expectedMutations: [
- {
- type: types.REQUEST_CURRENT_BOARD,
- },
- ],
- expectedActions: [{ type: 'setBoard', payload: mockBoard }],
- });
- });
-
- it('should commit mutation RECEIVE_BOARD_FAILURE on failure', async () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
-
- await testAction({
- action: actions.fetchBoard,
- payload,
- expectedMutations: [
- {
- type: types.REQUEST_CURRENT_BOARD,
- },
- {
- type: types.RECEIVE_BOARD_FAILURE,
- },
- ],
- });
- });
-});
-
-describe('setInitialBoardData', () => {
- it('sets data object', () => {
- const mockData = {
- foo: 'bar',
- bar: 'baz',
- };
-
- return testAction({
- action: actions.setInitialBoardData,
- payload: mockData,
- expectedMutations: [{ type: types.SET_INITIAL_BOARD_DATA, payload: mockData }],
- });
- });
-});
-
-describe('setBoardConfig', () => {
- it('sets board config object from board object', () => {
- return testAction({
- action: actions.setBoardConfig,
- payload: mockBoard,
- expectedMutations: [{ type: types.SET_BOARD_CONFIG, payload: mockBoardConfig }],
- });
- });
-});
-
-describe('setBoard', () => {
- it('dispatches setBoardConfig', () => {
- return testAction({
- action: actions.setBoard,
- payload: mockBoard,
- expectedMutations: [{ type: types.RECEIVE_BOARD_SUCCESS, payload: mockBoard }],
- expectedActions: [
- { type: 'setBoardConfig', payload: mockBoard },
- { type: 'performSearch', payload: { resetLists: true } },
- ],
- });
- });
-});
-
-describe('setFilters', () => {
- it.each([
- [
- 'with correct filters as payload',
- {
- filters: { labelName: 'label', foobar: 'not-a-filter', search: 'quick brown fox' },
- filterVariables: { labelName: 'label', search: 'quick brown fox', not: {} },
- },
- ],
- [
- "and use 'assigneeWildcardId' as filter variable for 'assigneeId' param",
- {
- filters: { assigneeId: 'None' },
- filterVariables: { assigneeWildcardId: 'NONE', not: {} },
- },
- ],
- ])('should commit mutation SET_FILTERS %s', (_, { filters, filterVariables }) => {
- const state = {
- filters: {},
- issuableType: TYPE_ISSUE,
- };
-
- return testAction(
- actions.setFilters,
- filters,
- state,
- [{ type: types.SET_FILTERS, payload: filterVariables }],
- [],
- );
- });
-});
-
-describe('performSearch', () => {
- it('should dispatch setFilters, fetchLists and resetIssues action', () => {
- return testAction(
- actions.performSearch,
- {},
- {},
- [],
- [
- { type: 'setFilters', payload: {} },
- { type: 'fetchLists', payload: { resetLists: false } },
- { type: 'resetIssues' },
- ],
- );
- });
-});
-
-describe('setActiveId', () => {
- it('should commit mutation SET_ACTIVE_ID', () => {
- const state = {
- activeId: inactiveId,
- };
-
- return testAction(
- actions.setActiveId,
- { id: 1, sidebarType: 'something' },
- state,
- [{ type: types.SET_ACTIVE_ID, payload: { id: 1, sidebarType: 'something' } }],
- [],
- );
- });
-});
-
-describe('fetchLists', () => {
- let state = {
- fullPath: 'gitlab-org',
- fullBoardId: 'gid://gitlab/Board/1',
- filterParams: {},
- boardType: 'group',
- issuableType: 'issue',
- };
-
- let queryResponse = {
- data: {
- group: {
- board: {
- hideBacklogList: true,
- lists: {
- nodes: [mockLists[1]],
- },
- },
- },
- },
- };
-
- const formattedLists = formatBoardLists(queryResponse.data.group.board.lists);
-
- it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- return testAction(
- actions.fetchLists,
- {},
- state,
- [
- {
- type: types.RECEIVE_BOARD_LISTS_SUCCESS,
- payload: formattedLists,
- },
- ],
- [],
- );
- });
-
- it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
-
- return testAction(
- actions.fetchLists,
- {},
- state,
- [
- {
- type: types.RECEIVE_BOARD_LISTS_FAILURE,
- },
- ],
- [],
- );
- });
-
- it('dispatch createList action when backlog list does not exist and is not hidden', () => {
- queryResponse = {
- data: {
- group: {
- board: {
- hideBacklogList: false,
- lists: {
- nodes: [mockLists[1]],
- },
- },
- },
- },
- };
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- return testAction(
- actions.fetchLists,
- {},
- state,
- [
- {
- type: types.RECEIVE_BOARD_LISTS_SUCCESS,
- payload: formattedLists,
- },
- ],
- [{ type: 'createList', payload: { backlog: true } }],
- );
- });
-
- it.each`
- issuableType | boardType | fullBoardId | isGroup | isProject
- ${TYPE_ISSUE} | ${WORKSPACE_GROUP} | ${'gid://gitlab/Board/1'} | ${true} | ${false}
- ${TYPE_ISSUE} | ${WORKSPACE_PROJECT} | ${'gid://gitlab/Board/1'} | ${false} | ${true}
- `(
- 'calls $issuableType query with correct variables',
- async ({ issuableType, boardType, fullBoardId, isGroup, isProject }) => {
- const commit = jest.fn();
- const dispatch = jest.fn();
-
- state = {
- fullPath: 'gitlab-org',
- fullBoardId,
- filterParams: {},
- boardType,
- issuableType,
- };
-
- const variables = {
- fullPath: 'gitlab-org',
- boardId: fullBoardId,
- filters: {},
- isGroup,
- isProject,
- };
-
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- await actions.fetchLists({ commit, state, dispatch });
-
- expect(gqlClient.query).toHaveBeenCalledWith(expect.objectContaining({ variables }));
- },
- );
-});
-
-describe('fetchMilestones', () => {
- const queryResponse = {
- data: {
- workspace: {
- milestones: {
- nodes: mockMilestones,
- },
- },
- },
- };
-
- const queryErrors = {
- data: {
- workspace: {
- errors: ['You cannot view these milestones'],
- milestones: {},
- },
- },
- };
-
- function createStore({
- state = {
- boardType: 'project',
- fullPath: 'gitlab-org/gitlab',
- milestones: [],
- milestonesLoading: false,
- },
- } = {}) {
- return new Vuex.Store({
- state,
- mutations,
- });
- }
-
- it('throws error if state.boardType is not group or project', () => {
- const store = createStore({
- state: {
- boardType: 'invalid',
- },
- });
-
- expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type'));
- });
-
- it.each([
- [
- 'project',
- {
- query: projectBoardMilestones,
- variables: { fullPath: 'gitlab-org/gitlab' },
- },
- ],
- [
- 'group',
- {
- query: groupBoardMilestones,
- variables: { fullPath: 'gitlab-org/gitlab' },
- },
- ],
- ])(
- 'when boardType is %s it calls fetchMilestones with the correct query and variables',
- (boardType, variables) => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- const store = createStore();
-
- store.state.boardType = boardType;
-
- actions.fetchMilestones(store);
-
- expect(gqlClient.query).toHaveBeenCalledWith(variables);
- },
- );
-
- it('sets milestonesLoading to true', () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- const store = createStore();
-
- actions.fetchMilestones(store);
-
- expect(store.state.milestonesLoading).toBe(true);
- });
-
- describe('success', () => {
- it('sets state.milestones from query result', async () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- const store = createStore();
-
- await actions.fetchMilestones(store);
-
- expect(store.state.milestonesLoading).toBe(false);
- expect(store.state.milestones).toBe(mockMilestones);
- });
- });
-
- describe('failure', () => {
- it('sets state.milestones from query result', async () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
-
- const store = createStore();
-
- await expect(actions.fetchMilestones(store)).rejects.toThrow();
-
- expect(store.state.milestonesLoading).toBe(false);
- expect(store.state.error).toBe('Failed to load milestones.');
- });
- });
-});
-
-describe('createList', () => {
- it('should dispatch createIssueList action', () => {
- return testAction({
- action: actions.createList,
- payload: { backlog: true },
- expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }],
- });
- });
-});
-
-describe('createIssueList', () => {
- let commit;
- let dispatch;
- let getters;
- let state;
-
- beforeEach(() => {
- state = {
- fullPath: 'gitlab-org',
- fullBoardId: 'gid://gitlab/Board/1',
- boardType: 'group',
- disabled: false,
- boardLists: [{ type: 'closed' }],
- };
- commit = jest.fn();
- dispatch = jest.fn();
- getters = {
- getListByLabelId: jest.fn(),
- };
- });
-
- it('should dispatch addList action when creating backlog list', async () => {
- const backlogList = {
- id: 'gid://gitlab/List/1',
- listType: 'backlog',
- title: 'Open',
- position: 0,
- };
-
- jest.spyOn(gqlClient, 'mutate').mockReturnValue(
- Promise.resolve({
- data: {
- boardListCreate: {
- list: backlogList,
- errors: [],
- },
- },
- }),
- );
-
- await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
-
- expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
- });
-
- it('dispatches highlightList after addList has succeeded', async () => {
- const list = {
- id: 'gid://gitlab/List/1',
- listType: 'label',
- title: 'Open',
- labelId: '4',
- };
-
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- boardListCreate: {
- list,
- errors: [],
- },
- },
- });
-
- await actions.createIssueList({ getters, state, commit, dispatch }, { labelId: '4' });
-
- expect(dispatch).toHaveBeenCalledWith('addList', list);
- expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
- });
-
- it('should commit CREATE_LIST_FAILURE mutation when API returns an error', async () => {
- jest.spyOn(gqlClient, 'mutate').mockReturnValue(
- Promise.resolve({
- data: {
- boardListCreate: {
- list: {},
- errors: ['foo'],
- },
- },
- }),
- );
-
- await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
-
- expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo');
- });
-
- it('highlights list and does not re-query if it already exists', async () => {
- const existingList = {
- id: 'gid://gitlab/List/1',
- listType: 'label',
- title: 'Some label',
- position: 1,
- };
-
- getters = {
- getListByLabelId: jest.fn().mockReturnValue(existingList),
- };
-
- await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
-
- expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
- expect(dispatch).toHaveBeenCalledTimes(1);
- expect(commit).not.toHaveBeenCalled();
- });
-});
-
-describe('addList', () => {
- const getters = {
- getListByTitle: jest.fn().mockReturnValue(mockList),
- };
-
- it('should commit RECEIVE_ADD_LIST_SUCCESS mutation and dispatch fetchItemsForList action', () => {
- return testAction({
- action: actions.addList,
- payload: mockLists[1],
- state: { ...getters },
- expectedMutations: [
- { type: types.RECEIVE_ADD_LIST_SUCCESS, payload: updateListPosition(mockLists[1]) },
- ],
- expectedActions: [{ type: 'fetchItemsForList', payload: { listId: mockList.id } }],
- });
- });
-});
-
-describe('fetchLabels', () => {
- it('should commit mutation RECEIVE_LABELS_SUCCESS on success', async () => {
- const queryResponse = {
- data: {
- group: {
- labels: {
- nodes: labels,
- },
- },
- },
- };
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- const commit = jest.fn();
- const state = { boardType: 'group' };
-
- await actions.fetchLabels({ state, commit });
-
- expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels);
- });
-});
-
-describe('moveList', () => {
- const backlogListId = 'gid://1';
- const closedListId = 'gid://5';
-
- const boardLists1 = {
- 'gid://3': { listType: '', position: 0 },
- 'gid://4': { listType: '', position: 1 },
- 'gid://5': { listType: '', position: 2 },
- };
-
- const boardLists2 = {
- [backlogListId]: { listType: ListType.backlog, position: -Infinity },
- [closedListId]: { listType: ListType.closed, position: Infinity },
- ...cloneDeep(boardLists1),
- };
-
- const movableListsOrder = ['gid://3', 'gid://4', 'gid://5'];
- const allListsOrder = [backlogListId, ...movableListsOrder, closedListId];
-
- it(`should not handle the event if the dragged item is not a "${DraggableItemTypes.list}"`, () => {
- return testAction({
- action: actions.moveList,
- payload: {
- item: { dataset: { listId: '', draggableItemType: DraggableItemTypes.card } },
- to: {
- children: [],
- },
- },
- state: {},
- expectedMutations: [],
- expectedActions: [],
- });
- });
-
- describe.each`
- draggableFrom | draggableTo | boardLists | boardListsOrder | expectedMovableListsOrder
- ${0} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://5', 'gid://3']}
- ${2} | ${0} | ${boardLists1} | ${movableListsOrder} | ${['gid://5', 'gid://3', 'gid://4']}
- ${0} | ${1} | ${boardLists1} | ${movableListsOrder} | ${['gid://4', 'gid://3', 'gid://5']}
- ${1} | ${2} | ${boardLists1} | ${movableListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
- ${2} | ${1} | ${boardLists1} | ${movableListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
- ${1} | ${3} | ${boardLists2} | ${allListsOrder} | ${['gid://4', 'gid://5', 'gid://3']}
- ${3} | ${1} | ${boardLists2} | ${allListsOrder} | ${['gid://5', 'gid://3', 'gid://4']}
- ${1} | ${2} | ${boardLists2} | ${allListsOrder} | ${['gid://4', 'gid://3', 'gid://5']}
- ${2} | ${3} | ${boardLists2} | ${allListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
- ${3} | ${2} | ${boardLists2} | ${allListsOrder} | ${['gid://3', 'gid://5', 'gid://4']}
- `(
- 'when moving a list from position $draggableFrom to $draggableTo with lists $boardListsOrder',
- ({ draggableFrom, draggableTo, boardLists, boardListsOrder, expectedMovableListsOrder }) => {
- const movedListId = boardListsOrder[draggableFrom];
- const displacedListId = boardListsOrder[draggableTo];
- const buildDraggablePayload = () => {
- return {
- item: {
- dataset: {
- listId: boardListsOrder[draggableFrom],
- draggableItemType: DraggableItemTypes.list,
- },
- },
- newIndex: draggableTo,
- to: {
- children: boardListsOrder.map((listId) => ({ dataset: { listId } })),
- },
- };
- };
-
- it('should commit MOVE_LIST mutations and dispatch updateList action with correct payloads', () => {
- return testAction({
- action: actions.moveList,
- payload: buildDraggablePayload(),
- state: { boardLists },
- expectedMutations: [
- {
- type: types.MOVE_LISTS,
- payload: expectedMovableListsOrder.map((listId, i) => ({ listId, position: i })),
- },
- ],
- expectedActions: [
- {
- type: 'updateList',
- payload: {
- listId: movedListId,
- position: movableListsOrder.findIndex((i) => i === displacedListId),
- },
- },
- ],
- });
- });
- },
- );
-
- describe('when moving from and to the same position', () => {
- it('should not commit MOVE_LIST and should not dispatch updateList', () => {
- const listId = 'gid://1000';
-
- return testAction({
- action: actions.moveList,
- payload: {
- item: { dataset: { listId, draggbaleItemType: DraggableItemTypes.list } },
- newIndex: 0,
- to: {
- children: [{ dataset: { listId } }],
- },
- },
- state: { boardLists: { [listId]: { position: 0 } } },
- expectedMutations: [],
- expectedActions: [],
- });
- });
- });
-});
-
-describe('updateList', () => {
- const listId = 'gid://gitlab/List/1';
- const createState = (boardItemsByListId = {}) => ({
- fullPath: 'gitlab-org',
- fullBoardId: 'gid://gitlab/Board/1',
- boardType: 'group',
- disabled: false,
- boardLists: [{ type: 'closed' }],
- issuableType: TYPE_ISSUE,
- boardItemsByListId,
- });
-
- describe('when state doesnt have list items', () => {
- it('calls fetchItemsByList', async () => {
- const dispatch = jest.fn();
-
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- updateBoardList: {
- errors: [],
- list: {
- id: listId,
- },
- },
- },
- });
-
- await actions.updateList({ commit: () => {}, state: createState(), dispatch }, { listId });
-
- expect(dispatch.mock.calls).toEqual([['fetchItemsForList', { listId }]]);
- });
- });
-
- describe('when state has list items', () => {
- it('doesnt call fetchItemsByList', async () => {
- const commit = jest.fn();
- const dispatch = jest.fn();
-
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- updateBoardList: {
- errors: [],
- list: {
- id: listId,
- },
- },
- },
- });
-
- await actions.updateList(
- { commit, state: createState({ [listId]: [] }), dispatch },
- { listId },
- );
-
- expect(dispatch.mock.calls).toEqual([]);
- });
- });
-
- it('should dispatch handleUpdateListFailure when API returns an error', () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- updateBoardList: {
- list: {},
- errors: [{ foo: 'bar' }],
- },
- },
- });
-
- return testAction(
- actions.updateList,
- { listId: 'gid://gitlab/List/1', position: 1 },
- createState(),
- [],
- [{ type: 'handleUpdateListFailure' }],
- );
- });
-});
-
-describe('handleUpdateListFailure', () => {
- it('should dispatch fetchLists action and commit SET_ERROR mutation', async () => {
- await testAction({
- action: actions.handleUpdateListFailure,
- expectedMutations: [
- {
- type: types.SET_ERROR,
- payload: 'An error occurred while updating the board list. Please try again.',
- },
- ],
- expectedActions: [{ type: 'fetchLists' }],
- });
- });
-});
-
-describe('toggleListCollapsed', () => {
- it('should commit TOGGLE_LIST_COLLAPSED mutation', async () => {
- const payload = { listId: 'gid://gitlab/List/1', collapsed: true };
- await testAction({
- action: actions.toggleListCollapsed,
- payload,
- expectedMutations: [
- {
- type: types.TOGGLE_LIST_COLLAPSED,
- payload,
- },
- ],
- });
- });
-});
-
-describe('removeList', () => {
- let state;
- let getters;
- const list = mockLists[1];
- const listId = list.id;
- const mutationVariables = {
- mutation: destroyBoardListMutation,
- variables: {
- listId,
- },
- };
-
- beforeEach(() => {
- state = {
- boardLists: mockListsById,
- issuableType: TYPE_ISSUE,
- };
- getters = {
- getListByTitle: jest.fn().mockReturnValue(mockList),
- };
- });
-
- afterEach(() => {
- state = null;
- });
-
- it('optimistically deletes the list', () => {
- const commit = jest.fn();
-
- actions.removeList({ commit, state, getters, dispatch: () => {} }, listId);
-
- expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]);
- });
-
- it('keeps the updated list if remove succeeds', async () => {
- const commit = jest.fn();
- const dispatch = jest.fn();
-
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- destroyBoardList: {
- errors: [],
- },
- },
- });
-
- await actions.removeList({ commit, state, getters, dispatch }, listId);
-
- expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
- expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]);
- expect(dispatch.mock.calls).toEqual([['fetchItemsForList', { listId: mockList.id }]]);
- });
-
- it('restores the list if update fails', async () => {
- const commit = jest.fn();
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue(Promise.reject());
-
- await actions.removeList({ commit, state, getters, dispatch: () => {} }, listId);
-
- expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
- expect(commit.mock.calls).toEqual([
- [types.REMOVE_LIST, listId],
- [types.REMOVE_LIST_FAILURE, mockListsById],
- ]);
- });
-
- it('restores the list if update response has errors', async () => {
- const commit = jest.fn();
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- destroyBoardList: {
- errors: ['update failed, ID invalid'],
- },
- },
- });
-
- await actions.removeList({ commit, state, getters, dispatch: () => {} }, listId);
-
- expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
- expect(commit.mock.calls).toEqual([
- [types.REMOVE_LIST, listId],
- [types.REMOVE_LIST_FAILURE, mockListsById],
- ]);
- });
-});
-
-describe('fetchItemsForList', () => {
- const listId = mockLists[0].id;
-
- const state = {
- fullPath: 'gitlab-org',
- fullBoardId: 'gid://gitlab/Board/1',
- filterParams: {},
- boardType: 'group',
- };
-
- const mockIssuesNodes = mockIssues.map((issue) => ({ node: issue }));
-
- const pageInfo = {
- endCursor: '',
- hasNextPage: false,
- };
-
- const queryResponse = {
- data: {
- group: {
- board: {
- lists: {
- nodes: [
- {
- id: listId,
- issues: {
- edges: mockIssuesNodes,
- pageInfo,
- },
- },
- ],
- },
- },
- },
- },
- };
-
- const formattedIssues = formatListIssues(queryResponse.data.group.board.lists);
-
- const listPageInfo = {
- [listId]: pageInfo,
- };
-
- describe('when list id is undefined', () => {
- it('does not call the query', async () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- await actions.fetchItemsForList(
- { state, getters: () => {}, commit: () => {} },
- { listId: undefined },
- );
-
- expect(gqlClient.query).toHaveBeenCalledTimes(0);
- });
- });
-
- it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- return testAction(
- actions.fetchItemsForList,
- { listId },
- state,
- [
- {
- type: types.REQUEST_ITEMS_FOR_LIST,
- payload: { listId, fetchNext: false },
- },
- {
- type: types.RECEIVE_ITEMS_FOR_LIST_SUCCESS,
- payload: { listItems: formattedIssues, listPageInfo, listId },
- },
- ],
- [],
- );
- });
-
- it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
-
- return testAction(
- actions.fetchItemsForList,
- { listId },
- state,
- [
- {
- type: types.REQUEST_ITEMS_FOR_LIST,
- payload: { listId, fetchNext: false },
- },
- { type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId },
- ],
- [],
- );
- });
-});
-
-describe('resetIssues', () => {
- it('commits RESET_ISSUES mutation', () => {
- return testAction(actions.resetIssues, {}, {}, [{ type: types.RESET_ISSUES }], []);
- });
-});
-
-describe('moveItem', () => {
- it('should dispatch moveIssue action with payload', () => {
- const payload = { mock: 'payload' };
-
- return testAction({
- action: actions.moveItem,
- payload,
- expectedActions: [{ type: 'moveIssue', payload }],
- });
- });
-});
-
-describe('moveIssue', () => {
- it('should dispatch a correct set of actions', () => {
- return testAction({
- action: actions.moveIssue,
- payload: mockMoveIssueParams,
- state: mockMoveState,
- expectedActions: [
- { type: 'moveIssueCard', payload: mockMoveData },
- { type: 'updateMovedIssue', payload: mockMoveData },
- { type: 'updateIssueOrder', payload: { moveData: mockMoveData } },
- ],
- });
- });
-});
-
-describe('moveIssueCard and undoMoveIssueCard', () => {
- describe('card should move without cloning', () => {
- let state;
- let params;
- let moveMutations;
- let undoMutations;
-
- describe('when re-ordering card', () => {
- beforeEach(() => {
- const itemId = 123;
- const fromListId = 'gid://gitlab/List/1';
- const toListId = 'gid://gitlab/List/1';
- const originalIssue = { foo: 'bar' };
- const originalIndex = 0;
- const moveBeforeId = undefined;
- const moveAfterId = undefined;
- const allItemsLoadedInList = true;
- const listPosition = undefined;
-
- state = {
- boardLists: {
- [toListId]: { listType: ListType.backlog },
- [fromListId]: { listType: ListType.backlog },
- },
- boardItems: { [itemId]: originalIssue },
- boardItemsByListId: { [fromListId]: [123] },
- };
- params = {
- itemId,
- fromListId,
- toListId,
- moveBeforeId,
- moveAfterId,
- listPosition,
- allItemsLoadedInList,
- };
- moveMutations = [
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } },
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: {
- itemId,
- listId: toListId,
- moveBeforeId,
- moveAfterId,
- listPosition,
- allItemsLoadedInList,
- atIndex: originalIndex,
- },
- },
- ];
- undoMutations = [
- { type: types.UPDATE_BOARD_ITEM, payload: originalIssue },
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } },
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: { itemId, listId: fromListId, atIndex: originalIndex },
- },
- ];
- });
-
- it('moveIssueCard commits a correct set of actions', () => {
- return testAction({
- action: actions.moveIssueCard,
- state,
- payload: getMoveData(state, params),
- expectedMutations: moveMutations,
- });
- });
-
- it('undoMoveIssueCard commits a correct set of actions', () => {
- return testAction({
- action: actions.undoMoveIssueCard,
- state,
- payload: getMoveData(state, params),
- expectedMutations: undoMutations,
- });
- });
- });
-
- describe.each([
- [
- 'issue moves out of backlog',
- {
- fromListType: ListType.backlog,
- toListType: ListType.label,
- },
- ],
- [
- 'issue card moves to closed',
- {
- fromListType: ListType.label,
- toListType: ListType.closed,
- },
- ],
- [
- 'issue card moves to non-closed, non-backlog list of the same type',
- {
- fromListType: ListType.label,
- toListType: ListType.label,
- },
- ],
- ])('when %s', (_, { toListType, fromListType }) => {
- beforeEach(() => {
- const itemId = 123;
- const fromListId = 'gid://gitlab/List/1';
- const toListId = 'gid://gitlab/List/2';
- const originalIssue = { foo: 'bar' };
- const originalIndex = 0;
- const moveBeforeId = undefined;
- const moveAfterId = undefined;
-
- state = {
- boardLists: {
- [fromListId]: { listType: fromListType },
- [toListId]: { listType: toListType },
- },
- boardItems: { [itemId]: originalIssue },
- boardItemsByListId: { [fromListId]: [123], [toListId]: [] },
- };
- params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId };
- moveMutations = [
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } },
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: { itemId, listId: toListId, moveBeforeId, moveAfterId },
- },
- ];
- undoMutations = [
- { type: types.UPDATE_BOARD_ITEM, payload: originalIssue },
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } },
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: { itemId, listId: fromListId, atIndex: originalIndex },
- },
- ];
- });
-
- it('moveIssueCard commits a correct set of actions', () => {
- return testAction({
- action: actions.moveIssueCard,
- state,
- payload: getMoveData(state, params),
- expectedMutations: moveMutations,
- });
- });
-
- it('undoMoveIssueCard commits a correct set of actions', () => {
- return testAction({
- action: actions.undoMoveIssueCard,
- state,
- payload: getMoveData(state, params),
- expectedMutations: undoMutations,
- });
- });
- });
- });
-
- describe('card should clone on move', () => {
- let state;
- let params;
- let moveMutations;
- let undoMutations;
-
- describe.each([
- [
- 'issue card moves to non-closed, non-backlog list of a different type',
- {
- fromListType: ListType.label,
- toListType: ListType.assignee,
- },
- ],
- ])('when %s', (_, { toListType, fromListType }) => {
- beforeEach(() => {
- const itemId = 123;
- const fromListId = 'gid://gitlab/List/1';
- const toListId = 'gid://gitlab/List/2';
- const originalIssue = { foo: 'bar' };
- const originalIndex = 0;
- const moveBeforeId = undefined;
- const moveAfterId = undefined;
-
- state = {
- boardLists: {
- [fromListId]: { listType: fromListType },
- [toListId]: { listType: toListType },
- },
- boardItems: { [itemId]: originalIssue },
- boardItemsByListId: { [fromListId]: [123], [toListId]: [] },
- };
- params = { itemId, fromListId, toListId, moveBeforeId, moveAfterId };
- moveMutations = [
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } },
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: { itemId, listId: toListId, moveBeforeId, moveAfterId },
- },
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: { itemId, listId: fromListId, atIndex: originalIndex },
- },
- ];
- undoMutations = [
- { type: types.UPDATE_BOARD_ITEM, payload: originalIssue },
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: fromListId } },
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload: { itemId, listId: toListId } },
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: { itemId, listId: fromListId, atIndex: originalIndex },
- },
- ];
- });
-
- it('moveIssueCard commits a correct set of actions', () => {
- return testAction({
- action: actions.moveIssueCard,
- state,
- payload: getMoveData(state, params),
- expectedMutations: moveMutations,
- });
- });
-
- it('undoMoveIssueCard commits a correct set of actions', () => {
- return testAction({
- action: actions.undoMoveIssueCard,
- state,
- payload: getMoveData(state, params),
- expectedMutations: undoMutations,
- });
- });
- });
- });
-});
-
-describe('updateMovedIssueCard', () => {
- const label1 = {
- id: 'label1',
- };
-
- it.each([
- [
- 'issue without a label is moved to a label list',
- {
- state: {
- boardLists: {
- from: {},
- to: {
- listType: ListType.label,
- label: label1,
- },
- },
- boardItems: {
- 1: {
- labels: [],
- },
- },
- },
- moveData: {
- itemId: 1,
- fromListId: 'from',
- toListId: 'to',
- },
- updatedIssue: { labels: [label1] },
- },
- ],
- ])(
- 'should commit UPDATE_BOARD_ITEM with a correctly updated issue data when %s',
- (_, { state, moveData, updatedIssue }) => {
- return testAction({
- action: actions.updateMovedIssue,
- payload: moveData,
- state,
- expectedMutations: [{ type: types.UPDATE_BOARD_ITEM, payload: updatedIssue }],
- });
- },
- );
-});
-
-describe('updateIssueOrder', () => {
- const issues = {
- [mockIssue.id]: mockIssue,
- [mockIssue2.id]: mockIssue2,
- };
-
- const state = {
- boardItems: issues,
- fullBoardId: 'gid://gitlab/Board/1',
- };
-
- const moveData = {
- itemId: mockIssue.id,
- fromListId: 'gid://gitlab/List/1',
- toListId: 'gid://gitlab/List/2',
- };
-
- it('calls mutate with the correct variables', () => {
- const mutationVariables = {
- mutation: issueMoveListMutation,
- variables: {
- projectPath: getProjectPath(mockIssue.referencePath),
- boardId: state.fullBoardId,
- iid: mockIssue.iid,
- fromListId: 1,
- toListId: 2,
- moveBeforeId: undefined,
- moveAfterId: undefined,
- },
- update: expect.anything(),
- };
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- issuableMoveList: {
- issuable: rawIssue,
- errors: [],
- },
- },
- });
-
- actions.updateIssueOrder({ state, commit: () => {}, dispatch: () => {} }, { moveData });
-
- expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
- });
-
- it('should commit MUTATE_ISSUE_SUCCESS mutation when successful', () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- issuableMoveList: {
- issuable: rawIssue,
- errors: [],
- },
- },
- });
-
- return testAction(
- actions.updateIssueOrder,
- { moveData },
- state,
- [
- {
- type: types.MUTATE_ISSUE_IN_PROGRESS,
- payload: true,
- },
- {
- type: types.MUTATE_ISSUE_SUCCESS,
- payload: { issue: rawIssue },
- },
- {
- type: types.MUTATE_ISSUE_IN_PROGRESS,
- payload: false,
- },
- ],
- [],
- );
- });
-
- it('should commit SET_ERROR and dispatch undoMoveIssueCard', () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- issuableMoveList: {
- issuable: {},
- errors: [{ foo: 'bar' }],
- },
- },
- });
-
- return testAction(
- actions.updateIssueOrder,
- { moveData },
- state,
- [
- {
- type: types.MUTATE_ISSUE_IN_PROGRESS,
- payload: true,
- },
- {
- type: types.MUTATE_ISSUE_IN_PROGRESS,
- payload: false,
- },
- {
- type: types.SET_ERROR,
- payload: 'An error occurred while moving the issue. Please try again.',
- },
- ],
- [{ type: 'undoMoveIssueCard', payload: moveData }],
- );
- });
-});
-
-describe('setAssignees', () => {
- const node = { username: 'name' };
-
- describe('when succeeds', () => {
- it('calls the correct mutation with the correct values', () => {
- return testAction(
- actions.setAssignees,
- { assignees: [node], iid: '1' },
- { commit: () => {} },
- [
- {
- type: 'UPDATE_BOARD_ITEM_BY_ID',
- payload: { prop: 'assignees', itemId: undefined, value: [node] },
- },
- ],
- [],
- );
- });
- });
-});
-
-describe('addListItem', () => {
- it('should commit ADD_BOARD_ITEM_TO_LIST and UPDATE_BOARD_ITEM mutations', () => {
- const payload = {
- list: mockLists[0],
- item: mockIssue,
- position: 0,
- inProgress: true,
- };
-
- return testAction(
- actions.addListItem,
- payload,
- {},
- [
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: {
- listId: mockLists[0].id,
- itemId: mockIssue.id,
- atIndex: 0,
- inProgress: true,
- },
- },
- { type: types.UPDATE_BOARD_ITEM, payload: mockIssue },
- ],
- [],
- );
- });
-
- it('should commit ADD_BOARD_ITEM_TO_LIST and UPDATE_BOARD_ITEM mutations, dispatch setActiveId action when inProgress is false', () => {
- const payload = {
- list: mockLists[0],
- item: mockIssue,
- position: 0,
- };
-
- return testAction(
- actions.addListItem,
- payload,
- {},
- [
- {
- type: types.ADD_BOARD_ITEM_TO_LIST,
- payload: {
- listId: mockLists[0].id,
- itemId: mockIssue.id,
- atIndex: 0,
- inProgress: false,
- },
- },
- { type: types.UPDATE_BOARD_ITEM, payload: mockIssue },
- ],
- [{ type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } }],
- );
- });
-});
-
-describe('removeListItem', () => {
- it('should commit REMOVE_BOARD_ITEM_FROM_LIST and REMOVE_BOARD_ITEM mutations', () => {
- const payload = {
- listId: mockLists[0].id,
- itemId: mockIssue.id,
- };
-
- return testAction(actions.removeListItem, payload, {}, [
- { type: types.REMOVE_BOARD_ITEM_FROM_LIST, payload },
- { type: types.REMOVE_BOARD_ITEM, payload: mockIssue.id },
- ]);
- });
-});
-
-describe('addListNewIssue', () => {
- const state = {
- boardType: 'group',
- fullPath: 'gitlab-org/gitlab',
- boardConfig: {
- labelIds: [],
- assigneeId: null,
- milestoneId: -1,
- },
- };
-
- const stateWithBoardConfig = {
- boardConfig: {
- labels: [
- {
- id: 5,
- title: 'Test',
- color: '#ff0000',
- description: 'testing;',
- textColor: 'white',
- },
- ],
- assigneeId: 2,
- milestoneId: 3,
- },
- };
-
- const fakeList = { id: 'gid://gitlab/List/123' };
-
- it('should add board scope to the issue being created', async () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- createIssuable: {
- issuable: mockIssue,
- errors: [],
- },
- },
- });
-
- await actions.addListNewIssue(
- { dispatch: jest.fn(), commit: jest.fn(), state: stateWithBoardConfig },
- { issueInput: mockIssue, list: fakeList },
- );
-
- expect(gqlClient.mutate).toHaveBeenCalledWith({
- mutation: issueCreateMutation,
- variables: {
- input: formatIssueInput(mockIssue, stateWithBoardConfig.boardConfig),
- },
- update: expect.anything(),
- });
- });
-
- it('should add board scope by merging attributes to the issue being created', async () => {
- const issue = {
- ...mockIssue,
- assigneeIds: ['gid://gitlab/User/1'],
- labelIds: ['gid://gitlab/GroupLabel/4'],
- };
-
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- createIssue: {
- issue,
- errors: [],
- },
- },
- });
-
- const payload = formatIssueInput(issue, stateWithBoardConfig.boardConfig);
-
- await actions.addListNewIssue(
- { dispatch: jest.fn(), commit: jest.fn(), state: stateWithBoardConfig },
- { issueInput: issue, list: fakeList },
- );
-
- expect(gqlClient.mutate).toHaveBeenCalledWith({
- mutation: issueCreateMutation,
- variables: {
- input: formatIssueInput(issue, stateWithBoardConfig.boardConfig),
- },
- update: expect.anything(),
- });
- expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']);
- expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']);
- });
-
- describe('when issue creation mutation request succeeds', () => {
- it('dispatches a correct set of mutations', () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- createIssuable: {
- issuable: mockIssue,
- errors: [],
- },
- },
- });
-
- return testAction({
- action: actions.addListNewIssue,
- payload: {
- issueInput: mockIssue,
- list: fakeList,
- placeholderId: 'tmp',
- },
- state,
- expectedActions: [
- {
- type: 'addListItem',
- payload: {
- list: fakeList,
- item: formatIssue({ ...mockIssue, id: 'tmp', isLoading: true }),
- position: 0,
- inProgress: true,
- },
- },
- { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } },
- {
- type: 'addListItem',
- payload: {
- list: fakeList,
- item: formatIssue(mockIssue),
- position: 0,
- },
- },
- ],
- });
- });
- });
-
- describe('when issue creation mutation request fails', () => {
- it('dispatches a correct set of mutations', () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- createIssue: {
- issue: mockIssue,
- errors: [{ foo: 'bar' }],
- },
- },
- });
-
- return testAction({
- action: actions.addListNewIssue,
- payload: {
- issueInput: mockIssue,
- list: fakeList,
- placeholderId: 'tmp',
- },
- state,
- expectedActions: [
- {
- type: 'addListItem',
- payload: {
- list: fakeList,
- item: formatIssue({ ...mockIssue, id: 'tmp', isLoading: true }),
- position: 0,
- inProgress: true,
- },
- },
- { type: 'removeListItem', payload: { listId: fakeList.id, itemId: 'tmp' } },
- ],
- expectedMutations: [
- {
- type: types.SET_ERROR,
- payload: 'An error occurred while creating the issue. Please try again.',
- },
- ],
- });
- });
- });
-});
-
-describe('setActiveIssueLabels', () => {
- const state = { boardItems: { [mockIssue.id]: mockIssue } };
- const getters = { activeBoardItem: { ...mockIssue, labels } };
- const testLabelIds = labels.map((label) => label.id);
- const input = {
- labelIds: testLabelIds,
- removeLabelIds: [],
- projectPath: 'h/b',
- labels,
- };
-
- it('should assign labels', () => {
- const payload = {
- itemId: getters.activeBoardItem.id,
- prop: 'labels',
- value: labels,
- };
-
- return testAction(
- actions.setActiveIssueLabels,
- input,
- { ...state, ...getters },
- [
- {
- type: types.UPDATE_BOARD_ITEM_BY_ID,
- payload,
- },
- ],
- [],
- );
- });
-
- it('should remove label', () => {
- const payload = {
- itemId: getters.activeBoardItem.id,
- prop: 'labels',
- value: [labels[1]],
- };
-
- return testAction(
- actions.setActiveIssueLabels,
- { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] },
- { ...state, ...getters },
- [
- {
- type: types.UPDATE_BOARD_ITEM_BY_ID,
- payload,
- },
- ],
- [],
- );
- });
-});
-
-describe('setActiveItemSubscribed', () => {
- const state = {
- boardItems: {
- [mockActiveIssue.id]: mockActiveIssue,
- },
- fullPath: 'gitlab-org',
- issuableType: TYPE_ISSUE,
- };
- const getters = { activeBoardItem: mockActiveIssue, isEpicBoard: false };
- const subscribedState = true;
- const input = {
- subscribedState,
- projectPath: 'gitlab-org/gitlab-test',
- };
-
- it('should commit subscribed status', () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- updateIssuableSubscription: {
- issue: {
- subscribed: subscribedState,
- },
- errors: [],
- },
- },
- });
-
- const payload = {
- itemId: getters.activeBoardItem.id,
- prop: 'subscribed',
- value: subscribedState,
- };
-
- return testAction(
- actions.setActiveItemSubscribed,
- input,
- { ...state, ...getters },
- [
- {
- type: types.UPDATE_BOARD_ITEM_BY_ID,
- payload,
- },
- ],
- [],
- );
- });
-
- it('throws error if fails', async () => {
- jest
- .spyOn(gqlClient, 'mutate')
- .mockResolvedValue({ data: { updateIssuableSubscription: { errors: ['failed mutation'] } } });
-
- await expect(actions.setActiveItemSubscribed({ getters }, input)).rejects.toThrow(Error);
- });
-});
-
-describe('setActiveItemTitle', () => {
- const state = {
- boardItems: { [mockIssue.id]: mockIssue },
- issuableType: TYPE_ISSUE,
- fullPath: 'path/f',
- };
- const getters = { activeBoardItem: mockIssue, isEpicBoard: false };
- const testTitle = 'Test Title';
- const input = {
- title: testTitle,
- projectPath: 'h/b',
- };
-
- it('should commit title after setting the issue', () => {
- jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
- data: {
- updateIssuableTitle: {
- issue: {
- title: testTitle,
- },
- errors: [],
- },
- },
- });
-
- const payload = {
- itemId: getters.activeBoardItem.id,
- prop: 'title',
- value: testTitle,
- };
-
- return testAction(
- actions.setActiveItemTitle,
- input,
- { ...state, ...getters },
- [
- {
- type: types.UPDATE_BOARD_ITEM_BY_ID,
- payload,
- },
- ],
- [],
- );
- });
-
- it('throws error if fails', async () => {
- jest
- .spyOn(gqlClient, 'mutate')
- .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
-
- await expect(actions.setActiveItemTitle({ getters }, input)).rejects.toThrow(Error);
- });
-});
-
-describe('setActiveItemConfidential', () => {
- const state = { boardItems: { [mockIssue.id]: mockIssue } };
- const getters = { activeBoardItem: mockIssue };
-
- it('set confidential value on board item', () => {
- const payload = {
- itemId: getters.activeBoardItem.id,
- prop: 'confidential',
- value: true,
- };
-
- return testAction(
- actions.setActiveItemConfidential,
- true,
- { ...state, ...getters },
- [
- {
- type: types.UPDATE_BOARD_ITEM_BY_ID,
- payload,
- },
- ],
- [],
- );
- });
-});
-
-describe('fetchGroupProjects', () => {
- const state = {
- fullPath: 'gitlab-org',
- };
-
- const pageInfo = {
- endCursor: '',
- hasNextPage: false,
- };
-
- const queryResponse = {
- data: {
- group: {
- projects: {
- nodes: mockGroupProjects,
- pageInfo: {
- endCursor: '',
- hasNextPage: false,
- },
- },
- },
- },
- };
-
- it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', () => {
- jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
-
- return testAction(
- actions.fetchGroupProjects,
- {},
- state,
- [
- {
- type: types.REQUEST_GROUP_PROJECTS,
- payload: false,
- },
- {
- type: types.RECEIVE_GROUP_PROJECTS_SUCCESS,
- payload: { projects: mockGroupProjects, pageInfo, fetchNext: false },
- },
- ],
- [],
- );
- });
-
- it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', () => {
- jest.spyOn(gqlClient, 'query').mockRejectedValue();
-
- return testAction(
- actions.fetchGroupProjects,
- {},
- state,
- [
- {
- type: types.REQUEST_GROUP_PROJECTS,
- payload: false,
- },
- {
- type: types.RECEIVE_GROUP_PROJECTS_FAILURE,
- },
- ],
- [],
- );
- });
-});
-
-describe('setSelectedProject', () => {
- it('should commit mutation SET_SELECTED_PROJECT', () => {
- const project = mockGroupProjects[0];
-
- return testAction(
- actions.setSelectedProject,
- project,
- {},
- [
- {
- type: types.SET_SELECTED_PROJECT,
- payload: project,
- },
- ],
- [],
- );
- });
-});
-
-describe('toggleBoardItemMultiSelection', () => {
- const boardItem = mockIssue;
- const boardItem2 = mockIssue2;
-
- it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => {
- return testAction(
- actions.toggleBoardItemMultiSelection,
- boardItem,
- { selectedBoardItems: [] },
- [
- {
- type: types.ADD_BOARD_ITEM_TO_SELECTION,
- payload: boardItem,
- },
- ],
- [],
- );
- });
-
- it('should commit mutation REMOVE_BOARD_ITEM_FROM_SELECTION if item is on selection state', () => {
- return testAction(
- actions.toggleBoardItemMultiSelection,
- boardItem,
- { selectedBoardItems: [mockIssue] },
- [
- {
- type: types.REMOVE_BOARD_ITEM_FROM_SELECTION,
- payload: boardItem,
- },
- ],
- [],
- );
- });
-
- it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => {
- return testAction(
- actions.toggleBoardItemMultiSelection,
- boardItem2,
- { activeId: mockActiveIssue.id, activeBoardItem: mockActiveIssue, selectedBoardItems: [] },
- [
- {
- type: types.ADD_BOARD_ITEM_TO_SELECTION,
- payload: mockActiveIssue,
- },
- {
- type: types.ADD_BOARD_ITEM_TO_SELECTION,
- payload: boardItem2,
- },
- ],
- [{ type: 'unsetActiveId' }],
- );
- });
-});
-
-describe('resetBoardItemMultiSelection', () => {
- it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => {
- return testAction({
- action: actions.resetBoardItemMultiSelection,
- state: { selectedBoardItems: [mockIssue] },
- expectedMutations: [
- {
- type: types.RESET_BOARD_ITEM_SELECTION,
- },
- ],
- });
- });
-});
-
-describe('toggleBoardItem', () => {
- it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => {
- return testAction({
- action: actions.toggleBoardItem,
- payload: { boardItem: mockIssue },
- state: {
- activeId: mockIssue.id,
- },
- expectedActions: [{ type: 'resetBoardItemMultiSelection' }, { type: 'unsetActiveId' }],
- });
- });
-
- it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => {
- return testAction({
- action: actions.toggleBoardItem,
- payload: { boardItem: mockIssue },
- state: {
- activeId: inactiveId,
- },
- expectedActions: [
- { type: 'resetBoardItemMultiSelection' },
- { type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } },
- ],
- });
- });
-});
-
-describe('setError', () => {
- it('should commit mutation SET_ERROR', () => {
- return testAction({
- action: actions.setError,
- payload: { message: 'mayday' },
- expectedMutations: [
- {
- payload: 'mayday',
- type: types.SET_ERROR,
- },
- ],
- });
- });
-
- it('should capture error using Sentry when captureError is true', () => {
- jest.spyOn(Sentry, 'captureException');
-
- const mockError = new Error();
- actions.setError(
- { commit: () => {} },
- {
- message: 'mayday',
- error: mockError,
- captureError: true,
- },
- );
-
- expect(Sentry.captureException).toHaveBeenNthCalledWith(1, mockError);
- });
-});
-
-describe('unsetError', () => {
- it('should commit mutation SET_ERROR with undefined as payload', () => {
- return testAction({
- action: actions.unsetError,
- expectedMutations: [
- {
- payload: undefined,
- type: types.SET_ERROR,
- },
- ],
- });
- });
-});
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
deleted file mode 100644
index 944a7493504..00000000000
--- a/spec/frontend/boards/stores/getters_spec.js
+++ /dev/null
@@ -1,203 +0,0 @@
-import { inactiveId } from '~/boards/constants';
-import getters from '~/boards/stores/getters';
-import {
- mockIssue,
- mockIssue2,
- mockIssues,
- mockIssuesByListId,
- issues,
- mockLists,
- mockGroupProject1,
- mockArchivedGroupProject,
-} from '../mock_data';
-
-describe('Boards - Getters', () => {
- describe('isSidebarOpen', () => {
- it('returns true when activeId is not equal to 0', () => {
- const state = {
- activeId: 1,
- };
-
- expect(getters.isSidebarOpen(state)).toBe(true);
- });
-
- it('returns false when activeId is equal to 0', () => {
- const state = {
- activeId: inactiveId,
- };
-
- expect(getters.isSidebarOpen(state)).toBe(false);
- });
- });
-
- describe('isSwimlanesOn', () => {
- it('returns false', () => {
- expect(getters.isSwimlanesOn()).toBe(false);
- });
- });
-
- describe('getBoardItemById', () => {
- const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' } };
-
- it.each`
- id | expected
- ${'gid://gitlab/Issue/1'} | ${'issue'}
- ${''} | ${{}}
- `('returns $expected when $id is passed to state', ({ id, expected }) => {
- expect(getters.getBoardItemById(state)(id)).toEqual(expected);
- });
- });
-
- describe('activeBoardItem', () => {
- it.each`
- id | expected
- ${'gid://gitlab/Issue/1'} | ${'issue'}
- ${''} | ${{ id: '', iid: '' }}
- `('returns $expected when $id is passed to state', ({ id, expected }) => {
- const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' }, activeId: id };
-
- expect(getters.activeBoardItem(state)).toEqual(expected);
- });
- });
-
- describe('groupPathByIssueId', () => {
- it('returns group path for the active issue', () => {
- const mockActiveIssue = {
- referencePath: 'gitlab-org/gitlab-test#1',
- };
- expect(getters.groupPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual(
- 'gitlab-org',
- );
- });
-
- it('returns group path of last subgroup for the active issue', () => {
- const mockActiveIssue = {
- referencePath: 'gitlab-org/subgroup/subsubgroup/gitlab-test#1',
- };
- expect(getters.groupPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual(
- 'gitlab-org/subgroup/subsubgroup',
- );
- });
-
- it('returns empty string as group path when active issue is an empty object', () => {
- const mockActiveIssue = {};
- expect(getters.groupPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual('');
- });
- });
-
- describe('projectPathByIssueId', () => {
- it('returns project path for the active issue', () => {
- const mockActiveIssue = {
- referencePath: 'gitlab-org/gitlab-test#1',
- };
- expect(getters.projectPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual(
- 'gitlab-org/gitlab-test',
- );
- });
-
- it('returns empty string as project path when active issue is an empty object', () => {
- const mockActiveIssue = {};
- expect(getters.projectPathForActiveIssue({}, { activeBoardItem: mockActiveIssue })).toEqual(
- '',
- );
- });
- });
-
- describe('getBoardItemsByList', () => {
- const boardsState = {
- boardItemsByListId: mockIssuesByListId,
- boardItems: issues,
- };
- it('returns issues for a given listId', () => {
- const getBoardItemById = (issueId) =>
- [mockIssue, mockIssue2].find(({ id }) => id === issueId);
-
- expect(
- getters.getBoardItemsByList(boardsState, { getBoardItemById })('gid://gitlab/List/2'),
- ).toEqual(mockIssues);
- });
- });
-
- const boardsState = {
- boardLists: {
- 'gid://gitlab/List/1': mockLists[0],
- 'gid://gitlab/List/2': mockLists[1],
- },
- };
-
- describe('getListByLabelId', () => {
- it('returns list for a given label id', () => {
- expect(getters.getListByLabelId(boardsState)('gid://gitlab/GroupLabel/121')).toEqual(
- mockLists[1],
- );
- });
- });
-
- describe('getListByTitle', () => {
- it('returns list for a given list title', () => {
- expect(getters.getListByTitle(boardsState)('To Do')).toEqual(mockLists[1]);
- });
- });
-
- describe('activeGroupProjects', () => {
- const state = {
- groupProjects: [mockGroupProject1, mockArchivedGroupProject],
- };
-
- it('returns only returns non-archived group projects', () => {
- expect(getters.activeGroupProjects(state)).toEqual([mockGroupProject1]);
- });
- });
-
- describe('isIssueBoard', () => {
- it.each`
- issuableType | expected
- ${'issue'} | ${true}
- ${'epic'} | ${false}
- `(
- 'returns $expected when issuableType on state is $issuableType',
- ({ issuableType, expected }) => {
- const state = {
- issuableType,
- };
-
- expect(getters.isIssueBoard(state)).toBe(expected);
- },
- );
- });
-
- describe('isEpicBoard', () => {
- it('returns false', () => {
- expect(getters.isEpicBoard()).toBe(false);
- });
- });
-
- describe('hasScope', () => {
- const boardConfig = {
- labels: [],
- assigneeId: null,
- iterationCadenceId: null,
- iterationId: null,
- milestoneId: null,
- weight: null,
- };
-
- it('returns false when boardConfig is empty', () => {
- const state = { boardConfig };
-
- expect(getters.hasScope(state)).toBe(false);
- });
-
- it('returns true when boardScope has a label', () => {
- const state = { boardConfig: { ...boardConfig, labels: ['foo'] } };
-
- expect(getters.hasScope(state)).toBe(true);
- });
-
- it('returns true when boardConfig has a value other than null', () => {
- const state = { boardConfig: { ...boardConfig, assigneeId: 3 } };
-
- expect(getters.hasScope(state)).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/boards/stores/state_spec.js b/spec/frontend/boards/stores/state_spec.js
deleted file mode 100644
index 35490a63567..00000000000
--- a/spec/frontend/boards/stores/state_spec.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import createState from '~/boards/stores/state';
-
-describe('createState', () => {
- it('is a function', () => {
- expect(createState).toEqual(expect.any(Function));
- });
-
- it('returns an object', () => {
- expect(createState()).toEqual(expect.any(Object));
- });
-});
diff --git a/spec/frontend/captcha/captcha_modal_spec.js b/spec/frontend/captcha/captcha_modal_spec.js
index 4bbed8ab3bb..977c685739f 100644
--- a/spec/frontend/captcha/captcha_modal_spec.js
+++ b/spec/frontend/captcha/captcha_modal_spec.js
@@ -1,6 +1,7 @@
import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
+import waitForPromises from 'helpers/wait_for_promises';
import CaptchaModal from '~/captcha/captcha_modal.vue';
import { initRecaptchaScript } from '~/captcha/init_recaptcha_script';
@@ -36,6 +37,7 @@ describe('Captcha Modal', () => {
beforeEach(() => {
grecaptcha = {
render: jest.fn(),
+ reset: jest.fn(),
};
initRecaptchaScript.mockResolvedValue(grecaptcha);
@@ -156,4 +158,65 @@ describe('Captcha Modal', () => {
});
});
});
+
+ describe('when showModal is false', () => {
+ beforeEach(() => {
+ createComponent({ props: { showModal: false, needsCaptchaResponse: true } });
+ });
+
+ it('does not render the modal', () => {
+ expect(findGlModal().exists()).toBe(false);
+ });
+
+ it('renders captcha', () => {
+ expect(grecaptcha.render).toHaveBeenCalledWith(wrapper.vm.$refs.captcha, {
+ sitekey: captchaSiteKey,
+ callback: expect.any(Function),
+ });
+ });
+ });
+
+ describe('needsCaptchaResponse watcher', () => {
+ describe('when showModal is true', () => {
+ beforeEach(() => {
+ createComponent({ props: { showModal: true, needsCaptchaResponse: false } });
+ wrapper.setProps({ needsCaptchaResponse: true });
+ });
+
+ it('shows modal', () => {
+ expect(showSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('when showModal is false', () => {
+ beforeEach(() => {
+ createComponent({ props: { showModal: false, needsCaptchaResponse: false } });
+ wrapper.setProps({ needsCaptchaResponse: true });
+ });
+
+ it('does not render the modal', () => {
+ expect(findGlModal().exists()).toBe(false);
+ });
+
+ it('renders captcha', () => {
+ expect(grecaptcha.render).toHaveBeenCalledWith(wrapper.vm.$refs.captcha, {
+ sitekey: captchaSiteKey,
+ callback: expect.any(Function),
+ });
+ });
+ });
+ });
+
+ describe('resetSession watcher', () => {
+ beforeEach(() => {
+ createComponent({ props: { showModal: false, needsCaptchaResponse: true } });
+ });
+
+ it('calls reset when resetSession is true', async () => {
+ await waitForPromises();
+ await wrapper.setProps({ resetSession: true });
+
+ expect(grecaptcha.reset).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js b/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js
index 658a135534b..1c791857df9 100644
--- a/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js
+++ b/spec/frontend/ci/catalog/components/details/ci_resource_about_spec.js
@@ -12,8 +12,8 @@ describe('CiResourceAbout', () => {
openMergeRequestsCount: 9,
latestVersion: {
id: 1,
- tagName: 'v1.0.0',
- tagPath: 'path/to/release',
+ name: 'v1.0.0',
+ path: 'path/to/release',
releasedAt: '2022-08-23T17:19:09Z',
},
webPath: 'path/to/project',
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 330163e9f39..f81344fa291 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
@@ -115,7 +115,7 @@ describe('CiResourceComponents', () => {
it('renders the component name and snippet', () => {
components.forEach((component) => {
expect(wrapper.text()).toContain(component.name);
- expect(wrapper.text()).toContain(component.path);
+ expect(wrapper.text()).toContain(component.includePath);
});
});
@@ -124,7 +124,7 @@ describe('CiResourceComponents', () => {
const button = findCopyToClipboardButton(i);
expect(button.props().icon).toBe('copy-to-clipboard');
- expect(button.attributes('data-clipboard-text')).toContain(component.path);
+ expect(button.attributes('data-clipboard-text')).toContain(component.includePath);
});
});
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 6af9daabea0..b35c8a40744 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
@@ -113,7 +113,7 @@ describe('CiResourceHeader', () => {
createComponent({
props: {
pipelineStatus: status,
- latestVersion: { tagName: '1.0.0', tagPath: 'path/to/release' },
+ latestVersion: { name: '1.0.0', path: 'path/to/release' },
},
});
});
diff --git a/spec/frontend/ci/catalog/components/list/catalog_search_spec.js b/spec/frontend/ci/catalog/components/list/catalog_search_spec.js
index c6f8498f2fd..803deeb0d45 100644
--- a/spec/frontend/ci/catalog/components/list/catalog_search_spec.js
+++ b/spec/frontend/ci/catalog/components/list/catalog_search_spec.js
@@ -1,4 +1,4 @@
-import { GlSearchBoxByClick, GlSorting, GlSortingItem } from '@gitlab/ui';
+import { GlSearchBoxByClick, GlSorting } 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';
@@ -8,7 +8,7 @@ describe('CatalogSearch', () => {
const findSearchBar = () => wrapper.findComponent(GlSearchBoxByClick);
const findSorting = () => wrapper.findComponent(GlSorting);
- const findAllSortingItems = () => wrapper.findAllComponents(GlSortingItem);
+ const findAllSortingItems = () => findSorting().props('sortOptions');
const createComponent = () => {
wrapper = shallowMountExtended(CatalogSearch, {});
@@ -23,13 +23,14 @@ describe('CatalogSearch', () => {
expect(findSearchBar().exists()).toBe(true);
});
- it('renders the sorting options', () => {
- expect(findSorting().exists()).toBe(true);
- expect(findAllSortingItems()).toHaveLength(1);
+ it('sets sorting options', () => {
+ const sortOptionsProp = findAllSortingItems();
+ expect(sortOptionsProp).toHaveLength(1);
+ expect(sortOptionsProp[0].text).toBe('Created at');
});
it('renders the `Created at` option as the default', () => {
- expect(findAllSortingItems().at(0).text()).toBe('Created at');
+ expect(findSorting().props('text')).toBe('Created at');
});
});
diff --git a/spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js b/spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js
new file mode 100644
index 00000000000..ea216300017
--- /dev/null
+++ b/spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js
@@ -0,0 +1,71 @@
+import { GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { trimText } from 'helpers/text_helper';
+import CatalogTabs from '~/ci/catalog/components/list/catalog_tabs.vue';
+import { SCOPE } from '~/ci/catalog/constants';
+
+describe('Catalog Tabs', () => {
+ let wrapper;
+
+ const defaultProps = {
+ isLoading: false,
+ resourceCounts: {
+ all: 11,
+ namespaces: 4,
+ },
+ };
+
+ const findAllTab = () => wrapper.findByTestId('resources-all-tab');
+ const findYourResourcesTab = () => wrapper.findByTestId('resources-your-tab');
+ const findLoadingIcons = () => wrapper.findAllComponents(GlLoadingIcon);
+
+ const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click');
+
+ const createComponent = (props = defaultProps) => {
+ wrapper = extendedWrapper(
+ shallowMount(CatalogTabs, {
+ propsData: {
+ ...props,
+ },
+ stubs: { GlTabs },
+ }),
+ );
+ };
+
+ describe('When count queries are loading', () => {
+ beforeEach(() => {
+ createComponent({ ...defaultProps, isLoading: true });
+ });
+
+ it('renders loading icons', () => {
+ expect(findLoadingIcons()).toHaveLength(2);
+ });
+ });
+
+ describe('When both tabs have resources', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders All tab with count', () => {
+ expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.resourceCounts.all}`);
+ });
+
+ it('renders your resources tab with count', () => {
+ expect(trimText(findYourResourcesTab().text())).toBe(
+ `Your resources ${defaultProps.resourceCounts.namespaces}`,
+ );
+ });
+
+ it.each`
+ tabIndex | expectedScope
+ ${0} | ${SCOPE.all}
+ ${1} | ${SCOPE.namespaces}
+ `('emits setScope with $expectedScope on tab change', ({ tabIndex, expectedScope }) => {
+ triggerTabChange(tabIndex);
+
+ expect(wrapper.emitted()).toEqual({ setScope: [[expectedScope]] });
+ });
+ });
+});
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 d74b133f386..15add3f307f 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
@@ -21,7 +21,7 @@ describe('CiResourcesListItem', () => {
const release = {
author: { name: 'author', webUrl: '/user/1' },
releasedAt: Date.now(),
- tagName: '1.0.0',
+ name: '1.0.0',
};
const defaultProps = {
resource,
@@ -114,7 +114,7 @@ describe('CiResourcesListItem', () => {
it('renders the version badge', () => {
expect(findBadge().exists()).toBe(true);
- expect(findBadge().text()).toBe(release.tagName);
+ expect(findBadge().text()).toBe(release.name);
});
});
});
diff --git a/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js b/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js
index aca20a83979..41c6ccdd112 100644
--- a/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js
+++ b/spec/frontend/ci/catalog/components/list/ci_resources_list_spec.js
@@ -3,20 +3,19 @@ import { GlKeysetPagination } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CiResourcesList from '~/ci/catalog/components/list/ci_resources_list.vue';
import CiResourcesListItem from '~/ci/catalog/components/list/ci_resources_list_item.vue';
-import { ciCatalogResourcesItemsCount } from '~/ci/catalog/graphql/settings';
import { catalogResponseBody, catalogSinglePageResponse } from '../../mock';
describe('CiResourcesList', () => {
let wrapper;
const createComponent = ({ props = {} } = {}) => {
- const { nodes, pageInfo, count } = catalogResponseBody.data.ciCatalogResources;
+ const { nodes, pageInfo } = catalogResponseBody.data.ciCatalogResources;
const defaultProps = {
currentPage: 1,
resources: nodes,
pageInfo,
- totalCount: count,
+ totalCount: 20,
};
wrapper = shallowMountExtended(CiResourcesList, {
@@ -36,11 +35,11 @@ describe('CiResourcesList', () => {
const findNextBtn = () => wrapper.findByTestId('nextButton');
describe('contains only one page', () => {
- const { nodes, pageInfo, count } = catalogSinglePageResponse.data.ciCatalogResources;
+ const { nodes, pageInfo } = catalogSinglePageResponse.data.ciCatalogResources;
beforeEach(async () => {
await createComponent({
- props: { currentPage: 1, resources: nodes, pageInfo, totalCount: count },
+ props: { currentPage: 1, resources: nodes, pageInfo, totalCount: nodes.length },
});
});
@@ -62,58 +61,56 @@ describe('CiResourcesList', () => {
});
describe.each`
- hasPreviousPage | hasNextPage | pageText | expectedTotal | currentPage
- ${false} | ${true} | ${'1 of 3'} | ${ciCatalogResourcesItemsCount} | ${1}
- ${true} | ${true} | ${'2 of 3'} | ${ciCatalogResourcesItemsCount} | ${2}
- ${true} | ${false} | ${'3 of 3'} | ${ciCatalogResourcesItemsCount} | ${3}
- `(
- 'when on page $pageText',
- ({ currentPage, expectedTotal, pageText, hasPreviousPage, hasNextPage }) => {
- const { nodes, pageInfo, count } = catalogResponseBody.data.ciCatalogResources;
-
- const previousPageState = hasPreviousPage ? 'enabled' : 'disabled';
- const nextPageState = hasNextPage ? 'enabled' : 'disabled';
-
- beforeEach(async () => {
- await createComponent({
- props: {
- currentPage,
- resources: nodes,
- pageInfo: { ...pageInfo, hasPreviousPage, hasNextPage },
- totalCount: count,
- },
- });
- });
+ hasPreviousPage | hasNextPage | pageText | currentPage
+ ${false} | ${true} | ${'1 of 3'} | ${1}
+ ${true} | ${true} | ${'2 of 3'} | ${2}
+ ${true} | ${false} | ${'3 of 3'} | ${3}
+ `('when on page $pageText', ({ currentPage, pageText, hasPreviousPage, hasNextPage }) => {
+ const { nodes, pageInfo } = catalogResponseBody.data.ciCatalogResources;
+ const count = 50; // We want 3 pages of data to test. There are 20 items per page.
+
+ const previousPageState = hasPreviousPage ? 'enabled' : 'disabled';
+ const nextPageState = hasNextPage ? 'enabled' : 'disabled';
- it('shows the right number of items', () => {
- expect(findResourcesListItems()).toHaveLength(expectedTotal);
+ beforeEach(async () => {
+ await createComponent({
+ props: {
+ currentPage,
+ resources: nodes,
+ pageInfo: { ...pageInfo, hasPreviousPage, hasNextPage },
+ totalCount: count,
+ },
});
+ });
- it(`shows the keyset control for previous page as ${previousPageState}`, () => {
- const disableAttr = findPrevBtn().attributes('disabled');
+ it('shows the right number of items', () => {
+ expect(findResourcesListItems()).toHaveLength(20);
+ });
- if (previousPageState === 'disabled') {
- expect(disableAttr).toBeDefined();
- } else {
- expect(disableAttr).toBeUndefined();
- }
- });
+ it(`shows the keyset control for previous page as ${previousPageState}`, () => {
+ const disableAttr = findPrevBtn().attributes('disabled');
- it(`shows the keyset control for next page as ${nextPageState}`, () => {
- const disableAttr = findNextBtn().attributes('disabled');
+ if (previousPageState === 'disabled') {
+ expect(disableAttr).toBeDefined();
+ } else {
+ expect(disableAttr).toBeUndefined();
+ }
+ });
- if (nextPageState === 'disabled') {
- expect(disableAttr).toBeDefined();
- } else {
- expect(disableAttr).toBeUndefined();
- }
- });
+ it(`shows the keyset control for next page as ${nextPageState}`, () => {
+ const disableAttr = findNextBtn().attributes('disabled');
- it('shows the correct count of current page', () => {
- expect(findPageCount().text()).toContain(pageText);
- });
- },
- );
+ if (nextPageState === 'disabled') {
+ expect(disableAttr).toBeDefined();
+ } else {
+ expect(disableAttr).toBeUndefined();
+ }
+ });
+
+ it('shows the correct count of current page', () => {
+ expect(findPageCount().text()).toContain(pageText);
+ });
+ });
describe('when there is an error getting the page count', () => {
beforeEach(() => {
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 e6fbd63f307..6fb5eed0d93 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,17 +7,25 @@ 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 CiResourcesPage from '~/ci/catalog/components/pages/ci_resources_page.vue';
+import CatalogSearch from '~/ci/catalog/components/list/catalog_search.vue';
+import CatalogTabs from '~/ci/catalog/components/list/catalog_tabs.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, resolvers } from '~/ci/catalog/graphql/settings';
+import { DEFAULT_SORT_VALUE, SCOPE } from '~/ci/catalog/constants';
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';
+import getCatalogResourcesCount from '~/ci/catalog/graphql/queries/get_ci_catalog_resources_count.query.graphql';
-import { emptyCatalogResponseBody, catalogResponseBody } from '../../mock';
+import {
+ emptyCatalogResponseBody,
+ catalogResponseBody,
+ catalogResourcesCountResponseBody,
+} from '../../mock';
Vue.use(VueApollo);
jest.mock('~/alert');
@@ -25,14 +33,23 @@ jest.mock('~/alert');
describe('CiResourcesPage', () => {
let wrapper;
let catalogResourcesResponse;
+ let catalogResourcesCountResponse;
- const defaultQueryVariables = { first: 20 };
+ const defaultQueryVariables = {
+ first: 20,
+ scope: SCOPE.all,
+ searchTerm: null,
+ sortValue: DEFAULT_SORT_VALUE,
+ };
const createComponent = () => {
- const handlers = [[getCatalogResources, catalogResourcesResponse]];
+ const handlers = [
+ [getCatalogResources, catalogResourcesResponse],
+ [getCatalogResourcesCount, catalogResourcesCountResponse],
+ ];
const mockApollo = createMockApollo(handlers, resolvers, { cacheConfig, typeDefs });
- wrapper = shallowMountExtended(ciResourcesPage, {
+ wrapper = shallowMountExtended(CiResourcesPage, {
apolloProvider: mockApollo,
});
@@ -41,12 +58,15 @@ describe('CiResourcesPage', () => {
const findCatalogHeader = () => wrapper.findComponent(CatalogHeader);
const findCatalogSearch = () => wrapper.findComponent(CatalogSearch);
+ const findCatalogTabs = () => wrapper.findComponent(CatalogTabs);
const findCiResourcesList = () => wrapper.findComponent(CiResourcesList);
const findLoadingState = () => wrapper.findComponent(CatalogListSkeletonLoader);
const findEmptyState = () => wrapper.findComponent(EmptyState);
beforeEach(() => {
catalogResourcesResponse = jest.fn();
+ catalogResourcesCountResponse = jest.fn();
+ catalogResourcesCountResponse.mockResolvedValue(catalogResourcesCountResponseBody);
});
describe('when initial queries are loading', () => {
@@ -83,31 +103,56 @@ describe('CiResourcesPage', () => {
expect(findCatalogSearch().exists()).toBe(true);
});
+ it('renders the tabs', () => {
+ expect(findCatalogTabs().exists()).toBe(true);
+ });
+
it('does not render the list', () => {
expect(findCiResourcesList().exists()).toBe(false);
});
});
describe('and there are resources', () => {
- const { nodes, pageInfo, count } = catalogResponseBody.data.ciCatalogResources;
+ const { nodes, pageInfo } = catalogResponseBody.data.ciCatalogResources;
beforeEach(async () => {
catalogResourcesResponse.mockResolvedValue(catalogResponseBody);
await createComponent();
});
+
it('renders the resources list', () => {
expect(findLoadingState().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
expect(findCiResourcesList().exists()).toBe(true);
});
+ it('renders the catalog tabs', () => {
+ expect(findCatalogTabs().exists()).toBe(true);
+ });
+
+ it('updates the scope after switching tabs', async () => {
+ await findCatalogTabs().vm.$emit('setScope', SCOPE.namespaces);
+
+ expect(catalogResourcesResponse).toHaveBeenCalledWith({
+ ...defaultQueryVariables,
+ scope: SCOPE.namespaces,
+ });
+
+ await findCatalogTabs().vm.$emit('setScope', SCOPE.all);
+
+ expect(catalogResourcesResponse).toHaveBeenCalledWith({
+ ...defaultQueryVariables,
+ scope: SCOPE.all,
+ });
+ });
+
it('passes down props to the resources list', () => {
expect(findCiResourcesList().props()).toMatchObject({
currentPage: 1,
resources: nodes,
pageInfo,
- totalCount: count,
+ totalCount: 0,
});
});
@@ -145,6 +190,7 @@ describe('CiResourcesPage', () => {
before: pageInfo.startCursor,
last: 20,
first: null,
+ scope: SCOPE.all,
});
}
});
@@ -190,10 +236,12 @@ describe('CiResourcesPage', () => {
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', () => {
+ it('renders the empty state and passes down the search query', async () => {
+ await findCatalogSearch().vm.$emit('update-search-term', newSearch);
+ await waitForPromises();
+
expect(findEmptyState().exists()).toBe(true);
expect(findEmptyState().props().searchTerm).toBe(newSearch);
});
diff --git a/spec/frontend/ci/catalog/mock.js b/spec/frontend/ci/catalog/mock.js
index e370ac5054f..c9256435990 100644
--- a/spec/frontend/ci/catalog/mock.js
+++ b/spec/frontend/ci/catalog/mock.js
@@ -10,12 +10,26 @@ export const emptyCatalogResponseBody = {
hasPreviousPage: false,
__typename: 'PageInfo',
},
- count: 0,
nodes: [],
},
},
};
+export const catalogResourcesCountResponseBody = {
+ data: {
+ ciCatalogResources: {
+ all: {
+ count: 1,
+ __typename: 'CiCatalogResourceConnection',
+ },
+ namespaces: {
+ count: 7,
+ __typename: 'CiCatalogResourceConnection',
+ },
+ },
+ },
+};
+
export const catalogResponseBody = {
data: {
ciCatalogResources: {
@@ -28,7 +42,6 @@ export const catalogResponseBody = {
hasPreviousPage: false,
__typename: 'PageInfo',
},
- count: 41,
nodes: [
{
id: 'gid://gitlab/Ci::Catalog::Resource/129',
@@ -248,7 +261,6 @@ export const catalogSinglePageResponse = {
hasPreviousPage: false,
__typename: 'PageInfo',
},
- count: 3,
nodes: [
{
id: 'gid://gitlab/Ci::Catalog::Resource/132',
@@ -298,8 +310,8 @@ export const catalogSharedDataMock = {
latestVersion: {
__typename: 'Release',
id: '3',
- tagName: '1.0.0',
- tagPath: 'path/to/release',
+ name: '1.0.0',
+ path: 'path/to/release',
releasedAt: Date.now(),
author: { id: 1, webUrl: 'profile/1', name: 'username' },
},
@@ -344,7 +356,7 @@ export const catalogAdditionalDetailsMock = {
],
},
},
- tagName: 'v1.0.2',
+ name: 'v1.0.2',
releasedAt: '2022-08-23T17:19:09Z',
},
],
@@ -366,8 +378,8 @@ const generateResourcesNodes = (count = 20, startId = 0) => {
latestVersion: {
__typename: 'Release',
id: '3',
- tagName: '1.0.0',
- tagPath: 'path/to/release',
+ name: '1.0.0',
+ path: 'path/to/release',
releasedAt: Date.now(),
author: { id: 1, webUrl: 'profile/1', name: 'username' },
},
@@ -387,14 +399,14 @@ const componentsMockData = {
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',
+ includePath: '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',
+ includePath: 'gitlab.com/gitlab-org/javascript-madness@~latest',
inputs: [
{ name: 'isFun', default: 'true', required: true },
{ name: 'RandomNumber', default: '10', required: false },
@@ -404,7 +416,7 @@ const componentsMockData = {
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',
+ includePath: 'gitlab.com/gitlab-org/go-go-go@~latest',
inputs: [{ name: 'version', default: '1.0.0', required: true }],
},
],
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_environments_dropdown/ci_environments_dropdown_spec.js
index 353b5fd3c47..d26827de57b 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js
+++ b/spec/frontend/ci/ci_environments_dropdown/ci_environments_dropdown_spec.js
@@ -1,14 +1,15 @@
import { GlListboxItem, GlCollapsibleListbox, GlDropdownDivider, GlIcon } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { allEnvironments, ENVIRONMENT_QUERY_LIMIT } from '~/ci/ci_variable_list/constants';
-import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
+import CiEnvironmentsDropdown from '~/ci/common/private/ci_environments_dropdown';
describe('Ci environments dropdown', () => {
let wrapper;
const envs = ['dev', 'prod', 'staging'];
const defaultProps = {
+ isEnvironmentRequired: true,
areEnvironmentsLoading: false,
+ canCreateWildcard: true,
environments: envs,
selectedEnvironmentScope: '',
};
@@ -33,19 +34,43 @@ describe('Ci environments dropdown', () => {
findListbox().vm.$emit('search', searchTerm);
};
- describe('No environments found', () => {
- beforeEach(() => {
- createComponent({ searchTerm: 'stable' });
+ describe('create wildcard button', () => {
+ describe('when canCreateWildcard is true', () => {
+ beforeEach(() => {
+ createComponent({ props: { canCreateWildcard: true }, searchTerm: 'stable' });
+ });
+
+ it('renders create button during search', () => {
+ expect(findCreateWildcardButton().exists()).toBe(true);
+ });
});
- it('renders dropdown divider', () => {
- expect(findDropdownDivider().exists()).toBe(true);
+ describe('when canCreateWildcard is false', () => {
+ beforeEach(() => {
+ createComponent({ props: { canCreateWildcard: false }, searchTerm: 'stable' });
+ });
+
+ it('does not render create button during search', () => {
+ expect(findCreateWildcardButton().exists()).toBe(false);
+ });
});
+ });
- it('renders create button with search term if environments do not contain search term', () => {
- const button = findCreateWildcardButton();
- expect(button.exists()).toBe(true);
- expect(button.text()).toBe('Create wildcard: stable');
+ describe('No environments found', () => {
+ describe('default behavior', () => {
+ beforeEach(() => {
+ createComponent({ searchTerm: 'stable' });
+ });
+
+ it('renders dropdown divider', () => {
+ expect(findDropdownDivider().exists()).toBe(true);
+ });
+
+ it('renders create button with search term if environments do not contain search term', () => {
+ const button = findCreateWildcardButton();
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe('Create wildcard: stable');
+ });
});
});
@@ -54,7 +79,7 @@ describe('Ci environments dropdown', () => {
createComponent({ props: { environments: envs } });
});
- it(`prepends * in listbox`, () => {
+ it('prepends * in listbox', () => {
expect(findListboxItemByIndex(0).text()).toBe('*');
});
@@ -67,6 +92,16 @@ describe('Ci environments dropdown', () => {
it('does not display active checkmark', () => {
expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true);
});
+
+ describe('when isEnvironmentRequired is false', () => {
+ beforeEach(() => {
+ createComponent({ props: { isEnvironmentRequired: false, environments: envs } });
+ });
+
+ it('adds Not applicable as an option', () => {
+ expect(findListboxItemByIndex(1).text()).toBe('Not applicable');
+ });
+ });
});
describe('when `*` is the value of selectedEnvironmentScope props', () => {
@@ -77,7 +112,7 @@ describe('Ci environments dropdown', () => {
});
it('shows the `All environments` text and not the wildcard', () => {
- expect(findListboxText()).toContain(allEnvironments.text);
+ expect(findListboxText()).toContain('All (default)');
expect(findListboxText()).not.toContain(wildcardScope);
});
});
@@ -124,9 +159,9 @@ describe('Ci environments dropdown', () => {
expect(wrapper.emitted('search-environment-scope')[1]).toEqual([currentEnv]);
});
- it('displays note about max environments shown', () => {
+ it('displays note about max environments', () => {
expect(findMaxEnvNote().exists()).toBe(true);
- expect(findMaxEnvNote().text()).toContain(String(ENVIRONMENT_QUERY_LIMIT));
+ expect(findMaxEnvNote().text()).toContain('30');
});
});
diff --git a/spec/frontend/ci/ci_variable_list/utils_spec.js b/spec/frontend/ci/ci_environments_dropdown/utils_spec.js
index fbcf0e7c5a5..6da0d7cdbca 100644
--- a/spec/frontend/ci/ci_variable_list/utils_spec.js
+++ b/spec/frontend/ci/ci_environments_dropdown/utils_spec.js
@@ -1,13 +1,19 @@
-import { convertEnvironmentScope, mapEnvironmentNames } from '~/ci/ci_variable_list/utils';
-import { allEnvironments } from '~/ci/ci_variable_list/constants';
+import {
+ convertEnvironmentScope,
+ mapEnvironmentNames,
+} from '~/ci/common/private/ci_environments_dropdown';
describe('utils', () => {
describe('convertEnvironmentScope', () => {
it('converts the * to the `All environments` text', () => {
- expect(convertEnvironmentScope('*')).toBe(allEnvironments.text);
+ expect(convertEnvironmentScope('*')).toBe('All (default)');
});
- it('returns the environment as is if not the *', () => {
+ it('converts the `Not applicable` to the `Not applicable`', () => {
+ expect(convertEnvironmentScope('Not applicable')).toBe('Not applicable');
+ });
+
+ it('returns other environments as-is', () => {
expect(convertEnvironmentScope('prod')).toBe('prod');
});
});
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js
index 567a49d663c..0b5440d1bee 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_group_variables_spec.js
@@ -9,7 +9,7 @@ import {
DELETE_MUTATION_ACTION,
UPDATE_MUTATION_ACTION,
} from '~/ci/ci_variable_list/constants';
-import getGroupEnvironments from '~/ci/ci_variable_list/graphql/queries/group_environments.query.graphql';
+import { getGroupEnvironments } from '~/ci/common/private/ci_environments_dropdown';
import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql';
import addGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql';
import deleteGroupVariable from '~/ci/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql';
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js
index 69b0d4261b2..66a085f2661 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_project_variables_spec.js
@@ -9,7 +9,7 @@ import {
DELETE_MUTATION_ACTION,
UPDATE_MUTATION_ACTION,
} from '~/ci/ci_variable_list/constants';
-import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql';
+import { getProjectEnvironments } from '~/ci/common/private/ci_environments_dropdown';
import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql';
import addProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql';
import deleteProjectVariable from '~/ci/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql';
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 721e2b831fc..645aaf798d4 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
@@ -11,7 +11,7 @@ import {
} 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 CiEnvironmentsDropdown from '~/ci/common/private/ci_environments_dropdown';
import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
import { awsTokenList } from '~/ci/ci_variable_list/components/ci_variable_autocomplete_tokens';
import {
@@ -113,6 +113,10 @@ describe('CI Variable Drawer', () => {
helpPagePath('ci/variables/index', { anchor: 'define-a-cicd-variable-in-the-ui' }),
);
});
+
+ it('value field is resizable', () => {
+ expect(findValueField().props('noResize')).toBe(false);
+ });
});
describe('validations', () => {
@@ -513,7 +517,7 @@ describe('CI Variable Drawer', () => {
it('title and confirm button renders the correct text', () => {
expect(findTitle().text()).toBe('Edit variable');
- expect(findConfirmBtn().text()).toBe('Edit variable');
+ expect(findConfirmBtn().text()).toBe('Save changes');
});
it('dispatches the edit-variable event', async () => {
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js
index 01d3cdf504d..078958fe44a 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js
@@ -2,14 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import CiVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue';
import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue';
import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
-
import {
ADD_VARIABLE_ACTION,
EDIT_VARIABLE_ACTION,
projectString,
} from '~/ci/ci_variable_list/constants';
-import { mapEnvironmentNames } from '~/ci/ci_variable_list/utils';
-
+import { mapEnvironmentNames } from '~/ci/common/private/ci_environments_dropdown';
import { mockEnvs, mockVariablesWithScopes, newVariable } from '../mocks';
describe('Ci variable table', () => {
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
index c90ff4cc682..f9c1cbe0d30 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_shared_spec.js
@@ -11,13 +11,11 @@ import { resolvers } from '~/ci/ci_variable_list/graphql/settings';
import ciVariableShared from '~/ci/ci_variable_list/components/ci_variable_shared.vue';
import ciVariableSettings from '~/ci/ci_variable_list/components/ci_variable_settings.vue';
import ciVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue';
-import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql';
+import { getProjectEnvironments } from '~/ci/common/private/ci_environments_dropdown';
import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql';
import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql';
import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql';
-
import {
- ENVIRONMENT_QUERY_LIMIT,
environmentFetchErrorText,
genericMutationErrorText,
variableFetchErrorText,
@@ -230,7 +228,7 @@ describe('Ci Variable Shared Component', () => {
it('initial query is called with the correct variables', () => {
expect(mockEnvironments).toHaveBeenCalledWith({
- first: ENVIRONMENT_QUERY_LIMIT,
+ first: 30,
fullPath: '/namespace/project/',
search: '',
});
diff --git a/spec/frontend/ci/ci_variable_list/mocks.js b/spec/frontend/ci/ci_variable_list/mocks.js
index 9c9c99ad5ea..35bca408f17 100644
--- a/spec/frontend/ci/ci_variable_list/mocks.js
+++ b/spec/frontend/ci/ci_variable_list/mocks.js
@@ -20,7 +20,7 @@ import updateProjectVariable from '~/ci/ci_variable_list/graphql/mutations/proje
import getAdminVariables from '~/ci/ci_variable_list/graphql/queries/variables.query.graphql';
import getGroupVariables from '~/ci/ci_variable_list/graphql/queries/group_variables.query.graphql';
-import getProjectEnvironments from '~/ci/ci_variable_list/graphql/queries/project_environments.query.graphql';
+import { getProjectEnvironments } from '~/ci/common/private/ci_environments_dropdown';
import getProjectVariables from '~/ci/ci_variable_list/graphql/queries/project_variables.query.graphql';
export const devName = 'dev';
diff --git a/spec/frontend/ci/pipeline_details/test_reports/mock_data.js b/spec/frontend/ci/pipeline_details/test_reports/mock_data.js
index 7c9f9287c86..643863c9d24 100644
--- a/spec/frontend/ci/pipeline_details/test_reports/mock_data.js
+++ b/spec/frontend/ci/pipeline_details/test_reports/mock_data.js
@@ -1,4 +1,4 @@
-import { TestStatus } from '~/ci/pipeline_details/constants';
+import { testStatus } from '~/ci/pipeline_details/constants';
export default [
{
@@ -7,7 +7,7 @@ export default [
execution_time: 0,
name: 'Test#skipped text',
stack_trace: null,
- status: TestStatus.SKIPPED,
+ status: testStatus.SKIPPED,
system_output: null,
},
{
@@ -16,7 +16,7 @@ export default [
execution_time: 0,
name: 'Test#error text',
stack_trace: null,
- status: TestStatus.ERROR,
+ status: testStatus.ERROR,
system_output: null,
},
{
@@ -25,7 +25,7 @@ export default [
execution_time: 0,
name: 'Test#unknown text',
stack_trace: null,
- status: TestStatus.UNKNOWN,
+ status: testStatus.UNKNOWN,
system_output: null,
},
];
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 d318aa36bcf..836c35977b4 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,7 +5,12 @@ 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 {
+ getParameterValues,
+ updateHistory,
+ removeParams,
+ setUrlParams,
+} 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';
@@ -17,6 +22,9 @@ Vue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
getParameterValues: jest.fn().mockReturnValue([]),
+ updateHistory: jest.fn().mockName('updateHistory'),
+ removeParams: jest.fn().mockName('removeParams'),
+ setUrlParams: jest.fn().mockName('setUrlParams'),
}));
describe('Test reports app', () => {
@@ -36,7 +44,7 @@ describe('Test reports app', () => {
removeSelectedSuiteIndex: jest.fn(),
};
- const createComponent = ({ state = {} } = {}) => {
+ const createComponent = ({ state = {}, getterStubs = {} } = {}) => {
store = new Vuex.Store({
modules: {
testReports: {
@@ -48,7 +56,10 @@ describe('Test reports app', () => {
...state,
},
actions: actionSpies,
- getters,
+ getters: {
+ ...getters,
+ ...getterStubs,
+ },
},
},
});
@@ -124,24 +135,41 @@ describe('Test reports app', () => {
describe('when a suite is clicked', () => {
beforeEach(() => {
- createComponent({ state: { hasFullReport: true } });
+ document.title = 'Test reports';
+ createComponent({
+ state: { hasFullReport: true },
+ getters: { getSelectedSuite: jest.fn().mockReturnValue({ name: 'test' }) },
+ });
testSummaryTable().vm.$emit('row-click', 0);
});
- it('should call setSelectedSuiteIndex and fetchTestSuite', () => {
- expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled();
- expect(actionSpies.fetchTestSuite).toHaveBeenCalled();
+ it('should call setSelectedSuiteIndex, fetchTestSuite and updateHistory', () => {
+ expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalledWith(expect.anything(Object), 0);
+ expect(actionSpies.fetchTestSuite).toHaveBeenCalledWith(expect.anything(Object), 0);
+ expect(setUrlParams).toHaveBeenCalledWith({ job_name: undefined });
+ expect(updateHistory).toHaveBeenCalledWith({
+ replace: true,
+ title: 'Test reports',
+ url: undefined,
+ });
});
});
describe('when clicking back to summary', () => {
beforeEach(() => {
+ document.title = 'Test reports';
createComponent({ state: { selectedSuiteIndex: 0 } });
testSummary().vm.$emit('on-back-click');
});
- it('should call removeSelectedSuiteIndex', () => {
+ it('should call removeSelectedSuiteIndex and updateHistory', () => {
expect(actionSpies.removeSelectedSuiteIndex).toHaveBeenCalled();
+ expect(removeParams).toHaveBeenCalledWith(['job_name']);
+ expect(updateHistory).toHaveBeenCalledWith({
+ replace: true,
+ title: 'Test reports',
+ url: undefined,
+ });
});
});
});
diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js
index 5bdea6bbcbf..181b8df31f4 100644
--- a/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js
@@ -5,7 +5,7 @@ import Vuex from 'vuex';
import testReports from 'test_fixtures/pipelines/test_report.json';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SuiteTable, { i18n } from '~/ci/pipeline_details/test_reports/test_suite_table.vue';
-import { TestStatus } from '~/ci/pipeline_details/constants';
+import { testStatus } from '~/ci/pipeline_details/constants';
import * as getters from '~/ci/pipeline_details/stores/test_reports/getters';
import { formatFilePath } from '~/ci/pipeline_details/stores/test_reports/utils';
import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from '~/ci/pipeline_details/stores/test_reports/constants';
@@ -92,10 +92,10 @@ describe('Test reports suite table', () => {
});
it.each([
- TestStatus.ERROR,
- TestStatus.FAILED,
- TestStatus.SKIPPED,
- TestStatus.SUCCESS,
+ testStatus.ERROR,
+ testStatus.FAILED,
+ testStatus.SKIPPED,
+ testStatus.SUCCESS,
'unknown',
])('renders the correct icon for test case with %s status', (status) => {
const test = testCases.findIndex((x) => x.status === status);
diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
index 37339b1c422..d379da390a4 100644
--- a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
@@ -1,24 +1,23 @@
-import { mount, shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
import WalkthroughPopover from '~/ci/pipeline_editor/components/popovers/walkthrough_popover.vue';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-
-Vue.config.ignoredElements = ['gl-emoji'];
describe('WalkthroughPopover component', () => {
let wrapper;
- const createComponent = (mountFn = shallowMount) => {
- return extendedWrapper(mountFn(WalkthroughPopover));
+ const createComponent = () => {
+ wrapper = shallowMount(WalkthroughPopover, {
+ components: {
+ GlEmoji: { template: '<img/>' },
+ },
+ });
};
describe('CTA button clicked', () => {
- beforeEach(async () => {
- wrapper = createComponent(mount);
- await wrapper.findByTestId('ctaBtn').trigger('click');
- });
-
it('emits "walkthrough-popover-cta-clicked" event', () => {
+ createComponent(shallowMount);
+ wrapper.findComponent(GlButton).vm.$emit('click');
+
expect(wrapper.emitted()['walkthrough-popover-cta-clicked']).toHaveLength(1);
});
});
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 f824dab9ae1..96de1d18aa2 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
@@ -18,6 +18,9 @@ describe('Pipelines CI Templates', () => {
showJenkinsCiPrompt: false,
...propsData,
},
+ components: {
+ GlEmoji: { template: '<img/>' },
+ },
stubs,
});
};
diff --git a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js
index c4476d01386..adb07d4086d 100644
--- a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js
+++ b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js
@@ -35,8 +35,7 @@ describe('RunnerTypeBadge', () => {
expect(findBadge().classes().sort()).toEqual(
[
...classes,
- 'gl-border',
- 'gl-display-inline-block',
+ 'gl-inset-border-1-gray-400',
'gl-max-w-full',
'gl-text-truncate',
'gl-bg-transparent!',
diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js
index 019f789d875..8a40c528c1d 100644
--- a/spec/frontend/clusters/agents/components/show_spec.js
+++ b/spec/frontend/clusters/agents/components/show_spec.js
@@ -76,6 +76,7 @@ describe('ClusterAgentShow', () => {
const findPaginationButtons = () => wrapper.findComponent(GlKeysetPagination);
const findTokenCount = () => wrapper.findByTestId('cluster-agent-token-count').text();
const findEESecurityTabSlot = () => wrapper.findByTestId('ee-security-tab');
+ const findEEWorkspacesTabSlot = () => wrapper.findByTestId('ee-workspaces-tab');
const findActivity = () => wrapper.findComponent(ActivityEvents);
const findIntegrationStatus = () => wrapper.findComponent(IntegrationStatus);
@@ -253,4 +254,23 @@ describe('ClusterAgentShow', () => {
expect(findEESecurityTabSlot().exists()).toBe(true);
});
});
+
+ describe('ee-workspaces-tab slot', () => {
+ it('does not display when a slot is not passed in', async () => {
+ createWrapperWithoutApollo({ clusterAgent: defaultClusterAgent });
+ await nextTick();
+ expect(findEEWorkspacesTabSlot().exists()).toBe(false);
+ });
+
+ it('does display when a slot is passed in', async () => {
+ createWrapperWithoutApollo({
+ clusterAgent: defaultClusterAgent,
+ slots: {
+ 'ee-workspaces-tab': `<gl-tab data-testid="ee-workspaces-tab">Workspaces Tab!</gl-tab>`,
+ },
+ });
+ await nextTick();
+ expect(findEEWorkspacesTabSlot().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/comment_templates/components/form_spec.js b/spec/frontend/comment_templates/components/form_spec.js
index b48feba5290..ab368a42483 100644
--- a/spec/frontend/comment_templates/components/form_spec.js
+++ b/spec/frontend/comment_templates/components/form_spec.js
@@ -74,7 +74,7 @@ describe('Comment templates form component', () => {
name: 'Test',
});
expect(trackingSpy).toHaveBeenCalledWith(
- expect.any(String),
+ undefined,
'i_code_review_saved_replies_create',
expect.any(Object),
);
@@ -135,6 +135,18 @@ describe('Comment templates form component', () => {
expect(findSubmitBtn().props('loading')).toBe(false);
});
+
+ it('shows markdown preview button', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.text()).toContain('Preview');
+ });
+
+ it('allows switching to rich text editor', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.text()).toContain('Switch to rich text editing');
+ });
});
describe('updates saved reply', () => {
diff --git a/spec/frontend/commit/components/signature_badge_spec.js b/spec/frontend/commit/components/signature_badge_spec.js
index d52ad2b43e2..4e8ad8e12f1 100644
--- a/spec/frontend/commit/components/signature_badge_spec.js
+++ b/spec/frontend/commit/components/signature_badge_spec.js
@@ -37,6 +37,7 @@ describe('Commit signature', () => {
describe.each`
signatureType | verificationStatus
${signatureTypes.GPG} | ${verificationStatuses.VERIFIED}
+ ${signatureTypes.GPG} | ${verificationStatuses.VERIFIED_SYSTEM}
${signatureTypes.GPG} | ${verificationStatuses.UNVERIFIED}
${signatureTypes.GPG} | ${verificationStatuses.UNVERIFIED_KEY}
${signatureTypes.GPG} | ${verificationStatuses.UNKNOWN_KEY}
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 94628f2b2c5..9f233f2f412 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
@@ -149,6 +149,10 @@ describe('content/components/wrappers/table_cell_base', () => {
},
);
+ it('does not show alignment options for table cells', () => {
+ expect(findDropdown().text()).not.toContain('Align');
+ });
+
describe("when current row is the table's header", () => {
beforeEach(async () => {
// Remove 2 rows condition
@@ -180,6 +184,44 @@ describe('content/components/wrappers/table_cell_base', () => {
});
describe.each`
+ currentAlignment | visibleOptions | newAlignment | command
+ ${'left'} | ${['center', 'right']} | ${'center'} | ${'alignColumnCenter'}
+ ${'center'} | ${['left', 'right']} | ${'right'} | ${'alignColumnRight'}
+ ${'right'} | ${['left', 'center']} | ${'left'} | ${'alignColumnLeft'}
+ `(
+ 'when align=$currentAlignment',
+ ({ currentAlignment, visibleOptions, newAlignment, command }) => {
+ beforeEach(async () => {
+ Object.assign(node.attrs, { align: currentAlignment });
+
+ createWrapper({ cellType: 'th' });
+
+ await nextTick();
+ });
+
+ visibleOptions.forEach((alignment) => {
+ it(`shows "Align column ${alignment}" option`, () => {
+ expect(findDropdown().text()).toContain(`Align column ${alignment}`);
+ });
+ });
+
+ it(`does not show "Align column ${currentAlignment}" option`, () => {
+ expect(findDropdown().text()).not.toContain(`Align column ${currentAlignment}`);
+ });
+
+ it('allows changing alignment', async () => {
+ const mocks = mockChainedCommands(editor, [command, 'run']);
+
+ await wrapper
+ .findByRole('button', { name: `Align column ${newAlignment}` })
+ .trigger('click');
+
+ expect(mocks[command]).toHaveBeenCalled();
+ });
+ },
+ );
+
+ describe.each`
attrs | rect
${{ rowspan: 2 }} | ${{ top: 0, left: 0, bottom: 2, right: 1 }}
${{ colspan: 2 }} | ${{ top: 0, left: 0, bottom: 1, right: 2 }}
diff --git a/spec/frontend/content_editor/extensions/copy_paste_spec.js b/spec/frontend/content_editor/extensions/copy_paste_spec.js
index 6969f4985a1..801385422d7 100644
--- a/spec/frontend/content_editor/extensions/copy_paste_spec.js
+++ b/spec/frontend/content_editor/extensions/copy_paste_spec.js
@@ -92,7 +92,7 @@ describe('content_editor/extensions/copy_paste', () => {
return Object.assign(new Event(eventName), {
clipboardData: {
types,
- getData: jest.fn((type) => data[type] || defaultData[type]),
+ getData: jest.fn((type) => data[type] ?? defaultData[type]),
setData: jest.fn(),
clearData: jest.fn(),
},
@@ -190,6 +190,17 @@ describe('content_editor/extensions/copy_paste', () => {
});
});
+ it('does not handle pasting when textContent is empty (eg. images)', async () => {
+ expect(
+ await triggerPasteEventHandler(
+ buildClipboardEvent({
+ types: ['text/plain'],
+ data: { 'text/plain': '' },
+ }),
+ ),
+ ).toBe(false);
+ });
+
describe('when pasting raw markdown source', () => {
it('shows a loading indicator while markdown is being processed', async () => {
await triggerPasteEventHandler(buildClipboardEvent());
diff --git a/spec/frontend/content_editor/extensions/task_item_spec.js b/spec/frontend/content_editor/extensions/task_item_spec.js
new file mode 100644
index 00000000000..a38a68112cd
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/task_item_spec.js
@@ -0,0 +1,115 @@
+import TaskList from '~/content_editor/extensions/task_list';
+import TaskItem from '~/content_editor/extensions/task_item';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+describe('content_editor/extensions/task_item', () => {
+ let tiptapEditor;
+ let doc;
+ let p;
+ let taskList;
+ let taskItem;
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({ extensions: [TaskList, TaskItem] });
+
+ ({
+ builders: { doc, p, taskList, taskItem },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ taskItem: { nodeType: TaskItem.name },
+ taskList: { nodeType: TaskList.name },
+ },
+ }));
+ });
+
+ it('renders a regular task item for non-inapplicable items', () => {
+ const initialDoc = doc(taskList(taskItem(p('foo'))));
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+
+ expect(tiptapEditor.view.dom.querySelector('li')).toMatchInlineSnapshot(`
+ <li
+ data-checked="false"
+ dir="auto"
+ >
+ <label>
+ <input
+ type="checkbox"
+ />
+ <span />
+ </label>
+ <div>
+ <p
+ dir="auto"
+ >
+ foo
+ </p>
+ </div>
+ </li>
+ `);
+ });
+
+ it('renders task item as disabled if it is inapplicable', () => {
+ const initialDoc = doc(taskList(taskItem({ inapplicable: true }, p('foo'))));
+
+ tiptapEditor.commands.setContent(initialDoc.toJSON());
+
+ expect(tiptapEditor.view.dom.querySelector('li')).toMatchInlineSnapshot(`
+ <li
+ data-checked="false"
+ data-inapplicable="true"
+ dir="auto"
+ >
+ <label>
+ <input
+ disabled=""
+ type="checkbox"
+ />
+ <span />
+ </label>
+ <div>
+ <p
+ dir="auto"
+ >
+ foo
+ </p>
+ </div>
+ </li>
+ `);
+ });
+
+ it('ignores any <s> tags in the task item', () => {
+ tiptapEditor.commands.setContent(`
+ <ul dir="auto" class="task-list">
+ <li class="task-list-item inapplicable">
+ <input disabled="" data-inapplicable="" class="task-list-item-checkbox" type="checkbox">
+ <s>foo</s>
+ </li>
+ </ul>
+ `);
+
+ expect(tiptapEditor.view.dom.querySelector('li')).toMatchInlineSnapshot(`
+ <li
+ data-checked="false"
+ data-inapplicable="true"
+ dir="auto"
+ >
+ <label>
+ <input
+ disabled=""
+ type="checkbox"
+ />
+ <span />
+ </label>
+ <div>
+ <p
+ dir="auto"
+ >
+ foo
+ </p>
+ </div>
+ </li>
+ `);
+ });
+});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index c329a12bcc4..4ae39f7a5a7 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -660,6 +660,24 @@ var a = 0;
);
});
+ it('correctly serializes a task list with inapplicable items', () => {
+ expect(
+ serialize(
+ taskList(
+ taskItem({ checked: true }, paragraph('list item 1')),
+ taskItem({ checked: true, inapplicable: true }, paragraph('list item 2')),
+ taskItem(paragraph('list item 3')),
+ ),
+ ),
+ ).toBe(
+ `
+* [x] list item 1
+* [~] list item 2
+* [ ] list item 3
+ `.trim(),
+ );
+ });
+
it('correctly serializes bullet task list with different bullet styles', () => {
expect(
serialize(
@@ -1080,6 +1098,38 @@ _An elephant at sunset_
);
});
+ it('correctly serializes a table with inline content with alignment', () => {
+ expect(
+ serialize(
+ table(
+ // each table cell must contain at least one paragraph
+ tableRow(
+ tableHeader({ align: 'center' }, paragraph('header')),
+ tableHeader({ align: 'right' }, paragraph('header')),
+ tableHeader({ align: 'left' }, paragraph('header')),
+ ),
+ tableRow(
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ ),
+ tableRow(
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ ),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+| header | header | header |
+|:------:|-------:|--------|
+| cell | cell | cell |
+| cell | cell | cell |
+ `.trim(),
+ );
+ });
+
it('correctly serializes a table with a pipe in a cell', () => {
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 4428fa682e7..f904f138e85 100644
--- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
+++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
@@ -20,6 +20,17 @@ const BULLET_LIST_HTML = `<ul data-sourcepos="1:1-3:24" dir="auto">
</li>
</ul>`;
+const MALFORMED_BULLET_LIST_HTML =
+ `<ul data-sourcepos="1:1-3:24" dir="auto">
+ <li data-sourcepos="1:1-1:13">list item 1</li>` +
+ // below line has malformed sourcepos
+ `<li data-sourcepos="5:1-5:24">list item 2
+ <ul data-sourcepos="3:3-3:24">
+ <li data-sourcepos="3:3-3:24">embedded list item 3</li>
+ </ul>
+ </li>
+</ul>`;
+
const BULLET_TASK_LIST_MARKDOWN = `- [ ] list item 1
+ [x] checked list item 2
+ [ ] embedded list item 1
@@ -85,6 +96,21 @@ const bulletListDoc = () =>
),
);
+const bulletListDocWithMalformedSourcepos = () =>
+ 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(
+ 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(
@@ -138,9 +164,10 @@ describe('content_editor/services/markdown_sourcemap', () => {
});
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}
+ description | sourceMarkdown | sourceHTML | expectedDoc
+ ${'bullet list'} | ${BULLET_LIST_MARKDOWN} | ${BULLET_LIST_HTML} | ${bulletListDoc}
+ ${'bullet list with malformed sourcepos'} | ${BULLET_LIST_MARKDOWN} | ${MALFORMED_BULLET_LIST_HTML} | ${bulletListDocWithMalformedSourcepos}
+ ${'bullet task list'} | ${BULLET_TASK_LIST_MARKDOWN} | ${BULLET_TASK_LIST_HTML} | ${bulletTaskListDoc}
`(
'gets markdown source for a rendered $description',
async ({ sourceMarkdown, sourceHTML, expectedDoc }) => {
diff --git a/spec/frontend/custom_emoji/components/__snapshots__/list_spec.js.snap b/spec/frontend/custom_emoji/components/__snapshots__/list_spec.js.snap
index c69547deb1c..a43b4aae586 100644
--- a/spec/frontend/custom_emoji/components/__snapshots__/list_spec.js.snap
+++ b/spec/frontend/custom_emoji/components/__snapshots__/list_spec.js.snap
@@ -141,7 +141,7 @@ exports[`Custom emoji settings list component renders table of custom emoji 1`]
class="gl-vertical-align-middle!"
role="cell"
>
- <gl-emoji
+ <div
data-fallback-src="https://gitlab.com/custom_emoji/custom_emoji/-/raw/main/img/confused_husky.gif"
data-name="confused_husky"
data-unicode-version="custom"
diff --git a/spec/frontend/custom_emoji/components/list_spec.js b/spec/frontend/custom_emoji/components/list_spec.js
index b5729d59464..4177aea2d33 100644
--- a/spec/frontend/custom_emoji/components/list_spec.js
+++ b/spec/frontend/custom_emoji/components/list_spec.js
@@ -21,6 +21,9 @@ function createComponent(propsData = {}) {
userPermissions: { createCustomEmoji: true },
...propsData,
},
+ stubs: {
+ GlEmoji: { template: '<div/>' },
+ },
});
}
diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js
index c4c7a9aea2d..e94734da4ce 100644
--- a/spec/frontend/deploy_keys/components/action_btn_spec.js
+++ b/spec/frontend/deploy_keys/components/action_btn_spec.js
@@ -1,28 +1,44 @@
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import data from 'test_fixtures/deploy_keys/keys.json';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import enableKeyMutation from '~/deploy_keys/graphql/mutations/enable_key.mutation.graphql';
import actionBtn from '~/deploy_keys/components/action_btn.vue';
-import eventHub from '~/deploy_keys/eventhub';
+
+Vue.use(VueApollo);
describe('Deploy keys action btn', () => {
const deployKey = data.enabled_keys[0];
let wrapper;
+ let enableKeyMock;
const findButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
+ enableKeyMock = jest.fn();
+
+ const mockResolvers = {
+ Mutation: {
+ enableKey: enableKeyMock,
+ },
+ };
+
+ const apolloProvider = createMockApollo([], mockResolvers);
wrapper = shallowMount(actionBtn, {
propsData: {
deployKey,
- type: 'enable',
category: 'primary',
variant: 'confirm',
icon: 'edit',
+ mutation: enableKeyMutation,
},
slots: {
default: 'Enable',
},
+ apolloProvider,
});
});
@@ -38,13 +54,26 @@ describe('Deploy keys action btn', () => {
});
});
- it('sends eventHub event with btn type', async () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
-
+ it('fires the passed mutation', async () => {
findButton().vm.$emit('click');
await nextTick();
- expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything());
+ expect(enableKeyMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { id: deployKey.id },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('emits the mutation error', async () => {
+ const error = new Error('oops!');
+ enableKeyMock.mockRejectedValue(error);
+ findButton().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[error]]);
});
it('shows loading spinner after click', async () => {
diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js
index de4112134ce..5e012bc1c51 100644
--- a/spec/frontend/deploy_keys/components/app_spec.js
+++ b/spec/frontend/deploy_keys/components/app_spec.js
@@ -1,28 +1,45 @@
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
-import data from 'test_fixtures/deploy_keys/keys.json';
+import { GlPagination } from '@gitlab/ui';
+import enabledKeys from 'test_fixtures/deploy_keys/enabled_keys.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { TEST_HOST } from 'spec/test_constants';
+import { captureException } from '~/sentry/sentry_browser_wrapper';
+import { mapDeployKey } from '~/deploy_keys/graphql/resolvers';
+import deployKeysQuery from '~/deploy_keys/graphql/queries/deploy_keys.query.graphql';
import deployKeysApp from '~/deploy_keys/components/app.vue';
import ConfirmModal from '~/deploy_keys/components/confirm_modal.vue';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
-import eventHub from '~/deploy_keys/eventhub';
import axios from '~/lib/utils/axios_utils';
-import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
-const TEST_ENDPOINT = `${TEST_HOST}/dummy/`;
+jest.mock('~/sentry/sentry_browser_wrapper');
+
+Vue.use(VueApollo);
describe('Deploy keys app component', () => {
let wrapper;
let mock;
+ let deployKeyMock;
+ let currentPageMock;
+ let currentScopeMock;
+ let confirmRemoveKeyMock;
+ let pageInfoMock;
+ let pageMutationMock;
+ let scopeMutationMock;
+ let disableKeyMock;
+ let resolvers;
const mountComponent = () => {
+ const apolloProvider = createMockApollo([[deployKeysQuery, deployKeyMock]], resolvers);
+
wrapper = mount(deployKeysApp, {
propsData: {
- endpoint: TEST_ENDPOINT,
+ projectPath: 'test/project',
projectId: '8',
},
+ apolloProvider,
});
return waitForPromises();
@@ -30,7 +47,28 @@ describe('Deploy keys app component', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- mock.onGet(TEST_ENDPOINT).reply(HTTP_STATUS_OK, data);
+ deployKeyMock = jest.fn();
+ currentPageMock = jest.fn();
+ currentScopeMock = jest.fn();
+ confirmRemoveKeyMock = jest.fn();
+ pageInfoMock = jest.fn();
+ scopeMutationMock = jest.fn();
+ pageMutationMock = jest.fn();
+ disableKeyMock = jest.fn();
+
+ resolvers = {
+ Query: {
+ currentPage: currentPageMock,
+ currentScope: currentScopeMock,
+ deployKeyToRemove: confirmRemoveKeyMock,
+ pageInfo: pageInfoMock,
+ },
+ Mutation: {
+ currentPage: pageMutationMock,
+ currentScope: scopeMutationMock,
+ disableKey: disableKeyMock,
+ },
+ };
});
afterEach(() => {
@@ -43,8 +81,7 @@ describe('Deploy keys app component', () => {
const findNavigationTabs = () => wrapper.findComponent(NavigationTabs);
it('renders loading icon while waiting for request', async () => {
- mock.onGet(TEST_ENDPOINT).reply(() => new Promise());
-
+ deployKeyMock.mockReturnValue(new Promise(() => {}));
mountComponent();
await nextTick();
@@ -52,85 +89,190 @@ describe('Deploy keys app component', () => {
});
it('renders keys panels', async () => {
+ const deployKeys = enabledKeys.keys.map(mapDeployKey);
+ deployKeyMock.mockReturnValue({
+ data: {
+ project: { id: 1, deployKeys, __typename: 'Project' },
+ },
+ });
await mountComponent();
expect(findKeyPanels().length).toBe(3);
});
- it.each`
- selector
- ${'.js-deployKeys-tab-enabled_keys'}
- ${'.js-deployKeys-tab-available_project_keys'}
- ${'.js-deployKeys-tab-public_keys'}
- `('$selector title exists', ({ selector }) => {
- return mountComponent().then(() => {
+ describe.each`
+ scope
+ ${'enabledKeys'}
+ ${'availableProjectKeys'}
+ ${'availablePublicKeys'}
+ `('tab $scope', ({ scope }) => {
+ let selector;
+
+ beforeEach(async () => {
+ selector = `.js-deployKeys-tab-${scope}`;
+ const deployKeys = enabledKeys.keys.map(mapDeployKey);
+ deployKeyMock.mockReturnValue({
+ data: {
+ project: { id: 1, deployKeys, __typename: 'Project' },
+ },
+ });
+
+ await mountComponent();
+ });
+
+ it('displays the title', () => {
const element = wrapper.find(selector);
expect(element.exists()).toBe(true);
});
+
+ it('triggers changing the scope on click', async () => {
+ await findNavigationTabs().vm.$emit('onChangeTab', scope);
+
+ expect(scopeMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { scope },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
});
- it('does not render key panels when keys object is empty', () => {
- mock.onGet(TEST_ENDPOINT).reply(HTTP_STATUS_OK, []);
+ it('captures a failed tab change', async () => {
+ const scope = 'fake scope';
+ const error = new Error('fail!');
- return mountComponent().then(() => {
- expect(findKeyPanels().length).toBe(0);
+ const deployKeys = enabledKeys.keys.map(mapDeployKey);
+ deployKeyMock.mockReturnValue({
+ data: {
+ project: { id: 1, deployKeys, __typename: 'Project' },
+ },
});
+
+ scopeMutationMock.mockRejectedValue(error);
+ await mountComponent();
+ await findNavigationTabs().vm.$emit('onChangeTab', scope);
+ await waitForPromises();
+
+ expect(captureException).toHaveBeenCalledWith(error, { tags: { deployKeyScope: scope } });
});
it('hasKeys returns true when there are keys', async () => {
+ const deployKeys = enabledKeys.keys.map(mapDeployKey);
+ deployKeyMock.mockReturnValue({
+ data: {
+ project: { id: 1, deployKeys, __typename: 'Project' },
+ },
+ });
await mountComponent();
expect(findNavigationTabs().exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(false);
});
- describe('enabling and disabling keys', () => {
- const key = data.public_keys[0];
- let getMethodMock;
- let putMethodMock;
+ describe('disabling keys', () => {
+ const key = mapDeployKey(enabledKeys.keys[0]);
+
+ beforeEach(() => {
+ deployKeyMock.mockReturnValue({
+ data: {
+ project: { id: 1, deployKeys: [key], __typename: 'Project' },
+ },
+ });
+ });
- const removeKey = async (keyEvent) => {
- eventHub.$emit(keyEvent, key, () => {});
+ it('re-fetches deploy keys when disabling a key', async () => {
+ confirmRemoveKeyMock.mockReturnValue(key);
+ await mountComponent();
+ expect(deployKeyMock).toHaveBeenCalledTimes(1);
await nextTick();
expect(findModal().props('visible')).toBe(true);
findModal().vm.$emit('remove');
- };
-
- beforeEach(() => {
- getMethodMock = jest.spyOn(axios, 'get');
- putMethodMock = jest.spyOn(axios, 'put');
+ await waitForPromises();
+ expect(deployKeyMock).toHaveBeenCalledTimes(2);
});
+ });
- afterEach(() => {
- getMethodMock.mockClear();
- putMethodMock.mockClear();
- });
+ describe('pagination', () => {
+ const key = mapDeployKey(enabledKeys.keys[0]);
+ let page;
+ let pageInfo;
+ let glPagination;
- it('re-fetches deploy keys when enabling a key', async () => {
- await mountComponent();
+ beforeEach(async () => {
+ page = 2;
+ pageInfo = {
+ total: 20,
+ perPage: 5,
+ nextPage: 3,
+ page,
+ previousPage: 1,
+ __typename: 'LocalPageInfo',
+ };
+ deployKeyMock.mockReturnValue({
+ data: {
+ project: { id: 1, deployKeys: [], __typename: 'Project' },
+ },
+ });
- eventHub.$emit('enable.key', key);
+ confirmRemoveKeyMock.mockReturnValue(key);
+ pageInfoMock.mockReturnValue(pageInfo);
+ currentPageMock.mockReturnValue(page);
+ await mountComponent();
+ glPagination = wrapper.findComponent(GlPagination);
+ });
- expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/enable`);
- expect(getMethodMock).toHaveBeenCalled();
+ it('shows pagination with correct page info', () => {
+ expect(glPagination.exists()).toBe(true);
+ expect(glPagination.props()).toMatchObject({
+ totalItems: pageInfo.total,
+ perPage: pageInfo.perPage,
+ value: page,
+ });
});
- it('re-fetches deploy keys when disabling a key', async () => {
- await mountComponent();
+ it('moves back a page', async () => {
+ await glPagination.vm.$emit('previous');
- await removeKey('disable.key');
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: page - 1 },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('moves forward a page', async () => {
+ await glPagination.vm.$emit('next');
- expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`);
- expect(getMethodMock).toHaveBeenCalled();
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: page + 1 },
+ expect.anything(),
+ expect.anything(),
+ );
});
- it('calls disableKey when removing a key', async () => {
- await mountComponent();
+ it('moves to specified page', async () => {
+ await glPagination.vm.$emit('input', 5);
+
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: 5 },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
- await removeKey('remove.key');
+ it('moves a page back if there are no more keys on this page', async () => {
+ await findModal().vm.$emit('remove');
+ await waitForPromises();
- expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`);
- expect(getMethodMock).toHaveBeenCalled();
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: page - 1 },
+ expect.anything(),
+ expect.anything(),
+ );
});
});
});
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
index e57da4df150..5410914da04 100644
--- a/spec/frontend/deploy_keys/components/key_spec.js
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -1,64 +1,85 @@
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import data from 'test_fixtures/deploy_keys/keys.json';
+import enabledKeys from 'test_fixtures/deploy_keys/enabled_keys.json';
+import availablePublicKeys from 'test_fixtures/deploy_keys/available_public_keys.json';
+import { createAlert } from '~/alert';
+import { mapDeployKey } from '~/deploy_keys/graphql/resolvers';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import key from '~/deploy_keys/components/key.vue';
-import DeployKeysStore from '~/deploy_keys/store';
+import ActionBtn from '~/deploy_keys/components/action_btn.vue';
import { getTimeago, localeDateFormat } from '~/lib/utils/datetime_utility';
+jest.mock('~/alert');
+
+Vue.use(VueApollo);
+
describe('Deploy keys key', () => {
let wrapper;
- let store;
+ let currentScopeMock;
const findTextAndTrim = (selector) => wrapper.find(selector).text().trim();
- const createComponent = (propsData) => {
+ const createComponent = async (propsData) => {
+ const resolvers = {
+ Query: {
+ currentScope: currentScopeMock,
+ },
+ };
+
+ const apolloProvider = createMockApollo([], resolvers);
wrapper = mount(key, {
propsData: {
- store,
endpoint: 'https://test.host/dummy/endpoint',
...propsData,
},
+ apolloProvider,
directives: {
GlTooltip: createMockDirective('gl-tooltip'),
},
});
+ await nextTick();
};
beforeEach(() => {
- store = new DeployKeysStore();
- store.keys = data;
+ currentScopeMock = jest.fn();
});
describe('enabled key', () => {
- const deployKey = data.enabled_keys[0];
+ const deployKey = mapDeployKey(enabledKeys.keys[0]);
- it('renders the keys title', () => {
- createComponent({ deployKey });
+ beforeEach(() => {
+ currentScopeMock.mockReturnValue('enabledKeys');
+ });
+
+ it('renders the keys title', async () => {
+ await createComponent({ deployKey });
expect(findTextAndTrim('.title')).toContain('My title');
});
- it('renders human friendly formatted created date', () => {
- createComponent({ deployKey });
+ it('renders human friendly formatted created date', async () => {
+ await createComponent({ deployKey });
expect(findTextAndTrim('.key-created-at')).toBe(
- `${getTimeago().format(deployKey.created_at)}`,
+ `${getTimeago().format(deployKey.createdAt)}`,
);
});
- it('renders human friendly expiration date', () => {
+ it('renders human friendly expiration date', async () => {
const expiresAt = new Date();
- createComponent({
- deployKey: { ...deployKey, expires_at: expiresAt },
+ await createComponent({
+ deployKey: { ...deployKey, expiresAt },
});
expect(findTextAndTrim('.key-expires-at')).toBe(`${getTimeago().format(expiresAt)}`);
});
- it('shows tooltip for expiration date', () => {
+ it('shows tooltip for expiration date', async () => {
const expiresAt = new Date();
- createComponent({
- deployKey: { ...deployKey, expires_at: expiresAt },
+ await createComponent({
+ deployKey: { ...deployKey, expiresAt },
});
const expiryComponent = wrapper.find('[data-testid="expires-at-tooltip"]');
@@ -68,55 +89,57 @@ describe('Deploy keys key', () => {
`${localeDateFormat.asDateTimeFull.format(expiresAt)}`,
);
});
- it('renders never when no expiration date', () => {
- createComponent({
- deployKey: { ...deployKey, expires_at: null },
+ it('renders never when no expiration date', async () => {
+ await createComponent({
+ deployKey: { ...deployKey, expiresAt: null },
});
expect(wrapper.find('[data-testid="expires-never"]').exists()).toBe(true);
});
- it('shows pencil button for editing', () => {
- createComponent({ deployKey });
+ it('shows pencil button for editing', async () => {
+ await createComponent({ deployKey });
expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true);
});
- it('shows disable button when the project is not deletable', () => {
- createComponent({ deployKey });
+ it('shows disable button when the project is not deletable', async () => {
+ await createComponent({ deployKey });
+ await waitForPromises();
expect(wrapper.find('.btn [data-testid="cancel-icon"]').exists()).toBe(true);
});
- it('shows remove button when the project is deletable', () => {
- createComponent({
- deployKey: { ...deployKey, destroyed_when_orphaned: true, almost_orphaned: true },
+ it('shows remove button when the project is deletable', async () => {
+ await createComponent({
+ deployKey: { ...deployKey, destroyedWhenOrphaned: true, almostOrphaned: true },
});
+ await waitForPromises();
expect(wrapper.find('.btn [data-testid="remove-icon"]').exists()).toBe(true);
});
});
describe('deploy key labels', () => {
- const deployKey = data.enabled_keys[0];
- const deployKeysProjects = [...deployKey.deploy_keys_projects];
- it('shows write access title when key has write access', () => {
- deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: true };
- createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
+ const deployKey = mapDeployKey(enabledKeys.keys[0]);
+ const deployKeysProjects = [...deployKey.deployKeysProjects];
+ it('shows write access title when key has write access', async () => {
+ deployKeysProjects[0] = { ...deployKeysProjects[0], canPush: true };
+ await createComponent({ deployKey: { ...deployKey, deployKeysProjects } });
expect(wrapper.find('.deploy-project-label').attributes('title')).toBe(
'Grant write permissions to this key',
);
});
- it('does not show write access title when key has write access', () => {
- deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: false };
- createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
+ it('does not show write access title when key has write access', async () => {
+ deployKeysProjects[0] = { ...deployKeysProjects[0], canPush: false };
+ await createComponent({ deployKey: { ...deployKey, deployKeysProjects } });
expect(wrapper.find('.deploy-project-label').attributes('title')).toBe('Read access only');
});
- it('shows expandable button if more than two projects', () => {
- createComponent({ deployKey });
+ it('shows expandable button if more than two projects', async () => {
+ await createComponent({ deployKey });
const labels = wrapper.findAll('.deploy-project-label');
expect(labels.length).toBe(2);
@@ -125,53 +148,68 @@ describe('Deploy keys key', () => {
});
it('expands all project labels after click', async () => {
- createComponent({ deployKey });
- const { length } = deployKey.deploy_keys_projects;
+ await createComponent({ deployKey });
+ const { length } = deployKey.deployKeysProjects;
wrapper.findAll('.deploy-project-label').at(1).trigger('click');
await nextTick();
const labels = wrapper.findAll('.deploy-project-label');
- expect(labels.length).toBe(length);
+ expect(labels).toHaveLength(length);
expect(labels.at(1).text()).not.toContain(`+${length} others`);
expect(labels.at(1).attributes('title')).not.toContain('Expand');
});
- it('shows two projects', () => {
- createComponent({
- deployKey: { ...deployKey, deploy_keys_projects: [...deployKeysProjects].slice(0, 2) },
+ it('shows two projects', async () => {
+ await createComponent({
+ deployKey: { ...deployKey, deployKeysProjects: [...deployKeysProjects].slice(0, 2) },
});
const labels = wrapper.findAll('.deploy-project-label');
expect(labels.length).toBe(2);
- expect(labels.at(1).text()).toContain(deployKey.deploy_keys_projects[1].project.full_name);
+ expect(labels.at(1).text()).toContain(deployKey.deployKeysProjects[1].project.fullName);
});
});
describe('public keys', () => {
- const deployKey = data.public_keys[0];
+ const deployKey = mapDeployKey(availablePublicKeys.keys[0]);
- it('renders deploy keys without any enabled projects', () => {
- createComponent({ deployKey: { ...deployKey, deploy_keys_projects: [] } });
+ it('renders deploy keys without any enabled projects', async () => {
+ await createComponent({ deployKey: { ...deployKey, deployKeysProjects: [] } });
expect(findTextAndTrim('.deploy-project-list')).toBe('None');
});
- it('shows enable button', () => {
- createComponent({ deployKey });
+ it('shows enable button', async () => {
+ await createComponent({ deployKey });
expect(findTextAndTrim('.btn')).toBe('Enable');
});
- it('shows pencil button for editing', () => {
- createComponent({ deployKey });
- expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true);
+ it('shows an error on enable failure', async () => {
+ await createComponent({ deployKey });
+
+ const error = new Error('oops!');
+ wrapper.findComponent(ActionBtn).vm.$emit('error', error);
+
+ await nextTick();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Error enabling deploy key',
+ captureError: true,
+ error,
+ });
});
- it('shows disable button when key is enabled', () => {
- store.keys.enabled_keys.push(deployKey);
+ it('shows pencil button for editing', async () => {
+ await createComponent({ deployKey });
+ expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true);
+ });
- createComponent({ deployKey });
+ it('shows disable button when key is enabled', async () => {
+ currentScopeMock.mockReturnValue('enabledKeys');
+ await createComponent({ deployKey });
+ await waitForPromises();
expect(wrapper.find('.btn [data-testid="cancel-icon"]').exists()).toBe(true);
});
diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js
index e63b269fe23..6e653010d8f 100644
--- a/spec/frontend/deploy_keys/components/keys_panel_spec.js
+++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js
@@ -1,7 +1,9 @@
import { mount } from '@vue/test-utils';
-import data from 'test_fixtures/deploy_keys/keys.json';
+import enabledKeys from 'test_fixtures/deploy_keys/enabled_keys.json';
import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue';
-import DeployKeysStore from '~/deploy_keys/store';
+import { mapDeployKey } from '~/deploy_keys/graphql/resolvers';
+
+const keys = enabledKeys.keys.map(mapDeployKey);
describe('Deploy keys panel', () => {
let wrapper;
@@ -9,14 +11,11 @@ describe('Deploy keys panel', () => {
const findTableRowHeader = () => wrapper.find('.table-row-header');
const mountComponent = (props) => {
- const store = new DeployKeysStore();
- store.keys = data;
wrapper = mount(deployKeysPanel, {
propsData: {
title: 'test',
- keys: data.enabled_keys,
+ keys,
showHelpBox: true,
- store,
endpoint: 'https://test.host/dummy/endpoint',
...props,
},
@@ -25,7 +24,7 @@ describe('Deploy keys panel', () => {
it('renders list of keys', () => {
mountComponent();
- expect(wrapper.findAll('.deploy-key').length).toBe(wrapper.vm.keys.length);
+ expect(wrapper.findAll('.deploy-key').length).toBe(keys.length);
});
it('renders table header', () => {
diff --git a/spec/frontend/deploy_keys/graphql/resolvers_spec.js b/spec/frontend/deploy_keys/graphql/resolvers_spec.js
index 458232697cb..486cbc525d1 100644
--- a/spec/frontend/deploy_keys/graphql/resolvers_spec.js
+++ b/spec/frontend/deploy_keys/graphql/resolvers_spec.js
@@ -64,7 +64,7 @@ describe('~/deploy_keys/graphql/resolvers', () => {
const scope = 'enabledKeys';
const page = 2;
mock
- .onGet(ENDPOINTS.enabledKeysEndpoint, { params: { page } })
+ .onGet(ENDPOINTS.enabledKeysEndpoint, { params: { page, per_page: 5 } })
.reply(HTTP_STATUS_OK, { keys: [key] });
const keys = await mockResolvers.Project.deployKeys(null, { scope, page }, { client });
@@ -157,6 +157,11 @@ describe('~/deploy_keys/graphql/resolvers', () => {
data: { currentPage: 1 },
});
});
+
+ it('throws failure on bad scope', () => {
+ scope = 'bad scope';
+ expect(() => mockResolvers.Mutation.currentScope(null, { scope }, { client })).toThrow(scope);
+ });
});
describe('disableKey', () => {
diff --git a/spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap b/spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap
new file mode 100644
index 00000000000..605f6335b5c
--- /dev/null
+++ b/spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap
@@ -0,0 +1,160 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Diffs tree list component pinned file files in folders pins 1.rb file 1`] = `
+Array [
+ "📁folder/",
+ "──1.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-2.rb",
+ "────nested-3.rb",
+ "──2.rb",
+ "──3.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-first.rb",
+ "root-last.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins 2.rb file 1`] = `
+Array [
+ "📁folder/",
+ "──2.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-2.rb",
+ "────nested-3.rb",
+ "──1.rb",
+ "──3.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-first.rb",
+ "root-last.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins 3.rb file 1`] = `
+Array [
+ "📁folder/",
+ "──3.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-2.rb",
+ "────nested-3.rb",
+ "──1.rb",
+ "──2.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-first.rb",
+ "root-last.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins nested-1.rb file 1`] = `
+Array [
+ "📁folder/sub-folder/",
+ "──nested-1.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-2.rb",
+ "────nested-3.rb",
+ "──1.rb",
+ "──2.rb",
+ "──3.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-first.rb",
+ "root-last.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins nested-2.rb file 1`] = `
+Array [
+ "📁folder/sub-folder/",
+ "──nested-2.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-3.rb",
+ "──1.rb",
+ "──2.rb",
+ "──3.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-first.rb",
+ "root-last.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins nested-3.rb file 1`] = `
+Array [
+ "📁folder/sub-folder/",
+ "──nested-3.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-2.rb",
+ "──1.rb",
+ "──2.rb",
+ "──3.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-first.rb",
+ "root-last.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins root-first.rb file 1`] = `
+Array [
+ "root-first.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-2.rb",
+ "────nested-3.rb",
+ "──1.rb",
+ "──2.rb",
+ "──3.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-last.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins root-last.rb file 1`] = `
+Array [
+ "root-last.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-2.rb",
+ "────nested-3.rb",
+ "──1.rb",
+ "──2.rb",
+ "──3.rb",
+ "📁folder-single",
+ "──single.rb",
+ "root-first.rb",
+]
+`;
+
+exports[`Diffs tree list component pinned file files in folders pins single.rb file 1`] = `
+Array [
+ "📁folder-single/",
+ "──single.rb",
+ "📁folder",
+ "──📁sub-folder",
+ "────nested-1.rb",
+ "────nested-2.rb",
+ "────nested-3.rb",
+ "──1.rb",
+ "──2.rb",
+ "──3.rb",
+ "root-first.rb",
+ "root-last.rb",
+]
+`;
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 63d9a2471b6..813db12e83f 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -31,6 +31,9 @@ import * as urlUtils from '~/lib/utils/url_utility';
import * as commonUtils from '~/lib/utils/common_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { stubPerformanceWebAPI } from 'helpers/performance';
+import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file';
+import waitForPromises from 'helpers/wait_for_promises';
+import { diffMetadata } from 'jest/diffs/mock_data/diff_metadata';
import createDiffsStore from '../create_diffs_store';
import diffsMockData from '../mock_data/merge_request_diffs';
@@ -38,6 +41,8 @@ const mergeRequestDiff = { version_index: 1 };
const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`;
const COMMIT_URL = `${TEST_HOST}/COMMIT/OLD`;
const UPDATED_COMMIT_URL = `${TEST_HOST}/COMMIT/NEW`;
+const ENDPOINT_BATCH_URL = `${TEST_HOST}/diff/endpointBatch`;
+const ENDPOINT_METADATA_URL = `${TEST_HOST}/diff/endpointMetadata`;
Vue.use(Vuex);
Vue.use(VueApollo);
@@ -77,8 +82,8 @@ describe('diffs/components/app', () => {
store.dispatch('diffs/setBaseConfig', {
endpoint: TEST_ENDPOINT,
- endpointMetadata: `${TEST_HOST}/diff/endpointMetadata`,
- endpointBatch: `${TEST_HOST}/diff/endpointBatch`,
+ endpointMetadata: ENDPOINT_METADATA_URL,
+ endpointBatch: ENDPOINT_BATCH_URL,
endpointDiffForPath: TEST_ENDPOINT,
projectPath: 'namespace/project',
dismissEndpoint: '',
@@ -126,7 +131,7 @@ describe('diffs/components/app', () => {
const fetchResolver = () => {
store.state.diffs.retrievingBatches = false;
store.state.notes.doneFetchingBatchDiscussions = true;
- store.state.notes.discussions = 'test';
+ store.state.notes.discussions = [];
return Promise.resolve({ real_size: 100 });
};
jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn());
@@ -861,4 +866,32 @@ describe('diffs/components/app', () => {
expect(loadSpy).not.toHaveBeenCalledWith({ file: store.state.diffs.diffFiles[0] });
});
});
+
+ describe('pinned file', () => {
+ const pinnedFileUrl = 'http://localhost.test/pinned-file';
+ let pinnedFile;
+
+ beforeEach(() => {
+ pinnedFile = getDiffFileMock();
+ mock.onGet(pinnedFileUrl).reply(HTTP_STATUS_OK, { diff_files: [pinnedFile] });
+ mock
+ .onGet(new RegExp(ENDPOINT_BATCH_URL))
+ .reply(HTTP_STATUS_OK, { diff_files: [], pagination: {} });
+ mock.onGet(new RegExp(ENDPOINT_METADATA_URL)).reply(HTTP_STATUS_OK, diffMetadata);
+
+ createComponent({ shouldShow: true, pinnedFileUrl });
+ });
+
+ it('fetches and displays pinned file', async () => {
+ await waitForPromises();
+
+ expect(wrapper.findComponent({ name: 'DynamicScroller' }).props('items')[0].file_hash).toBe(
+ pinnedFile.file_hash,
+ );
+ });
+
+ it('shows a spinner during loading', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
index d6539a5bffa..c02875963fd 100644
--- a/spec/frontend/diffs/components/diff_file_header_spec.js
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -1,8 +1,8 @@
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import { cloneDeep } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
@@ -20,6 +20,7 @@ import { truncateSha } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { TEST_HOST } from 'spec/test_constants';
import testAction from '../../__helpers__/vuex_action_helper';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
@@ -73,6 +74,7 @@ describe('DiffFileHeader component', () => {
setFileCollapsedByUser: jest.fn(),
setFileForcedOpen: jest.fn(),
reviewFile: jest.fn(),
+ unpinFile: jest.fn(),
},
},
},
@@ -87,7 +89,7 @@ describe('DiffFileHeader component', () => {
});
const findHeader = () => wrapper.findComponent({ ref: 'header' });
- const findTitleLink = () => wrapper.findComponent({ ref: 'titleWrapper' });
+ const findTitleLink = () => wrapper.findByTestId('file-title');
const findExpandButton = () => wrapper.findComponent({ ref: 'expandDiffToFullFileButton' });
const findFileActions = () => wrapper.find('.file-actions');
const findModeChangedLine = () => wrapper.findComponent({ ref: 'fileMode' });
@@ -105,7 +107,7 @@ describe('DiffFileHeader component', () => {
mockStoreConfig = cloneDeep(defaultMockStoreConfig);
const store = new Vuex.Store({ ...mockStoreConfig, ...options.store });
- wrapper = shallowMount(DiffFileHeader, {
+ wrapper = shallowMountExtended(DiffFileHeader, {
propsData: {
diffFile,
canCurrentUserFork: false,
@@ -711,4 +713,23 @@ describe('DiffFileHeader component', () => {
expect(wrapper.find('[data-testid="comment-files-button"]').exists()).toEqual(true);
});
+
+ describe('pinned file', () => {
+ beforeEach(() => {
+ window.gon.features = { pinnedFile: true };
+ });
+
+ it('has pinned URL search param', () => {
+ createComponent();
+ const url = new URL(TEST_HOST + findTitleLink().attributes('href'));
+ expect(url.searchParams.get('pin')).toBe(diffFile.file_hash);
+ });
+
+ it('can unpin file', () => {
+ createComponent({ props: { addMergeRequestButtons: true, pinned: true } });
+ const unpinButton = wrapper.findComponentByTestId('unpin-button');
+ unpinButton.vm.$emit('click');
+ expect(mockStoreConfig.modules.diffs.actions.unpinFile).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index a9fbf4632ac..444f4102e26 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -29,6 +29,7 @@ import createNotesStore from '~/notes/stores/modules';
import diffsModule from '~/diffs/store/modules';
import { SOMETHING_WENT_WRONG, SAVING_THE_COMMENT_FAILED } from '~/diffs/i18n';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
+import { SET_PINNED_FILE_HASH } from '~/diffs/store/mutation_types';
import { getDiffFileMock } from '../mock_data/diff_file';
import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable';
import diffsMockData from '../mock_data/merge_request_diffs';
@@ -90,49 +91,6 @@ function markFileToBeRendered(store, index = 0) {
});
}
-function createComponent({ file, first = false, last = false, options = {}, props = {} }) {
- const diffs = diffsModule();
- diffs.actions = {
- ...diffs.actions,
- prefetchFileNeighbors: prefetchFileNeighborsMock,
- saveDiffDiscussion: saveDiffDiscussionMock,
- };
-
- diffs.getters = {
- ...diffs.getters,
- diffCompareDropdownTargetVersions: () => [],
- diffCompareDropdownSourceVersions: () => [],
- };
-
- const store = new Vuex.Store({
- ...createNotesStore(),
- modules: { diffs },
- });
-
- store.state.diffs = {
- mergeRequestDiff: diffsMockData[0],
- diffFiles: [file],
- };
-
- const wrapper = shallowMountExtended(DiffFileComponent, {
- store,
- propsData: {
- file,
- canCurrentUserFork: false,
- viewDiffsFileByFile: false,
- isFirstFile: first,
- isLastFile: last,
- ...props,
- },
- ...options,
- });
-
- return {
- wrapper,
- store,
- };
-}
-
const findDiffHeader = (wrapper) => wrapper.findComponent(DiffFileHeaderComponent);
const findDiffContentArea = (wrapper) => wrapper.findByTestId('content-area');
const findLoader = (wrapper) => wrapper.findByTestId('loader-icon');
@@ -159,15 +117,58 @@ const triggerSaveDraftNote = (wrapper, note, parent, error) =>
findNoteForm(wrapper).vm.$emit('handleFormUpdateAddToReview', note, false, parent, error);
describe('DiffFile', () => {
- let readableFile;
let wrapper;
let store;
let axiosMock;
+ function createComponent({
+ file = getReadableFile(),
+ first = false,
+ last = false,
+ options = {},
+ props = {},
+ } = {}) {
+ const diffs = diffsModule();
+ diffs.actions = {
+ ...diffs.actions,
+ prefetchFileNeighbors: prefetchFileNeighborsMock,
+ saveDiffDiscussion: saveDiffDiscussionMock,
+ };
+
+ diffs.getters = {
+ ...diffs.getters,
+ diffCompareDropdownTargetVersions: () => [],
+ diffCompareDropdownSourceVersions: () => [],
+ };
+
+ store = new Vuex.Store({
+ ...createNotesStore(),
+ modules: { diffs },
+ });
+
+ store.state.diffs = {
+ ...store.state.diffs,
+ mergeRequestDiff: diffsMockData[0],
+ diffFiles: [file],
+ };
+
+ wrapper = shallowMountExtended(DiffFileComponent, {
+ store,
+ propsData: {
+ file,
+ canCurrentUserFork: false,
+ viewDiffsFileByFile: false,
+ isFirstFile: first,
+ isLastFile: last,
+ ...props,
+ },
+ ...options,
+ });
+ }
+
beforeEach(() => {
- readableFile = getReadableFile();
axiosMock = new MockAdapter(axios);
- ({ wrapper, store } = createComponent({ file: readableFile }));
+ createComponent();
});
afterEach(() => {
@@ -186,7 +187,6 @@ describe('DiffFile', () => {
`('$description', ({ fileByFile }) => {
createComponent({
props: { viewDiffsFileByFile: fileByFile },
- file: readableFile,
});
if (fileByFile) {
@@ -217,11 +217,11 @@ describe('DiffFile', () => {
forceHasDiff({ store, ...file });
}
- ({ wrapper, store } = createComponent({
+ createComponent({
file: store.state.diffs.diffFiles[0],
first,
last,
- }));
+ });
await nextTick();
@@ -233,14 +233,13 @@ describe('DiffFile', () => {
);
it('emits the "first file shown" and "files end" events when in File-by-File mode', async () => {
- ({ wrapper, store } = createComponent({
- file: getReadableFile(),
+ createComponent({
first: false,
last: false,
props: {
viewDiffsFileByFile: true,
},
- }));
+ });
await nextTick();
@@ -253,11 +252,11 @@ describe('DiffFile', () => {
describe('after loading the diff', () => {
it('indicates that it loaded the file', async () => {
forceHasDiff({ store, inlineLines: [], parallelLines: [], readableText: true });
- ({ wrapper, store } = createComponent({
+ createComponent({
file: store.state.diffs.diffFiles[0],
first: true,
last: true,
- }));
+ });
jest.spyOn(wrapper.vm, 'loadCollapsedDiff').mockResolvedValue(getReadableFile());
jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn());
@@ -314,11 +313,11 @@ describe('DiffFile', () => {
`('should be $bool when { userIsLoggedIn: $loggedIn }', ({ loggedIn, bool }) => {
setLoggedIn(loggedIn);
- ({ wrapper } = createComponent({
+ createComponent({
props: {
file: store.state.diffs.diffFiles[0],
},
- }));
+ });
expect(wrapper.vm.showLocalFileReviews).toBe(bool);
});
@@ -556,7 +555,7 @@ describe('DiffFile', () => {
describe('general (other) collapsed', () => {
it('should be expandable for unreadable files', async () => {
- ({ wrapper, store } = createComponent({ file: getUnreadableFile() }));
+ createComponent({ file: getUnreadableFile() });
makeFileAutomaticallyCollapsed(store);
await nextTick();
@@ -622,7 +621,7 @@ describe('DiffFile', () => {
renderIt: true,
};
- ({ wrapper, store } = createComponent({ file }));
+ createComponent({ file });
expect(wrapper.findByTestId('conflictsAlert').exists()).toBe(false);
});
@@ -634,7 +633,7 @@ describe('DiffFile', () => {
renderIt: true,
};
- ({ wrapper, store } = createComponent({ file }));
+ createComponent({ file });
expect(wrapper.findByTestId('conflictsAlert').exists()).toBe(true);
});
@@ -656,9 +655,9 @@ describe('DiffFile', () => {
...extraProps,
};
- ({ wrapper, store } = createComponent({
+ createComponent({
file,
- }));
+ });
expect(wrapper.findByTestId('file-discussions').exists()).toEqual(exists);
},
@@ -676,9 +675,9 @@ describe('DiffFile', () => {
hasCommentForm,
};
- ({ wrapper, store } = createComponent({
+ createComponent({
file,
- }));
+ });
expect(findNoteForm(wrapper).exists()).toEqual(exists);
},
@@ -694,9 +693,9 @@ describe('DiffFile', () => {
discussions,
};
- ({ wrapper, store } = createComponent({
+ createComponent({
file,
- }));
+ });
expect(wrapper.findByTestId('diff-file-discussions').exists()).toEqual(exists);
});
@@ -712,10 +711,10 @@ describe('DiffFile', () => {
const errorCallback = jest.fn();
beforeEach(() => {
- ({ wrapper, store } = createComponent({
+ createComponent({
file,
options: { provide: { glFeatures: { commentOnFiles: true } } },
- }));
+ });
});
it('calls saveDiffDiscussionMock', () => {
@@ -771,10 +770,10 @@ describe('DiffFile', () => {
const errorCallback = jest.fn();
beforeEach(async () => {
- ({ wrapper, store } = createComponent({
+ createComponent({
file,
options: { provide: { glFeatures: { commentOnFiles: true } } },
- }));
+ });
triggerSaveDraftNote(wrapper, note, parentElement, errorCallback);
@@ -791,4 +790,13 @@ describe('DiffFile', () => {
});
});
});
+
+ describe('pinned file', () => {
+ it('passes down pinned prop', async () => {
+ createComponent();
+ store.commit(`diffs/${SET_PINNED_FILE_HASH}`, getReadableFile().file_hash);
+ await nextTick();
+ expect(wrapper.findComponent(DiffFileHeaderComponent).props('pinned')).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js
index 6e9eb433924..bd9592e4f5e 100644
--- a/spec/frontend/diffs/components/diff_row_utils_spec.js
+++ b/spec/frontend/diffs/components/diff_row_utils_spec.js
@@ -6,6 +6,7 @@ import {
NEW_NO_NEW_LINE_TYPE,
EMPTY_CELL_TYPE,
} from '~/diffs/constants';
+import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file';
const LINE_CODE = 'abc123';
@@ -108,15 +109,47 @@ describe('diff_row_utils', () => {
describe('lineHref', () => {
it(`should return #${LINE_CODE}`, () => {
- expect(utils.lineHref({ line_code: LINE_CODE })).toEqual(`#${LINE_CODE}`);
+ expect(utils.lineHref({ line_code: LINE_CODE }, {})).toEqual(`#${LINE_CODE}`);
});
it(`should return '#' if line is undefined`, () => {
- expect(utils.lineHref()).toEqual('#');
+ expect(utils.lineHref()).toEqual('');
});
it(`should return '#' if line_code is undefined`, () => {
- expect(utils.lineHref({})).toEqual('#');
+ expect(utils.lineHref({}, {})).toEqual('');
+ });
+
+ describe('pinned file', () => {
+ beforeEach(() => {
+ window.gon.features = { pinnedFile: true };
+ });
+
+ afterEach(() => {
+ delete window.gon.features;
+ });
+
+ it(`should return pinned file URL`, () => {
+ const diffFile = getDiffFileMock();
+ expect(utils.lineHref({ line_code: LINE_CODE }, { diffFile })).toEqual(
+ `?pin=${diffFile.file_hash}#${LINE_CODE}`,
+ );
+ });
+ });
+ });
+
+ describe('pinnedFileHref', () => {
+ beforeEach(() => {
+ window.gon.features = { pinnedFile: true };
+ });
+
+ afterEach(() => {
+ delete window.gon.features;
+ });
+
+ it(`should return pinned file URL`, () => {
+ const diffFile = getDiffFileMock();
+ expect(utils.pinnedFileHref(diffFile)).toEqual(`?pin=${diffFile.file_hash}`);
});
});
diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js
index a54cf9b8bff..230839f0ecf 100644
--- a/spec/frontend/diffs/components/tree_list_spec.js
+++ b/spec/frontend/diffs/components/tree_list_spec.js
@@ -7,6 +7,9 @@ import batchComments from '~/batch_comments/stores/modules/batch_comments';
import DiffFileRow from '~/diffs/components//diff_file_row.vue';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { SET_PINNED_FILE_HASH, SET_TREE_DATA, SET_DIFF_FILES } from '~/diffs/store/mutation_types';
+import { generateTreeList } from '~/diffs/utils/tree_worker_utils';
+import { sortTree } from '~/ide/stores/utils';
describe('Diffs tree list component', () => {
let wrapper;
@@ -58,6 +61,14 @@ describe('Diffs tree list component', () => {
const setupFilesInState = () => {
const treeEntries = {
+ app: {
+ key: 'app',
+ path: 'app',
+ name: 'app',
+ type: 'tree',
+ tree: [],
+ opened: true,
+ },
'index.js': {
addedLines: 0,
changed: true,
@@ -71,6 +82,8 @@ describe('Diffs tree list component', () => {
type: 'blob',
parentPath: 'app',
tree: [],
+ file_path: 'app/index.js',
+ file_hash: 'app-index',
},
'test.rb': {
addedLines: 0,
@@ -85,20 +98,39 @@ describe('Diffs tree list component', () => {
type: 'blob',
parentPath: 'app',
tree: [],
+ file_path: 'app/test.rb',
+ file_hash: 'app-test',
},
- app: {
- key: 'app',
- path: 'app',
- name: 'app',
- type: 'tree',
+ LICENSE: {
+ addedLines: 0,
+ changed: true,
+ deleted: false,
+ fileHash: 'LICENSE',
+ key: 'LICENSE',
+ name: 'LICENSE',
+ path: 'LICENSE',
+ removedLines: 0,
+ tempFile: true,
+ type: 'blob',
+ parentPath: '/',
tree: [],
+ file_path: 'LICENSE',
+ file_hash: 'LICENSE',
},
};
Object.assign(store.state.diffs, {
treeEntries,
- tree: [treeEntries['index.js'], treeEntries.app],
+ tree: [
+ treeEntries.LICENSE,
+ {
+ ...treeEntries.app,
+ tree: [treeEntries['index.js'], treeEntries['test.rb']],
+ },
+ ],
});
+
+ return treeEntries;
};
describe('default', () => {
@@ -149,7 +181,7 @@ describe('Diffs tree list component', () => {
});
it('renders tree', () => {
- expect(getScroller().props('items')).toHaveLength(2);
+ expect(getScroller().props('items')).toHaveLength(4);
});
it('hides file stats', () => {
@@ -169,7 +201,7 @@ describe('Diffs tree list component', () => {
store.state.diffs.renderTreeList = false;
await nextTick();
- expect(getScroller().props('items')).toHaveLength(3);
+ expect(getScroller().props('items')).toHaveLength(5);
});
});
@@ -188,4 +220,59 @@ describe('Diffs tree list component', () => {
expect(getFileRow().props('viewedFiles')).toBe(viewedDiffFileIds);
});
});
+
+ describe('pinned file', () => {
+ const filePaths = [
+ ['nested-1.rb', 'folder/sub-folder/'],
+ ['nested-2.rb', 'folder/sub-folder/'],
+ ['nested-3.rb', 'folder/sub-folder/'],
+ ['1.rb', 'folder/'],
+ ['2.rb', 'folder/'],
+ ['3.rb', 'folder/'],
+ ['single.rb', 'folder-single/'],
+ ['root-first.rb'],
+ ['root-last.rb'],
+ ];
+
+ const pinFile = (fileHash) => {
+ store.commit(`diffs/${SET_PINNED_FILE_HASH}`, fileHash);
+ };
+
+ const setupFiles = (diffFiles) => {
+ const { treeEntries, tree } = generateTreeList(diffFiles);
+ store.commit(`diffs/${SET_DIFF_FILES}`, diffFiles);
+ store.commit(`diffs/${SET_TREE_DATA}`, {
+ treeEntries,
+ tree: sortTree(tree),
+ });
+ };
+
+ const createFile = (name, path = '') => ({
+ file_hash: name,
+ path: `${path}${name}`,
+ new_path: `${path}${name}`,
+ file_path: `${path}${name}`,
+ });
+
+ beforeEach(() => {
+ createComponent();
+ setupFiles(filePaths.map(([name, path]) => createFile(name, path)));
+ });
+
+ describe('files in folders', () => {
+ it.each(filePaths.map((path) => path[0]))('pins %s file', async (pinnedFile) => {
+ pinFile(pinnedFile);
+ await nextTick();
+ const items = getScroller().props('items');
+ expect(
+ items.map(
+ (item) =>
+ `${'─'.repeat(item.level * 2)}${item.type === 'tree' ? '📁' : ''}${
+ item.name || item.path
+ }`,
+ ),
+ ).toMatchSnapshot();
+ });
+ });
+ });
});
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index be3b30e8e7a..ceaaa32a0e8 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -11,7 +11,7 @@ import {
PARALLEL_DIFF_VIEW_TYPE,
EVT_MR_PREPARED,
} from '~/diffs/constants';
-import { LOAD_SINGLE_DIFF_FAILED, BUILDING_YOUR_MR, SOMETHING_WENT_WRONG } from '~/diffs/i18n';
+import { BUILDING_YOUR_MR, SOMETHING_WENT_WRONG } from '~/diffs/i18n';
import * as diffActions from '~/diffs/store/actions';
import * as types from '~/diffs/store/mutation_types';
import * as utils from '~/diffs/store/utils';
@@ -28,6 +28,8 @@ import {
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '~/notes/event_hub';
import diffsEventHub from '~/diffs/event_hub';
+import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { diffMetadata } from '../mock_data/diff_metadata';
jest.mock('~/alert');
@@ -37,6 +39,8 @@ jest.mock('~/lib/utils/secret_detection', () => ({
containsSensitiveToken: jest.requireActual('~/lib/utils/secret_detection').containsSensitiveToken,
}));
+const endpointDiffForPath = '/diffs/set/endpoint/path';
+
describe('DiffsStoreActions', () => {
let mock;
@@ -78,7 +82,6 @@ describe('DiffsStoreActions', () => {
const endpoint = '/diffs/set/endpoint';
const endpointMetadata = '/diffs/set/endpoint/metadata';
const endpointBatch = '/diffs/set/endpoint/batch';
- const endpointDiffForPath = '/diffs/set/endpoint/path';
const endpointCoverage = '/diffs/set/coverage_reports';
const projectPath = '/root/project';
const dismissEndpoint = '/-/user_callouts';
@@ -180,8 +183,8 @@ describe('DiffsStoreActions', () => {
new_path: 'new/123',
w: '1',
view: 'inline',
+ diff_head: true,
};
- const endpointDiffForPath = '/diffs/set/endpoint/path';
const diffForPath = mergeUrlParams(defaultParams, endpointDiffForPath);
const treeEntry = {
fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818',
@@ -256,7 +259,9 @@ describe('DiffsStoreActions', () => {
// wait for the mocked network request to return and start processing the .then
await waitForPromises();
- expect(mock.history.get[0].url).toEqual(finalPath);
+ expect(mock.history.get[0].url).toContain(
+ 'old_path=old%2F123&new_path=new%2F123&w=1&view=inline&commit_id=123',
+ );
});
describe('version parameters', () => {
@@ -285,6 +290,7 @@ describe('DiffsStoreActions', () => {
endpointDiffForPath,
);
state.mergeRequestDiff = { version_path: versionPath };
+ state.endpointBatch = versionPath;
mock.onGet(finalPath).reply(HTTP_STATUS_OK, fileResult);
diffActions.prefetchSingleFile({ state, getters, commit }, treeEntry);
@@ -349,8 +355,8 @@ describe('DiffsStoreActions', () => {
new_path: 'new/123',
w: '1',
view: 'inline',
+ diff_head: true,
};
- const endpointDiffForPath = '/diffs/set/endpoint/path';
const diffForPath = mergeUrlParams(defaultParams, endpointDiffForPath);
const treeEntry = {
fileHash: 'e334a2a10f036c00151a04cea7938a5d4213a818',
@@ -445,7 +451,9 @@ describe('DiffsStoreActions', () => {
// wait for the mocked network request to return and start processing the .then
await waitForPromises();
- expect(mock.history.get[0].url).toEqual(finalPath);
+ expect(mock.history.get[0].url).toContain(
+ 'old_path=old%2F123&new_path=new%2F123&w=1&view=inline&commit_id=123',
+ );
});
describe('version parameters', () => {
@@ -473,7 +481,7 @@ describe('DiffsStoreActions', () => {
{ ...defaultParams, diff_id, start_sha },
endpointDiffForPath,
);
- state.mergeRequestDiff = { version_path: versionPath };
+ state.endpointBatch = versionPath;
mock.onGet(finalPath).reply(HTTP_STATUS_OK, fileResult);
diffActions.fetchFileByFile({ state, getters, commit });
@@ -490,8 +498,8 @@ describe('DiffsStoreActions', () => {
describe('fetchDiffFilesBatch', () => {
it('should fetch batch diff files', () => {
const endpointBatch = '/fetch/diffs_batch';
- const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 7 } };
- const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 7 } };
+ const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 2 } };
+ const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 2 } };
mock
.onGet(
mergeUrlParams(
@@ -520,7 +528,7 @@ describe('DiffsStoreActions', () => {
return testAction(
diffActions.fetchDiffFilesBatch,
- {},
+ undefined,
{ endpointBatch, diffViewType: 'inline', diffFiles: [], perPage: 5 },
[
{ type: types.SET_BATCH_LOADING_STATE, payload: 'loading' },
@@ -532,7 +540,6 @@ describe('DiffsStoreActions', () => {
{ type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' },
{ type: types.SET_CURRENT_DIFF_FILE, payload: 'test2' },
{ type: types.SET_RETRIEVING_BATCHES, payload: false },
- { type: types.SET_BATCH_LOADING_STATE, payload: 'error' },
],
[],
);
@@ -690,7 +697,7 @@ describe('DiffsStoreActions', () => {
describe('setHighlightedRow', () => {
it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => {
- return testAction(diffActions.setHighlightedRow, 'ABC_123', {}, [
+ return testAction(diffActions.setHighlightedRow, { lineCode: 'ABC_123' }, {}, [
{ type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' },
{ type: types.SET_CURRENT_DIFF_FILE, payload: 'ABC' },
]);
@@ -1310,14 +1317,17 @@ describe('DiffsStoreActions', () => {
diffActions.goToFile({ state, dispatch, getters, commit }, file);
expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, fileHash);
- expect(dispatch).toHaveBeenCalledTimes(0);
+ expect(dispatch).not.toHaveBeenCalledWith('fetchFileByFile');
});
describe('when the tree entry has not been loaded', () => {
it('updates location hash', () => {
diffActions.goToFile({ state, commit, getters, dispatch }, file);
- expect(document.location.hash).toBe('#test');
+ expect(historyPushState).toHaveBeenCalledWith(new URL(`${TEST_HOST}#test`), {
+ skipScrolling: true,
+ });
+ expect(scrollToElement).toHaveBeenCalledWith('.diff-files-holder', { duration: 0 });
});
it('loads the file and then scrolls to it', async () => {
@@ -1333,21 +1343,12 @@ describe('DiffsStoreActions', () => {
expect(commonUtils.scrollToElement).toHaveBeenCalledWith('.diff-files-holder', {
duration: 0,
});
- expect(dispatch).toHaveBeenCalledTimes(1);
+ expect(dispatch).toHaveBeenCalledWith('fetchFileByFile');
});
- it('shows an alert when there was an error fetching the file', async () => {
- dispatch = jest.fn().mockRejectedValue();
-
+ it('unpins the file', () => {
diffActions.goToFile({ state, commit, getters, dispatch }, file);
-
- // Wait for the fetchFileByFile dispatch to return, to trigger the catch
- await waitForPromises();
-
- expect(createAlert).toHaveBeenCalledTimes(1);
- expect(createAlert).toHaveBeenCalledWith({
- message: expect.stringMatching(LOAD_SINGLE_DIFF_FAILED),
- });
+ expect(dispatch).toHaveBeenCalledWith('unpinFile');
});
});
});
@@ -1969,7 +1970,7 @@ describe('DiffsStoreActions', () => {
0,
{ flatBlobsList: [{ fileHash: '123' }] },
[{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }],
- [],
+ [{ type: 'unpinFile' }],
);
});
@@ -1979,7 +1980,7 @@ describe('DiffsStoreActions', () => {
0,
{ viewDiffsFileByFile: true, flatBlobsList: [{ fileHash: '123' }] },
[{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }],
- [{ type: 'fetchFileByFile' }],
+ [{ type: 'unpinFile' }, { type: 'fetchFileByFile' }],
);
});
});
@@ -2120,4 +2121,84 @@ describe('DiffsStoreActions', () => {
);
});
});
+
+ describe('fetchPinnedFile', () => {
+ it('fetches pinned file', async () => {
+ const pinnedFileHref = `${TEST_HOST}/pinned-file`;
+ const pinnedFile = getDiffFileMock();
+ const diffFiles = [pinnedFile];
+ const hubSpy = jest.spyOn(diffsEventHub, '$emit');
+ mock.onGet(new RegExp(pinnedFileHref)).reply(HTTP_STATUS_OK, { diff_files: diffFiles });
+
+ await testAction(
+ diffActions.fetchPinnedFile,
+ pinnedFileHref,
+ {},
+ [
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' },
+ { type: types.SET_RETRIEVING_BATCHES, payload: true },
+ {
+ type: types.SET_DIFF_DATA_BATCH,
+ payload: { diff_files: diffFiles, updatePosition: false },
+ },
+ { type: types.SET_PINNED_FILE_HASH, payload: pinnedFile.file_hash },
+ { type: types.SET_CURRENT_DIFF_FILE, payload: pinnedFile.file_hash },
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' },
+ { type: types.SET_RETRIEVING_BATCHES, payload: false },
+ ],
+ [],
+ );
+
+ jest.runAllTimers();
+ expect(hubSpy).toHaveBeenCalledWith('diffFilesModified');
+ expect(handleLocationHash).toHaveBeenCalled();
+ });
+
+ it('handles load error', async () => {
+ const pinnedFileHref = `${TEST_HOST}/pinned-file`;
+ const hubSpy = jest.spyOn(diffsEventHub, '$emit');
+ mock.onGet(new RegExp(pinnedFileHref)).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ try {
+ await testAction(
+ diffActions.fetchPinnedFile,
+ pinnedFileHref,
+ {},
+ [
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' },
+ { type: types.SET_RETRIEVING_BATCHES, payload: true },
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'error' },
+ { type: types.SET_RETRIEVING_BATCHES, payload: false },
+ ],
+ [],
+ );
+ } catch (error) {
+ expect(error.response.status).toBe(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ jest.runAllTimers();
+ expect(hubSpy).not.toHaveBeenCalledWith('diffFilesModified');
+ expect(handleLocationHash).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('unpinFile', () => {
+ it('unpins pinned file', () => {
+ const pinnedFile = getDiffFileMock();
+ setWindowLocation(`${TEST_HOST}/?pin=${pinnedFile.file_hash}#${pinnedFile.file_hash}_10_10`);
+ testAction(
+ diffActions.unpinFile,
+ undefined,
+ { pinnedFile },
+ [{ type: types.SET_PINNED_FILE_HASH, payload: null }],
+ [],
+ );
+ expect(window.location.hash).toBe('');
+ expect(window.location.search).toBe('');
+ });
+
+ it('does nothing when no pinned file present', () => {
+ testAction(diffActions.unpinFile, undefined, {}, [], []);
+ });
+ });
});
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index 8097f0976f6..cb0f40534fe 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -1,6 +1,7 @@
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants';
import * as getters from '~/diffs/store/getters';
import state from '~/diffs/store/modules/diff_state';
+import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file';
import discussion from '../mock_data/diff_discussions';
describe('Diffs Module Getters', () => {
@@ -495,4 +496,35 @@ describe('Diffs Module Getters', () => {
},
);
});
+
+ describe('diffFiles', () => {
+ it('proxies diffFiles state', () => {
+ const diffFiles = [getDiffFileMock()];
+ expect(getters.diffFiles({ diffFiles }, {})).toBe(diffFiles);
+ });
+
+ it('pins the file', () => {
+ const pinnedFile = getDiffFileMock();
+ const regularFile = getDiffFileMock();
+ const diffFiles = [regularFile, pinnedFile];
+ expect(getters.diffFiles({ diffFiles }, { pinnedFile })).toStrictEqual([
+ pinnedFile,
+ regularFile,
+ ]);
+ });
+ });
+
+ describe('pinnedFile', () => {
+ it('returns pinnedFile', () => {
+ const pinnedFile = getDiffFileMock();
+ const diffFiles = [pinnedFile];
+ expect(getters.pinnedFile({ diffFiles, pinnedFileHash: pinnedFile.file_hash }, {})).toBe(
+ pinnedFile,
+ );
+ });
+
+ it('returns null if no pinned file is set', () => {
+ expect(getters.pinnedFile({}, {})).toBe(null);
+ });
+ });
});
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index a5be41aa69f..8d52cd39542 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -92,7 +92,7 @@ describe('DiffsStoreMutations', () => {
});
});
- describe('SET_DIFF_DATA_BATCH_DATA', () => {
+ describe('SET_DIFF_DATA_BATCH', () => {
it('should set diff data batch type properly', () => {
const mockFile = getDiffFileMock();
const state = {
@@ -108,6 +108,39 @@ describe('DiffsStoreMutations', () => {
expect(state.diffFiles[0].collapsed).toEqual(false);
expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true);
});
+
+ it('should update diff position by default', () => {
+ const mockFile = getDiffFileMock();
+ const state = {
+ diffFiles: [mockFile, { ...mockFile, file_hash: 'foo', file_path: 'foo' }],
+ treeEntries: { [mockFile.file_path]: { fileHash: mockFile.file_hash } },
+ };
+ const diffMock = {
+ diff_files: [mockFile],
+ };
+
+ mutations[types.SET_DIFF_DATA_BATCH](state, diffMock);
+
+ expect(state.diffFiles[1].file_hash).toBe(mockFile.file_hash);
+ expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true);
+ });
+
+ it('should not update diff position', () => {
+ const mockFile = getDiffFileMock();
+ const state = {
+ diffFiles: [mockFile, { ...mockFile, file_hash: 'foo', file_path: 'foo' }],
+ treeEntries: { [mockFile.file_path]: { fileHash: mockFile.file_hash } },
+ };
+ const diffMock = {
+ diff_files: [mockFile],
+ updatePosition: false,
+ };
+
+ mutations[types.SET_DIFF_DATA_BATCH](state, diffMock);
+
+ expect(state.diffFiles[0].file_hash).toBe(mockFile.file_hash);
+ expect(state.treeEntries[mockFile.file_path].diffLoaded).toBe(true);
+ });
});
describe('SET_COVERAGE_DATA', () => {
@@ -122,6 +155,17 @@ describe('DiffsStoreMutations', () => {
});
});
+ describe('SET_DIFF_TREE_ENTRY', () => {
+ it('should set tree entry', () => {
+ const file = getDiffFileMock();
+ const state = { treeEntries: { [file.file_path]: {} } };
+
+ mutations[types.SET_DIFF_TREE_ENTRY](state, file);
+
+ expect(state.treeEntries[file.file_path].diffLoaded).toBe(true);
+ });
+ });
+
describe('SET_DIFF_VIEW_TYPE', () => {
it('should set diff view type properly', () => {
const state = {};
@@ -1076,4 +1120,15 @@ describe('DiffsStoreMutations', () => {
expect(state.diffFiles[0].viewer.forceOpen).toBe(true);
});
});
+
+ describe('SET_PINNED_FILE_HASH', () => {
+ it('set pinned file hash', () => {
+ const state = {};
+ const file = getDiffFileMock();
+
+ mutations[types.SET_PINNED_FILE_HASH](state, file.file_hash);
+
+ expect(state.pinnedFileHash).toBe(file.file_hash);
+ });
+ });
});
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 6331269d6e8..019ed663d82 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -476,6 +476,17 @@ describe('DiffsStoreUtils', () => {
expect(updatedFilesList).toEqual([mock, fakeNewFile]);
});
+ it('updates diff position', () => {
+ const priorFiles = [mock, { ...mock, file_hash: 'foo', file_path: 'foo' }];
+ const updatedFilesList = utils.prepareDiffData({
+ diff: { diff_files: [mock] },
+ priorFiles,
+ updatePosition: true,
+ });
+
+ expect(updatedFilesList[1].file_hash).toEqual(mock.file_hash);
+ });
+
it('completes an existing split diff without overwriting existing diffs', () => {
// The current state has a file that has only loaded inline lines
const priorFiles = [{ ...mock }];
diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js
index 7986509074e..7a37f53c7a6 100644
--- a/spec/frontend/editor/schema/ci/ci_schema_spec.js
+++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js
@@ -38,8 +38,10 @@ 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';
+import WorkflowAutoCancelOnJobFailureYaml from './yaml_tests/positive_tests/workflow/auto_cancel/on_job_failure.yml';
+import WorkflowAutoCancelOnNewCommitYaml from './yaml_tests/positive_tests/workflow/auto_cancel/on_new_commit.yml';
+import WorkflowRulesAutoCancelOnJobFailureYaml from './yaml_tests/positive_tests/workflow/rules/auto_cancel/on_job_failure.yml';
+import WorkflowRulesAutoCancelOnNewCommitYaml from './yaml_tests/positive_tests/workflow/rules/auto_cancel/on_new_commit.yml';
// YAML NEGATIVE TEST
import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml';
@@ -66,7 +68,10 @@ 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';
+import WorkflowAutoCancelOnJobFailureNegativeYaml from './yaml_tests/negative_tests/workflow/auto_cancel/on_job_failure.yml';
+import WorkflowAutoCancelOnNewCommitNegativeYaml from './yaml_tests/negative_tests/workflow/auto_cancel/on_new_commit.yml';
+import WorkflowRulesAutoCancelOnJobFailureNegativeYaml from './yaml_tests/negative_tests/workflow/rules/auto_cancel/on_job_failure.yml';
+import WorkflowRulesAutoCancelOnNewCommitNegativeYaml from './yaml_tests/negative_tests/workflow/rules/auto_cancel/on_new_commit.yml';
const ajv = new Ajv({
strictTypes: false,
@@ -110,8 +115,10 @@ describe('positive tests', () => {
SecretsYaml,
NeedsParallelMatrixYaml,
ScriptYaml,
- AutoCancelPipelineOnJobFailureAllYaml,
- AutoCancelPipelineOnJobFailureNoneYaml,
+ WorkflowAutoCancelOnJobFailureYaml,
+ WorkflowAutoCancelOnNewCommitYaml,
+ WorkflowRulesAutoCancelOnJobFailureYaml,
+ WorkflowRulesAutoCancelOnNewCommitYaml,
}),
)('schema validates %s', (_, input) => {
// We construct a new "JSON" from each main key that is inside a
@@ -157,7 +164,10 @@ describe('negative tests', () => {
NeedsParallelMatrixWrongParallelValueYaml,
NeedsParallelMatrixWrongMatrixValueYaml,
ScriptNegativeYaml,
- AutoCancelPipelineNegativeYaml,
+ WorkflowAutoCancelOnJobFailureNegativeYaml,
+ WorkflowAutoCancelOnNewCommitNegativeYaml,
+ WorkflowRulesAutoCancelOnJobFailureNegativeYaml,
+ WorkflowRulesAutoCancelOnNewCommitNegativeYaml,
}),
)('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/image.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml
index ad37cd6c3ba..d6bc3cccf41 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml
@@ -26,6 +26,17 @@ invalid_image_platform:
docker:
platform: ["arm64"] # The expected value is a string, not an array
+invalid_image_user:
+ image:
+ name: alpine:latest
+ docker:
+ user: ["dave"] # The expected value is a string, not an array
+
+empty_image_user:
+ image:
+ name: alpine:latest
+ docker:
+ user: ""
invalid_image_executor_opts:
image:
name: alpine:latest
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml
index 4baf4c6b850..23d667eeeff 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml
@@ -71,3 +71,21 @@ job_with_secrets_with_missing_required_name_property:
azure_key_vault:
name:
version: latest
+
+job_with_gcp_secret_manager_secret_without_name:
+ script:
+ - echo $TEST_DB_PASSWORD
+ secrets:
+ TEST_DB_PASSWORD:
+ gcp_secret_manager:
+ version: latest
+ token: $TEST_TOKEN
+
+job_with_gcp_secret_manager_secret_without_token:
+ script:
+ - echo $TEST_DB_PASSWORD
+ secrets:
+ TEST_DB_PASSWORD:
+ gcp_secret_manager:
+ name: my-secret
+
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 e14ac9ca86e..fd05d2606e5 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
@@ -50,3 +50,17 @@ invalid_service_platform:
- name: mysql:5.7
docker:
platform: ["arm64"] # The expected value is a string, not an array
+
+invalid_service_user:
+ script: echo "Specifying user."
+ services:
+ - name: mysql:5.7
+ docker:
+ user: ["dave"] # The expected value is a string, not an array
+
+empty_service_user:
+ script: echo "Specifying user"
+ services:
+ - name: alpine:latest
+ docker:
+ user: ""
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/workflow/auto_cancel/on_job_failure.yml
index 0ba3e5632e3..2bf9effe1be 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_job_failure.yml
@@ -1,4 +1,3 @@
-# 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/workflow/auto_cancel/on_new_commit.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_new_commit.yml
new file mode 100644
index 00000000000..371662efd24
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_new_commit.yml
@@ -0,0 +1,3 @@
+workflow:
+ auto_cancel:
+ on_new_commit: unexpected_value
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_job_failure.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_job_failure.yml
new file mode 100644
index 00000000000..11029a85189
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_job_failure.yml
@@ -0,0 +1,7 @@
+workflow:
+ auto_cancel:
+ on_job_failure: all
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ auto_cancel:
+ on_job_failure: unexpected_value
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_new_commit.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_new_commit.yml
new file mode 100644
index 00000000000..4c7e9be9018
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_new_commit.yml
@@ -0,0 +1,7 @@
+workflow:
+ auto_cancel:
+ on_new_commit: interruptible
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ auto_cancel:
+ on_new_commit: unexpected_value
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
deleted file mode 100644
index b99eb50e962..00000000000
--- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-# 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
index 4c2559d0800..020cce80fd3 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/image.yml
@@ -30,6 +30,19 @@ valid_image_with_docker:
docker:
platform: linux/amd64
+valid_image_with_docker_user:
+ image:
+ name: ubuntu:latest
+ docker:
+ user: ubuntu
+
+valid_image_with_docker_multiple_options:
+ image:
+ name: ubuntu:latest
+ docker:
+ platform: linux/arm64
+ user: ubuntu
+
valid_image_full:
image:
name: alpine:latest
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml
index af3107974b9..e615fa52dc5 100644
--- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml
@@ -43,3 +43,32 @@ valid_job_with_azure_key_vault_secrets_name_and_version:
azure_key_vault:
name: 'test'
version: 'version'
+
+valid_job_with_gcp_secret_manager_name:
+ script:
+ - echo $TEST_DB_PASSWORD
+ secrets:
+ TEST_DB_PASSWORD:
+ gcp_secret_manager:
+ name: 'test'
+ token: $TEST_TOKEN
+
+valid_job_with_gcp_secret_manager_name_and_numbered_version:
+ script:
+ - echo $TEST_DB_PASSWORD
+ secrets:
+ TEST_DB_PASSWORD:
+ gcp_secret_manager:
+ name: 'test'
+ version: 2
+ token: $TEST_TOKEN
+
+valid_job_with_gcp_secret_manager_name_and_string_version:
+ script:
+ - echo $TEST_DB_PASSWORD
+ secrets:
+ TEST_DB_PASSWORD:
+ gcp_secret_manager:
+ name: 'test'
+ version: 'latest'
+ token: $TEST_TOKEN
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 1d19ee52cc3..0f45b075f53 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
@@ -36,3 +36,18 @@ services_platform_string:
- name: mysql:5.7
docker:
platform: arm64
+
+services_with_docker_user:
+ script: echo "Specifying platform."
+ services:
+ - name: mysql:5.7
+ docker:
+ user: ubuntu
+
+services_with_docker_multiple_options:
+ script: echo "Specifying platform."
+ services:
+ - name: mysql:5.7
+ docker:
+ platform: linux/arm64
+ user: ubuntu
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/workflow/auto_cancel/on_job_failure.yml
index bf84ff16f42..79d18f40721 100644
--- 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/workflow/auto_cancel/on_job_failure.yml
@@ -1,4 +1,3 @@
-# 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/workflow/auto_cancel/on_new_commit.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_new_commit.yml
new file mode 100644
index 00000000000..a1641878e4d
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_new_commit.yml
@@ -0,0 +1,3 @@
+workflow:
+ auto_cancel:
+ on_new_commit: conservative
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_job_failure.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_job_failure.yml
new file mode 100644
index 00000000000..9050566fbd3
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_job_failure.yml
@@ -0,0 +1,7 @@
+workflow:
+ auto_cancel:
+ on_job_failure: all
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ auto_cancel:
+ on_job_failure: none
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_new_commit.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_new_commit.yml
new file mode 100644
index 00000000000..c5ec387fe50
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_new_commit.yml
@@ -0,0 +1,7 @@
+workflow:
+ auto_cancel:
+ on_new_commit: interruptible
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ auto_cancel:
+ on_new_commit: none
diff --git a/spec/frontend/emoji/components/emoji_group_spec.js b/spec/frontend/emoji/components/emoji_group_spec.js
index a2a46bedd7b..a2e3643356f 100644
--- a/spec/frontend/emoji/components/emoji_group_spec.js
+++ b/spec/frontend/emoji/components/emoji_group_spec.js
@@ -13,6 +13,7 @@ function factory(propsData = {}) {
propsData,
stubs: {
GlButton,
+ GlEmoji: { template: '<div/>' },
},
}),
);
diff --git a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
index 97100557ef3..852b5318c77 100644
--- a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
+++ b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js
@@ -1,5 +1,4 @@
import {
- generateServicePortsString,
getDeploymentsStatuses,
getDaemonSetStatuses,
getStatefulSetStatuses,
@@ -12,35 +11,6 @@ import {
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: {
diff --git a/spec/frontend/environments/kubernetes_status_bar_spec.js b/spec/frontend/environments/kubernetes_status_bar_spec.js
index dcd628354e1..e4bf8f3ea07 100644
--- a/spec/frontend/environments/kubernetes_status_bar_spec.js
+++ b/spec/frontend/environments/kubernetes_status_bar_spec.js
@@ -10,7 +10,6 @@ import {
} from '~/environments/constants';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { s__ } from '~/locale';
import { mockKasTunnelUrl } from './mock_data';
Vue.use(VueApollo);
@@ -23,6 +22,8 @@ const configuration = {
},
};
const environmentName = 'environment_name';
+const kustomizationResourcePath =
+ 'kustomize.toolkit.fluxcd.io/v1beta1/namespaces/my-namespace/kustomizations/app';
describe('~/environments/components/kubernetes_status_bar.vue', () => {
let wrapper;
@@ -97,7 +98,7 @@ describe('~/environments/components/kubernetes_status_bar.vue', () => {
});
it('renders sync status as Unavailable', () => {
- expect(findSyncBadge().text()).toBe(s__('Deployment|Unavailable'));
+ expect(findSyncBadge().text()).toBe('Unavailable');
});
});
@@ -106,8 +107,7 @@ describe('~/environments/components/kubernetes_status_bar.vue', () => {
describe('if the provided resource is a Kustomization', () => {
beforeEach(() => {
- fluxResourcePath =
- 'kustomize.toolkit.fluxcd.io/v1beta1/namespaces/my-namespace/kustomizations/app';
+ fluxResourcePath = kustomizationResourcePath;
createWrapper({ fluxResourcePath });
});
@@ -178,6 +178,47 @@ describe('~/environments/components/kubernetes_status_bar.vue', () => {
});
});
+ describe('when receives data from the Flux', () => {
+ const createApolloProviderWithKustomizations = (result) => {
+ const mockResolvers = {
+ Query: {
+ fluxKustomizationStatus: jest.fn().mockReturnValue([result]),
+ fluxHelmReleaseStatus: fluxHelmReleaseStatusQuery,
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+ const message = 'Message from Flux';
+
+ it.each`
+ status | type | reason | statusText | statusPopover
+ ${'True'} | ${'Stalled'} | ${''} | ${'Stalled'} | ${message}
+ ${'True'} | ${'Reconciling'} | ${''} | ${'Reconciling'} | ${'Flux sync reconciling'}
+ ${'Unknown'} | ${'Ready'} | ${'Progressing'} | ${'Reconciling'} | ${message}
+ ${'True'} | ${'Ready'} | ${''} | ${'Reconciled'} | ${'Flux sync reconciled successfully'}
+ ${'False'} | ${'Ready'} | ${''} | ${'Failed'} | ${message}
+ ${'Unknown'} | ${'Ready'} | ${''} | ${'Unknown'} | ${'Unable to detect state. How are states detected?'}
+ `(
+ 'renders sync status as $statusText when status is $status, type is $type, and reason is $reason',
+ async ({ status, type, reason, statusText, statusPopover }) => {
+ createWrapper({
+ fluxResourcePath: kustomizationResourcePath,
+ apolloProvider: createApolloProviderWithKustomizations({
+ status,
+ type,
+ reason,
+ message,
+ }),
+ });
+ await waitForPromises();
+
+ expect(findSyncBadge().text()).toBe(statusText);
+ expect(findPopover().text()).toBe(statusPopover);
+ },
+ );
+ });
+
describe('when Flux API errored', () => {
const error = new Error('Error from the cluster_client API');
const createApolloProviderWithErrors = () => {
@@ -212,9 +253,7 @@ describe('~/environments/components/kubernetes_status_bar.vue', () => {
it('renders popover with an API error message', () => {
expect(findPopover().text()).toBe(error.message);
- expect(findPopover().props('title')).toBe(
- s__('Deployment|Flux sync status is unavailable'),
- );
+ expect(findPopover().props('title')).toBe('Flux sync status is unavailable');
});
});
});
diff --git a/spec/frontend/error_tracking/components/error_details_info_spec.js b/spec/frontend/error_tracking/components/error_details_info_spec.js
index a3f4b0e0dd8..f563fee0ec0 100644
--- a/spec/frontend/error_tracking/components/error_details_info_spec.js
+++ b/spec/frontend/error_tracking/components/error_details_info_spec.js
@@ -47,6 +47,13 @@ describe('ErrorDetails', () => {
expect(wrapper.findByTestId('user-count-card').text()).toMatchInterpolatedText('Users 2');
});
+ it('should not render a card with user counts if integrated error tracking', () => {
+ mountComponent({
+ integrated: true,
+ });
+ expect(wrapper.findByTestId('user-count-card').exists()).toBe(false);
+ });
+
describe('first seen card', () => {
it('if firstSeen is missing, does not render a card', () => {
mountComponent({
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 823f7132fdd..91518002f0e 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -146,6 +146,28 @@ describe('ErrorTrackingList', () => {
expect(findErrorListRows().length).toEqual(store.state.list.errors.length);
});
+ describe('user count', () => {
+ it('shows user count', () => {
+ mountComponent({
+ integratedErrorTrackingEnabled: false,
+ stubs: {
+ GlTable: false,
+ },
+ });
+ expect(findErrorListTable().find('thead').text()).toContain('Users');
+ });
+
+ it('does not show user count', () => {
+ mountComponent({
+ integratedErrorTrackingEnabled: true,
+ stubs: {
+ GlTable: false,
+ },
+ });
+ expect(findErrorListTable().find('thead').text()).not.toContain('Users');
+ });
+ });
+
describe.each([
['/test-project/-/error_tracking'],
['/test-project/-/error_tracking/'], // handles leading '/' https://gitlab.com/gitlab-org/gitlab/-/issues/430211
diff --git a/spec/frontend/fixtures/static/oauth_remember_me.html b/spec/frontend/fixtures/static/oauth_remember_me.html
index 60277ecf66e..d7519dd695f 100644
--- a/spec/frontend/fixtures/static/oauth_remember_me.html
+++ b/spec/frontend/fixtures/static/oauth_remember_me.html
@@ -1,20 +1,20 @@
-<div id="oauth-container">
+<div class="js-oauth-login">
<input id="remember_me_omniauth" type="checkbox" />
<form method="post" action="http://example.com/">
- <button class="js-oauth-login twitter" type="submit">
+ <button class="twitter" type="submit">
<span>Twitter</span>
</button>
</form>
<form method="post" action="http://example.com/">
- <button class="js-oauth-login github" type="submit">
+ <button class="github" type="submit">
<span>GitHub</span>
</button>
</form>
<form method="post" action="http://example.com/?redirect_fragment=L1">
- <button class="js-oauth-login facebook" type="submit">
+ <button class="facebook" type="submit">
<span>Facebook</span>
</button>
</form>
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
index 8ac410c87b1..027c1709e0b 100644
--- a/spec/frontend/groups/components/app_spec.js
+++ b/spec/frontend/groups/components/app_spec.js
@@ -58,6 +58,9 @@ describe('AppComponent', () => {
mocks: {
$toast,
},
+ provide: {
+ emptySearchIllustration: '/assets/illustrations/empty-state/empty-search-md.svg',
+ },
});
vm = wrapper.vm;
};
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
index 94460de9dd6..26c97a7cb41 100644
--- a/spec/frontend/groups/components/group_item_spec.js
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -11,6 +11,8 @@ import {
VISIBILITY_LEVEL_PRIVATE_STRING,
VISIBILITY_LEVEL_INTERNAL_STRING,
VISIBILITY_LEVEL_PUBLIC_STRING,
+ GROUP_VISIBILITY_TYPE,
+ PROJECT_VISIBILITY_TYPE,
} from '~/visibility_level/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -24,7 +26,7 @@ const createComponent = (
) => {
return mountExtended(GroupItem, {
propsData,
- components: { GroupFolder },
+ components: { GroupFolder, GroupItem },
provide,
});
};
@@ -115,6 +117,51 @@ describe('GroupItemComponent', () => {
wrapper.destroy();
});
});
+
+ describe('visibilityTooltip', () => {
+ describe('if item represents group', () => {
+ it.each`
+ visibilityLevel | visibilityTooltip
+ ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_PUBLIC_STRING]}
+ ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_INTERNAL_STRING]}
+ ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]}
+ `(
+ 'should return corresponding text when visibility level is $visibilityLevel',
+ ({ visibilityLevel, visibilityTooltip }) => {
+ const group = { ...mockParentGroupItem };
+
+ group.type = 'group';
+ group.visibility = visibilityLevel;
+ wrapper = createComponent({ group });
+
+ expect(wrapper.vm.visibilityTooltip).toBe(visibilityTooltip);
+ wrapper.destroy();
+ },
+ );
+ });
+
+ describe('if item represents project', () => {
+ it.each`
+ visibilityLevel | visibilityTooltip
+ ${VISIBILITY_LEVEL_PUBLIC_STRING} | ${PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PUBLIC_STRING]}
+ ${VISIBILITY_LEVEL_INTERNAL_STRING} | ${PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_INTERNAL_STRING]}
+ ${VISIBILITY_LEVEL_PRIVATE_STRING} | ${PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]}
+ `(
+ 'should return corresponding text when visibility level is $visibilityLevel',
+ ({ visibilityLevel, visibilityTooltip }) => {
+ const group = { ...mockParentGroupItem };
+
+ group.type = 'project';
+ group.lastActivityAt = '2017-04-09T18:40:39.101Z';
+ group.visibility = visibilityLevel;
+ wrapper = createComponent({ group });
+
+ expect(wrapper.vm.visibilityTooltip).toBe(visibilityTooltip);
+ wrapper.destroy();
+ },
+ );
+ });
+ });
});
describe('methods', () => {
@@ -261,10 +308,9 @@ describe('GroupItemComponent', () => {
});
it.each`
- attr | value
- ${'itemscope'} | ${'itemscope'}
- ${'itemtype'} | ${'https://schema.org/Organization'}
- ${'itemprop'} | ${'subOrganization'}
+ attr | value
+ ${'itemtype'} | ${'https://schema.org/Organization'}
+ ${'itemprop'} | ${'subOrganization'}
`('does set correct $attr', ({ attr, value } = {}) => {
expect(wrapper.attributes(attr)).toBe(value);
});
@@ -281,7 +327,7 @@ describe('GroupItemComponent', () => {
});
describe('visibility warning popover', () => {
- const findPopover = () => extendedWrapper(wrapper.findComponent(GlPopover));
+ const findPopover = () => wrapper.findComponent(GlPopover);
const itDoesNotRenderVisibilityWarningPopover = () => {
it('does not render visibility warning popover', () => {
@@ -343,9 +389,10 @@ describe('GroupItemComponent', () => {
if (isPopoverShown) {
it('renders visibility warning popover with `Learn more` link', () => {
- const popover = findPopover();
+ const popover = extendedWrapper(findPopover());
expect(popover.exists()).toBe(true);
+
expect(
popover.findByRole('link', { name: GroupItem.i18n.learnMore }).attributes('href'),
).toBe(
diff --git a/spec/frontend/groups/components/group_name_and_path_spec.js b/spec/frontend/groups/components/group_name_and_path_spec.js
index 0a18e657c94..59c42e54af6 100644
--- a/spec/frontend/groups/components/group_name_and_path_spec.js
+++ b/spec/frontend/groups/components/group_name_and_path_spec.js
@@ -1,7 +1,7 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { merge } from 'lodash';
-import { GlAlert, GlDropdown, GlTruncate, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlTruncate, GlDropdownItem } from '@gitlab/ui';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -74,7 +74,8 @@ describe('GroupNameAndPath', () => {
const findSubgroupNameField = () => wrapper.findByLabelText('Subgroup name');
const findSubgroupSlugField = () => wrapper.findByLabelText('Subgroup slug');
const findSelectedGroup = () => wrapper.findComponent(GlTruncate);
- const findAlert = () => extendedWrapper(wrapper.findComponent(GlAlert));
+ const findChangeUrlAlert = () => extendedWrapper(wrapper.findByTestId('changing-url-alert'));
+ const findDotInPathAlert = () => extendedWrapper(wrapper.findByTestId('dot-in-path-alert'));
const apiMockAvailablePath = () => {
getGroupPathAvailability.mockResolvedValueOnce({
@@ -181,6 +182,12 @@ describe('GroupNameAndPath', () => {
expectLoadingMessageExists();
});
+ it('shows warning alert on using dot in path', () => {
+ createComponentEditGroup();
+
+ expect(findDotInPathAlert().exists()).toBe(true);
+ });
+
describe('when path is available', () => {
it('does not update `Group URL` field', async () => {
apiMockAvailablePath();
@@ -396,8 +403,10 @@ describe('GroupNameAndPath', () => {
it('shows warning alert with `Learn more` link', () => {
createComponentEditGroup();
- expect(findAlert().exists()).toBe(true);
- expect(findAlert().findByRole('link', { name: 'Learn more' }).attributes('href')).toBe(
+ expect(findChangeUrlAlert().exists()).toBe(true);
+ expect(
+ findChangeUrlAlert().findByRole('link', { name: 'Learn more' }).attributes('href'),
+ ).toBe(
helpPagePath('user/group/manage', {
anchor: 'change-a-groups-path',
}),
diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js
index 6bed744685f..8b80330c910 100644
--- a/spec/frontend/groups/components/overview_tabs_spec.js
+++ b/spec/frontend/groups/components/overview_tabs_spec.js
@@ -1,4 +1,4 @@
-import { GlSorting, GlSortingItem, GlTab } from '@gitlab/ui';
+import { GlSorting, GlTab } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
@@ -17,6 +17,7 @@ import {
ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
ACTIVE_TAB_SHARED,
ACTIVE_TAB_ARCHIVED,
+ OVERVIEW_TABS_SORTING_ITEMS,
SORTING_ITEM_NAME,
SORTING_ITEM_UPDATED,
SORTING_ITEM_STARS,
@@ -44,6 +45,7 @@ describe('OverviewTabs', () => {
newProjectIllustration: '',
emptyProjectsIllustration: '',
emptySubgroupIllustration: '',
+ emptySearchIllustration: '',
canCreateSubgroups: false,
canCreateProjects: false,
initialSort: 'name_asc',
@@ -73,6 +75,7 @@ describe('OverviewTabs', () => {
const findTab = (name) => wrapper.findByRole('tab', { name });
const findSelectedTab = () => wrapper.findByRole('tab', { selected: true });
const findSearchInput = () => wrapper.findByPlaceholderText(OverviewTabs.i18n.searchPlaceholder);
+ const findGlSorting = () => wrapper.findComponent(GlSorting);
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
@@ -300,7 +303,7 @@ describe('OverviewTabs', () => {
describe('when sort is changed', () => {
beforeEach(async () => {
await setup();
- wrapper.findAllComponents(GlSortingItem).at(2).vm.$emit('click');
+ findGlSorting().vm.$emit('sortByChange', SORTING_ITEM_UPDATED.label);
await nextTick();
});
@@ -402,12 +405,15 @@ describe('OverviewTabs', () => {
});
it('sets sort dropdown', () => {
- expect(wrapper.findComponent(GlSorting).props()).toMatchObject({
+ const expectedSortOptions = OVERVIEW_TABS_SORTING_ITEMS.map(({ label }) => {
+ return { value: label, text: label };
+ });
+ expect(findGlSorting().props()).toMatchObject({
text: SORTING_ITEM_UPDATED.label,
isAscending: false,
+ sortBy: SORTING_ITEM_UPDATED.label,
+ sortOptions: expectedSortOptions,
});
-
- expect(wrapper.findAllComponents(GlSortingItem).at(2).vm.$attrs.active).toBe(true);
});
});
});
diff --git a/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js b/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js
index 1bcff8a44be..777190149d1 100644
--- a/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js
+++ b/spec/frontend/groups_projects/components/more_actions_dropdown_spec.js
@@ -1,4 +1,8 @@
-import { GlDisclosureDropdownItem, GlDisclosureDropdown } from '@gitlab/ui';
+import {
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+} from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import moreActionsDropdown from '~/groups_projects/components/more_actions_dropdown.vue';
@@ -28,6 +32,7 @@ describe('moreActionsDropdown', () => {
const showDropdown = () => {
findDropdown().vm.$emit('show');
};
+ const findDropdownGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup);
describe('copy id', () => {
describe('project namespace type', () => {
@@ -72,6 +77,29 @@ describe('moreActionsDropdown', () => {
});
});
+ describe('dropdown group', () => {
+ it('is not rendered if no path is set', () => {
+ createComponent({
+ provideData: {
+ requestAccessPath: undefined,
+ leavePath: '',
+ withdrawPath: null,
+ },
+ });
+
+ expect(findDropdownGroup().exists()).toBe(false);
+ });
+
+ it('is rendered if path is set', () => {
+ createComponent({
+ provideData: {
+ requestAccessPath: 'path/to/request/access',
+ },
+ });
+ expect(findDropdownGroup().exists()).toBe(true);
+ });
+ });
+
describe('request access', () => {
it('does not render request access link', async () => {
createComponent();
diff --git a/spec/frontend/ide/lib/alerts/environment_spec.js b/spec/frontend/ide/lib/alerts/environment_spec.js
deleted file mode 100644
index d645209345c..00000000000
--- a/spec/frontend/ide/lib/alerts/environment_spec.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { GlLink } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import Environments from '~/ide/lib/alerts/environments.vue';
-
-describe('~/ide/lib/alerts/environment.vue', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = mount(Environments);
- });
-
- it('shows a message regarding environments', () => {
- expect(wrapper.text()).toBe(
- "No deployments detected. Use environments to control your software's continuous deployment. Learn more about deployment jobs.",
- );
- });
-
- it('links to the help page on environments', () => {
- expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/help/ci/environments/index.md');
- });
-});
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index cd099e60070..8e63b5801e8 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -2,12 +2,10 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
-import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import services from '~/ide/services';
-import { query, mutate } from '~/ide/services/gql';
+import { query } from '~/ide/services/gql';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { escapeFileUrl } from '~/lib/utils/url_utility';
-import ciConfig from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql';
import { projectData } from '../mock_data';
jest.mock('~/api');
@@ -276,35 +274,6 @@ describe('IDE services', () => {
});
});
});
- describe('getCiConfig', () => {
- const TEST_PROJECT_PATH = 'foo/bar';
- const TEST_CI_CONFIG = 'test config';
-
- it('queries with the given CI config and project', () => {
- const result = { data: { ciConfig: { test: 'data' } } };
- query.mockResolvedValue(result);
- return services.getCiConfig(TEST_PROJECT_PATH, TEST_CI_CONFIG).then((data) => {
- expect(data).toEqual(result.data.ciConfig);
- expect(query).toHaveBeenCalledWith({
- query: ciConfig,
- variables: { projectPath: TEST_PROJECT_PATH, content: TEST_CI_CONFIG },
- });
- });
- });
- });
- describe('dismissUserCallout', () => {
- it('mutates the callout to dismiss', () => {
- const result = { data: { callouts: { test: 'data' } } };
- mutate.mockResolvedValue(result);
- return services.dismissUserCallout('test').then((data) => {
- expect(data).toEqual(result.data);
- expect(mutate).toHaveBeenCalledWith({
- mutation: dismissUserCallout,
- variables: { input: { featureName: 'test' } },
- });
- });
- });
- });
describe('getProjectPermissionsData', () => {
const TEST_PROJECT_PATH = 'foo/bar';
diff --git a/spec/frontend/ide/stores/actions/alert_spec.js b/spec/frontend/ide/stores/actions/alert_spec.js
deleted file mode 100644
index 1321c402ebb..00000000000
--- a/spec/frontend/ide/stores/actions/alert_spec.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-import service from '~/ide/services';
-import {
- detectEnvironmentsGuidance,
- dismissEnvironmentsGuidance,
-} from '~/ide/stores/actions/alert';
-import * as types from '~/ide/stores/mutation_types';
-
-jest.mock('~/ide/services');
-
-describe('~/ide/stores/actions/alert', () => {
- describe('detectEnvironmentsGuidance', () => {
- it('should try to fetch CI info', () => {
- const stages = ['a', 'b', 'c'];
- service.getCiConfig.mockResolvedValue({ stages });
-
- return testAction(
- detectEnvironmentsGuidance,
- 'the content',
- { currentProjectId: 'gitlab/test' },
- [{ type: types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, payload: stages }],
- [],
- () => expect(service.getCiConfig).toHaveBeenCalledWith('gitlab/test', 'the content'),
- );
- });
- });
- describe('dismissCallout', () => {
- it('should try to dismiss the given callout', () => {
- const callout = { featureName: 'test', dismissedAt: 'now' };
-
- service.dismissUserCallout.mockResolvedValue({ userCalloutCreate: { userCallout: callout } });
-
- return testAction(
- dismissEnvironmentsGuidance,
- undefined,
- {},
- [{ type: types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT }],
- [],
- () =>
- expect(service.dismissUserCallout).toHaveBeenCalledWith(
- 'web_ide_ci_environments_guidance',
- ),
- );
- });
- });
-});
diff --git a/spec/frontend/ide/stores/getters/alert_spec.js b/spec/frontend/ide/stores/getters/alert_spec.js
deleted file mode 100644
index 7068b8e637f..00000000000
--- a/spec/frontend/ide/stores/getters/alert_spec.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { getAlert } from '~/ide/lib/alerts';
-import EnvironmentsMessage from '~/ide/lib/alerts/environments.vue';
-import { createStore } from '~/ide/stores';
-import * as getters from '~/ide/stores/getters/alert';
-import { file } from '../../helpers';
-
-describe('IDE store alert getters', () => {
- let localState;
- let localStore;
-
- beforeEach(() => {
- localStore = createStore();
- localState = localStore.state;
- });
-
- describe('alerts', () => {
- describe('shows an alert about environments', () => {
- let alert;
-
- beforeEach(() => {
- const f = file('.gitlab-ci.yml');
- localState.openFiles.push(f);
- localState.currentActivityView = 'repo-commit-section';
- localState.environmentsGuidanceAlertDetected = true;
- localState.environmentsGuidanceAlertDismissed = false;
-
- const alertKey = getters.getAlert(localState)(f);
- alert = getAlert(alertKey);
- });
-
- it('has a message suggesting to use environments', () => {
- expect(alert.message).toEqual(EnvironmentsMessage);
- });
-
- it('dispatches to dismiss the callout on dismiss', () => {
- jest.spyOn(localStore, 'dispatch').mockImplementation();
- alert.dismiss(localStore);
- expect(localStore.dispatch).toHaveBeenCalledWith('dismissEnvironmentsGuidance');
- });
-
- it('should be a tip alert', () => {
- expect(alert.props).toEqual({ variant: 'tip' });
- });
- });
- });
-});
diff --git a/spec/frontend/ide/stores/mutations/alert_spec.js b/spec/frontend/ide/stores/mutations/alert_spec.js
deleted file mode 100644
index 2840ec4ebb7..00000000000
--- a/spec/frontend/ide/stores/mutations/alert_spec.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as types from '~/ide/stores/mutation_types';
-import mutations from '~/ide/stores/mutations/alert';
-
-describe('~/ide/stores/mutations/alert', () => {
- const state = {};
-
- describe(types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, () => {
- it('checks the stages for any that configure environments', () => {
- mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
- nodes: [{ groups: { nodes: [{ jobs: { nodes: [{}] } }] } }],
- });
- expect(state.environmentsGuidanceAlertDetected).toBe(true);
- mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
- nodes: [{ groups: { nodes: [{ jobs: { nodes: [{ environment: {} }] } }] } }],
- });
- expect(state.environmentsGuidanceAlertDetected).toBe(false);
- });
- });
-
- describe(types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, () => {
- it('stops environments guidance', () => {
- mutations[types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state);
- expect(state.environmentsGuidanceAlertDismissed).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/import_entities/import_groups/components/import_status_spec.js b/spec/frontend/import_entities/import_groups/components/import_status_spec.js
index 8d055d45dd8..e0cabb86dcf 100644
--- a/spec/frontend/import_entities/import_groups/components/import_status_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_status_spec.js
@@ -88,7 +88,6 @@ describe('Group import status component', () => {
id: 2,
entityId: 11,
hasFailures: true,
- showDetailsLink: true,
status: STATUSES.FINISHED,
},
});
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 c26d1d921a5..4f4288196ab 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -70,6 +70,7 @@ describe('InviteModalBase', () => {
const findDisabledInput = () => wrapper.findByTestId('disabled-input');
const findCancelButton = () => wrapper.findByTestId('invite-modal-cancel');
const findActionButton = () => wrapper.findByTestId('invite-modal-submit');
+ const findModal = () => wrapper.findComponent(GlModal);
describe('rendering the modal', () => {
let trackingSpy;
@@ -82,7 +83,7 @@ describe('InviteModalBase', () => {
});
it('renders the modal with the correct title', () => {
- expect(wrapper.findComponent(GlModal).props('title')).toBe(propsData.modalTitle);
+ expect(findModal().props('title')).toBe(propsData.modalTitle);
});
it('displays the introText', () => {
@@ -200,9 +201,7 @@ describe('InviteModalBase', () => {
});
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- const modal = wrapper.findComponent(GlModal);
-
- modal.vm.$emit('shown');
+ findModal().vm.$emit('shown');
expectTracking('render', ON_SHOW_TRACK_LABEL, 'default');
unmockTracking();
@@ -280,4 +279,14 @@ describe('InviteModalBase', () => {
state: false,
});
});
+
+ it('emits the shown event when the modal is shown', () => {
+ createComponent();
+ // Verify that the shown event isn't emitted when the component is first created.
+ expect(wrapper.emitted('shown')).toBeUndefined();
+
+ findModal().vm.$emit('shown');
+
+ expect(wrapper.emitted('shown')).toHaveLength(1);
+ });
});
diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js
index 4d71a35ff99..abae43c3dbb 100644
--- a/spec/frontend/invite_members/utils/member_utils_spec.js
+++ b/spec/frontend/invite_members/utils/member_utils_spec.js
@@ -1,8 +1,4 @@
-import {
- memberName,
- triggerExternalAlert,
- inviteMembersTrackingOptions,
-} from '~/invite_members/utils/member_utils';
+import { memberName, triggerExternalAlert } from '~/invite_members/utils/member_utils';
jest.mock('~/lib/utils/url_utility');
@@ -22,13 +18,3 @@ describe('Trigger External Alert', () => {
expect(triggerExternalAlert()).toBe(false);
});
});
-
-describe('inviteMembersTrackingOptions', () => {
- it('returns options with a label', () => {
- expect(inviteMembersTrackingOptions({ label: '_label_' })).toEqual({ label: '_label_' });
- });
-
- it('handles options that has no label', () => {
- expect(inviteMembersTrackingOptions({})).toEqual({ label: undefined });
- });
-});
diff --git a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js
index f4f4936a134..b81bdc6ac74 100644
--- a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js
+++ b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js
@@ -8,13 +8,15 @@ import ProjectDropdown from '~/jira_connect/branches/components/project_dropdown
import { PROJECTS_PER_PAGE } from '~/jira_connect/branches/constants';
import getProjectsQuery from '~/jira_connect/branches/graphql/queries/get_projects.query.graphql';
-import { mockProjects } from '../mock_data';
+import { mockProjects, mockProjects2 } from '../mock_data';
const mockProjectsQueryResponse = {
data: {
projects: {
+ __typename: 'ProjectsConnection',
nodes: mockProjects,
pageInfo: {
+ __typename: 'PageInfo',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
@@ -121,6 +123,80 @@ describe('ProjectDropdown', () => {
});
});
+ describe('when projects query succeeds and has pagination', () => {
+ const mockProjectsWithPaginationQueryResponse = {
+ data: {
+ projects: {
+ __typename: 'ProjectsConnection',
+ nodes: mockProjects2,
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: 'end123',
+ },
+ },
+ },
+ };
+ const mockGetProjectsQuery = jest.fn();
+
+ beforeEach(async () => {
+ mockGetProjectsQuery
+ .mockResolvedValueOnce(mockProjectsWithPaginationQueryResponse)
+ .mockResolvedValueOnce(mockProjectsQueryResponse);
+
+ createComponent({
+ mockApollo: createMockApolloProvider({
+ mockGetProjectsQuery,
+ }),
+ });
+ await waitForPromises();
+ });
+
+ afterEach(() => {
+ mockGetProjectsQuery.mockReset();
+ });
+
+ it('uses infinite-scroll', () => {
+ expect(findDropdown().props()).toMatchObject({
+ infiniteScroll: true,
+ infiniteScrollLoading: false,
+ });
+ });
+
+ describe('when "bottom-reached" event is emitted', () => {
+ beforeEach(() => {
+ findDropdown().vm.$emit('bottom-reached');
+ });
+
+ it('sets infinite-scroll-loading to true', () => {
+ expect(findDropdown().props('infiniteScrollLoading')).toBe(true);
+ });
+
+ it('calls fetchMore to get next page', () => {
+ expect(mockGetProjectsQuery).toHaveBeenCalledTimes(2);
+ expect(mockGetProjectsQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ after: 'end123',
+ }),
+ );
+ });
+
+ it('appends query results to "items"', async () => {
+ const allProjects = [...mockProjects2, ...mockProjects];
+
+ await waitForPromises();
+
+ expect(findDropdown().props('infiniteScrollLoading')).toBe(false);
+
+ const dropdownItems = findDropdown().props('items');
+ expect(dropdownItems).toHaveLength(allProjects.length);
+ expect(dropdownItems).toMatchObject(allProjects);
+ });
+ });
+ });
+
describe('when projects query fails', () => {
beforeEach(async () => {
createComponent({
diff --git a/spec/frontend/jira_connect/branches/mock_data.js b/spec/frontend/jira_connect/branches/mock_data.js
index 1720e0118c8..a9e7182cb86 100644
--- a/spec/frontend/jira_connect/branches/mock_data.js
+++ b/spec/frontend/jira_connect/branches/mock_data.js
@@ -31,6 +31,36 @@ export const mockProjects = [
},
},
];
+export const mockProjects2 = [
+ {
+ id: 'gitlab-test',
+ name: 'gitlab-test',
+ nameWithNamespace: 'gitlab-test',
+ avatarUrl: 'https://gitlab.com',
+ path: 'gitlab-test-path',
+ fullPath: 'gitlab-test-path',
+ repository: {
+ empty: false,
+ },
+ userPermissions: {
+ pushCode: true,
+ },
+ },
+ {
+ id: 'gitlab-shell',
+ name: 'GitLab Shell',
+ nameWithNamespace: 'gitlab-org/gitlab-shell',
+ avatarUrl: 'https://gitlab.com',
+ path: 'gitlab-shell',
+ fullPath: 'gitlab-org/gitlab-shell',
+ repository: {
+ empty: false,
+ },
+ userPermissions: {
+ pushCode: true,
+ },
+ },
+];
export const mockProjectQueryResponse = (branchNames = mockBranchNames) => ({
data: {
diff --git a/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js
index 369b8f32c2d..e873da07a2a 100644
--- a/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js
+++ b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js
@@ -1,7 +1,7 @@
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 { PAGE_SIZE } from '~/kubernetes_dashboard/constants';
import { mockPodsTableItems } from '../graphql/mock_data';
let wrapper;
@@ -26,25 +26,24 @@ describe('Workload table component', () => {
{
key: 'name',
label: 'Name',
- thClass: TABLE_HEADING_CLASSES,
sortable: true,
+ tdClass: 'gl-md-w-half gl-lg-w-40p gl-word-break-word',
},
{
key: 'status',
label: 'Status',
- thClass: TABLE_HEADING_CLASSES,
sortable: true,
+ tdClass: 'gl-md-w-15',
},
{
key: 'namespace',
label: 'Namespace',
- thClass: TABLE_HEADING_CLASSES,
sortable: true,
+ tdClass: 'gl-md-w-30p gl-lg-w-40p gl-word-break-word',
},
{
key: 'age',
label: 'Age',
- thClass: TABLE_HEADING_CLASSES,
sortable: true,
},
];
@@ -57,13 +56,11 @@ describe('Workload table component', () => {
{
key: 'field-1',
label: 'Field-1',
- thClass: TABLE_HEADING_CLASSES,
sortable: true,
},
{
key: 'field-2',
label: 'Field-2',
- thClass: TABLE_HEADING_CLASSES,
sortable: true,
},
];
diff --git a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js
index 674425a5bc9..8f733d382b2 100644
--- a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js
+++ b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js
@@ -351,3 +351,249 @@ export const mockDaemonSetsTableItems = [
];
export const k8sDaemonSetsMock = [readyDaemonSet, failedDaemonSet];
+
+const completedJob = {
+ status: { failed: 0, succeeded: 1 },
+ spec: { completions: 1 },
+ metadata: {
+ name: 'job-1',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+const failedJob = {
+ status: { failed: 1, succeeded: 1 },
+ spec: { completions: 2 },
+ metadata: {
+ name: 'job-2',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+const anotherFailedJob = {
+ status: { failed: 0, succeeded: 1 },
+ spec: { completions: 2 },
+ metadata: {
+ name: 'job-3',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+export const mockJobsStats = [
+ {
+ title: 'Completed',
+ value: 1,
+ },
+ {
+ title: 'Failed',
+ value: 2,
+ },
+];
+
+export const mockJobsTableItems = [
+ {
+ name: 'job-1',
+ namespace: 'default',
+ status: 'Completed',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'Job',
+ },
+ {
+ name: 'job-2',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Job',
+ },
+ {
+ name: 'job-3',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Job',
+ },
+];
+
+export const k8sJobsMock = [completedJob, failedJob, anotherFailedJob];
+
+const readyCronJob = {
+ status: { active: 0, lastScheduleTime: '2023-07-31T11:50:17Z' },
+ spec: { suspend: 0 },
+ metadata: {
+ name: 'cronJob-1',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+const suspendedCronJob = {
+ status: { active: 0, lastScheduleTime: null },
+ spec: { suspend: 1 },
+ metadata: {
+ name: 'cronJob-2',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+const failedCronJob = {
+ status: { active: 1, lastScheduleTime: null },
+ spec: { suspend: 0 },
+ metadata: {
+ name: 'cronJob-3',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+};
+
+export const mockCronJobsStats = [
+ {
+ title: 'Ready',
+ value: 1,
+ },
+ {
+ title: 'Failed',
+ value: 1,
+ },
+ {
+ title: 'Suspended',
+ value: 1,
+ },
+];
+
+export const mockCronJobsTableItems = [
+ {
+ name: 'cronJob-1',
+ namespace: 'default',
+ status: 'Ready',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'CronJob',
+ },
+ {
+ name: 'cronJob-2',
+ namespace: 'default',
+ status: 'Suspended',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'CronJob',
+ },
+ {
+ name: 'cronJob-3',
+ namespace: 'default',
+ status: 'Failed',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'CronJob',
+ },
+];
+
+export const k8sCronJobsMock = [readyCronJob, suspendedCronJob, failedCronJob];
+
+export const k8sServicesMock = [
+ {
+ metadata: {
+ name: 'my-first-service',
+ namespace: 'default',
+ creationTimestamp: '2023-07-31T11:50:17Z',
+ labels: {},
+ annotations: {},
+ },
+ spec: {
+ ports: [
+ {
+ name: 'https',
+ protocol: 'TCP',
+ port: 443,
+ targetPort: 8443,
+ },
+ ],
+ clusterIP: '10.96.0.1',
+ externalIP: '-',
+ type: 'ClusterIP',
+ },
+ },
+ {
+ metadata: {
+ name: 'my-second-service',
+ namespace: 'default',
+ creationTimestamp: '2023-11-21T11:50:59Z',
+ labels: {},
+ annotations: {},
+ },
+ spec: {
+ ports: [
+ {
+ name: 'http',
+ protocol: 'TCP',
+ appProtocol: 'http',
+ port: 80,
+ targetPort: 'http',
+ nodePort: 31989,
+ },
+ {
+ name: 'https',
+ protocol: 'TCP',
+ appProtocol: 'https',
+ port: 443,
+ targetPort: 'https',
+ nodePort: 32679,
+ },
+ ],
+ clusterIP: '10.105.219.238',
+ externalIP: '-',
+ type: 'NodePort',
+ },
+ },
+];
+
+export const mockServicesTableItems = [
+ {
+ name: 'my-first-service',
+ namespace: 'default',
+ type: 'ClusterIP',
+ clusterIP: '10.96.0.1',
+ externalIP: '-',
+ ports: '443/TCP',
+ age: '114d',
+ labels: {},
+ annotations: {},
+ kind: 'Service',
+ },
+ {
+ name: 'my-second-service',
+ namespace: 'default',
+ type: 'NodePort',
+ clusterIP: '10.105.219.238',
+ externalIP: '-',
+ ports: '80:31989/TCP, 443:32679/TCP',
+ age: '1d',
+ labels: {},
+ annotations: {},
+ kind: 'Service',
+ },
+];
diff --git a/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js
index 516d91af947..01e2c3d2716 100644
--- a/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js
+++ b/spec/frontend/kubernetes_dashboard/graphql/resolvers/kubernetes_spec.js
@@ -1,16 +1,22 @@
-import { CoreV1Api, WatchApi, AppsV1Api } from '@gitlab/cluster-client';
+import { CoreV1Api, WatchApi, AppsV1Api, BatchV1Api } 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 k8sDashboardJobsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_jobs.query.graphql';
+import k8sDashboardCronJobsQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_cron_jobs.query.graphql';
+import k8sDashboardServicesQuery from '~/kubernetes_dashboard/graphql/queries/k8s_dashboard_services.query.graphql';
import {
k8sPodsMock,
k8sDeploymentsMock,
k8sStatefulSetsMock,
k8sReplicaSetsMock,
k8sDaemonSetsMock,
+ k8sJobsMock,
+ k8sCronJobsMock,
+ k8sServicesMock,
} from '../mock_data';
describe('~/frontend/environments/graphql/resolvers', () => {
@@ -456,4 +462,250 @@ describe('~/frontend/environments/graphql/resolvers', () => {
).rejects.toThrow('API error');
});
});
+
+ describe('k8sJobs', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockJobsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockJobsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sJobsMock,
+ });
+ });
+
+ const mockAllJobsListFn = jest.fn().mockImplementation(mockJobsListFn);
+
+ describe('when the Jobs data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(BatchV1Api.prototype, 'listBatchV1JobForAllNamespaces')
+ .mockImplementation(mockAllJobsListFn);
+ jest.spyOn(mockWatcher, 'subscribeToStream').mockImplementation(mockJobsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all Jobs from the cluster_client library and watch the events', async () => {
+ const Jobs = await mockResolvers.Query.k8sJobs(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllJobsListFn).toHaveBeenCalled();
+ expect(mockJobsListWatcherFn).toHaveBeenCalled();
+
+ expect(Jobs).toEqual(k8sJobsMock);
+ });
+
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sJobs(null, { configuration, namespace: '' }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sDashboardJobsQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sJobs: [] },
+ });
+ });
+ });
+
+ it('should not watch Jobs from the cluster_client library when the Jobs data is not present', async () => {
+ jest.spyOn(BatchV1Api.prototype, 'listBatchV1JobForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sJobs(null, { configuration }, { client });
+
+ expect(mockJobsListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(BatchV1Api.prototype, 'listBatchV1JobForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sJobs(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+
+ describe('k8sCronJobs', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockCronJobsListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockCronJobsListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sCronJobsMock,
+ });
+ });
+
+ const mockAllCronJobsListFn = jest.fn().mockImplementation(mockCronJobsListFn);
+
+ describe('when the CronJobs data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(BatchV1Api.prototype, 'listBatchV1CronJobForAllNamespaces')
+ .mockImplementation(mockAllCronJobsListFn);
+ jest.spyOn(mockWatcher, 'subscribeToStream').mockImplementation(mockCronJobsListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all CronJobs from the cluster_client library and watch the events', async () => {
+ const CronJobs = await mockResolvers.Query.k8sCronJobs(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllCronJobsListFn).toHaveBeenCalled();
+ expect(mockCronJobsListWatcherFn).toHaveBeenCalled();
+
+ expect(CronJobs).toEqual(k8sCronJobsMock);
+ });
+
+ it('should update cache with the new data when received from the library', async () => {
+ await mockResolvers.Query.k8sCronJobs(null, { configuration, namespace: '' }, { client });
+
+ expect(client.writeQuery).toHaveBeenCalledWith({
+ query: k8sDashboardCronJobsQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sCronJobs: [] },
+ });
+ });
+ });
+
+ it('should not watch CronJobs from the cluster_client library when the CronJobs data is not present', async () => {
+ jest.spyOn(BatchV1Api.prototype, 'listBatchV1CronJobForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sCronJobs(null, { configuration }, { client });
+
+ expect(mockCronJobsListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(BatchV1Api.prototype, 'listBatchV1CronJobForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sCronJobs(null, { configuration }, { client }),
+ ).rejects.toThrow('API error');
+ });
+ });
+
+ describe('k8sServices', () => {
+ const client = { writeQuery: jest.fn() };
+
+ const mockWatcher = WatchApi.prototype;
+ const mockServicesListWatcherFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve(mockWatcher);
+ });
+
+ const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
+ if (eventName === 'data') {
+ callback([]);
+ }
+ });
+
+ const mockServicesListFn = jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: k8sServicesMock,
+ });
+ });
+
+ const mockAllServicesListFn = jest.fn().mockImplementation(mockServicesListFn);
+
+ describe('when the Services data is present', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
+ .mockImplementation(mockAllServicesListFn);
+ jest.spyOn(mockWatcher, 'subscribeToStream').mockImplementation(mockServicesListWatcherFn);
+ jest.spyOn(mockWatcher, 'on').mockImplementation(mockOnDataFn);
+ });
+
+ it('should request all Services from the cluster_client library and watch the events', async () => {
+ const Services = await mockResolvers.Query.k8sServices(
+ null,
+ {
+ configuration,
+ },
+ { client },
+ );
+
+ expect(mockAllServicesListFn).toHaveBeenCalled();
+ expect(mockServicesListWatcherFn).toHaveBeenCalled();
+
+ expect(Services).toEqual(k8sServicesMock);
+ });
+
+ 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: k8sDashboardServicesQuery,
+ variables: { configuration, namespace: '' },
+ data: { k8sServices: [] },
+ });
+ });
+ });
+
+ it('should not watch Services from the cluster_client library when the Services data is not present', async () => {
+ jest.spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces').mockImplementation(
+ jest.fn().mockImplementation(() => {
+ return Promise.resolve({
+ items: [],
+ });
+ }),
+ );
+
+ await mockResolvers.Query.k8sServices(null, { configuration }, { client });
+
+ expect(mockServicesListWatcherFn).not.toHaveBeenCalled();
+ });
+
+ it('should throw an error if the API call fails', async () => {
+ jest
+ .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces')
+ .mockRejectedValue(new Error('API error'));
+
+ await expect(
+ mockResolvers.Query.k8sServices(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
index 2892d657aea..1fd89e67e79 100644
--- a/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js
+++ b/spec/frontend/kubernetes_dashboard/helpers/k8s_integration_helper_spec.js
@@ -3,6 +3,9 @@ import {
calculateDeploymentStatus,
calculateStatefulSetStatus,
calculateDaemonSetStatus,
+ calculateJobStatus,
+ calculateCronJobStatus,
+ generateServicePortsString,
} from '~/kubernetes_dashboard/helpers/k8s_integration_helper';
import { useFakeDate } from 'helpers/fake_date';
@@ -90,4 +93,81 @@ describe('k8s_integration_helper', () => {
expect(calculateDaemonSetStatus(item)).toBe(expected);
});
});
+
+ describe('calculateJobStatus', () => {
+ const completed = {
+ status: { failed: 0, succeeded: 2 },
+ spec: { completions: 2 },
+ };
+ const failed = {
+ status: { failed: 1, succeeded: 1 },
+ spec: { completions: 2 },
+ };
+ const anotherFailed = {
+ status: { failed: 0, succeeded: 1 },
+ spec: { completions: 2 },
+ };
+
+ it.each`
+ condition | item | expected
+ ${'there are no failed and succeeded amount is equal to completions number'} | ${completed} | ${'Completed'}
+ ${'there are some failed statuses'} | ${failed} | ${'Failed'}
+ ${'there are some failed and succeeded amount is not equal to completions number'} | ${anotherFailed} | ${'Failed'}
+ `('returns status as $expected when $condition', ({ item, expected }) => {
+ expect(calculateJobStatus(item)).toBe(expected);
+ });
+ });
+
+ describe('calculateCronJobStatus', () => {
+ const ready = {
+ status: { active: 0, lastScheduleTime: '2023-11-21T11:50:59Z' },
+ spec: { suspend: 0 },
+ };
+ const failed = {
+ status: { active: 1, lastScheduleTime: null },
+ spec: { suspend: 0 },
+ };
+ const suspended = {
+ status: { active: 0, lastScheduleTime: '2023-11-21T11:50:59Z' },
+ spec: { suspend: 1 },
+ };
+
+ it.each`
+ condition | item | expected
+ ${'there are no active and the lastScheduleTime is present'} | ${ready} | ${'Ready'}
+ ${'there are some active and the lastScheduleTime is not present'} | ${failed} | ${'Failed'}
+ ${'there are some suspend in the spec'} | ${suspended} | ${'Suspended'}
+ `('returns status as $expected when $condition', ({ item, expected }) => {
+ expect(calculateCronJobStatus(item)).toBe(expected);
+ });
+ });
+
+ 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}`);
+ });
+ });
});
diff --git a/spec/frontend/kubernetes_dashboard/pages/cron_jobs_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/cron_jobs_page_spec.js
new file mode 100644
index 00000000000..3d5eadf920a
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/cron_jobs_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 CronJobsPage from '~/kubernetes_dashboard/pages/cron_jobs_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import { k8sCronJobsMock, mockCronJobsStats, mockCronJobsTableItems } from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard cronJobs 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: {
+ k8sCronJobs: jest.fn().mockReturnValue(k8sCronJobsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(CronJobsPage, {
+ 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 cronJobs loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets cronJobs data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual(mockCronJobsStats);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockCronJobsTableItems);
+ });
+ });
+
+ 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: {
+ k8sCronJobs: 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/jobs_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/jobs_page_spec.js
new file mode 100644
index 00000000000..a7148ae2394
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/jobs_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 JobsPage from '~/kubernetes_dashboard/pages/jobs_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import { k8sJobsMock, mockJobsStats, mockJobsTableItems } from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard jobs 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: {
+ k8sJobs: jest.fn().mockReturnValue(k8sJobsMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(JobsPage, {
+ 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 jobs loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets jobs data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual(mockJobsStats);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockJobsTableItems);
+ });
+ });
+
+ 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: {
+ k8sJobs: 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/services_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/services_page_spec.js
new file mode 100644
index 00000000000..c76f4330cd6
--- /dev/null
+++ b/spec/frontend/kubernetes_dashboard/pages/services_page_spec.js
@@ -0,0 +1,104 @@
+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 ServicesPage from '~/kubernetes_dashboard/pages/services_page.vue';
+import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue';
+import { SERVICES_TABLE_FIELDS } from '~/kubernetes_dashboard/constants';
+import { useFakeDate } from 'helpers/fake_date';
+import { k8sServicesMock, mockServicesTableItems } from '../graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Kubernetes dashboard services 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: {
+ k8sServices: jest.fn().mockReturnValue(k8sServicesMock),
+ },
+ };
+
+ return createMockApollo([], mockResolvers);
+ };
+
+ const createWrapper = (apolloProvider = createApolloProvider()) => {
+ wrapper = shallowMount(ServicesPage, {
+ 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 services loaded', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when gets services data', () => {
+ useFakeDate(2023, 10, 23, 10, 10);
+
+ it('sets correct stats object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('stats')).toEqual([]);
+ });
+
+ it('sets correct table items object for the WorkloadLayout', async () => {
+ createWrapper();
+ await waitForPromises();
+
+ expect(findWorkloadLayout().props('items')).toMatchObject(mockServicesTableItems);
+ expect(findWorkloadLayout().props('fields')).toMatchObject(SERVICES_TABLE_FIELDS);
+ });
+ });
+
+ 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: {
+ k8sServices: 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/number_utility_spec.js b/spec/frontend/lib/utils/number_utils_spec.js
index 07e3e2f0422..831a2f975b6 100644
--- a/spec/frontend/lib/utils/number_utility_spec.js
+++ b/spec/frontend/lib/utils/number_utils_spec.js
@@ -14,12 +14,6 @@ import {
isNumeric,
isPositiveInteger,
} from '~/lib/utils/number_utils';
-import {
- BYTES_FORMAT_BYTES,
- BYTES_FORMAT_KIB,
- BYTES_FORMAT_MIB,
- BYTES_FORMAT_GIB,
-} from '~/lib/utils/constants';
describe('Number Utils', () => {
describe('formatRelevantDigits', () => {
@@ -87,23 +81,23 @@ describe('Number Utils', () => {
describe('numberToHumanSizeSplit', () => {
it('should return bytes', () => {
- expect(numberToHumanSizeSplit(654)).toEqual(['654', BYTES_FORMAT_BYTES]);
- expect(numberToHumanSizeSplit(-654)).toEqual(['-654', BYTES_FORMAT_BYTES]);
+ expect(numberToHumanSizeSplit(654)).toEqual(['654', 'B']);
+ expect(numberToHumanSizeSplit(-654)).toEqual(['-654', 'B']);
});
it('should return KiB', () => {
- expect(numberToHumanSizeSplit(1079)).toEqual(['1.05', BYTES_FORMAT_KIB]);
- expect(numberToHumanSizeSplit(-1079)).toEqual(['-1.05', BYTES_FORMAT_KIB]);
+ expect(numberToHumanSizeSplit(1079)).toEqual(['1.05', 'KiB']);
+ expect(numberToHumanSizeSplit(-1079)).toEqual(['-1.05', 'KiB']);
});
it('should return MiB', () => {
- expect(numberToHumanSizeSplit(10485764)).toEqual(['10.00', BYTES_FORMAT_MIB]);
- expect(numberToHumanSizeSplit(-10485764)).toEqual(['-10.00', BYTES_FORMAT_MIB]);
+ expect(numberToHumanSizeSplit(10485764)).toEqual(['10.00', 'MiB']);
+ expect(numberToHumanSizeSplit(-10485764)).toEqual(['-10.00', 'MiB']);
});
it('should return GiB', () => {
- expect(numberToHumanSizeSplit(10737418240)).toEqual(['10.00', BYTES_FORMAT_GIB]);
- expect(numberToHumanSizeSplit(-10737418240)).toEqual(['-10.00', BYTES_FORMAT_GIB]);
+ expect(numberToHumanSizeSplit(10737418240)).toEqual(['10.00', 'GiB']);
+ expect(numberToHumanSizeSplit(-10737418240)).toEqual(['-10.00', 'GiB']);
});
});
diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js
index a8da6e8969f..b97827208d6 100644
--- a/spec/frontend/lib/utils/secret_detection_spec.js
+++ b/spec/frontend/lib/utils/secret_detection_spec.js
@@ -14,6 +14,8 @@ describe('containsSensitiveToken', () => {
'1234567890',
'!@#$%^&*()_+',
'https://example.com',
+ 'Some tokens are prefixed with glpat- or glcbt-, for example.',
+ 'glpat-FAKE',
];
it.each(nonSensitiveMessages)('returns false for message: %s', (message) => {
@@ -32,6 +34,9 @@ describe('containsSensitiveToken', () => {
'https://example.com/feed?feed_token=123456789_abcdefghij',
'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'token: gldt-cgyKc1k_AsnEpmP-5fRL',
+ 'curl "https://gitlab.example.com/api/v4/groups/33/scim/identities" --header "PRIVATE-TOKEN: glsoat-cgyKc1k_AsnEpmP-5fRL',
+ 'CI_JOB_TOKEN=glcbt-FFFF_cgyKc1k_AsnEpmP-5fRL',
+ 'Use this secret job token: glcbt-1_cgyKc1k_AsnEpmP-5fRL',
];
it.each(sensitiveMessages)('returns true for message: %s', (message) => {
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index 6821ed56857..692beac7ed3 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -42,6 +42,14 @@ describe('text_utility', () => {
it('returns string with first letter capitalized', () => {
expect(textUtils.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab');
});
+
+ it('returns empty string when given string is empty', () => {
+ expect(textUtils.capitalizeFirstCharacter('')).toEqual('');
+ });
+
+ it('returns empty string when given string is invalid', () => {
+ expect(textUtils.capitalizeFirstCharacter(undefined)).toEqual('');
+ });
});
describe('slugify', () => {
diff --git a/spec/frontend/logo_spec.js b/spec/frontend/logo_spec.js
index 8e39e75bd3b..51f47fb89ba 100644
--- a/spec/frontend/logo_spec.js
+++ b/spec/frontend/logo_spec.js
@@ -10,7 +10,7 @@ describe('initPortraitLogoDetection', () => {
};
beforeEach(() => {
- setHTMLFixture('<img class="gl-visibility-hidden gl-h-9 js-portrait-logo-detection" />');
+ setHTMLFixture('<img class="gl-visibility-hidden gl-h-10 js-portrait-logo-detection" />');
initPortraitLogoDetection();
img = document.querySelector('img');
});
@@ -27,12 +27,12 @@ describe('initPortraitLogoDetection', () => {
it('removes gl-visibility-hidden', () => {
expect(img.classList).toContain('gl-visibility-hidden');
- expect(img.classList).toContain('gl-h-9');
+ expect(img.classList).toContain('gl-h-10');
loadImage();
expect(img.classList).not.toContain('gl-visibility-hidden');
- expect(img.classList).toContain('gl-h-9');
+ expect(img.classList).toContain('gl-h-10');
});
});
@@ -44,7 +44,7 @@ describe('initPortraitLogoDetection', () => {
it('removes gl-visibility-hidden', () => {
expect(img.classList).toContain('gl-visibility-hidden');
- expect(img.classList).toContain('gl-h-9');
+ expect(img.classList).toContain('gl-h-10');
loadImage();
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 66a447e73d3..07d8b4b8b3d 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,4 +1,4 @@
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlButton } 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';
@@ -8,13 +8,22 @@ 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 ActionsDropdown from '~/ml/model_registry/components/actions_dropdown.vue';
import { mockModels, startCursor, defaultPageInfo } from '../mock_data';
let wrapper;
-const createWrapper = (
- propsData = { models: mockModels, pageInfo: defaultPageInfo, modelCount: 2 },
-) => {
- wrapper = shallowMountExtended(IndexMlModels, { propsData });
+
+const createWrapper = (propsData = {}) => {
+ wrapper = shallowMountExtended(IndexMlModels, {
+ propsData: {
+ models: mockModels,
+ pageInfo: defaultPageInfo,
+ modelCount: 2,
+ createModelPath: 'path/to/create',
+ canWriteModelRegistry: false,
+ ...propsData,
+ },
+ });
};
const findModelRow = (index) => wrapper.findAllComponents(ModelRow).at(index);
@@ -24,8 +33,10 @@ const findSearchBar = () => wrapper.findComponent(SearchBar);
const findTitleArea = () => wrapper.findComponent(TitleArea);
const findModelCountMetadataItem = () => findTitleArea().findComponent(MetadataItem);
const findBadge = () => wrapper.findComponent(GlBadge);
+const findCreateButton = () => findTitleArea().findComponent(GlButton);
+const findActionsDropdown = () => wrapper.findComponent(ActionsDropdown);
-describe('MlModelsIndex', () => {
+describe('ml/model_registry/apps/index_ml_models', () => {
describe('empty state', () => {
beforeEach(() => createWrapper({ models: [], pageInfo: defaultPageInfo }));
@@ -40,6 +51,28 @@ describe('MlModelsIndex', () => {
it('does not show search bar', () => {
expect(findSearchBar().exists()).toBe(false);
});
+
+ it('renders the extra actions button', () => {
+ expect(findActionsDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('create button', () => {
+ describe('when user has no permission to write model registry', () => {
+ it('does not display create button', () => {
+ createWrapper();
+
+ expect(findCreateButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when user has permission to write model registry', () => {
+ it('displays create button', () => {
+ createWrapper({ canWriteModelRegistry: true });
+
+ expect(findCreateButton().attributes().href).toBe('path/to/create');
+ });
+ });
});
describe('with data', () => {
diff --git a/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js
new file mode 100644
index 00000000000..204c021c080
--- /dev/null
+++ b/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js
@@ -0,0 +1,119 @@
+import {
+ GlAlert,
+ GlButton,
+ GlFormInput,
+ GlFormTextarea,
+ GlForm,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import NewMlModel from '~/ml/model_registry/apps/new_ml_model.vue';
+import createModelMutation from '~/ml/model_registry/graphql/mutations/create_model.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { visitUrl } from '~/lib/utils/url_utility';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createModelResponses } from '../graphql_mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
+
+describe('ml/model_registry/apps/new_ml_model.vue', () => {
+ let wrapper;
+ let apolloProvider;
+
+ Vue.use(VueApollo);
+
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'captureException').mockImplementation();
+ });
+
+ const mountComponent = (resolver = jest.fn().mockResolvedValue(createModelResponses.success)) => {
+ const requestHandlers = [[createModelMutation, resolver]];
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMountExtended(NewMlModel, {
+ apolloProvider,
+ propsData: { projectPath: 'project/path' },
+ stubs: { GlSprintf },
+ });
+ };
+
+ const findButton = () => wrapper.findComponent(GlButton);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+ const findTextarea = () => wrapper.findComponent(GlFormTextarea);
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findDocAlert = () => wrapper.findComponent(GlAlert);
+ const findDocLink = () => findDocAlert().findComponent(GlLink);
+ const findErrorAlert = () => wrapper.findByTestId('new-model-errors');
+
+ const submitForm = async () => {
+ findForm().vm.$emit('submit', { preventDefault: () => {} });
+ await waitForPromises();
+ };
+
+ it('renders the button', () => {
+ mountComponent();
+
+ expect(findButton().text()).toBe('Create model');
+ });
+
+ it('shows link to docs', () => {
+ mountComponent();
+
+ expect(findDocAlert().text()).toBe(
+ 'Creating models is also possible through the MLflow client. Follow the documentation to learn more.',
+ );
+ expect(findDocLink().attributes().href).toBe('/help/user/project/ml/model_registry/index.md');
+ });
+
+ it('submits the query with correct parameters', async () => {
+ const resolver = jest.fn().mockResolvedValue(createModelResponses.success);
+ mountComponent(resolver);
+
+ findInput().vm.$emit('input', 'model_name');
+ findTextarea().vm.$emit('input', 'A description');
+
+ await submitForm();
+
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ projectPath: 'project/path',
+ name: 'model_name',
+ description: 'A description',
+ }),
+ );
+ });
+
+ it('navigates to the new page when result is successful', async () => {
+ mountComponent();
+
+ await submitForm();
+
+ expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1');
+ });
+
+ it('shows errors when result is a top level error', async () => {
+ const error = new Error('Failure!');
+ mountComponent(jest.fn().mockRejectedValue({ error }));
+
+ await submitForm();
+
+ expect(findErrorAlert().text()).toBe('An error has occurred when saving the model.');
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+
+ it('shows errors when result is a validation error', async () => {
+ mountComponent(jest.fn().mockResolvedValue(createModelResponses.validationFailure));
+
+ await submitForm();
+
+ expect(findErrorAlert().text()).toBe("Name is invalid, Name can't be blank");
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+});
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 1fe0f5f88b3..7e991687496 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,7 +1,7 @@
import { GlBadge, GlTab } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
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';
@@ -19,7 +19,7 @@ let wrapper;
Vue.use(VueApollo);
const createWrapper = (model = MODEL) => {
- wrapper = shallowMount(ShowMlModel, {
+ wrapper = shallowMountExtended(ShowMlModel, {
apolloProvider,
propsData: { model },
stubs: { GlTab },
@@ -37,6 +37,7 @@ const findCandidatesCountBadge = () => findCandidateTab().findComponent(GlBadge)
const findTitleArea = () => wrapper.findComponent(TitleArea);
const findEmptyState = () => wrapper.findComponent(EmptyState);
const findVersionCountMetadataItem = () => findTitleArea().findComponent(MetadataItem);
+const findVersionLink = () => wrapper.findByTestId('model-version-link');
describe('ShowMlModel', () => {
describe('Title', () => {
@@ -67,8 +68,10 @@ describe('ShowMlModel', () => {
expect(findModelVersionDetail().props('modelVersion')).toBe(MODEL.latestVersion);
});
- it('displays the title', () => {
- expect(findDetailTab().text()).toContain('Latest version: 1.2.3');
+ it('displays a link to latest version', () => {
+ expect(findDetailTab().text()).toContain('Latest version:');
+ expect(findVersionLink().attributes('href')).toBe(MODEL.latestVersion.path);
+ expect(findVersionLink().text()).toBe('1.2.3');
});
});
diff --git a/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js b/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js
new file mode 100644
index 00000000000..6285d7360c7
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js
@@ -0,0 +1,39 @@
+import { mount } from '@vue/test-utils';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
+import ActionsDropdown from '~/ml/model_registry/components/actions_dropdown.vue';
+
+describe('ml/model_registry/components/actions_dropdown', () => {
+ let wrapper;
+
+ const showToast = jest.fn();
+
+ const createWrapper = () => {
+ wrapper = mount(ActionsDropdown, {
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
+ provide: {
+ mlflowTrackingUrl: 'path/to/mlflow',
+ },
+ });
+ };
+
+ const findCopyLinkDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
+
+ it('has data-clipboard-text set to the correct url', () => {
+ createWrapper();
+
+ expect(findCopyLinkDropdownItem().text()).toBe('Copy MLflow tracking URL');
+ expect(findCopyLinkDropdownItem().attributes()['data-clipboard-text']).toBe('path/to/mlflow');
+ });
+
+ it('shows a success toast after copying the url to the clipboard', () => {
+ createWrapper();
+
+ findCopyLinkDropdownItem().find('button').trigger('click');
+
+ expect(showToast).toHaveBeenCalledWith('Copied MLflow tracking URL to clipboard');
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/candidate_list_spec.js b/spec/frontend/ml/model_registry/components/candidate_list_spec.js
index c10222a99fd..8491c7be16f 100644
--- a/spec/frontend/ml/model_registry/components/candidate_list_spec.js
+++ b/spec/frontend/ml/model_registry/components/candidate_list_spec.js
@@ -1,13 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { GlAlert } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { mount } 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 SearchableList from '~/ml/model_registry/components/searchable_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';
@@ -24,10 +22,7 @@ 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 findSearchableList = () => wrapper.findComponent(SearchableList);
const findAllRows = () => wrapper.findAllComponents(CandidateListRow);
const mountComponent = ({
@@ -37,15 +32,12 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
const requestHandlers = [[getModelCandidatesQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMount(CandidateList, {
+ wrapper = mount(CandidateList, {
apolloProvider,
propsData: {
modelId: 2,
...props,
},
- stubs: {
- RegistryList,
- },
});
};
@@ -60,25 +52,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
await waitForPromises();
});
- it('displays empty slot message', () => {
+ it('shows empty state', () => {
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', () => {
@@ -90,19 +66,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
});
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');
+ expect(findSearchableList().props('errorMessage')).toBe(
+ 'Failed to load model candidates with error: Failure!',
+ );
});
it('error is logged in sentry', () => {
@@ -116,21 +82,11 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
await waitForPromises();
});
- it('displays package registry list', () => {
- expect(findRegistryList().exists()).toEqual(true);
+ it('Passes items to list', () => {
+ expect(findSearchableList().props('items')).toEqual(graphqlCandidates);
});
- 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);
+ it('displays package version rows', () => {
expect(findAllRows()).toHaveLength(graphqlCandidates.length);
});
@@ -143,17 +99,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
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', () => {
+ describe('when list requests update', () => {
const resolver = jest.fn().mockResolvedValue(modelCandidatesQuery());
beforeEach(async () => {
@@ -161,21 +109,17 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
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 fetch-page fetches the next set of records', async () => {
+ findSearchableList().vm.$emit('fetch-page', {
+ after: 'eyJpZCI6IjIifQ',
+ first: 30,
+ id: 'gid://gitlab/Ml::Model/2',
+ });
- 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 }),
+ expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
);
});
});
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
index 41f7e71c543..f5d6acf3bae 100644
--- a/spec/frontend/ml/model_registry/components/model_version_list_spec.js
+++ b/spec/frontend/ml/model_registry/components/model_version_list_spec.js
@@ -1,13 +1,11 @@
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 { mountExtended } 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 SearchableList from '~/ml/model_registry/components/searchable_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';
@@ -25,11 +23,8 @@ describe('ModelVersionList', () => {
let wrapper;
let apolloProvider;
- const findAlert = () => wrapper.findComponent(GlAlert);
- const findLoader = () => wrapper.findComponent(PackagesListLoader);
- const findRegistryList = () => wrapper.findComponent(RegistryList);
+ const findSearchableList = () => wrapper.findComponent(SearchableList);
const findEmptyState = () => wrapper.findComponent(EmptyState);
- const findListRow = () => wrapper.findComponent(ModelVersionRow);
const findAllRows = () => wrapper.findAllComponents(ModelVersionRow);
const mountComponent = ({
@@ -39,15 +34,12 @@ describe('ModelVersionList', () => {
const requestHandlers = [[getModelVersionsQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMountExtended(ModelVersionList, {
+ wrapper = mountExtended(ModelVersionList, {
apolloProvider,
propsData: {
modelId: 2,
...props,
},
- stubs: {
- RegistryList,
- },
});
};
@@ -65,22 +57,6 @@ describe('ModelVersionList', () => {
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', () => {
@@ -92,19 +68,9 @@ describe('ModelVersionList', () => {
});
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');
+ expect(findSearchableList().props('errorMessage')).toBe(
+ 'Failed to load model versions with error: Failure!',
+ );
});
it('error is logged in sentry', () => {
@@ -118,21 +84,11 @@ describe('ModelVersionList', () => {
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('Passes items to list', () => {
+ expect(findSearchableList().props('items')).toEqual(graphqlModelVersions);
});
it('displays package version rows', () => {
- expect(findAllRows().exists()).toEqual(true);
expect(findAllRows()).toHaveLength(graphqlModelVersions.length);
});
@@ -145,17 +101,9 @@ describe('ModelVersionList', () => {
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', () => {
+ describe('when list requests update', () => {
const resolver = jest.fn().mockResolvedValue(modelVersionsQuery());
beforeEach(async () => {
@@ -163,21 +111,17 @@ describe('ModelVersionList', () => {
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 fetch-page fetches the next set of records', async () => {
+ findSearchableList().vm.$emit('fetch-page', {
+ after: 'eyJpZCI6IjIifQ',
+ first: 30,
+ id: 'gid://gitlab/Ml::Model/2',
+ });
- 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 }),
+ expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
);
});
});
diff --git a/spec/frontend/ml/model_registry/components/searchable_list_spec.js b/spec/frontend/ml/model_registry/components/searchable_list_spec.js
new file mode 100644
index 00000000000..ea58a9a830a
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/searchable_list_spec.js
@@ -0,0 +1,170 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import SearchableList from '~/ml/model_registry/components/searchable_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 { defaultPageInfo } from '../mock_data';
+
+describe('ml/model_registry/components/searchable_list.vue', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLoader = () => wrapper.findComponent(PackagesListLoader);
+ const findRegistryList = () => wrapper.findComponent(RegistryList);
+ const findEmptyState = () => wrapper.findByTestId('empty-state-slot');
+ const findFirstRow = () => wrapper.findByTestId('element');
+ const findRows = () => wrapper.findAllByTestId('element');
+
+ const defaultProps = {
+ items: ['a', 'b', 'c'],
+ pageInfo: defaultPageInfo,
+ isLoading: false,
+ errorMessage: '',
+ };
+
+ const mountComponent = (props = {}) => {
+ wrapper = shallowMountExtended(SearchableList, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ stubs: {
+ RegistryList,
+ },
+ slots: {
+ 'empty-state': '<div data-testid="empty-state-slot">This is empty</div>',
+ item: '<div data-testid="element"></div>',
+ },
+ });
+ };
+
+ describe('when list is loaded and has no data', () => {
+ beforeEach(() => mountComponent({ items: [] }));
+
+ it('shows empty state', () => {
+ expect(findEmptyState().text()).toBe('This is empty');
+ });
+
+ it('does not display loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not display rows', () => {
+ expect(findFirstRow().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 errorMessage', () => {
+ beforeEach(() => mountComponent({ errorMessage: 'Failure!' }));
+
+ it('shows error message', () => {
+ expect(findAlert().text()).toContain('Failure!');
+ });
+
+ it('is not dismissible', () => {
+ expect(findAlert().props('dismissible')).toBe(false);
+ });
+
+ it('is of variant danger', () => {
+ expect(findAlert().attributes('variant')).toBe('danger');
+ });
+
+ it('hides loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('hides registry list', () => {
+ expect(findRegistryList().exists()).toBe(false);
+ });
+
+ it('hides empty state', () => {
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('if loading', () => {
+ beforeEach(() => mountComponent({ isLoading: true }));
+
+ it('shows loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('hides error message', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('hides registry list', () => {
+ expect(findRegistryList().exists()).toBe(false);
+ });
+
+ it('hides empty state', () => {
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('when list is loaded with data', () => {
+ beforeEach(() => mountComponent());
+
+ it('displays package registry list', () => {
+ expect(findRegistryList().exists()).toEqual(true);
+ });
+
+ it('binds the right props', () => {
+ expect(findRegistryList().props()).toMatchObject({
+ items: ['a', 'b', 'c'],
+ isLoading: false,
+ pagination: defaultPageInfo,
+ hiddenDelete: true,
+ });
+ });
+
+ it('displays package version rows', () => {
+ expect(findRows().exists()).toEqual(true);
+ expect(findRows()).toHaveLength(3);
+ });
+
+ 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', () => {
+ beforeEach(() => mountComponent());
+
+ it('when list emits next-page emits fetchPage with correct pageInfo', () => {
+ findRegistryList().vm.$emit('next-page');
+
+ const expectedNewPageInfo = {
+ after: 'eyJpZCI6IjIifQ',
+ first: 30,
+ last: null,
+ };
+
+ expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]);
+ });
+
+ it('when list emits prev-page emits fetchPage with correct pageInfo', () => {
+ findRegistryList().vm.$emit('prev-page');
+
+ const expectedNewPageInfo = {
+ before: 'eyJpZCI6IjE2In0',
+ first: null,
+ last: 30,
+ };
+
+ expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]);
+ });
+ });
+});
diff --git a/spec/frontend/ml/model_registry/graphql_mock_data.js b/spec/frontend/ml/model_registry/graphql_mock_data.js
index 1c31ee4627f..27424fbf0df 100644
--- a/spec/frontend/ml/model_registry/graphql_mock_data.js
+++ b/spec/frontend/ml/model_registry/graphql_mock_data.js
@@ -114,3 +114,27 @@ export const emptyCandidateQuery = {
},
},
};
+
+export const createModelResponses = {
+ success: {
+ data: {
+ mlModelCreate: {
+ model: {
+ id: 'gid://gitlab/Ml::Model/1',
+ _links: {
+ showPath: '/some/project/-/ml/models/1',
+ },
+ },
+ errors: [],
+ },
+ },
+ },
+ validationFailure: {
+ data: {
+ mlModelCreate: {
+ model: null,
+ errors: ['Name is invalid', "Name can't be blank"],
+ },
+ },
+ },
+};
diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js
index 4399df38990..d8bb6a8eedb 100644
--- a/spec/frontend/ml/model_registry/mock_data.js
+++ b/spec/frontend/ml/model_registry/mock_data.js
@@ -42,6 +42,7 @@ export const newCandidate = () => ({
const LATEST_VERSION = {
version: '1.2.3',
+ path: 'path/to/modelversion',
};
export const makeModel = ({ latestVersion } = { latestVersion: LATEST_VERSION }) => ({
diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js
index 33295d46fea..4fea216302f 100644
--- a/spec/frontend/oauth_remember_me_spec.js
+++ b/spec/frontend/oauth_remember_me_spec.js
@@ -5,13 +5,13 @@ import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me';
describe('OAuthRememberMe', () => {
const findFormAction = (selector) => {
- return $(`#oauth-container .js-oauth-login${selector}`).parent('form').attr('action');
+ return $(`.js-oauth-login ${selector}`).parent('form').attr('action');
};
beforeEach(() => {
setHTMLFixture(htmlOauthRememberMe);
- new OAuthRememberMe({ container: $('#oauth-container') }).bindEvents();
+ new OAuthRememberMe({ container: $('.js-oauth-login') }).bindEvents();
});
afterEach(() => {
@@ -19,7 +19,7 @@ describe('OAuthRememberMe', () => {
});
it('adds and removes the "remember_me" query parameter from all OAuth login buttons', () => {
- $('#oauth-container #remember_me_omniauth').click();
+ $('.js-oauth-login #remember_me_omniauth').click();
expect(findFormAction('.twitter')).toBe('http://example.com/?remember_me=1');
expect(findFormAction('.github')).toBe('http://example.com/?remember_me=1');
@@ -27,7 +27,7 @@ describe('OAuthRememberMe', () => {
'http://example.com/?redirect_fragment=L1&remember_me=1',
);
- $('#oauth-container #remember_me_omniauth').click();
+ $('.js-oauth-login #remember_me_omniauth').click();
expect(findFormAction('.twitter')).toBe('http://example.com/');
expect(findFormAction('.github')).toBe('http://example.com/');
diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js
index e7b68a2346e..0a852d9e000 100644
--- a/spec/frontend/observability/client_spec.js
+++ b/spec/frontend/observability/client_spec.js
@@ -279,6 +279,38 @@ describe('buildClient', () => {
'&attr_name=name1&attr_value=value1',
);
});
+ describe('date range time filter', () => {
+ it('handles custom date range period filter', async () => {
+ await client.fetchTraces({
+ filters: {
+ period: [{ operator: '=', value: '2023-01-01 - 2023-02-01' }],
+ },
+ });
+ expect(getQueryParam()).not.toContain('period=');
+ expect(getQueryParam()).toContain(
+ 'start_time=2023-01-01T00:00:00.000Z&end_time=2023-02-01T00:00:00.000Z',
+ );
+ });
+
+ it.each([
+ 'invalid - 2023-02-01',
+ '2023-02-01 - invalid',
+ 'invalid - invalid',
+ '2023-01-01 / 2023-02-01',
+ '2023-01-01 2023-02-01',
+ '2023-01-01 - 2023-02-01 - 2023-02-01',
+ ])('ignore invalid values', async (val) => {
+ await client.fetchTraces({
+ filters: {
+ period: [{ operator: '=', value: val }],
+ },
+ });
+
+ expect(getQueryParam()).not.toContain('start_time=');
+ expect(getQueryParam()).not.toContain('end_time=');
+ expect(getQueryParam()).not.toContain('period=');
+ });
+ });
it('handles repeated params', async () => {
await client.fetchTraces({
diff --git a/spec/frontend/organizations/new/components/app_spec.js b/spec/frontend/organizations/new/components/app_spec.js
index 4f31baedbf6..e3e1c5b9684 100644
--- a/spec/frontend/organizations/new/components/app_spec.js
+++ b/spec/frontend/organizations/new/components/app_spec.js
@@ -24,10 +24,14 @@ describe('OrganizationNewApp', () => {
let wrapper;
let mockApollo;
+ const file = new File(['foo'], 'foo.jpg', {
+ type: 'text/plain',
+ });
+
+ const successfulResponseHandler = jest.fn().mockResolvedValue(organizationCreateResponse);
+
const createComponent = ({
- handlers = [
- [organizationCreateMutation, jest.fn().mockResolvedValue(organizationCreateResponse)],
- ],
+ handlers = [[organizationCreateMutation, successfulResponseHandler]],
} = {}) => {
mockApollo = createMockApollo(handlers);
@@ -36,7 +40,12 @@ describe('OrganizationNewApp', () => {
const findForm = () => wrapper.findComponent(NewEditForm);
const submitForm = async () => {
- findForm().vm.$emit('submit', { name: 'Foo bar', path: 'foo-bar' });
+ findForm().vm.$emit('submit', {
+ name: 'Foo bar',
+ path: 'foo-bar',
+ description: 'Foo bar description',
+ avatar: file,
+ });
await nextTick();
};
@@ -74,7 +83,15 @@ describe('OrganizationNewApp', () => {
await waitForPromises();
});
- it('redirects user to organization web url', () => {
+ it('calls mutation with correct variables and redirects user to organization web url', () => {
+ expect(successfulResponseHandler).toHaveBeenCalledWith({
+ input: {
+ name: 'Foo bar',
+ path: 'foo-bar',
+ description: 'Foo bar description',
+ avatar: file,
+ },
+ });
expect(visitUrlWithAlerts).toHaveBeenCalledWith(
organizationCreateResponse.data.organizationCreate.organization.webUrl,
[
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 d1c637331a8..52e81d7fb5d 100644
--- a/spec/frontend/organizations/settings/general/components/organization_settings_spec.js
+++ b/spec/frontend/organizations/settings/general/components/organization_settings_spec.js
@@ -5,7 +5,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import OrganizationSettings from '~/organizations/settings/general/components/organization_settings.vue';
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 {
+ FORM_FIELD_NAME,
+ FORM_FIELD_ID,
+ FORM_FIELD_AVATAR,
+ FORM_FIELD_DESCRIPTION,
+} from '~/organizations/shared/constants';
import organizationUpdateMutation from '~/organizations/settings/general/graphql/mutations/organization_update.mutation.graphql';
import {
organizationUpdateResponse,
@@ -38,22 +43,33 @@ describe('OrganizationSettings', () => {
},
};
+ const file = new File(['foo'], 'foo.jpg', {
+ type: 'text/plain',
+ });
+
const successfulResponseHandler = jest.fn().mockResolvedValue(organizationUpdateResponse);
const createComponent = ({
handlers = [[organizationUpdateMutation, successfulResponseHandler]],
+ provide = {},
} = {}) => {
mockApollo = createMockApollo(handlers);
wrapper = shallowMountExtended(OrganizationSettings, {
- provide: defaultProvide,
+ provide: { ...defaultProvide, ...provide },
apolloProvider: mockApollo,
});
};
const findForm = () => wrapper.findComponent(NewEditForm);
- const submitForm = async () => {
- findForm().vm.$emit('submit', { name: 'Foo bar', path: 'foo-bar' });
+ const submitForm = async (data = {}) => {
+ findForm().vm.$emit('submit', {
+ name: 'Foo bar',
+ path: 'foo-bar',
+ description: 'Foo bar description',
+ avatar: file,
+ ...data,
+ });
await nextTick();
};
@@ -75,7 +91,7 @@ describe('OrganizationSettings', () => {
expect(findForm().props()).toMatchObject({
loading: false,
initialFormValues: defaultProvide.organization,
- fieldsToRender: [FORM_FIELD_NAME, FORM_FIELD_ID],
+ fieldsToRender: [FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_DESCRIPTION, FORM_FIELD_AVATAR],
});
});
@@ -108,6 +124,8 @@ describe('OrganizationSettings', () => {
input: {
id: 'gid://gitlab/Organizations::Organization/1',
name: 'Foo bar',
+ description: 'Foo bar description',
+ avatar: file,
},
});
expect(visitUrlWithAlerts).toHaveBeenCalledWith(window.location.href, [
@@ -162,5 +180,48 @@ describe('OrganizationSettings', () => {
});
});
});
+
+ describe('when organization has avatar', () => {
+ beforeEach(() => {
+ createComponent({
+ provide: { organization: { ...defaultProvide.organization, avatar: 'avatar.jpg' } },
+ });
+ });
+
+ describe('when avatar is explicitly removed', () => {
+ beforeEach(async () => {
+ await submitForm({ avatar: null });
+ await waitForPromises();
+ });
+
+ it('sets `avatar` argument to `null`', () => {
+ expect(successfulResponseHandler).toHaveBeenCalledWith({
+ input: {
+ id: 'gid://gitlab/Organizations::Organization/1',
+ name: 'Foo bar',
+ description: 'Foo bar description',
+ avatar: null,
+ },
+ });
+ });
+ });
+
+ describe('when avatar is not changed', () => {
+ beforeEach(async () => {
+ await submitForm({ avatar: 'avatar.jpg' });
+ await waitForPromises();
+ });
+
+ it('does not pass `avatar` argument', () => {
+ expect(successfulResponseHandler).toHaveBeenCalledWith({
+ input: {
+ id: 'gid://gitlab/Organizations::Organization/1',
+ name: 'Foo bar',
+ description: 'Foo bar description',
+ },
+ });
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/organizations/shared/components/groups_view_spec.js b/spec/frontend/organizations/shared/components/groups_view_spec.js
index 8d6ea60ffd2..e51d6a98743 100644
--- a/spec/frontend/organizations/shared/components/groups_view_spec.js
+++ b/spec/frontend/organizations/shared/components/groups_view_spec.js
@@ -25,13 +25,20 @@ describe('GroupsView', () => {
newGroupPath: '/groups/new',
};
+ const defaultPropsData = {
+ listItemClass: 'gl-px-5',
+ };
+
const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers);
wrapper = shallowMountExtended(GroupsView, {
apolloProvider: mockApollo,
provide: defaultProvide,
- propsData,
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
});
};
@@ -115,6 +122,7 @@ describe('GroupsView', () => {
expect(wrapper.findComponent(GroupsList).props()).toEqual({
groups: formatGroups(organizationGroups.nodes),
showGroupIcon: true,
+ listItemClass: defaultPropsData.listItemClass,
});
});
});
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 1fcfc20bf1a..5be26ef7cc3 100644
--- a/spec/frontend/organizations/shared/components/new_edit_form_spec.js
+++ b/spec/frontend/organizations/shared/components/new_edit_form_spec.js
@@ -1,9 +1,16 @@
-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 AvatarUploadDropzone from '~/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import {
+ FORM_FIELD_NAME,
+ FORM_FIELD_ID,
+ FORM_FIELD_PATH,
+ FORM_FIELD_AVATAR,
+} from '~/organizations/shared/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
describe('NewEditForm', () => {
@@ -12,6 +19,7 @@ describe('NewEditForm', () => {
const defaultProvide = {
organizationsPath: '/-/organizations',
rootUrl: 'http://127.0.0.1:3000/',
+ previewMarkdownPath: '/-/organizations/preview_markdown',
};
const defaultPropsData = {
@@ -32,6 +40,8 @@ describe('NewEditForm', () => {
const findNameField = () => wrapper.findByLabelText('Organization name');
const findIdField = () => wrapper.findByLabelText('Organization ID');
const findUrlField = () => wrapper.findComponent(OrganizationUrlField);
+ const findDescriptionField = () => wrapper.findByLabelText('Organization description (optional)');
+ const findAvatarField = () => wrapper.findComponent(AvatarUploadDropzone);
const setUrlFieldValue = async (value) => {
findUrlField().vm.$emit('input', value);
@@ -53,6 +63,56 @@ describe('NewEditForm', () => {
expect(findUrlField().exists()).toBe(true);
});
+ it('renders `Organization avatar` field', () => {
+ createComponent();
+
+ expect(findAvatarField().props()).toMatchObject({
+ value: null,
+ entity: { [FORM_FIELD_NAME]: '', [FORM_FIELD_PATH]: '', [FORM_FIELD_AVATAR]: null },
+ label: 'Organization avatar',
+ });
+ });
+
+ it('renders `Organization description` field as markdown editor', () => {
+ createComponent();
+
+ expect(findDescriptionField().exists()).toBe(true);
+ expect(wrapper.findComponent(MarkdownField).props()).toMatchObject({
+ markdownPreviewPath: defaultProvide.previewMarkdownPath,
+ markdownDocsPath: helpPagePath('user/organization/index', {
+ anchor: 'organization-description-supported-markdown',
+ }),
+ textareaValue: '',
+ restrictedToolBarItems: [
+ 'code',
+ 'quote',
+ 'bullet-list',
+ 'numbered-list',
+ 'task-list',
+ 'collapsible-section',
+ 'table',
+ 'attach-file',
+ 'full-screen',
+ ],
+ });
+ });
+
+ describe('when `Organization avatar` field is changed', () => {
+ const file = new File(['foo'], 'foo.jpg', {
+ type: 'text/plain',
+ });
+
+ beforeEach(() => {
+ window.URL.revokeObjectURL = jest.fn();
+ createComponent();
+ findAvatarField().vm.$emit('input', file);
+ });
+
+ it('updates `value` prop', () => {
+ expect(findAvatarField().props('value')).toEqual(file);
+ });
+ });
+
it('requires `Organization URL` field to be a minimum of two characters', async () => {
createComponent();
@@ -121,11 +181,14 @@ describe('NewEditForm', () => {
await findNameField().setValue('Foo bar');
await setUrlFieldValue('foo-bar');
+ await findDescriptionField().setValue('Foo bar description');
await submitForm();
});
it('emits `submit` event with form values', () => {
- expect(wrapper.emitted('submit')).toEqual([[{ name: 'Foo bar', path: 'foo-bar' }]]);
+ expect(wrapper.emitted('submit')).toEqual([
+ [{ name: 'Foo bar', path: 'foo-bar', description: 'Foo bar description', avatar: null }],
+ ]);
});
});
@@ -186,7 +249,7 @@ describe('NewEditForm', () => {
});
it('shows button with loading icon', () => {
- expect(wrapper.findComponent(GlButton).props('loading')).toBe(true);
+ expect(wrapper.findByTestId('submit-button').props('loading')).toBe(true);
});
});
diff --git a/spec/frontend/organizations/shared/components/projects_view_spec.js b/spec/frontend/organizations/shared/components/projects_view_spec.js
index 490b0c89348..3cc71927bfa 100644
--- a/spec/frontend/organizations/shared/components/projects_view_spec.js
+++ b/spec/frontend/organizations/shared/components/projects_view_spec.js
@@ -25,13 +25,20 @@ describe('ProjectsView', () => {
newProjectPath: '/projects/new',
};
+ const defaultPropsData = {
+ listItemClass: 'gl-px-5',
+ };
+
const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers);
wrapper = shallowMountExtended(ProjectsView, {
apolloProvider: mockApollo,
provide: defaultProvide,
- propsData,
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
});
};
@@ -115,6 +122,7 @@ describe('ProjectsView', () => {
expect(wrapper.findComponent(ProjectsList).props()).toEqual({
projects: formatProjects(organizationProjects.nodes),
showProjectIcon: true,
+ listItemClass: defaultPropsData.listItemClass,
});
});
});
diff --git a/spec/frontend/organizations/show/components/app_spec.js b/spec/frontend/organizations/show/components/app_spec.js
index 46496e40bdd..6cf8845bdbe 100644
--- a/spec/frontend/organizations/show/components/app_spec.js
+++ b/spec/frontend/organizations/show/components/app_spec.js
@@ -1,6 +1,7 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import App from '~/organizations/show/components/app.vue';
import OrganizationAvatar from '~/organizations/show/components/organization_avatar.vue';
+import OrganizationDescription from '~/organizations/show/components/organization_description.vue';
import GroupsAndProjects from '~/organizations/show/components/groups_and_projects.vue';
import AssociationCount from '~/organizations/show/components/association_counts.vue';
@@ -34,6 +35,12 @@ describe('OrganizationShowApp', () => {
);
});
+ it('renders organization description and passes organization prop', () => {
+ expect(wrapper.findComponent(OrganizationDescription).props('organization')).toEqual(
+ defaultPropsData.organization,
+ );
+ });
+
it('renders groups and projects component and passes `groupsAndProjectsOrganizationPath` prop', () => {
expect(
wrapper.findComponent(GroupsAndProjects).props('groupsAndProjectsOrganizationPath'),
diff --git a/spec/frontend/organizations/show/components/organization_description_spec.js b/spec/frontend/organizations/show/components/organization_description_spec.js
new file mode 100644
index 00000000000..2aaf6f24a72
--- /dev/null
+++ b/spec/frontend/organizations/show/components/organization_description_spec.js
@@ -0,0 +1,46 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import OrganizationDescription from '~/organizations/show/components/organization_description.vue';
+
+describe('OrganizationDescription', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ organization: {
+ id: 1,
+ name: 'GitLab',
+ description_html: '<h1>Foo bar description</h1><script>alert("foo")</script>',
+ },
+ };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = mountExtended(OrganizationDescription, {
+ propsData: { ...defaultPropsData, ...propsData },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when organization has description', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders description as safe HTML', () => {
+ expect(wrapper.element.innerHTML).toBe('<h1>Foo bar description</h1>');
+ });
+ });
+
+ describe('when organization does not have description', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: { organization: { ...defaultPropsData.organization, description_html: '' } },
+ });
+ });
+
+ it('renders nothing', () => {
+ expect(wrapper.html()).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
index 500fb0d7598..6fee0d1b825 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
@@ -207,6 +207,7 @@ describe('Details Header', () => {
expect(findSize().props()).toMatchObject({
icon: 'disk',
text: numberToHumanSize(size),
+ textTooltip: 'Includes both tagged and untagged images',
});
});
});
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 cbf2184d879..78d7f4183b7 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
@@ -28,19 +28,15 @@ exports[`packages_list_row renders 1`] = `
<div
class="gl-align-items-center gl-display-flex gl-min-w-0 gl-mr-3"
>
- <router-link-stub
- ariacurrentvalue="page"
+ <a
class="gl-min-w-0 gl-text-body"
data-testid="details-link"
- event="click"
- tag="a"
- to="[object Object]"
>
<gl-truncate-stub
position="end"
text="@gitlab-org/package-15"
/>
- </router-link-stub>
+ </a>
</div>
</div>
<div
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
index 9f8fd4e28e7..afcb1798878 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
@@ -1,6 +1,7 @@
import { GlFormCheckbox, GlSprintf, GlTruncate } from '@gitlab/ui';
import Vue from 'vue';
import VueRouter from 'vue-router';
+import { RouterLinkStub } from '@vue/test-utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -58,6 +59,7 @@ describe('packages_list_row', () => {
ListItem,
GlSprintf,
TimeagoTooltip,
+ RouterLink: RouterLinkStub,
},
propsData: {
packageEntity,
diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
index 3ce8e91d43d..32ddc087b32 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
@@ -73,11 +73,11 @@ describe('Group Settings App', () => {
};
describe.each`
- finder | entitySpecificProps | successMessage | errorMessage
- ${findPackageSettings} | ${packageSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'}
- ${findPackageForwardingSettings} | ${packageForwardingSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'}
- ${findDependencyProxySettings} | ${dependencyProxyProps} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'}
- `('settings blocks', ({ finder, entitySpecificProps, successMessage, errorMessage }) => {
+ finder | entitySpecificProps
+ ${findPackageSettings} | ${packageSettingsProps}
+ ${findPackageForwardingSettings} | ${packageForwardingSettingsProps}
+ ${findDependencyProxySettings} | ${dependencyProxyProps}
+ `('settings blocks', ({ finder, entitySpecificProps }) => {
beforeEach(() => {
mountComponent();
return waitForApolloQueryAndRender();
@@ -94,7 +94,7 @@ describe('Group Settings App', () => {
describe('success event', () => {
it('shows a success toast', () => {
finder().vm.$emit('success');
- expect(show).toHaveBeenCalledWith(successMessage);
+ expect(show).toHaveBeenCalledWith('Settings saved successfully.');
});
it('hides the error alert', async () => {
@@ -121,7 +121,7 @@ describe('Group Settings App', () => {
});
it('alert has the right text', () => {
- expect(findAlert().text()).toBe(errorMessage);
+ expect(findAlert().text()).toBe('An error occurred while saving the settings.');
});
it('dismissing the alert removes it', async () => {
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js
new file mode 100644
index 00000000000..bdb3db7a1b9
--- /dev/null
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js
@@ -0,0 +1,97 @@
+import { GlTable, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import PackagesProtectionRules from '~/packages_and_registries/settings/project/components/packages_protection_rules.vue';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql';
+
+import { packagesProtectionRuleQueryPayload, packagesProtectionRulesData } from '../mock_data';
+
+Vue.use(VueApollo);
+
+describe('Packages protection rules project settings', () => {
+ let wrapper;
+ let fakeApollo;
+
+ const defaultProvidedValues = {
+ projectPath: 'path',
+ };
+ const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTableRows = () => findTable().find('tbody').findAll('tr');
+
+ const mountComponent = (mountFn = shallowMount, provide = defaultProvidedValues, config) => {
+ wrapper = mountFn(PackagesProtectionRules, {
+ stubs: {
+ SettingsBlock,
+ },
+ provide,
+ ...config,
+ });
+ };
+
+ const createComponent = ({
+ mountFn = shallowMount,
+ provide = defaultProvidedValues,
+ resolver = jest.fn().mockResolvedValue(packagesProtectionRuleQueryPayload()),
+ } = {}) => {
+ const requestHandlers = [[packagesProtectionRuleQuery, resolver]];
+
+ fakeApollo = createMockApollo(requestHandlers);
+
+ mountComponent(mountFn, provide, {
+ apolloProvider: fakeApollo,
+ });
+ };
+
+ it('renders the setting block with table', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findSettingsBlock().exists()).toBe(true);
+ expect(findTable().exists()).toBe(true);
+ });
+
+ describe('table package protection rules', () => {
+ it('renders table with packages protection rules', async () => {
+ createComponent({ mountFn: mount });
+
+ await waitForPromises();
+
+ expect(findTable().exists()).toBe(true);
+
+ packagesProtectionRulesData.forEach((protectionRule, i) => {
+ expect(findTableRows().at(i).text()).toContain(protectionRule.packageNamePattern);
+ expect(findTableRows().at(i).text()).toContain(protectionRule.packageType);
+ expect(findTableRows().at(i).text()).toContain(protectionRule.pushProtectedUpToAccessLevel);
+ });
+ });
+
+ it('displays table in busy state and shows loading icon inside table', async () => {
+ createComponent({ mountFn: mount });
+
+ expect(findTableLoadingIcon().exists()).toBe(true);
+ expect(findTableLoadingIcon().attributes('aria-label')).toBe('Loading');
+
+ expect(findTable().attributes('aria-busy')).toBe('true');
+
+ await waitForPromises();
+
+ expect(findTableLoadingIcon().exists()).toBe(false);
+ expect(findTable().attributes('aria-busy')).toBe('false');
+ });
+
+ it('renders table', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findTable().exists()).toBe(true);
+ });
+ });
+});
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 dfcabd14489..1afc9b62ba2 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 PackagesProtectionRules from '~/packages_and_registries/settings/project/components/packages_protection_rules.vue';
import DependencyProxyPackagesSettings from 'ee_component/packages_and_registries/settings/project/components/dependency_proxy_packages_settings.vue';
import {
SHOW_SETUP_SUCCESS_ALERT,
@@ -19,6 +20,7 @@ describe('Registry Settings app', () => {
const findContainerExpirationPolicy = () => wrapper.findComponent(ContainerExpirationPolicy);
const findPackagesCleanupPolicy = () => wrapper.findComponent(PackagesCleanupPolicy);
+ const findPackagesProtectionRules = () => wrapper.findComponent(PackagesProtectionRules);
const findDependencyProxyPackagesSettings = () =>
wrapper.findComponent(DependencyProxyPackagesSettings);
const findAlert = () => wrapper.findComponent(GlAlert);
@@ -29,6 +31,7 @@ describe('Registry Settings app', () => {
showPackageRegistrySettings: true,
showDependencyProxySettings: false,
...(IS_EE && { showDependencyProxySettings: true }),
+ glFeatures: { packagesProtectedPackages: true },
};
const mountComponent = (provide = defaultProvide) => {
@@ -95,6 +98,7 @@ describe('Registry Settings app', () => {
expect(findContainerExpirationPolicy().exists()).toBe(showContainerRegistrySettings);
expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings);
+ expect(findPackagesProtectionRules().exists()).toBe(showPackageRegistrySettings);
},
);
@@ -108,5 +112,20 @@ describe('Registry Settings app', () => {
expect(findDependencyProxyPackagesSettings().exists()).toBe(value);
});
}
+
+ describe('when feature flag "packagesProtectedPackages" is disabled', () => {
+ it.each([true, false])(
+ 'package protection rules settings is hidden if showPackageRegistrySettings is %s',
+ (showPackageRegistrySettings) => {
+ mountComponent({
+ ...defaultProvide,
+ showPackageRegistrySettings,
+ glFeatures: { packagesProtectedPackages: false },
+ });
+
+ expect(findPackagesProtectionRules().exists()).toBe(false);
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
index 3204ca01f99..5c546289b14 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
@@ -79,3 +79,36 @@ export const packagesCleanupPolicyMutationPayload = ({ override, errors = [] } =
},
},
});
+
+export const packagesProtectionRulesData = [
+ {
+ id: `gid://gitlab/Packages::Protection::Rule/14`,
+ packageNamePattern: `@flight/flight-maintainer-14-*`,
+ packageType: 'NPM',
+ pushProtectedUpToAccessLevel: 'MAINTAINER',
+ },
+ {
+ id: `gid://gitlab/Packages::Protection::Rule/15`,
+ packageNamePattern: `@flight/flight-maintainer-15-*`,
+ packageType: 'NPM',
+ pushProtectedUpToAccessLevel: 'MAINTAINER',
+ },
+ {
+ id: 'gid://gitlab/Packages::Protection::Rule/16',
+ packageNamePattern: '@flight/flight-owner-16-*',
+ packageType: 'NPM',
+ pushProtectedUpToAccessLevel: 'OWNER',
+ },
+];
+
+export const packagesProtectionRuleQueryPayload = ({ override, errors = [] } = {}) => ({
+ data: {
+ project: {
+ id: '1',
+ packagesProtectionRules: {
+ nodes: override || packagesProtectionRulesData,
+ },
+ errors,
+ },
+ },
+});
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 3db77469d6b..1c9d8f17210 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,13 +1,13 @@
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 ImportStatus from '~/import_entities/import_groups/components/import_status.vue';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
@@ -39,6 +39,7 @@ describe('BulkImportsHistoryApp', () => {
destination_slug: 'top-level-group-12',
destination_namespace: 'h5bp',
created_at: '2021-07-08T10:03:44.743Z',
+ has_failures: false,
failures: [],
},
{
@@ -56,6 +57,7 @@ describe('BulkImportsHistoryApp', () => {
project_id: null,
created_at: '2021-07-13T12:52:26.664Z',
updated_at: '2021-07-13T13:34:49.403Z',
+ has_failures: true,
failures: [
{
pipeline_class: 'BulkImports::Groups::Pipelines::GroupPipeline',
@@ -72,15 +74,19 @@ describe('BulkImportsHistoryApp', () => {
let mock;
const mockRealtimeChangesPath = '/import/realtime_changes.json';
- function createComponent({ shallow = true } = {}) {
+ function createComponent({ shallow = true, provide } = {}) {
const mountFn = shallow ? shallowMount : mount;
wrapper = mountFn(BulkImportsHistoryApp, {
- provide: { realtimeChangesPath: mockRealtimeChangesPath },
+ provide: {
+ realtimeChangesPath: mockRealtimeChangesPath,
+ ...provide,
+ },
});
}
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const findPaginationBar = () => wrapper.findComponent(PaginationBar);
+ const findImportStatusAt = (index) => wrapper.findAllComponents(ImportStatus).at(index);
beforeEach(() => {
gon.api_version = 'v4';
@@ -201,77 +207,59 @@ describe('BulkImportsHistoryApp', () => {
expect(findLocalStorageSync().props('value')).toBe(NEW_PAGE_SIZE);
});
- it('renders link to destination_full_path for destination group', async () => {
- createComponent({ shallow: false });
- await waitForPromises();
-
- expect(wrapper.find('tbody tr a').attributes().href).toBe(
- `/${DUMMY_RESPONSE[0].destination_full_path}`,
- );
- });
-
- it('renders destination as text when destination_full_path is not defined', async () => {
- const RESPONSE = [{ ...DUMMY_RESPONSE[0], destination_full_path: null }];
-
- mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS);
- createComponent({ shallow: false });
- await waitForPromises();
-
- expect(wrapper.find('tbody tr a').exists()).toBe(false);
- expect(wrapper.find('tbody tr span').text()).toBe(
- `${DUMMY_RESPONSE[0].destination_namespace}/${DUMMY_RESPONSE[0].destination_slug}/`,
- );
- });
-
- it('adds slash to group urls', async () => {
- createComponent({ shallow: false });
- await waitForPromises();
-
- expect(wrapper.find('tbody tr a').text()).toBe(`${DUMMY_RESPONSE[0].destination_full_path}/`);
- });
+ describe('table rendering', () => {
+ beforeEach(async () => {
+ createComponent({ shallow: false });
+ await waitForPromises();
+ });
- it('does not prefixes project urls with slash', async () => {
- createComponent({ shallow: false });
- await waitForPromises();
+ it('renders link to destination_full_path for destination group', () => {
+ expect(wrapper.find('tbody tr a').attributes().href).toBe(
+ `/${DUMMY_RESPONSE[0].destination_full_path}`,
+ );
+ });
- expect(wrapper.findAll('tbody tr a').at(1).text()).toBe(
- DUMMY_RESPONSE[1].destination_full_path,
- );
- });
+ it('renders destination as text when destination_full_path is not defined', async () => {
+ const RESPONSE = [{ ...DUMMY_RESPONSE[0], destination_full_path: null }];
- describe('details button', () => {
- beforeEach(() => {
- mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS);
createComponent({ shallow: false });
- return waitForPromises();
+ await waitForPromises();
+
+ expect(wrapper.find('tbody tr a').exists()).toBe(false);
+ expect(wrapper.find('tbody tr span').text()).toBe(
+ `${DUMMY_RESPONSE[0].destination_namespace}/${DUMMY_RESPONSE[0].destination_slug}/`,
+ );
});
- it('renders details button if relevant item has failures', () => {
- expect(
- extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(),
- ).toBe(true);
+ it('adds slash to group urls', () => {
+ expect(wrapper.find('tbody tr a').text()).toBe(`${DUMMY_RESPONSE[0].destination_full_path}/`);
});
- it('does not render details button if relevant item has no failures', () => {
- expect(
- extendedWrapper(wrapper.find('tbody').findAll('tr').at(0)).findByText('Details').exists(),
- ).toBe(false);
+ it('does not prefix project urls with slash', () => {
+ expect(wrapper.findAll('tbody tr a').at(1).text()).toBe(
+ DUMMY_RESPONSE[1].destination_full_path,
+ );
});
- it('expands details when details button is clicked', async () => {
- const ORIGINAL_ROW_INDEX = 1;
- await extendedWrapper(wrapper.find('tbody').findAll('tr').at(ORIGINAL_ROW_INDEX))
- .findByText('Details')
- .trigger('click');
+ it('renders finished import status', () => {
+ expect(findImportStatusAt(0).text()).toBe('Complete');
+ });
- const detailsRowContent = wrapper
- .find('tbody')
- .findAll('tr')
- .at(ORIGINAL_ROW_INDEX + 1)
- .find('pre');
+ it('renders failed import status with details link', async () => {
+ createComponent({
+ shallow: false,
+ provide: {
+ detailsPath: '/mock-details',
+ },
+ });
+ await waitForPromises();
- expect(detailsRowContent.exists()).toBe(true);
- expect(JSON.parse(detailsRowContent.text())).toStrictEqual(DUMMY_RESPONSE[1].failures);
+ const failedImportStatus = findImportStatusAt(1);
+ const failedImportStatusLink = failedImportStatus.find('a');
+ expect(failedImportStatus.text()).toContain('Failed');
+ expect(failedImportStatusLink.text()).toBe('See failures');
+ expect(failedImportStatusLink.attributes('href')).toContain('/mock-details');
});
});
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 8145eb6fbd4..d64a05c93d2 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
@@ -8,9 +8,9 @@ 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 catalogResourcesCreate from '~/ci/catalog/graphql/mutations/catalog_resources_create.mutation.graphql';
+import catalogResourcesDestroy from '~/ci/catalog/graphql/mutations/catalog_resources_destroy.mutation.graphql';
+import getCiCatalogSettingsQuery from '~/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql';
import CiCatalogSettings from '~/pages/projects/shared/permissions/components/ci_catalog_settings.vue';
import { generateCatalogSettingsResponse } from './mock_data';
diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
index 6ff2bb42d8d..7607381a981 100644
--- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
+++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
@@ -5,7 +5,7 @@ import preserveUrlFragment from '~/pages/sessions/new/preserve_url_fragment';
describe('preserve_url_fragment', () => {
const findFormAction = (selector) => {
- return $(`.omniauth-container ${selector}`).parent('form').attr('action');
+ return $(`.js-oauth-login ${selector}`).parent('form').attr('action');
};
beforeEach(() => {
@@ -44,9 +44,7 @@ describe('preserve_url_fragment', () => {
});
it('when "remember-me" is present', () => {
- $('.js-oauth-login')
- .parent('form')
- .attr('action', (i, href) => `${href}?remember_me=1`);
+ $('.js-oauth-login form').attr('action', (i, href) => `${href}?remember_me=1`);
preserveUrlFragment('#L65');
diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
index 7a018236314..1ccb56a0697 100644
--- a/spec/frontend/performance_bar/components/performance_bar_app_spec.js
+++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
@@ -17,6 +17,9 @@ describe('performance bar app', () => {
statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/',
peekUrl: '/-/peek/results',
},
+ stubs: {
+ GlEmoji: { template: '<div/>' },
+ },
});
};
diff --git a/spec/frontend/performance_bar/components/request_warning_spec.js b/spec/frontend/performance_bar/components/request_warning_spec.js
index a4f0d388e33..a85f83e9da7 100644
--- a/spec/frontend/performance_bar/components/request_warning_spec.js
+++ b/spec/frontend/performance_bar/components/request_warning_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RequestWarning from '~/performance_bar/components/request_warning.vue';
Vue.config.ignoredElements = ['gl-emoji'];
@@ -8,9 +8,20 @@ describe('request warning', () => {
let wrapper;
const htmlId = 'request-123';
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(RequestWarning, {
+ propsData,
+ stubs: {
+ GlEmoji: { template: `<div id="${htmlId}" />` },
+ },
+ });
+ };
+
+ const findEmoji = () => wrapper.findByTestId('warning');
+
describe('when the request has warnings', () => {
beforeEach(() => {
- wrapper = shallowMount(RequestWarning, {
+ createComponent({
propsData: {
htmlId,
warnings: ['gitaly calls: 30 over 10', 'gitaly duration: 1500 over 1000'],
@@ -19,14 +30,14 @@ describe('request warning', () => {
});
it('adds a warning emoji with the correct ID', () => {
- expect(wrapper.find('span gl-emoji[id]').attributes('id')).toEqual(htmlId);
- expect(wrapper.find('span gl-emoji[id]').element.dataset.name).toEqual('warning');
+ expect(findEmoji().attributes('id')).toEqual(htmlId);
+ expect(findEmoji().element.dataset.name).toEqual('warning');
});
});
describe('when the request does not have warnings', () => {
beforeEach(() => {
- wrapper = shallowMount(RequestWarning, {
+ createComponent({
propsData: {
htmlId,
warnings: [],
@@ -35,7 +46,7 @@ describe('request warning', () => {
});
it('does nothing', () => {
- expect(wrapper.html()).toBe('');
+ expect(findEmoji().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
index 144d9e76869..83c4a7435a8 100644
--- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js
+++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { createAlert, VARIANT_DANGER, VARIANT_INFO } from '~/alert';
+import { createAlert, VARIANT_DANGER } from '~/alert';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
import { i18n } from '~/profile/preferences/constants';
@@ -32,11 +32,17 @@ describe('ProfilePreferences component', () => {
profilePreferencesPath: '/update-profile',
formEl: document.createElement('form'),
};
+ const showToast = jest.fn();
function createComponent(options = {}) {
const { props = {}, provide = {}, attachTo } = options;
return extendedWrapper(
shallowMount(ProfilePreferences, {
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
provide: {
...defaultProvide,
...provide,
@@ -136,10 +142,7 @@ describe('ProfilePreferences component', () => {
const successEvent = new CustomEvent('ajax:success');
form.dispatchEvent(successEvent);
- expect(createAlert).toHaveBeenCalledWith({
- message: i18n.defaultSuccess,
- variant: VARIANT_INFO,
- });
+ expect(showToast).toHaveBeenCalledWith(i18n.defaultSuccess);
});
it('displays the custom success message', () => {
@@ -147,7 +150,7 @@ describe('ProfilePreferences component', () => {
const successEvent = new CustomEvent('ajax:success', { detail: [{ message }] });
form.dispatchEvent(successEvent);
- expect(createAlert).toHaveBeenCalledWith({ message, variant: VARIANT_INFO });
+ expect(showToast).toHaveBeenCalledWith(message);
});
it('displays the default error message', () => {
diff --git a/spec/frontend/projects/commit/components/commit_comments_button_spec.js b/spec/frontend/projects/commit/components/commit_comments_button_spec.js
deleted file mode 100644
index 873270c5be1..00000000000
--- a/spec/frontend/projects/commit/components/commit_comments_button_spec.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import CommitCommentsButton from '~/projects/commit/components/commit_comments_button.vue';
-
-describe('CommitCommentsButton', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = extendedWrapper(
- shallowMount(CommitCommentsButton, {
- propsData: {
- commentsCount: 1,
- ...props,
- },
- }),
- );
- };
-
- const tooltip = () => wrapper.findByTestId('comment-button-wrapper');
-
- describe('Comment Button', () => {
- it('has proper tooltip and button attributes for 1 comment', () => {
- createComponent();
-
- expect(tooltip().attributes('title')).toBe('1 comment on this commit');
- expect(tooltip().text()).toBe('1');
- });
-
- it('has proper tooltip and button attributes for multiple comments', () => {
- createComponent({ commentsCount: 2 });
-
- expect(tooltip().attributes('title')).toBe('2 comments on this commit');
- expect(tooltip().text()).toBe('2');
- });
-
- it('does not show when there are no comments', () => {
- createComponent({ commentsCount: 0 });
-
- expect(tooltip().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js
index ceac4435282..be923c1f643 100644
--- a/spec/frontend/projects/new/components/new_project_url_select_spec.js
+++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js
@@ -1,13 +1,12 @@
import {
GlButton,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
+ GlCollapsibleListbox,
+ GlListboxItem,
GlTruncate,
GlSearchBoxByType,
} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -99,16 +98,17 @@ describe('NewProjectUrlSelect component', () => {
};
const findButtonLabel = () => wrapper.findComponent(GlButton);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox);
const findSelectedPath = () => wrapper.findComponent(GlTruncate);
const findHiddenNamespaceInput = () => wrapper.find(`[name="${defaultProvide.inputName}`);
+ const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem);
+ const findToggleButton = () => findDropdown().findComponent(GlButton);
const findHiddenSelectedNamespaceInput = () =>
wrapper.find('[name="project[selected_namespace_id]"]');
const clickDropdownItem = async () => {
- wrapper.findComponent(GlDropdownItem).vm.$emit('click');
- await nextTick();
+ await findAllListboxItems().at(0).trigger('click');
};
const showDropdown = async () => {
@@ -135,7 +135,7 @@ describe('NewProjectUrlSelect component', () => {
});
it('renders a dropdown without the class', () => {
- expect(findDropdown().props('toggleClass')).not.toContain('gl-text-gray-500!');
+ expect(findToggleButton().classes()).not.toContain('gl-text-gray-500!');
});
it('renders a hidden input with the given namespace id', () => {
@@ -165,7 +165,7 @@ describe('NewProjectUrlSelect component', () => {
});
it('renders a dropdown with the class', () => {
- expect(findDropdown().props('toggleClass')).toContain('gl-text-gray-500!');
+ expect(findToggleButton().classes()).toContain('gl-text-gray-500!');
});
it("renders a hidden input with the user's namespace id", () => {
@@ -179,28 +179,22 @@ describe('NewProjectUrlSelect component', () => {
});
});
- it('focuses on the input when the dropdown is opened', async () => {
- wrapper = mountComponent();
-
- await showDropdown();
-
- expect(focusInputSpy).toHaveBeenCalledTimes(1);
- });
-
it('renders expected dropdown items', async () => {
wrapper = mountComponent({ mountFn: mount });
await showDropdown();
- const listItems = wrapper.findAll('li');
-
- expect(listItems).toHaveLength(6);
- expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups');
- expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[0].fullPath);
- expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[1].fullPath);
- expect(listItems.at(3).text()).toBe(data.currentUser.groups.nodes[2].fullPath);
- expect(listItems.at(4).findComponent(GlDropdownSectionHeader).text()).toBe('Users');
- expect(listItems.at(5).text()).toBe(data.currentUser.namespace.fullPath);
+ const { fullPath: text, id: value } = data.currentUser.namespace;
+ const userOptions = [{ text, value }];
+ const groupOptions = data.currentUser.groups.nodes.map((node) => ({
+ text: node.fullPath,
+ value: node.id,
+ }));
+
+ expect(findDropdown().props('items')).toEqual([
+ { text: 'Groups', options: groupOptions },
+ { text: 'Users', options: userOptions },
+ ]);
});
it('does not render users section when user namespace id is not provided', async () => {
@@ -211,8 +205,12 @@ describe('NewProjectUrlSelect component', () => {
await showDropdown();
- expect(wrapper.findAllComponents(GlDropdownSectionHeader)).toHaveLength(1);
- expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(0).text()).toBe('Groups');
+ const groupOptions = data.currentUser.groups.nodes.map((node) => ({
+ text: node.fullPath,
+ value: node.id,
+ }));
+
+ expect(findDropdown().props('items')).toEqual([{ text: 'Groups', options: groupOptions }]);
});
describe('query fetching', () => {
@@ -248,12 +246,15 @@ describe('NewProjectUrlSelect component', () => {
});
it('filters the dropdown items to the selected group and children', () => {
- const listItems = wrapper.findAll('li');
+ const filteredgroupOptions = data.currentUser.groups.nodes.filter((group) =>
+ group.fullPath.startsWith(fullPath),
+ );
+ const groupOptions = filteredgroupOptions.map((node) => ({
+ text: node.fullPath,
+ value: node.id,
+ }));
- expect(listItems).toHaveLength(3);
- expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups');
- expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[1].fullPath);
- expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[2].fullPath);
+ expect(findDropdown().props('items')).toEqual([{ text: 'Groups', options: groupOptions }]);
});
it('sets the selection to the group', () => {
@@ -278,7 +279,7 @@ describe('NewProjectUrlSelect component', () => {
wrapper = mountComponent({ search: 'no matches', queryResponse, mountFn: mount });
await waitForPromises();
- expect(wrapper.find('li').text()).toBe('No matches found');
+ expect(wrapper.find('[data-testid="listbox-no-results-text"]').text()).toBe('No matches found');
});
it('emits `update-visibility` event to update the visibility radio options', async () => {
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 4e3554131c6..75b239d2d94 100644
--- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
@@ -74,20 +74,14 @@ describe('Access Level Dropdown', () => {
const createComponent = ({
accessLevelsData = mockAccessLevelsData,
accessLevel = ACCESS_LEVELS.PUSH,
- hasLicense,
- label,
- disabled,
- preselectedItems,
stubs = {},
+ ...optionalProps
} = {}) => {
wrapper = shallowMountExtended(AccessDropdown, {
propsData: {
accessLevelsData,
accessLevel,
- hasLicense,
- label,
- disabled,
- preselectedItems,
+ ...optionalProps,
},
stubs: {
GlSprintf,
@@ -114,10 +108,19 @@ describe('Access Level Dropdown', () => {
it('should make an api call for users, groups && deployKeys when user has a license', () => {
createComponent();
expect(getUsers).toHaveBeenCalled();
- expect(getGroups).toHaveBeenCalled();
+ expect(getGroups).toHaveBeenCalledWith({ withProjectAccess: false });
expect(getDeployKeys).toHaveBeenCalled();
});
+ describe('withProjectAccess', () => {
+ it('should make an api call for users, groups && deployKeys when user has a license', () => {
+ createComponent({ groupsWithProjectAccess: true });
+ expect(getUsers).toHaveBeenCalled();
+ expect(getGroups).toHaveBeenCalledWith({ withProjectAccess: true });
+ expect(getDeployKeys).toHaveBeenCalled();
+ });
+ });
+
it('should make an api call for deployKeys but not for users or groups when user does not have a license', () => {
createComponent({ hasLicense: false });
expect(getUsers).not.toHaveBeenCalled();
@@ -132,7 +135,7 @@ describe('Access Level Dropdown', () => {
findSearchBox().vm.$emit('input', query);
await nextTick();
expect(getUsers).toHaveBeenCalledWith(query);
- expect(getGroups).toHaveBeenCalled();
+ expect(getGroups).toHaveBeenCalledWith({ withProjectAccess: false });
expect(getDeployKeys).toHaveBeenCalledWith(query);
});
});
diff --git a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
index dd534bec25d..e86759ec6ca 100644
--- a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
+++ b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
@@ -1,16 +1,21 @@
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlCollapsibleListbox, GlDisclosureDropdown } from '@gitlab/ui';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import BranchRules from '~/projects/settings/repository/branch_rules/app.vue';
import BranchRule from '~/projects/settings/repository/branch_rules/components/branch_rule.vue';
import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql';
+import createBranchRuleMutation from '~/projects/settings/repository/branch_rules/graphql/mutations/create_branch_rule.mutation.graphql';
+
import { createAlert } from '~/alert';
import {
branchRulesMockResponse,
appProvideMock,
+ createBranchRuleMockResponse,
} from 'ee_else_ce_jest/projects/settings/repository/branch_rules/mock_data';
import {
I18N,
@@ -31,16 +36,33 @@ Vue.use(VueApollo);
describe('Branch rules app', () => {
let wrapper;
let fakeApollo;
-
+ const openBranches = [
+ { text: 'branch1', id: 'branch1', title: 'branch1' },
+ { text: 'branch2', id: 'branch2', title: 'branch2' },
+ ];
const branchRulesQuerySuccessHandler = jest.fn().mockResolvedValue(branchRulesMockResponse);
-
- const createComponent = async ({ queryHandler = branchRulesQuerySuccessHandler } = {}) => {
- fakeApollo = createMockApollo([[branchRulesQuery, queryHandler]]);
+ const addRuleMutationSuccessHandler = jest.fn().mockResolvedValue(createBranchRuleMockResponse);
+
+ const createComponent = async ({
+ glFeatures = { addBranchRule: true },
+ queryHandler = branchRulesQuerySuccessHandler,
+ mutationHandler = addRuleMutationSuccessHandler,
+ } = {}) => {
+ fakeApollo = createMockApollo([
+ [branchRulesQuery, queryHandler],
+ [createBranchRuleMutation, mutationHandler],
+ ]);
wrapper = mountExtended(BranchRules, {
apolloProvider: fakeApollo,
- provide: appProvideMock,
- stubs: { GlModal: stubComponent(GlModal, { template: RENDER_ALL_SLOTS_TEMPLATE }) },
+ provide: {
+ ...appProvideMock,
+ glFeatures,
+ },
+ stubs: {
+ GlDisclosureDropdown,
+ GlModal: stubComponent(GlModal, { template: RENDER_ALL_SLOTS_TEMPLATE }),
+ },
directives: { GlModal: createMockDirective('gl-modal') },
});
@@ -51,9 +73,32 @@ describe('Branch rules app', () => {
const findEmptyState = () => wrapper.findByTestId('empty');
const findAddBranchRuleButton = () => wrapper.findByRole('button', I18N.addBranchRule);
const findModal = () => wrapper.findComponent(GlModal);
+ const findAddBranchRuleDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findCreateBranchRuleListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+
+ beforeEach(() => {
+ window.gon = {
+ open_branches: openBranches,
+ };
+ setWindowLocation(TEST_HOST);
+ });
beforeEach(() => createComponent());
+ it('renders branch rules', () => {
+ const { nodes } = branchRulesMockResponse.data.project.branchRules;
+
+ expect(findAllBranchRules().length).toBe(nodes.length);
+
+ expect(findAllBranchRules().at(0).props('name')).toBe(nodes[0].name);
+
+ expect(findAllBranchRules().at(0).props('branchProtection')).toEqual(nodes[0].branchProtection);
+
+ expect(findAllBranchRules().at(1).props('name')).toBe(nodes[1].name);
+
+ expect(findAllBranchRules().at(1).props('branchProtection')).toEqual(nodes[1].branchProtection);
+ });
+
it('displays an error if branch rules query fails', async () => {
await createComponent({ queryHandler: jest.fn().mockRejectedValue() });
expect(createAlert).toHaveBeenCalledWith({ message: I18N.queryError });
@@ -64,21 +109,65 @@ describe('Branch rules app', () => {
expect(findEmptyState().text()).toBe(I18N.emptyState);
});
- it('renders branch rules', () => {
- const { nodes } = branchRulesMockResponse.data.project.branchRules;
-
- expect(findAllBranchRules().length).toBe(nodes.length);
+ describe('Add branch rule', () => {
+ it('renders an Add branch rule dropdown', () => {
+ expect(findAddBranchRuleDropdown().props('toggleText')).toBe('Add branch rule');
+ });
- expect(findAllBranchRules().at(0).props('name')).toBe(nodes[0].name);
+ it('renders a modal with correct props/attributes', () => {
+ expect(findModal().props()).toMatchObject({
+ title: I18N.createBranchRule,
+ modalId: BRANCH_PROTECTION_MODAL_ID,
+ actionCancel: {
+ text: 'Create branch rule',
+ },
+ actionPrimary: {
+ attributes: {
+ disabled: true,
+ variant: 'confirm',
+ },
+ text: 'Create protected branch',
+ },
+ });
+ });
- expect(findAllBranchRules().at(0).props('branchProtection')).toEqual(nodes[0].branchProtection);
+ it('renders listbox with branch names', () => {
+ expect(findCreateBranchRuleListbox().exists()).toBe(true);
+ expect(findCreateBranchRuleListbox().props('items')).toHaveLength(openBranches.length);
+ expect(findCreateBranchRuleListbox().props('toggleText')).toBe(
+ 'Select Branch or create wildcard',
+ );
+ });
- expect(findAllBranchRules().at(1).props('name')).toBe(nodes[1].name);
+ it('when the primary modal action is clicked it calls create rule mutation', async () => {
+ findCreateBranchRuleListbox().vm.$emit('select', openBranches[0].text);
+ await nextTick();
+ findModal().vm.$emit('primary');
+ await nextTick();
+ await nextTick();
+ expect(addRuleMutationSuccessHandler).toHaveBeenCalledWith({
+ name: 'branch1',
+ projectPath: 'some/project/path',
+ });
+ });
- expect(findAllBranchRules().at(1).props('branchProtection')).toEqual(nodes[1].branchProtection);
+ it('shows alert when mutation fails', async () => {
+ createComponent({ mutationHandler: jest.fn().mockRejectedValue() });
+ findCreateBranchRuleListbox().vm.$emit('select', openBranches[0].text);
+ await nextTick();
+ findModal().vm.$emit('primary');
+ await waitForPromises();
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Something went wrong while creating branch rule.',
+ });
+ });
});
- describe('Add branch rule', () => {
+ describe('Add branch rule when addBranchRule FF disabled', () => {
+ beforeEach(() => {
+ window.gon.open_branches = openBranches;
+ createComponent({ glFeatures: { addBranchRule: false } });
+ });
it('renders an Add branch rule button', () => {
expect(findAddBranchRuleButton().exists()).toBe(true);
});
diff --git a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js
index d169397241d..5981647ce38 100644
--- a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js
+++ b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js
@@ -65,8 +65,22 @@ export const branchRulesMockResponse = {
},
};
+export const createBranchRuleMockResponse = {
+ data: {
+ branchRuleCreate: {
+ errors: [],
+ branchRule: {
+ name: '*dkd',
+ __typename: 'BranchRule',
+ },
+ __typename: 'BranchRuleCreatePayload',
+ },
+ },
+};
+
export const appProvideMock = {
projectPath: 'some/project/path',
+ branchRulesPath: 'settings/repository/branch_rules',
};
export const branchRuleProvideMock = {
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index c02c1bb959c..983db8846c6 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -165,7 +165,9 @@ Object {
dir="auto"
>
Best. Release.
- <strong>
+ <strong
+ data-sourcepos="1:16-1:24"
+ >
Ever.
</strong>
<gl-emoji
@@ -400,7 +402,9 @@ Object {
dir="auto"
>
Best. Release.
- <strong>
+ <strong
+ data-sourcepos="1:16-1:24"
+ >
Ever.
</strong>
<gl-emoji
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index 15436832be8..90f31dca232 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -319,6 +319,25 @@ describe('Release edit/new component', () => {
expect(actions.saveRelease).not.toHaveBeenCalled();
});
});
+
+ describe('when tag notes are loading', () => {
+ beforeEach(async () => {
+ await factory({
+ store: {
+ modules: {
+ editNew: {
+ state: {
+ isFetchingTagNotes: true,
+ },
+ },
+ },
+ },
+ });
+ });
+ it('renders the submit button as disabled', () => {
+ expect(findSubmitButton().attributes('disabled')).toBeDefined();
+ });
+ });
});
describe('delete', () => {
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index b8507dc5fb4..4417dc67dc4 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -1,11 +1,13 @@
import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { GlAlert } from '@gitlab/ui';
import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
+import getCiCatalogSettingsQuery from '~/ci/catalog/graphql/queries/get_ci_catalog_settings.query.graphql';
import { createAlert, VARIANT_SUCCESS } from '~/alert';
import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesIndexApp from '~/releases/components/app_index.vue';
@@ -16,6 +18,7 @@ import ReleasesPagination from '~/releases/components/releases_pagination.vue';
import ReleasesSort from '~/releases/components/releases_sort.vue';
import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants';
import { deleteReleaseSessionKey } from '~/releases/release_notification_service';
+import { generateCatalogSettingsResponse } from '../mock_data';
Vue.use(VueApollo);
@@ -46,19 +49,22 @@ describe('app_index.vue', () => {
let noReleases;
let queryMock;
let toast;
+ let ciCatalogSettingsResponse;
const createComponent = ({
singleResponse = Promise.resolve(singleRelease),
fullResponse = Promise.resolve(allReleases),
} = {}) => {
- const apolloProvider = createMockApollo([
+ const handlers = [
[
allReleasesQuery,
queryMock.mockImplementation((vars) => {
return vars.first === 1 ? singleResponse : fullResponse;
}),
],
- ]);
+ [getCiCatalogSettingsQuery, ciCatalogSettingsResponse],
+ ];
+ const apolloProvider = createMockApollo(handlers);
toast = jest.fn();
@@ -98,6 +104,7 @@ describe('app_index.vue', () => {
const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
const findPagination = () => wrapper.findComponent(ReleasesPagination);
const findSort = () => wrapper.findComponent(ReleasesSort);
+ const findCatalogAlert = () => wrapper.findComponent(GlAlert);
// Tests
describe('component states', () => {
@@ -162,7 +169,9 @@ describe('app_index.vue', () => {
error: expect.any(Error),
});
} else {
- expect(createAlert).not.toHaveBeenCalled();
+ expect(createAlert).not.toHaveBeenCalledWith({
+ error: expect.any(Error),
+ });
}
});
@@ -412,7 +421,6 @@ describe('app_index.vue', () => {
});
it('shows a toast', () => {
- expect(createAlert).toHaveBeenCalledTimes(1);
expect(createAlert).toHaveBeenCalledWith({
message: `Release ${release} has been successfully deleted.`,
variant: VARIANT_SUCCESS,
@@ -423,4 +431,32 @@ describe('app_index.vue', () => {
expect(window.sessionStorage.getItem(key)).toBe(null);
});
});
+
+ describe('CI/CD Catalog Alert', () => {
+ beforeEach(() => {
+ ciCatalogSettingsResponse = jest.fn();
+ });
+
+ describe('when the project is a catalog resource', () => {
+ beforeEach(async () => {
+ ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse(true));
+ await createComponent();
+ });
+
+ it('renders the CI/CD Catalog alert', () => {
+ expect(findCatalogAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('when the project is not a catalog resource', () => {
+ beforeEach(async () => {
+ ciCatalogSettingsResponse.mockResolvedValue(generateCatalogSettingsResponse(false));
+ await createComponent();
+ });
+
+ it('does not render the CI/CD Catalog alert', () => {
+ expect(findCatalogAlert().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js
index c89182faa44..d0ed883fb5b 100644
--- a/spec/frontend/releases/mock_data.js
+++ b/spec/frontend/releases/mock_data.js
@@ -15,3 +15,14 @@ export const pageInfoHeadersWithPagination = {
'X-TOTAL': '21',
'X-TOTAL-PAGES': '2',
};
+
+export const generateCatalogSettingsResponse = (isCatalogResource = false) => {
+ return {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/149',
+ isCatalogResource,
+ },
+ },
+ };
+};
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index a55b6cdef92..4dc55c12464 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -4,6 +4,7 @@ import testAction from 'helpers/vuex_action_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { getTag } from '~/api/tags_api';
import { createAlert } from '~/alert';
+import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import AccessorUtilities from '~/lib/utils/accessor';
import { s__ } from '~/locale';
@@ -128,6 +129,38 @@ describe('Release edit/new actions', () => {
{ type: types.INITIALIZE_RELEASE, payload: release },
{ type: types.UPDATE_CREATE_FROM, payload: createFrom },
],
+ expectedActions: [{ type: 'fetchTagNotes', payload: release.tagName }],
+ });
+ });
+
+ it('with no tag name, does not fetch tag information', () => {
+ const release = {
+ tagName: '',
+ tagMessage: 'hello',
+ name: '',
+ description: '',
+ milestones: [],
+ groupMilestones: [],
+ releasedAt: new Date(),
+ assets: {
+ links: [],
+ },
+ };
+ const createFrom = 'main';
+
+ window.localStorage.setItem(`${state.projectPath}/release/new`, JSON.stringify(release));
+ window.localStorage.setItem(
+ `${state.projectPath}/release/new/createFrom`,
+ JSON.stringify(createFrom),
+ );
+
+ return testAction({
+ action: actions.loadDraftRelease,
+ state,
+ expectedMutations: [
+ { type: types.INITIALIZE_RELEASE, payload: release },
+ { type: types.UPDATE_CREATE_FROM, payload: createFrom },
+ ],
});
});
});
@@ -988,6 +1021,7 @@ describe('Release edit/new actions', () => {
expect(getTag).toHaveBeenCalledWith(state.projectId, tagName);
});
+
it('creates an alert on error', async () => {
error = new Error();
getTag.mockRejectedValue(error);
@@ -1007,5 +1041,23 @@ describe('Release edit/new actions', () => {
});
expect(getTag).toHaveBeenCalledWith(state.projectId, tagName);
});
+
+ it('assumes creating a tag on 404', async () => {
+ error = { response: { status: HTTP_STATUS_NOT_FOUND } };
+ getTag.mockRejectedValue(error);
+
+ await testAction({
+ action: actions.fetchTagNotes,
+ payload: tagName,
+ state,
+ expectedMutations: [
+ { type: types.REQUEST_TAG_NOTES },
+ { type: types.RECEIVE_TAG_NOTES_SUCCESS, payload: {} },
+ ],
+ expectedActions: [{ type: 'setNewTag' }, { type: 'setCreating' }],
+ });
+
+ expect(getTag).toHaveBeenCalledWith(state.projectId, tagName);
+ });
});
});
diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js
index 24490e19296..30a3c78641c 100644
--- a/spec/frontend/releases/stores/modules/detail/getters_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js
@@ -424,7 +424,7 @@ describe('Release edit/new getters', () => {
describe('formattedReleaseNotes', () => {
it.each`
- description | includeTagNotes | tagNotes | included | showCreateFrom
+ description | includeTagNotes | tagNotes | included | isNewTag
${'release notes'} | ${true} | ${'tag notes'} | ${true} | ${false}
${'release notes'} | ${true} | ${''} | ${false} | ${false}
${'release notes'} | ${false} | ${'tag notes'} | ${false} | ${false}
@@ -432,25 +432,24 @@ describe('Release edit/new getters', () => {
${'release notes'} | ${true} | ${''} | ${false} | ${true}
${'release notes'} | ${false} | ${'tag notes'} | ${false} | ${true}
`(
- 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes and showCreateFrom=$showCreateFrom',
- ({ description, includeTagNotes, tagNotes, included, showCreateFrom }) => {
+ 'should include tag notes=$included when includeTagNotes=$includeTagNotes and tagNotes=$tagNotes and isNewTag=$isNewTag',
+ ({ description, includeTagNotes, tagNotes, included, isNewTag }) => {
let state;
- if (showCreateFrom) {
+ if (isNewTag) {
state = {
release: { description, tagMessage: tagNotes },
includeTagNotes,
- showCreateFrom,
};
} else {
- state = { release: { description }, includeTagNotes, tagNotes, showCreateFrom };
+ state = { release: { description }, includeTagNotes, tagNotes };
}
const text = `### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`;
if (included) {
- expect(getters.formattedReleaseNotes(state)).toContain(text);
+ expect(getters.formattedReleaseNotes(state, { isNewTag })).toContain(text);
} else {
- expect(getters.formattedReleaseNotes(state)).not.toContain(text);
+ expect(getters.formattedReleaseNotes(state, { isNewTag })).not.toContain(text);
}
},
);
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index 889260fc478..1c70bfcf0ef 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -40,7 +40,15 @@ jest.mock('~/lib/utils/url_utility', () => ({
setUrlParams: jest.fn(),
joinPaths: jest.fn().mockReturnValue(''),
visitUrl: jest.fn(),
+ queryToObject: jest.fn().mockReturnValue({ scope: 'projects', search: '' }),
+ objectToQuery: jest.fn((params) =>
+ Object.keys(params)
+ .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
+ .join('&'),
+ ),
+ getBaseURL: jest.fn().mockReturnValue('http://gdk.test:3000'),
}));
+
jest.mock('~/lib/logger', () => ({
logError: jest.fn(),
}));
@@ -328,6 +336,23 @@ describe('Global Search Store Actions', () => {
});
});
+ describe('fetchSidebarCount uses wild card seach', () => {
+ beforeEach(() => {
+ state.navigation = mapValues(MOCK_NAVIGATION_DATA, (navItem) => ({
+ ...navItem,
+ count_link: '/search/count?scope=projects&search=',
+ }));
+ state.urlQuery.search = '';
+ });
+
+ it('should use wild card', async () => {
+ await testAction({ action: actions.fetchSidebarCount, state, expectedMutations: [] });
+ expect(mock.history.get[0].url).toBe(
+ 'http://gdk.test:3000/search/count?scope=projects&search=*',
+ );
+ });
+ });
+
describe.each`
action | axiosMock | type | expectedMutations | errorLogs
${actions.fetchAllAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION} | ${0}
diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js
index 9efee2a409a..f1826e0e138 100644
--- a/spec/frontend/security_configuration/components/feature_card_spec.js
+++ b/spec/frontend/security_configuration/components/feature_card_spec.js
@@ -1,7 +1,7 @@
import { GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { securityFeatures } from '~/security_configuration/constants';
+import { securityFeatures } from 'jest/security_configuration/mock_data';
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';
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
index 208256afdbd..f47d4f69cd0 100644
--- a/spec/frontend/security_configuration/mock_data.js
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -1,11 +1,17 @@
import {
SAST_NAME,
SAST_SHORT_NAME,
- SAST_DESCRIPTION,
- SAST_HELP_PATH,
- SAST_CONFIG_HELP_PATH,
+ SAST_IAC_NAME,
+ SAST_IAC_SHORT_NAME,
} from '~/security_configuration/constants';
-import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
+import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+import {
+ REPORT_TYPE_SAST,
+ REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ REPORT_TYPE_SAST_IAC,
+} from '~/vue_shared/security_reports/constants';
export const testProjectPath = 'foo/bar';
export const testProviderIds = [101, 102, 103];
@@ -16,6 +22,71 @@ export const testTrainingUrls = [
'https://www.vendornamethree.com/url',
];
+const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.');
+const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index');
+const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', {
+ anchor: 'configuration',
+});
+
+const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature');
+const BAS_BADGE_TOOLTIP = s__(
+ 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.',
+);
+const BAS_DESCRIPTION = s__(
+ 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.',
+);
+const BAS_HELP_PATH = helpPagePath('user/application_security/breach_and_attack_simulation/index');
+const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)');
+const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS');
+const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__(
+ 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.',
+);
+const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath(
+ 'user/application_security/breach_and_attack_simulation/index',
+ { anchor: 'extend-dynamic-application-security-testing-dast' },
+);
+const BAS_DAST_FEATURE_FLAG_NAME = s__(
+ 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)',
+);
+
+const SAST_IAC_DESCRIPTION = __(
+ 'Analyze your infrastructure as code configuration files for known vulnerabilities.',
+);
+const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index');
+const SAST_IAC_CONFIG_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index', {
+ anchor: 'configuration',
+});
+
+export const securityFeatures = [
+ {
+ anchor: 'bas',
+ badge: {
+ alwaysDisplay: true,
+ text: BAS_BADGE_TEXT,
+ tooltipText: BAS_BADGE_TOOLTIP,
+ variant: 'info',
+ },
+ description: BAS_DESCRIPTION,
+ name: BAS_NAME,
+ helpPath: BAS_HELP_PATH,
+ secondary: {
+ configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH,
+ description: BAS_DAST_FEATURE_FLAG_DESCRIPTION,
+ name: BAS_DAST_FEATURE_FLAG_NAME,
+ },
+ shortName: BAS_SHORT_NAME,
+ type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION,
+ },
+ {
+ name: SAST_IAC_NAME,
+ shortName: SAST_IAC_SHORT_NAME,
+ description: SAST_IAC_DESCRIPTION,
+ helpPath: SAST_IAC_HELP_PATH,
+ configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH,
+ type: REPORT_TYPE_SAST_IAC,
+ },
+];
+
const createSecurityTrainingProviders = ({ providerOverrides = {} }) => [
{
id: testProviderIds[0],
diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js
index 3c6d4baa30f..f2eeaca8987 100644
--- a/spec/frontend/security_configuration/utils_spec.js
+++ b/spec/frontend/security_configuration/utils_spec.js
@@ -6,6 +6,46 @@ describe('augmentFeatures', () => {
{
name: 'SAST',
type: 'SAST',
+ security_features: {
+ type: 'SAST',
+ },
+ },
+ ];
+
+ const expectedMockSecurityFeatures = [
+ {
+ name: 'SAST',
+ type: 'SAST',
+ securityFeatures: {
+ type: 'SAST',
+ },
+ },
+ ];
+
+ const expectedInvalidMockSecurityFeatures = [
+ {
+ foo: 'bar',
+ name: 'SAST',
+ type: 'SAST',
+ securityFeatures: {
+ type: 'SAST',
+ },
+ },
+ ];
+
+ const expectedSecondarymockSecurityFeatures = [
+ {
+ name: 'DAST',
+ type: 'DAST',
+ helpPath: '/help/user/application_security/dast/index',
+ secondary: {
+ type: 'DAST PROFILES',
+ name: 'DAST PROFILES',
+ },
+ securityFeatures: {
+ type: 'DAST',
+ helpPath: '/help/user/application_security/dast/index',
+ },
},
];
@@ -17,6 +57,10 @@ describe('augmentFeatures', () => {
type: 'DAST PROFILES',
name: 'DAST PROFILES',
},
+ security_features: {
+ type: 'DAST',
+ help_path: '/help/user/application_security/dast/index',
+ },
},
];
@@ -31,6 +75,9 @@ describe('augmentFeatures', () => {
name: 'SAST',
type: 'SAST',
customField: 'customvalue',
+ securityFeatures: {
+ type: 'SAST',
+ },
},
];
@@ -38,6 +85,9 @@ describe('augmentFeatures', () => {
{
name: 'DAST',
type: 'dast',
+ security_features: {
+ type: 'DAST',
+ },
},
];
@@ -48,6 +98,9 @@ describe('augmentFeatures', () => {
customField: 'customvalue',
onDemandAvailable: false,
badge: {},
+ security_features: {
+ type: 'dast',
+ },
},
];
@@ -58,6 +111,9 @@ describe('augmentFeatures', () => {
customField: 'customvalue',
onDemandAvailable: true,
badge: {},
+ security_features: {
+ type: 'dast',
+ },
},
];
@@ -70,11 +126,15 @@ describe('augmentFeatures', () => {
];
const expectedOutputDefault = {
- augmentedSecurityFeatures: mockSecurityFeatures,
+ augmentedSecurityFeatures: expectedMockSecurityFeatures,
+ };
+
+ const expectedInvalidOutputDefault = {
+ augmentedSecurityFeatures: expectedInvalidMockSecurityFeatures,
};
const expectedOutputSecondary = {
- augmentedSecurityFeatures: mockSecurityFeatures,
+ augmentedSecurityFeatures: expectedSecondarymockSecurityFeatures,
};
const expectedOutputCustomFeature = {
@@ -88,6 +148,9 @@ describe('augmentFeatures', () => {
type: 'dast',
customField: 'customvalue',
onDemandAvailable: false,
+ securityFeatures: {
+ type: 'dast',
+ },
},
],
};
@@ -100,52 +163,62 @@ describe('augmentFeatures', () => {
customField: 'customvalue',
onDemandAvailable: true,
badge: {},
+ securityFeatures: {
+ type: 'dast',
+ },
},
],
};
describe('returns an object with augmentedSecurityFeatures when', () => {
- it('given an empty array', () => {
- expect(augmentFeatures(mockSecurityFeatures, [])).toEqual(expectedOutputDefault);
+ it('given an properly formatted array', () => {
+ expect(augmentFeatures(mockSecurityFeatures)).toEqual(expectedOutputDefault);
});
it('given an invalid populated array', () => {
- expect(augmentFeatures(mockSecurityFeatures, mockInvalidCustomFeature)).toEqual(
- expectedOutputDefault,
- );
+ expect(
+ augmentFeatures([{ ...mockSecurityFeatures[0], ...mockInvalidCustomFeature[0] }]),
+ ).toEqual(expectedInvalidOutputDefault);
});
it('features have secondary key', () => {
- expect(augmentFeatures(mockSecurityFeatures, mockFeaturesWithSecondary, [])).toEqual(
- expectedOutputSecondary,
- );
+ expect(
+ augmentFeatures([{ ...mockSecurityFeatures[0], ...mockFeaturesWithSecondary[0] }]),
+ ).toEqual(expectedOutputSecondary);
});
it('given a valid populated array', () => {
- expect(augmentFeatures(mockSecurityFeatures, mockValidCustomFeature)).toEqual(
- expectedOutputCustomFeature,
- );
+ expect(
+ augmentFeatures([{ ...mockSecurityFeatures[0], ...mockValidCustomFeature[0] }]),
+ ).toEqual(expectedOutputCustomFeature);
});
});
describe('returns an object with camelcased keys', () => {
it('given a customfeature in snakecase', () => {
- expect(augmentFeatures(mockSecurityFeatures, mockValidCustomFeatureSnakeCase)).toEqual(
- expectedOutputCustomFeature,
- );
+ expect(
+ augmentFeatures([{ ...mockSecurityFeatures[0], ...mockValidCustomFeatureSnakeCase[0] }]),
+ ).toEqual(expectedOutputCustomFeature);
});
});
describe('follows onDemandAvailable', () => {
it('deletes badge when false', () => {
expect(
- augmentFeatures(mockSecurityFeaturesDast, mockValidCustomFeatureWithOnDemandAvailableFalse),
+ augmentFeatures([
+ {
+ ...mockSecurityFeaturesDast[0],
+ ...mockValidCustomFeatureWithOnDemandAvailableFalse[0],
+ },
+ ]),
).toEqual(expectedOutputCustomFeatureWithOnDemandAvailableFalse);
});
it('keeps badge when true', () => {
expect(
- augmentFeatures(mockSecurityFeaturesDast, mockValidCustomFeatureWithOnDemandAvailableTrue),
+ augmentFeatures([
+ { ...mockSecurityFeaturesDast[0], ...mockValidCustomFeatureWithOnDemandAvailableTrue[0] },
+ ]),
).toEqual(expectedOutputCustomFeatureWithOnDemandAvailableTrue);
});
});
diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js
index 5e2ff73878f..7180e10e7b1 100644
--- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -7,6 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import { workspaceLabelsQueries, workspaceCreateLabelMutation } from '~/sidebar/queries/constants';
import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue';
+import SibebarColorPicker from '~/sidebar/components/sidebar_color_picker.vue';
import { DEFAULT_LABEL_COLOR } from '~/sidebar/components/labels/labels_select_widget/constants';
import {
mockCreateLabelResponse as createAbuseReportLabelSuccessfulResponse,
@@ -14,7 +15,6 @@ import {
} from '../../../../admin/abuse_report/mock_data';
import {
mockRegularLabel,
- mockSuggestedColors,
createLabelSuccessfulResponse,
workspaceLabelsQueryResponse,
workspaceLabelsQueryEmptyResponse,
@@ -22,8 +22,6 @@ import {
jest.mock('~/alert');
-const colors = Object.keys(mockSuggestedColors);
-
Vue.use(VueApollo);
const userRecoverableError = {
@@ -51,9 +49,7 @@ const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a
describe('DropdownContentsCreateView', () => {
let wrapper;
- const findAllColors = () => wrapper.findAllComponents(GlLink);
- const findSelectedColor = () => wrapper.find('[data-testid="selected-color"]');
- const findSelectedColorText = () => wrapper.find('[data-testid="selected-color-text"]');
+ const findSibebarColorPicker = () => wrapper.findComponent(SibebarColorPicker);
const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
const findLabelTitleInput = () => wrapper.find('[data-testid="label-title-input"]');
@@ -62,7 +58,7 @@ describe('DropdownContentsCreateView', () => {
const fillLabelAttributes = () => {
findLabelTitleInput().vm.$emit('input', 'Test title');
- findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ findSibebarColorPicker().vm.$emit('input', '#009966');
};
const createComponent = ({
@@ -94,38 +90,9 @@ describe('DropdownContentsCreateView', () => {
});
};
- beforeEach(() => {
- gon.suggested_label_colors = mockSuggestedColors;
- });
-
- it('renders a palette of 21 colors', () => {
- createComponent();
- expect(findAllColors()).toHaveLength(21);
- });
-
- it('selects a color after clicking on colored block', async () => {
- createComponent();
- expect(findSelectedColorText().attributes('value')).toBe(DEFAULT_LABEL_COLOR);
-
- findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
- await nextTick();
-
- expect(findSelectedColor().attributes('value')).toBe('#009966');
- });
-
- it('shows correct color hex code after selecting a color', async () => {
- createComponent();
- expect(findSelectedColorText().attributes('value')).toBe(DEFAULT_LABEL_COLOR);
-
- findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
- await nextTick();
-
- expect(findSelectedColorText().attributes('value')).toBe(colors[0]);
- });
-
it('disables a Create button if label title is not set', async () => {
createComponent();
- findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ findSibebarColorPicker().vm.$emit('input', '#fff');
await nextTick();
expect(findCreateButton().props('disabled')).toBe(true);
@@ -134,7 +101,7 @@ describe('DropdownContentsCreateView', () => {
it('disables a Create button if color is not set', async () => {
createComponent();
findLabelTitleInput().vm.$emit('input', 'Test title');
- findSelectedColorText().vm.$emit('input', '');
+ findSibebarColorPicker().vm.$emit('input', '');
await nextTick();
expect(findCreateButton().props('disabled')).toBe(true);
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 5039f00fe4b..eb7ab2953c6 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
@@ -58,30 +58,6 @@ export const mockConfig = {
attrWorkspacePath: 'test',
};
-export const mockSuggestedColors = {
- '#009966': 'Green-cyan',
- '#8fbc8f': 'Dark sea green',
- '#3cb371': 'Medium sea green',
- '#00b140': 'Green screen',
- '#013220': 'Dark green',
- '#6699cc': 'Blue-gray',
- '#0000ff': 'Blue',
- '#e6e6fa': 'Lavender',
- '#9400d3': 'Dark violet',
- '#330066': 'Deep violet',
- '#808080': 'Gray',
- '#36454f': 'Charcoal grey',
- '#f7e7ce': 'Champagne',
- '#c21e56': 'Rose red',
- '#cc338b': 'Magenta-pink',
- '#dc143c': 'Crimson',
- '#ff0000': 'Red',
- '#cd5b45': 'Dark coral',
- '#eee600': 'Titanium yellow',
- '#ed9121': 'Carrot orange',
- '#c39953': 'Aztec Gold',
-};
-
export const createLabelSuccessfulResponse = {
data: {
labelCreate: {
diff --git a/spec/frontend/sidebar/components/mock_data.js b/spec/frontend/sidebar/components/mock_data.js
index a9a00b3cfdf..b1b52674eb5 100644
--- a/spec/frontend/sidebar/components/mock_data.js
+++ b/spec/frontend/sidebar/components/mock_data.js
@@ -56,3 +56,27 @@ export const issueCrmContactsUpdateResponse = {
},
},
};
+
+export const mockSuggestedColors = {
+ '#009966': 'Green-cyan',
+ '#8fbc8f': 'Dark sea green',
+ '#3cb371': 'Medium sea green',
+ '#00b140': 'Green screen',
+ '#013220': 'Dark green',
+ '#6699cc': 'Blue-gray',
+ '#0000ff': 'Blue',
+ '#e6e6fa': 'Lavender',
+ '#9400d3': 'Dark violet',
+ '#330066': 'Deep violet',
+ '#808080': 'Gray',
+ '#36454f': 'Charcoal grey',
+ '#f7e7ce': 'Champagne',
+ '#c21e56': 'Rose red',
+ '#cc338b': 'Magenta-pink',
+ '#dc143c': 'Crimson',
+ '#ff0000': 'Red',
+ '#cd5b45': 'Dark coral',
+ '#eee600': 'Titanium yellow',
+ '#ed9121': 'Carrot orange',
+ '#c39953': 'Aztec Gold',
+};
diff --git a/spec/frontend/sidebar/components/sidebar_color_picker_spec.js b/spec/frontend/sidebar/components/sidebar_color_picker_spec.js
new file mode 100644
index 00000000000..7ce556fe368
--- /dev/null
+++ b/spec/frontend/sidebar/components/sidebar_color_picker_spec.js
@@ -0,0 +1,58 @@
+import { GlFormInput, GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import SibebarColorPicker from '~/sidebar/components/sidebar_color_picker.vue';
+import { mockSuggestedColors } from './mock_data';
+
+describe('SibebarColorPicker', () => {
+ let wrapper;
+ const findAllColors = () => wrapper.findAllComponents(GlLink);
+ const findFirstColor = () => findAllColors().at(0);
+ const findColorPicker = () => wrapper.findComponent(GlFormInput);
+ const findColorPickerText = () => wrapper.findByTestId('selected-color-text');
+
+ const createComponent = ({ value = '' } = {}) => {
+ wrapper = shallowMountExtended(SibebarColorPicker, {
+ propsData: {
+ value,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ gon.suggested_label_colors = mockSuggestedColors;
+ });
+
+ it('renders a palette of 21 colors', () => {
+ createComponent();
+ expect(findAllColors()).toHaveLength(21);
+ });
+
+ it('renders value of the color in textbox', () => {
+ createComponent({ value: '#343434' });
+ expect(findColorPickerText().attributes('value')).toBe('#343434');
+ });
+
+ describe('color picker', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('emits color on click of suggested color link', () => {
+ findFirstColor().vm.$emit('click', new Event('mouseclick'));
+
+ expect(wrapper.emitted('input')).toEqual([['#009966']]);
+ });
+
+ it('emits color on selecting color from picker', () => {
+ findColorPicker().vm.$emit('input', '#ffffff');
+
+ expect(wrapper.emitted('input')).toEqual([['#ffffff']]);
+ });
+
+ it('emits color on typing the hex code in the input', () => {
+ findColorPickerText().vm.$emit('input', '#000000');
+
+ expect(wrapper.emitted('input')).toEqual([['#000000']]);
+ });
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js
index ffbc789d220..c2f608b4f52 100644
--- a/spec/frontend/super_sidebar/components/create_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/create_menu_spec.js
@@ -31,6 +31,7 @@ describe('CreateMenu component', () => {
stubs: {
InviteMembersTrigger,
GlDisclosureDropdown,
+ GlEmoji: { template: '<div/>' },
},
directives: {
GlTooltip: createMockDirective('gl-tooltip'),
diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js
index 4af3247693b..7d50a2b3441 100644
--- a/spec/frontend/super_sidebar/components/user_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/user_menu_spec.js
@@ -506,6 +506,64 @@ describe('UserMenu component', () => {
});
});
+ describe('Admin Mode items', () => {
+ const findEnterAdminModeItem = () => wrapper.findByTestId('enter-admin-mode-item');
+ const findLeaveAdminModeItem = () => wrapper.findByTestId('leave-admin-mode-item');
+
+ describe('when user is not admin', () => {
+ it('should not render', () => {
+ createWrapper({
+ admin_mode: {
+ user_is_admin: false,
+ },
+ });
+ expect(findEnterAdminModeItem().exists()).toBe(false);
+ expect(findLeaveAdminModeItem().exists()).toBe(false);
+ });
+ });
+
+ describe('when user is admin but admin mode feature is not enabled', () => {
+ it('should not render', () => {
+ createWrapper({
+ admin_mode: {
+ user_is_admin: true,
+ admin_mode_feature_enabled: false,
+ },
+ });
+ expect(findEnterAdminModeItem().exists()).toBe(false);
+ expect(findLeaveAdminModeItem().exists()).toBe(false);
+ });
+ });
+
+ describe('when user is admin, admin mode feature is enabled but inactive', () => {
+ it('should render only "enter admin mode" item', () => {
+ createWrapper({
+ admin_mode: {
+ user_is_admin: true,
+ admin_mode_feature_enabled: true,
+ admin_mode_active: false,
+ },
+ });
+ expect(findEnterAdminModeItem().exists()).toBe(true);
+ expect(findLeaveAdminModeItem().exists()).toBe(false);
+ });
+ });
+
+ describe('when user is admin, admin mode feature is enabled and active', () => {
+ it('should render only "leave admin mode" item', () => {
+ createWrapper({
+ admin_mode: {
+ user_is_admin: true,
+ admin_mode_feature_enabled: true,
+ admin_mode_active: true,
+ },
+ });
+ expect(findEnterAdminModeItem().exists()).toBe(false);
+ expect(findLeaveAdminModeItem().exists()).toBe(true);
+ });
+ });
+ });
+
describe('Sign out group', () => {
const findSignOutGroup = () => wrapper.findByTestId('sign-out-group');
diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js
index fc264ad5e0a..067caec5ff4 100644
--- a/spec/frontend/super_sidebar/mock_data.js
+++ b/spec/frontend/super_sidebar/mock_data.js
@@ -175,6 +175,11 @@ export const userMenuMockPipelineMinutes = {
export const userMenuMockData = {
name: 'Orange Fox',
username: 'thefox',
+ admin_mode: {
+ user_is_admin: false,
+ admin_mode_feature_enabled: false,
+ admin_mode_active: false,
+ },
avatar_url: invalidUrl,
has_link_to_profile: true,
link_to_profile: '/thefox',
@@ -210,102 +215,6 @@ export const frecentProjectsMock = [
},
];
-export const cachedFrequentProjects = JSON.stringify([
- {
- id: 1,
- name: 'Cached project 1',
- namespace: 'Cached Namespace 1 / Cached project 1',
- webUrl: '/cached-namespace-1/cached-project-1',
- avatarUrl: '/uploads/-/avatar1.png',
- lastAccessedOn: 1676325329054,
- frequency: 10,
- },
- {
- id: 2,
- name: 'Cached project 2',
- namespace: 'Cached Namespace 2 / Cached project 2',
- webUrl: '/cached-namespace-2/cached-project-2',
- avatarUrl: '/uploads/-/avatar2.png',
- lastAccessedOn: 1674314684308,
- frequency: 8,
- },
- {
- id: 3,
- name: 'Cached project 3',
- namespace: 'Cached Namespace 3 / Cached project 3',
- webUrl: '/cached-namespace-3/cached-project-3',
- avatarUrl: '/uploads/-/avatar3.png',
- lastAccessedOn: 1664977333191,
- frequency: 12,
- },
- {
- id: 4,
- name: 'Cached project 4',
- namespace: 'Cached Namespace 4 / Cached project 4',
- webUrl: '/cached-namespace-4/cached-project-4',
- avatarUrl: '/uploads/-/avatar4.png',
- lastAccessedOn: 1674315407569,
- frequency: 3,
- },
- {
- id: 5,
- name: 'Cached project 5',
- namespace: 'Cached Namespace 5 / Cached project 5',
- webUrl: '/cached-namespace-5/cached-project-5',
- avatarUrl: '/uploads/-/avatar5.png',
- lastAccessedOn: 1677084729436,
- frequency: 21,
- },
- {
- id: 6,
- name: 'Cached project 6',
- namespace: 'Cached Namespace 6 / Cached project 6',
- webUrl: '/cached-namespace-6/cached-project-6',
- avatarUrl: '/uploads/-/avatar6.png',
- lastAccessedOn: 1676325329679,
- frequency: 5,
- },
-]);
-
-export const cachedFrequentGroups = JSON.stringify([
- {
- id: 1,
- name: 'Cached group 1',
- namespace: 'Cached Namespace 1',
- webUrl: '/cached-namespace-1/cached-group-1',
- avatarUrl: '/uploads/-/avatar1.png',
- lastAccessedOn: 1676325329054,
- frequency: 10,
- },
- {
- id: 2,
- name: 'Cached group 2',
- namespace: 'Cached Namespace 2',
- webUrl: '/cached-namespace-2/cached-group-2',
- avatarUrl: '/uploads/-/avatar2.png',
- lastAccessedOn: 1674314684308,
- frequency: 8,
- },
- {
- id: 3,
- name: 'Cached group 3',
- namespace: 'Cached Namespace 3',
- webUrl: '/cached-namespace-3/cached-group-3',
- avatarUrl: '/uploads/-/avatar3.png',
- lastAccessedOn: 1664977333191,
- frequency: 12,
- },
- {
- id: 4,
- name: 'Cached group 4',
- namespace: 'Cached Namespace 4',
- webUrl: '/cached-namespace-4/cached-group-4',
- avatarUrl: '/uploads/-/avatar4.png',
- lastAccessedOn: 1674315407569,
- frequency: 3,
- },
-]);
-
export const unsortedFrequentItems = [
{ id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
{ id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
diff --git a/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js b/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js
new file mode 100644
index 00000000000..e4f99d401a2
--- /dev/null
+++ b/spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js
@@ -0,0 +1,51 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import NamespaceStorageApp from '~/usage_quotas/storage/components/namespace_storage_app.vue';
+import StorageUsageStatistics from '~/usage_quotas/storage/components/storage_usage_statistics.vue';
+import { defaultNamespaceProvideValues } from '../mock_data';
+
+const defaultProps = {
+ namespaceLoadingError: false,
+ projectsLoadingError: false,
+ isNamespaceStorageStatisticsLoading: false,
+ // hardcoding object until we move test_fixtures from ee/ to here
+ namespace: {
+ rootStorageStatistics: {
+ storageSize: 1234,
+ },
+ },
+};
+
+describe('NamespaceStorageApp', () => {
+ /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
+ let wrapper;
+
+ const findStorageUsageStatistics = () => wrapper.findComponent(StorageUsageStatistics);
+
+ const createComponent = ({ provide = {}, props = {} } = {}) => {
+ wrapper = shallowMountExtended(NamespaceStorageApp, {
+ provide: {
+ ...defaultNamespaceProvideValues,
+ ...provide,
+ },
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ describe('Namespace usage overview', () => {
+ describe('StorageUsageStatistics', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('passes the correct props to StorageUsageStatistics', () => {
+ expect(findStorageUsageStatistics().props()).toMatchObject({
+ usedStorage: defaultProps.namespace.rootStorageStatistics.storageSize,
+ loading: false,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js b/spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js
new file mode 100644
index 00000000000..c79b6b94ac1
--- /dev/null
+++ b/spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js
@@ -0,0 +1,44 @@
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import StorageUsageOverviewCard from '~/usage_quotas/storage/components/storage_usage_overview_card.vue';
+import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('StorageUsageOverviewCard', () => {
+ /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
+ let wrapper;
+ const defaultProps = {
+ purchasedStorage: 0,
+ // hardcoding value until we move test_fixtures from ee/ to here
+ usedStorage: 1234,
+ loading: false,
+ };
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(StorageUsageOverviewCard, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ NumberToHumanSize,
+ },
+ });
+ };
+
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ it('displays the used storage value', () => {
+ createComponent();
+ expect(wrapper.text()).toContain(numberToHumanSize(defaultProps.usedStorage, 1));
+ });
+
+ describe('skeleton loader', () => {
+ it('renders skeleton loader when loading prop is true', () => {
+ createComponent({ props: { loading: true } });
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('does not render skeleton loader when loading prop is false', () => {
+ createComponent({ props: { loading: false } });
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js b/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js
new file mode 100644
index 00000000000..73d02dc273f
--- /dev/null
+++ b/spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js
@@ -0,0 +1,43 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import StorageUsageStatistics from '~/usage_quotas/storage/components/storage_usage_statistics.vue';
+import StorageUsageOverviewCard from '~/usage_quotas/storage/components/storage_usage_overview_card.vue';
+
+const defaultProps = {
+ // hardcoding value until we move test_fixtures from ee/ to here
+ usedStorage: 1234,
+ loading: false,
+};
+
+describe('StorageUsageStatistics', () => {
+ /** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
+ let wrapper;
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(StorageUsageStatistics, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ const findOverviewSubtitle = () => wrapper.findByTestId('overview-subtitle');
+ const findStorageUsageOverviewCard = () => wrapper.findComponent(StorageUsageOverviewCard);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows the namespace storage overview subtitle', () => {
+ expect(findOverviewSubtitle().text()).toBe('Namespace overview');
+ });
+
+ describe('StorageStatisticsCard', () => {
+ it('passes the correct props to StorageUsageOverviewCard', () => {
+ expect(findStorageUsageOverviewCard().props()).toEqual({
+ usedStorage: defaultProps.usedStorage,
+ loading: false,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js
index 16c03a13028..266c1150815 100644
--- a/spec/frontend/usage_quotas/storage/mock_data.js
+++ b/spec/frontend/usage_quotas/storage/mock_data.js
@@ -6,3 +6,5 @@ export const mockEmptyResponse = { data: { project: null } };
export const defaultProjectProvideValues = {
projectPath: '/project-path',
};
+
+export const defaultNamespaceProvideValues = {};
diff --git a/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js
index 9bd46267daa..88ee9375180 100644
--- a/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/mr_widget_rebase_spec.js
@@ -398,4 +398,20 @@ describe('Merge request widget rebase component', () => {
expect(toast).toHaveBeenCalledWith('Rebase completed');
});
});
+
+ // This may happen when the session of a user is expired.
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/413627
+ describe('with empty project', () => {
+ it('does not throw any error', async () => {
+ const fn = async () => {
+ createWrapper({
+ handler: 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_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
index 1b7338744e8..c9cc34e2cfc 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
@@ -212,6 +212,19 @@ describe('ReadyToMerge', () => {
expect(findMergeButton().text()).toBe('Set to auto-merge');
expect(findMergeHelperText().text()).toBe('Merge when pipeline succeeds');
});
+
+ it('should show merge help text when pipeline has failed and has an auto merge strategy', () => {
+ createComponent({
+ mr: {
+ pipeline: { status: 'FAILED' },
+ availableAutoMergeStrategies: MWPS_MERGE_STRATEGY,
+ hasCI: true,
+ },
+ });
+
+ expect(findMergeButton().text()).toBe('Set to auto-merge');
+ expect(findMergeHelperText().text()).toBe('Merge when pipeline succeeds');
+ });
});
describe('merge immediately dropdown', () => {
@@ -858,6 +871,42 @@ describe('ReadyToMerge', () => {
});
});
+ describe('only allow merge if pipeline succeeds', () => {
+ beforeEach(() => {
+ const response = JSON.parse(JSON.stringify(readyToMergeResponse));
+ response.data.project.onlyAllowMergeIfPipelineSucceeds = true;
+ response.data.project.mergeRequest.headPipeline = {
+ id: 1,
+ active: true,
+ status: '',
+ path: '',
+ };
+
+ readyToMergeResponseSpy = jest.fn().mockResolvedValueOnce(response);
+ });
+
+ it('hides merge immediately dropdown when subscription returns', async () => {
+ createComponent({ mr: { id: 1 } });
+
+ await waitForPromises();
+
+ expect(findMergeImmediatelyDropdown().exists()).toBe(false);
+
+ mockedSubscription.next({
+ data: {
+ mergeRequestMergeStatusUpdated: {
+ ...readyToMergeResponse.data.project.mergeRequest,
+ headPipeline: { id: 1, active: true, status: '', path: '' },
+ },
+ },
+ });
+
+ await waitForPromises();
+
+ expect(findMergeImmediatelyDropdown().exists()).toBe(false);
+ });
+ });
+
describe('commit message', () => {
it('updates commit message from subscription', async () => {
createComponent({ mr: { id: 1 } });
diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
index 9296e548081..85166549771 100644
--- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js
@@ -264,7 +264,7 @@ describe('MrWidgetOptions', () => {
expect(findMergePipelineForkAlert().exists()).toBe(false);
});
- it('hides the alert when merge pipelines are not enabled', async () => {
+ it('hides the alert when merged results pipelines are not enabled', async () => {
createComponent({
updatedMrData: {
source_project_id: 1,
@@ -275,7 +275,7 @@ describe('MrWidgetOptions', () => {
expect(findMergePipelineForkAlert().exists()).toBe(false);
});
- it('shows the alert when merge pipelines are enabled and the source project and target project are different', async () => {
+ it('shows the alert when merged results pipelines are enabled and the source project and target project are different', async () => {
createComponent({
updatedMrData: {
source_project_id: 1,
diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js
index 976866af27c..d063db1e34b 100644
--- a/spec/frontend/vue_shared/components/file_row_spec.js
+++ b/spec/frontend/vue_shared/components/file_row_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
import { nextTick } from 'vue';
import { file } from 'jest/ide/helpers';
import { escapeFileUrl } from '~/lib/utils/url_utility';
@@ -153,4 +154,16 @@ describe('File row component', () => {
expect(wrapper.findComponent(FileIcon).props('submodule')).toBe(submodule);
});
+
+ it('renders pinned icon', () => {
+ createComponent({
+ file: {
+ ...file(),
+ pinned: true,
+ },
+ level: 0,
+ });
+
+ expect(wrapper.findComponent(GlIcon).props('name')).toBe('thumbtack');
+ });
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/daterange_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/daterange_token_spec.js
new file mode 100644
index 00000000000..ef0e3dbbb8e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/daterange_token_spec.js
@@ -0,0 +1,170 @@
+import {
+ GlDaterangePicker,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchSuggestionList,
+ GlFilteredSearchToken,
+} from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import DaterangeToken from '~/vue_shared/components/filtered_search_bar/tokens/daterange_token.vue';
+import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
+
+const CUSTOM_DATE = 'custom-date';
+
+describe('DaterangeToken', () => {
+ let wrapper;
+
+ const defaultProps = {
+ active: true,
+ value: {
+ data: '',
+ },
+ config: {
+ operators: OPERATORS_IS,
+ options: [
+ {
+ value: 'last_week',
+ title: 'Last week',
+ },
+ {
+ value: 'last_month',
+ title: 'Last month',
+ },
+ ],
+ },
+ };
+
+ function createComponent(props = {}) {
+ return mountExtended(DaterangeToken, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ Portal: true,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
+ },
+ });
+ }
+
+ const findGlFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findDateRangePicker = () => wrapper.findComponent(GlDaterangePicker);
+ const findAllSuggestions = () => wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const selectSuggestion = (suggestion) =>
+ wrapper.findComponent(GlFilteredSearchSuggestionList).vm.$emit('suggestion', suggestion);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('renders a filtered search token', () => {
+ expect(findGlFilteredSearchToken().exists()).toBe(true);
+ });
+
+ it('remove the options from the token config', () => {
+ expect(findGlFilteredSearchToken().props('config').options).toBeUndefined();
+ });
+
+ it('does not set the token as view-only', () => {
+ expect(findGlFilteredSearchToken().props('viewOnly')).toBe(false);
+ });
+
+ it('does not show the date picker by default', () => {
+ expect(findDateRangePicker().exists()).toBe(false);
+ });
+
+ it('does not re-activate the token', async () => {
+ await wrapper.setProps({ active: false });
+ expect(findGlFilteredSearchToken().props('active')).toBe(false);
+ });
+
+ it('does not override the value', async () => {
+ await wrapper.setProps({ value: { data: 'value' } });
+ expect(findGlFilteredSearchToken().props('value')).toEqual({ data: 'value' });
+ });
+
+ it('renders a list of suggestions as specified by the config', () => {
+ const suggestions = findAllSuggestions();
+ expect(suggestions.exists()).toBe(true);
+ expect(suggestions).toHaveLength(defaultProps.config.options.length + 1);
+ [...defaultProps.config.options, { value: CUSTOM_DATE, title: 'Custom' }].forEach(
+ (option, i) => {
+ expect(suggestions.at(i).props('value')).toBe(option.value);
+ expect(suggestions.at(i).text()).toBe(option.title);
+ },
+ );
+ });
+
+ it('sets the dataSegmentInputAttributes', () => {
+ expect(findGlFilteredSearchToken().props('dataSegmentInputAttributes')).toEqual({
+ id: 'time_range_data_segment_input',
+ });
+ });
+
+ describe('when a default option is selected', () => {
+ const option = defaultProps.config.options[0].value;
+ beforeEach(async () => {
+ await selectSuggestion(option);
+ });
+ it('does not show the date picker if default option is selected', () => {
+ expect(findDateRangePicker().exists()).toBe(false);
+ });
+
+ it('sets the value', () => {
+ expect(findGlFilteredSearchToken().emitted().select).toEqual([[option]]);
+ expect(findGlFilteredSearchToken().emitted().complete).toEqual([[option]]);
+ });
+ });
+
+ describe('when custom-date option is selected', () => {
+ beforeEach(async () => {
+ await selectSuggestion(CUSTOM_DATE);
+ });
+
+ it('sets the token as view-only', () => {
+ expect(findGlFilteredSearchToken().props('viewOnly')).toBe(true);
+ });
+
+ it('shows the date picker', () => {
+ expect(findDateRangePicker().exists()).toBe(true);
+ const today = new Date();
+ expect(findDateRangePicker().props('defaultStartDate')).toEqual(today);
+ expect(findDateRangePicker().props('startOpened')).toBe(true);
+ });
+
+ it('re-activate the token while the date picker is open', async () => {
+ await wrapper.setProps({ active: false });
+ expect(findGlFilteredSearchToken().props('active')).toBe(true);
+ });
+
+ it('overrides the value', async () => {
+ await wrapper.setProps({ value: { data: 'value' } });
+ expect(findGlFilteredSearchToken().props('value')).toEqual({ data: '' });
+ });
+
+ it('sets the dataSegmentInputAttributes', () => {
+ expect(findGlFilteredSearchToken().props('dataSegmentInputAttributes')).toEqual({
+ id: 'time_range_data_segment_input',
+ placeholder: 'YYYY-MM-DD - YYYY-MM-DD',
+ style: 'padding-left: 23px;',
+ });
+ });
+
+ it('sets the date range and hides the picker upon selection', async () => {
+ await findDateRangePicker().vm.$emit('input', {
+ startDate: new Date('October 13, 2014 11:13:00'),
+ endDate: new Date('October 13, 2014 11:13:00'),
+ });
+ expect(findGlFilteredSearchToken().emitted().complete).toEqual([
+ [CUSTOM_DATE],
+ [`"2014-10-13 - 2014-10-13"`],
+ ]);
+ expect(findGlFilteredSearchToken().emitted().select).toEqual([
+ [CUSTOM_DATE],
+ [`"2014-10-13 - 2014-10-13"`],
+ ]);
+ expect(findDateRangePicker().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js
index 38d54eff872..a755f35332f 100644
--- a/spec/frontend/vue_shared/components/gl_countdown_spec.js
+++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js
@@ -44,6 +44,10 @@ describe('GlCountdown', () => {
it('displays 00:00:00', () => {
expect(wrapper.text()).toContain('00:00:00');
});
+
+ it('emits `timer-expired` event', () => {
+ expect(wrapper.emitted('timer-expired')).toStrictEqual([[]]);
+ });
});
describe('when an invalid date is passed', () => {
diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js
index cba9f78790d..f0b33284125 100644
--- a/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js
+++ b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js
@@ -1,4 +1,4 @@
-import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
+import { GlAvatarLabeled, GlIcon, GlBadge } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import GroupsListItem from '~/vue_shared/components/groups_list/groups_list_item.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -7,7 +7,6 @@ import {
VISIBILITY_LEVEL_INTERNAL_STRING,
GROUP_VISIBILITY_TYPE,
} from '~/visibility_level/constants';
-import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
@@ -112,7 +111,7 @@ describe('GroupsListItem', () => {
it('renders access role badge', () => {
createComponent();
- expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe(
+ expect(findAvatarLabeled().findComponent(GlBadge).text()).toBe(
ACCESS_LEVEL_LABELS[group.accessLevel.integerValue],
);
});
diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js
index ec6a1dc9576..072b27b4807 100644
--- a/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js
+++ b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js
@@ -8,6 +8,7 @@ describe('GroupsList', () => {
const defaultPropsData = {
groups,
+ listItemClass: 'gl-px-5',
};
const createComponent = () => {
@@ -23,6 +24,9 @@ describe('GroupsList', () => {
const expectedProps = groupsListItemWrappers.map((groupsListItemWrapper) =>
groupsListItemWrapper.props(),
);
+ const expectedClasses = groupsListItemWrappers.map((groupsListItemWrapper) =>
+ groupsListItemWrapper.classes(),
+ );
expect(expectedProps).toEqual(
defaultPropsData.groups.map((group) => ({
@@ -30,6 +34,9 @@ describe('GroupsList', () => {
showGroupIcon: false,
})),
);
+ expect(expectedClasses).toEqual(
+ defaultPropsData.groups.map(() => [defaultPropsData.listItemClass]),
+ );
});
describe('when `GroupsListItem` emits `delete` event', () => {
diff --git a/spec/frontend/vue_shared/components/help_page_link/help_page_link_spec.js b/spec/frontend/vue_shared/components/help_page_link/help_page_link_spec.js
new file mode 100644
index 00000000000..5c17558b9cf
--- /dev/null
+++ b/spec/frontend/vue_shared/components/help_page_link/help_page_link_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount, Wrapper } from '@vue/test-utils'; // eslint-disable-line no-unused-vars
+import { GlLink } from '@gitlab/ui';
+import HelpPageLink from '~/vue_shared/components/help_page_link/help_page_link.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+/** @type { Wrapper } */
+let wrapper;
+
+const createComponent = (props = {}, slots = {}) => {
+ wrapper = shallowMount(HelpPageLink, {
+ propsData: {
+ ...props,
+ },
+ slots,
+ stubs: {
+ GlLink: true,
+ },
+ });
+};
+
+const findGlLink = () => wrapper.findComponent(GlLink);
+
+describe('HelpPageLink', () => {
+ it('renders a link', () => {
+ const href = 'user/usage_quotas';
+ createComponent({ href });
+
+ const link = findGlLink();
+ const expectedHref = helpPagePath(href, { anchor: null });
+ expect(link.attributes().href).toBe(expectedHref);
+ });
+
+ it('adds the anchor', () => {
+ const href = 'user/usage_quotas';
+ const anchor = 'namespace-storage-limit';
+ createComponent({ href, anchor });
+
+ const link = findGlLink();
+ const expectedHref = helpPagePath(href, { anchor });
+ expect(link.attributes().href).toBe(expectedHref);
+ });
+
+ it('renders slot content', () => {
+ const href = 'user/usage_quotas';
+ const slotContent = 'slot content';
+ createComponent({ href }, { default: slotContent });
+
+ const link = findGlLink();
+ expect(link.text()).toBe(slotContent);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js
index 11c57fc5768..01122fe1103 100644
--- a/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/comment_templates_dropdown_spec.js
@@ -98,7 +98,7 @@ describe('Comment templates dropdown', () => {
await selectSavedReply();
expect(trackingSpy).toHaveBeenCalledWith(
- expect.any(String),
+ undefined,
TRACKING_SAVED_REPLIES_USE,
expect.any(Object),
);
@@ -111,7 +111,7 @@ describe('Comment templates dropdown', () => {
await selectSavedReply();
expect(trackingSpy).toHaveBeenCalledWith(
- expect.any(String),
+ undefined,
TRACKING_SAVED_REPLIES_USE_IN_MR,
expect.any(Object),
);
@@ -137,7 +137,7 @@ describe('Comment templates dropdown', () => {
await selectSavedReply();
expect(trackingSpy).toHaveBeenCalledWith(
- expect.any(String),
+ undefined,
TRACKING_SAVED_REPLIES_USE_IN_OTHER,
expect.any(Object),
);
diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
index edb11bd581b..3b8422d8351 100644
--- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
@@ -142,23 +142,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
);
});
- describe('if gitlab is installed under a relative url', () => {
- beforeEach(() => {
- window.gon = { relative_url_root: '/gitlab' };
- });
-
- it('passes render_quick_actions param to renderMarkdownPath if quick actions are enabled', async () => {
- buildWrapper({ propsData: { supportsQuickActions: true } });
-
- await enableContentEditor();
-
- expect(mock.history.post).toHaveLength(1);
- expect(mock.history.post[0].url).toBe(
- `${window.location.origin}/gitlab/api/markdown?render_quick_actions=true`,
- );
- });
- });
-
it('does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled', async () => {
buildWrapper({ propsData: { supportsQuickActions: false } });
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
index 7cf560745b6..a5a5a43effe 100644
--- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
@@ -12,7 +12,6 @@ import {
VISIBILITY_LEVEL_PRIVATE_STRING,
PROJECT_VISIBILITY_TYPE,
} from '~/visibility_level/constants';
-import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
import { FEATURABLE_DISABLED, FEATURABLE_ENABLED } from '~/featurable/constants';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -92,7 +91,7 @@ describe('ProjectsListItem', () => {
it('renders access role badge', () => {
createComponent();
- expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe(
+ expect(findAvatarLabeled().findComponent(GlBadge).text()).toBe(
ACCESS_LEVEL_LABELS[project.permissions.projectAccess.accessLevel],
);
});
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js
index fb195dfe08e..6530157811c 100644
--- a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js
@@ -9,6 +9,7 @@ describe('ProjectsList', () => {
const defaultPropsData = {
projects: convertObjectPropsToCamelCase(projects, { deep: true }),
+ listItemClass: 'gl-px-5',
};
const createComponent = () => {
@@ -24,6 +25,9 @@ describe('ProjectsList', () => {
const expectedProps = projectsListItemWrappers.map((projectsListItemWrapper) =>
projectsListItemWrapper.props(),
);
+ const expectedClasses = projectsListItemWrappers.map((projectsListItemWrapper) =>
+ projectsListItemWrapper.classes(),
+ );
expect(expectedProps).toEqual(
defaultPropsData.projects.map((project) => ({
@@ -31,6 +35,9 @@ describe('ProjectsList', () => {
showProjectIcon: false,
})),
);
+ expect(expectedClasses).toEqual(
+ defaultPropsData.projects.map(() => [defaultPropsData.listItemClass]),
+ );
});
describe('when `ProjectListItem` emits `delete` event', () => {
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
deleted file mode 100644
index 260eddbb37d..00000000000
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
-import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
-
-describe('RunnerInstructions component', () => {
- let wrapper;
-
- const findModalButton = () => wrapper.findByTestId('show-modal-button');
- const findModal = () => wrapper.findComponent(RunnerInstructionsModal);
-
- const createComponent = () => {
- wrapper = shallowMountExtended(RunnerInstructions, {
- directives: {
- GlModal: createMockDirective('gl-tooltip'),
- },
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- it('should show the "Show runner installation instructions" button', () => {
- expect(findModalButton().text()).toBe('Show runner installation instructions');
- });
-
- it('should render the modal', () => {
- const modalId = getBinding(findModal().element, 'gl-modal');
-
- expect(findModalButton().attributes('modal-id')).toBe(modalId);
- });
-});
diff --git a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
index 623a8739907..a3bf3ca23e3 100644
--- a/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
+++ b/spec/frontend/vue_shared/components/segmented_control_button_group_spec.js
@@ -122,6 +122,7 @@ describe('~/vue_shared/components/segmented_control_button_group.vue', () => {
[[{ value: '1' }]],
[[{ value: 1, disabled: true }]],
[[{ value: true, disabled: false }]],
+ [[{ value: true, props: { 'data-testid': 'test' } }]],
])('with options=%j, passes validation', (options) => {
createComponent({ options });
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 86dc9afaacc..745886161ce 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
@@ -42,6 +42,8 @@ describe('Source Viewer component', () => {
let wrapper;
let fakeApollo;
const CHUNKS_MOCK = [CHUNK_1, CHUNK_2];
+ const projectPath = 'test';
+ const currentRef = 'main';
const hash = '#L142';
const blameDataQueryHandlerSuccess = jest.fn().mockResolvedValue(BLAME_DATA_QUERY_RESPONSE_MOCK);
@@ -57,8 +59,8 @@ describe('Source Viewer component', () => {
propsData: {
blob: { ...blob, ...BLOB_DATA_MOCK },
chunks: CHUNKS_MOCK,
- projectPath: 'test',
- currentRef: 'main',
+ projectPath,
+ currentRef,
showBlame,
},
});
@@ -116,6 +118,18 @@ describe('Source Viewer component', () => {
expect(findBlameComponents().at(0).props()).toMatchObject({ blameInfo });
});
+ it('calls the blame data query', async () => {
+ await triggerChunkAppear();
+
+ expect(blameDataQueryHandlerSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filePath: BLOB_DATA_MOCK.path,
+ fullPath: projectPath,
+ ref: currentRef,
+ }),
+ );
+ });
+
it('calls the query only once per chunk', async () => {
// 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.
diff --git a/spec/frontend/vue_shared/components/upload_dropzone/avatar_upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/avatar_upload_dropzone_spec.js
new file mode 100644
index 00000000000..6313bf588a0
--- /dev/null
+++ b/spec/frontend/vue_shared/components/upload_dropzone/avatar_upload_dropzone_spec.js
@@ -0,0 +1,116 @@
+import { GlAvatar, GlButton, GlTruncate } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import AvatarUploadDropzone from '~/vue_shared/components/upload_dropzone/avatar_upload_dropzone.vue';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+
+describe('AvatarUploadDropzone', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ entity: { id: 1, name: 'Foo' },
+ value: null,
+ label: 'Avatar',
+ };
+
+ const file = new File(['foo'], 'foo.jpg', {
+ type: 'text/plain',
+ });
+ const file2 = new File(['bar'], 'bar.jpg', {
+ type: 'text/plain',
+ });
+ const blob = 'blob:http://127.0.0.1:3000/0046cf8c-ea21-4720-91ef-2e354d570c75';
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(AvatarUploadDropzone, {
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
+ });
+ };
+
+ const findUploadDropzone = () => wrapper.findComponent(UploadDropzone);
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ beforeEach(() => {
+ window.URL.createObjectURL = jest.fn().mockImplementation(() => blob);
+ window.URL.revokeObjectURL = jest.fn();
+ });
+
+ it('renders `GlAvatar` with correct props', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(GlAvatar).props()).toMatchObject({
+ entityId: defaultPropsData.entity.id,
+ entityName: defaultPropsData.entity.name,
+ shape: AVATAR_SHAPE_OPTION_RECT,
+ size: 96,
+ src: null,
+ });
+ });
+
+ it('renders label', () => {
+ createComponent();
+
+ expect(wrapper.findByText(defaultPropsData.label).exists()).toBe(true);
+ });
+
+ describe('when `value` prop is updated', () => {
+ beforeEach(() => {
+ createComponent();
+
+ // setProps is justified here because we are testing the component's
+ // reactive behavior which constitutes an exception
+ // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
+ wrapper.setProps({ value: file });
+ });
+
+ it('updates `GlAvatar` `src` prop', () => {
+ expect(wrapper.findComponent(GlAvatar).props('src')).toBe(blob);
+ });
+
+ it('renders remove button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ it('renders truncated file name', () => {
+ expect(wrapper.findComponent(GlTruncate).props('text')).toBe('foo.jpg');
+ });
+
+ it('does not render upload dropzone', () => {
+ expect(findUploadDropzone().exists()).toBe(false);
+ });
+
+ describe('when `value` prop is updated a second time', () => {
+ beforeEach(() => {
+ wrapper.setProps({ value: file2 });
+ });
+
+ it('revokes the object URL of the previous avatar', () => {
+ expect(window.URL.revokeObjectURL).toHaveBeenCalledWith(blob);
+ });
+ });
+
+ describe('when avatar is removed', () => {
+ beforeEach(() => {
+ findButton().vm.$emit('click');
+ });
+
+ it('emits `input` event with `null` payload', () => {
+ expect(wrapper.emitted('input')).toEqual([[null]]);
+ });
+ });
+ });
+
+ describe('when `UploadDropzone` emits `change` event', () => {
+ beforeEach(() => {
+ createComponent();
+ findUploadDropzone().vm.$emit('change', file);
+ });
+
+ it('emits `input` event', () => {
+ expect(wrapper.emitted('input')).toEqual([[file]]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js
index 119b892392f..e1b79ad7b14 100644
--- a/spec/frontend/vue_shared/components/user_select_spec.js
+++ b/spec/frontend/vue_shared/components/user_select_spec.js
@@ -3,6 +3,7 @@ import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import searchUsersQuery from '~/graphql_shared/queries/project_autocomplete_users.query.graphql';
@@ -39,6 +40,8 @@ const waitForSearch = async () => {
await waitForPromises();
};
+const focusInput = jest.fn();
+
Vue.use(VueApollo);
describe('User select dropdown', () => {
@@ -100,6 +103,11 @@ describe('User select dropdown', () => {
hide: hideDropdownMock,
},
},
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
+ methods: {
+ focusInput,
+ },
+ }),
},
});
};
@@ -409,6 +417,43 @@ describe('User select dropdown', () => {
expect(findUnselectedParticipants()).toHaveLength(0);
expect(findEmptySearchResults().exists()).toBe(true);
});
+
+ it('clears search term and focuses search field after selecting a user', async () => {
+ createComponent({
+ searchQueryHandler: jest.fn().mockResolvedValue(searchAutocompleteQueryResponse),
+ });
+ await waitForPromises();
+
+ findSearchField().vm.$emit('input', 'roo');
+ await waitForSearch();
+
+ findUnselectedParticipants().at(0).trigger('click');
+ await nextTick();
+
+ expect(findSearchField().props('value')).toBe('');
+ expect(focusInput).toHaveBeenCalled();
+ });
+
+ it('clears search term and focuses search field after unselecting a user', async () => {
+ createComponent({
+ props: {
+ value: [searchAutocompleteQueryResponse.data.workspace.users[0]],
+ },
+ searchQueryHandler: jest.fn().mockResolvedValue(searchAutocompleteQueryResponse),
+ });
+ await waitForPromises();
+
+ expect(findSelectedParticipants()).toHaveLength(1);
+
+ findSearchField().vm.$emit('input', 'roo');
+ await waitForSearch();
+
+ findSelectedParticipants().at(0).trigger('click');
+ await nextTick();
+
+ expect(findSearchField().props('value')).toBe('');
+ expect(focusInput).toHaveBeenCalled();
+ });
});
describe('when on merge request sidebar', () => {
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 98a87ddbcce..e898b3977d8 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
@@ -1,4 +1,4 @@
-import { GlLink, GlLabel, GlIcon, GlFormCheckbox, GlSprintf } from '@gitlab/ui';
+import { GlBadge, GlLink, GlLabel, GlIcon, GlFormCheckbox, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
import { useFakeDate } from 'helpers/fake_date';
import { shallowMountExtended as shallowMount } from 'helpers/vue_test_utils_helper';
@@ -46,10 +46,11 @@ describe('IssuableItem', () => {
const mockAuthor = mockIssuable.author;
let wrapper;
- const findTimestampWrapper = () => wrapper.find('[data-testid="issuable-timestamp"]');
+ const findTimestampWrapper = () => wrapper.findByTestId('issuable-timestamp');
const findWorkItemTypeIcon = () => wrapper.findComponent(WorkItemTypeIcon);
const findIssuableTitleLink = () => wrapper.findComponentByTestId('issuable-title-link');
const findIssuableItemWrapper = () => wrapper.findByTestId('issuable-item-wrapper');
+ const findStatusEl = () => wrapper.findByTestId('issuable-status');
beforeEach(() => {
gon.gitlab_url = MOCK_GITLAB_URL;
@@ -290,7 +291,7 @@ describe('IssuableItem', () => {
await nextTick();
- const titleEl = wrapper.find('[data-testid="issuable-title"]');
+ const titleEl = wrapper.findByTestId('issuable-title');
expect(titleEl.exists()).toBe(true);
expect(titleEl.findComponent(GlLink).attributes('href')).toBe(expectedHref);
@@ -329,7 +330,7 @@ describe('IssuableItem', () => {
await nextTick();
expect(
- wrapper.find('[data-testid="issuable-title"]').findComponent(GlLink).attributes('target'),
+ wrapper.findByTestId('issuable-title').findComponent(GlLink).attributes('target'),
).toBe('_blank');
});
@@ -343,7 +344,7 @@ describe('IssuableItem', () => {
await nextTick();
- const confidentialEl = wrapper.find('[data-testid="issuable-title"]').findComponent(GlIcon);
+ const confidentialEl = wrapper.findByTestId('issuable-title').findComponent(GlIcon);
expect(confidentialEl.exists()).toBe(true);
expect(confidentialEl.props('name')).toBe('eye-slash');
@@ -368,7 +369,7 @@ describe('IssuableItem', () => {
it('renders task status', () => {
wrapper = createComponent();
- const taskStatus = wrapper.find('[data-testid="task-status"]');
+ const taskStatus = wrapper.findByTestId('task-status');
const expected = `${mockIssuable.taskCompletionStatus.completedCount} of ${mockIssuable.taskCompletionStatus.count} checklist items completed`;
expect(taskStatus.text()).toBe(expected);
@@ -389,7 +390,7 @@ describe('IssuableItem', () => {
it('renders issuable reference', () => {
wrapper = createComponent();
- const referenceEl = wrapper.find('[data-testid="issuable-reference"]');
+ const referenceEl = wrapper.findByTestId('issuable-reference');
expect(referenceEl.exists()).toBe(true);
expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`);
@@ -414,7 +415,7 @@ describe('IssuableItem', () => {
it('renders issuable createdAt info', () => {
wrapper = createComponent();
- const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]');
+ const createdAtEl = wrapper.findByTestId('issuable-created-at');
expect(createdAtEl.exists()).toBe(true);
expect(createdAtEl.attributes('title')).toBe(
@@ -426,7 +427,7 @@ describe('IssuableItem', () => {
it('renders issuable author info', () => {
wrapper = createComponent();
- const authorEl = wrapper.find('[data-testid="issuable-author"]');
+ const authorEl = wrapper.findByTestId('issuable-author');
expect(authorEl.exists()).toBe(true);
expect(authorEl.attributes()).toMatchObject({
@@ -497,20 +498,52 @@ describe('IssuableItem', () => {
});
});
- it('renders issuable status via slot', () => {
- wrapper = createComponent({
- issuableSymbol: '#',
- issuable: mockIssuable,
- slots: {
- status: `
- <b class="js-status">${mockIssuable.state}</b>
- `,
- },
+ describe('status', () => {
+ it('renders issuable status via slot', () => {
+ wrapper = createComponent({
+ issuableSymbol: '#',
+ issuable: mockIssuable,
+ slots: {
+ status: `
+ <b data-testid="js-status">${mockIssuable.state}</b>
+ `,
+ },
+ });
+ const statusEl = wrapper.findByTestId('js-status');
+
+ expect(statusEl.exists()).toBe(true);
+ expect(statusEl.text()).toBe(`${mockIssuable.state}`);
+ });
+
+ it('renders issuable status as badge', () => {
+ const closedMockIssuable = { ...mockIssuable, state: 'closed' };
+ wrapper = createComponent({
+ issuableSymbol: '#',
+ issuable: closedMockIssuable,
+ slots: {
+ status: closedMockIssuable.state,
+ },
+ });
+ const statusEl = findStatusEl();
+
+ expect(statusEl.findComponent(GlBadge).exists()).toBe(true);
+ expect(statusEl.text()).toBe(`${closedMockIssuable.state}`);
});
- const statusEl = wrapper.find('.js-status');
- expect(statusEl.exists()).toBe(true);
- expect(statusEl.text()).toBe(`${mockIssuable.state}`);
+ it('renders issuable status without badge if open', () => {
+ wrapper = createComponent({
+ issuableSymbol: '#',
+ issuable: mockIssuable,
+ slots: {
+ status: mockIssuable.state,
+ },
+ });
+
+ const statusEl = findStatusEl();
+
+ expect(statusEl.findComponent(GlBadge).exists()).toBe(false);
+ expect(statusEl.text()).toBe(`${mockIssuable.state}`);
+ });
});
it('renders discussions count', () => {
@@ -543,7 +576,7 @@ describe('IssuableItem', () => {
it('renders issuable updatedAt info', () => {
wrapper = createComponent();
- const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]');
+ const timestampEl = wrapper.findByTestId('issuable-timestamp');
expect(timestampEl.attributes('title')).toBe(
localeDateFormat.asDateTimeFull.format(mockIssuable.updatedAt),
@@ -566,7 +599,7 @@ describe('IssuableItem', () => {
issuable: { ...mockIssuable, closedAt, state: 'closed' },
});
- const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]');
+ const timestampEl = wrapper.findByTestId('issuable-timestamp');
expect(timestampEl.attributes('title')).toBe(
localeDateFormat.asDateTimeFull.format(closedAt),
diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
index fe89c525fea..cbde3c4a065 100644
--- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js
@@ -208,6 +208,20 @@ describe('Work item comment form component', () => {
['Something went wrong while updating the task. Please try again.'],
]);
});
+
+ it('emits `submitForm` event on closing of work item', async () => {
+ createComponent({
+ isNewDiscussion: true,
+ });
+
+ findWorkItemToggleStateButton().vm.$emit('submit-comment');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('submitForm')).toEqual([
+ [{ commentText: draftComment, isNoteInternal: false }],
+ ]);
+ });
});
describe('internal note', () => {
@@ -239,6 +253,17 @@ describe('Work item comment form component', () => {
expect(findConfirmButton().text()).toBe(WorkItemCommentForm.i18n.addInternalNote);
});
+
+ it('emits `submitForm` event on closing of work item', async () => {
+ findInternalNoteCheckbox().vm.$emit('input', true);
+ findWorkItemToggleStateButton().vm.$emit('submit-comment');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('submitForm')).toEqual([
+ [{ commentText: draftComment, isNoteInternal: true }],
+ ]);
+ });
});
});
});
diff --git a/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js
new file mode 100644
index 00000000000..171493e87f8
--- /dev/null
+++ b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js
@@ -0,0 +1,161 @@
+import { GlForm, GlCollapsibleListbox, GlLoadingIcon } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __ } from '~/locale';
+import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue';
+
+describe('WorkItemSidebarDropdownWidgetWithEdit component', () => {
+ let wrapper;
+
+ const findHeader = () => wrapper.find('h3');
+ const findEditButton = () => wrapper.findByTestId('edit-button');
+ const findApplyButton = () => wrapper.findByTestId('apply-button');
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findLabel = () => wrapper.find('label');
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+
+ const createComponent = ({
+ itemValue = null,
+ canUpdate = true,
+ isEditing = false,
+ updateInProgress = false,
+ } = {}) => {
+ wrapper = mountExtended(WorkItemSidebarDropdownWidgetWithEdit, {
+ propsData: {
+ dropdownLabel: __('Iteration'),
+ dropdownName: 'iteration',
+ listItems: [],
+ itemValue,
+ canUpdate,
+ updateInProgress,
+ headerText: __('Select iteration'),
+ },
+ });
+
+ if (isEditing) {
+ findEditButton().vm.$emit('click');
+ }
+ };
+
+ 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();
+
+ findEditButton().vm.$emit('click');
+
+ 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().vm.$emit('click');
+
+ await nextTick();
+
+ expect(findLabel().exists()).toBe(true);
+ expect(findForm().exists()).toBe(true);
+ });
+
+ it('is replaced by Apply button while editing', async () => {
+ createComponent();
+
+ findEditButton().vm.$emit('click');
+
+ await nextTick();
+
+ expect(findEditButton().exists()).toBe(false);
+ expect(findApplyButton().exists()).toBe(true);
+ });
+ });
+
+ describe('loading icon', () => {
+ it('shows loading icon while update is in progress', async () => {
+ createComponent({ updateInProgress: true });
+
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('value', () => {
+ it('shows None when no item value is set', () => {
+ createComponent({ itemValue: null });
+
+ expect(wrapper.text()).toContain('None');
+ });
+ });
+
+ describe('form', () => {
+ it('is not shown while not editing', () => {
+ createComponent();
+
+ expect(findForm().exists()).toBe(false);
+ });
+
+ it('is shown while editing', async () => {
+ createComponent({ isEditing: true });
+ await nextTick();
+
+ expect(findForm().exists()).toBe(true);
+ });
+ });
+
+ describe('Dropdown', () => {
+ it('is not shown while not editing', () => {
+ createComponent();
+
+ expect(findCollapsibleListbox().exists()).toBe(false);
+ });
+
+ it('renders the collapsible listbox with required props', async () => {
+ createComponent({ isEditing: true });
+
+ await nextTick();
+
+ expect(findCollapsibleListbox().exists()).toBe(true);
+ expect(findCollapsibleListbox().props()).toMatchObject({
+ items: [],
+ headerText: 'Select iteration',
+ category: 'primary',
+ loading: false,
+ isCheckCentered: true,
+ searchable: true,
+ searching: false,
+ infiniteScroll: false,
+ noResultsText: 'No matching results',
+ toggleText: 'None',
+ searchPlaceholder: 'Search',
+ resetButtonLabel: 'Clear',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/shared/work_item_token_input_spec.js b/spec/frontend/work_items/components/shared/work_item_token_input_spec.js
index 5726aaaa2d0..f9ba34b3bb6 100644
--- a/spec/frontend/work_items/components/shared/work_item_token_input_spec.js
+++ b/spec/frontend/work_items/components/shared/work_item_token_input_spec.js
@@ -8,23 +8,78 @@ import WorkItemTokenInput from '~/work_items/components/shared/work_item_token_i
import { WORK_ITEM_TYPE_ENUM_TASK } from '~/work_items/constants';
import groupWorkItemsQuery from '~/work_items/graphql/group_work_items.query.graphql';
import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql';
-import {
- availableWorkItemsResponse,
- searchWorkItemsTextResponse,
- searchWorkItemsIidResponse,
- searchWorkItemsTextIidResponse,
-} from '../../mock_data';
+import workItemsByReferencesQuery from '~/work_items/graphql/work_items_by_references.query.graphql';
+import { searchWorkItemsResponse } from '../../mock_data';
Vue.use(VueApollo);
describe('WorkItemTokenInput', () => {
let wrapper;
- const availableWorkItemsResolver = jest.fn().mockResolvedValue(availableWorkItemsResponse);
- const groupSearchedWorkItemResolver = jest.fn().mockResolvedValue(searchWorkItemsTextResponse);
- const searchWorkItemTextResolver = jest.fn().mockResolvedValue(searchWorkItemsTextResponse);
- const searchWorkItemIidResolver = jest.fn().mockResolvedValue(searchWorkItemsIidResponse);
- const searchWorkItemTextIidResolver = jest.fn().mockResolvedValue(searchWorkItemsTextIidResponse);
+ const availableWorkItemsResolver = jest.fn().mockResolvedValue(
+ searchWorkItemsResponse({
+ workItems: [
+ {
+ id: 'gid://gitlab/WorkItem/458',
+ iid: '2',
+ title: 'Task 1',
+ confidential: false,
+ __typename: 'WorkItem',
+ },
+ {
+ id: 'gid://gitlab/WorkItem/459',
+ iid: '3',
+ title: 'Task 2',
+ confidential: false,
+ __typename: 'WorkItem',
+ },
+ {
+ id: 'gid://gitlab/WorkItem/460',
+ iid: '4',
+ title: 'Task 3',
+ confidential: false,
+ __typename: 'WorkItem',
+ },
+ ],
+ }),
+ );
+
+ const mockWorkItem = {
+ id: 'gid://gitlab/WorkItem/459',
+ iid: '3',
+ title: 'Task 2',
+ confidential: false,
+ __typename: 'WorkItem',
+ };
+ const groupSearchedWorkItemResolver = jest.fn().mockResolvedValue(
+ searchWorkItemsResponse({
+ workItems: [mockWorkItem],
+ }),
+ );
+ const searchWorkItemTextResolver = jest.fn().mockResolvedValue(
+ searchWorkItemsResponse({
+ workItems: [mockWorkItem],
+ }),
+ );
+ const mockworkItemReferenceQueryResponse = {
+ data: {
+ workItemsByReference: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/705',
+ iid: '111',
+ title: 'Objective linked items 104',
+ confidential: false,
+ __typename: 'WorkItem',
+ },
+ ],
+ __typename: 'WorkItemConnection',
+ },
+ },
+ };
+ const workItemReferencesQueryResolver = jest
+ .fn()
+ .mockResolvedValue(mockworkItemReferenceQueryResponse);
const createComponent = async ({
workItemsToAdd = [],
@@ -38,6 +93,7 @@ describe('WorkItemTokenInput', () => {
apolloProvider: createMockApollo([
[projectWorkItemsQuery, workItemsResolver],
[groupWorkItemsQuery, groupSearchedWorkItemResolver],
+ [workItemsByReferencesQuery, workItemReferencesQueryResolver],
]),
provide: {
isGroup,
@@ -58,6 +114,7 @@ describe('WorkItemTokenInput', () => {
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
const findGlAlert = () => wrapper.findComponent(GlAlert);
+ const findNoMatchFoundMessage = () => wrapper.findByTestId('no-match-found-namespace-message');
it('searches for available work items on focus', async () => {
createComponent({ workItemsResolver: availableWorkItemsResolver });
@@ -68,42 +125,155 @@ describe('WorkItemTokenInput', () => {
fullPath: 'test-project-path',
searchTerm: '',
types: [WORK_ITEM_TYPE_ENUM_TASK],
- in: undefined,
iid: null,
- isNumber: false,
+ searchByIid: false,
+ searchByText: true,
});
expect(findTokenSelector().props('dropdownItems')).toHaveLength(3);
});
- it.each`
- inputType | input | resolver | searchTerm | iid | isNumber | length
- ${'iid'} | ${'101'} | ${searchWorkItemIidResolver} | ${'101'} | ${'101'} | ${true} | ${1}
- ${'text'} | ${'Task 2'} | ${searchWorkItemTextResolver} | ${'Task 2'} | ${null} | ${false} | ${1}
- ${'iid and text'} | ${'123'} | ${searchWorkItemTextIidResolver} | ${'123'} | ${'123'} | ${true} | ${2}
- `(
- 'searches by $inputType for available work items when typing in input',
- async ({ input, resolver, searchTerm, iid, isNumber, length }) => {
- createComponent({ workItemsResolver: resolver });
+ it('renders red border around token selector input when work item is not valid', () => {
+ createComponent({
+ areWorkItemsToAddValid: false,
+ });
+
+ expect(findTokenSelector().props('containerClass')).toBe('gl-inset-border-1-red-500!');
+ });
+
+ describe('when input data is provided', () => {
+ const fillWorkItemInput = (input) => {
findTokenSelector().vm.$emit('focus');
findTokenSelector().vm.$emit('text-input', input);
+ };
+
+ const mockWorkItemResponseItem1 = {
+ id: 'gid://gitlab/WorkItem/460',
+ iid: '101',
+ title: 'Task 3',
+ confidential: false,
+ __typename: 'WorkItem',
+ };
+ const mockWorkItemResponseItem2 = {
+ id: 'gid://gitlab/WorkItem/461',
+ iid: '3',
+ title: 'Task 123',
+ confidential: false,
+ __typename: 'WorkItem',
+ };
+ const mockWorkItemResponseItem3 = {
+ id: 'gid://gitlab/WorkItem/462',
+ iid: '123',
+ title: 'Task 2',
+ confidential: false,
+ __typename: 'WorkItem',
+ };
+
+ const searchWorkItemIidResolver = jest.fn().mockResolvedValue(
+ searchWorkItemsResponse({
+ workItemsByIid: [mockWorkItemResponseItem1],
+ }),
+ );
+ const searchWorkItemTextIidResolver = jest.fn().mockResolvedValue(
+ searchWorkItemsResponse({
+ workItems: [mockWorkItemResponseItem2],
+ workItemsByIid: [mockWorkItemResponseItem3],
+ }),
+ );
+
+ const emptyWorkItemResolver = jest.fn().mockResolvedValue(searchWorkItemsResponse());
+
+ const validIid = mockWorkItemResponseItem1.iid;
+ const validWildCardIid = `#${mockWorkItemResponseItem1.iid}`;
+ const searchedText = mockWorkItem.title;
+ const searchedIidText = mockWorkItemResponseItem3.iid;
+ const nonExistentIid = '111';
+ const nonExistentWorkItem = 'Key result';
+ const validWorkItemUrl = 'http://localhost/gitlab-org/test-project-path/-/work_items/111';
+ const validWorkItemReference = 'gitlab-org/test-project-path#111';
+ const invalidWorkItemUrl = 'invalid-url/gitlab-org/test-project-path/-/work_items/101';
+
+ it.each`
+ inputType | input | resolver | searchTerm | iid | searchByText | searchByIid | length
+ ${'iid'} | ${validIid} | ${searchWorkItemIidResolver} | ${validIid} | ${validIid} | ${true} | ${true} | ${1}
+ ${'text'} | ${searchedText} | ${searchWorkItemTextResolver} | ${searchedText} | ${null} | ${true} | ${false} | ${1}
+ ${'iid and text'} | ${searchedIidText} | ${searchWorkItemTextIidResolver} | ${searchedIidText} | ${searchedIidText} | ${true} | ${true} | ${2}
+ `(
+ 'lists work items when $inputType is valid',
+ async ({ input, resolver, searchTerm, iid, searchByIid, searchByText, length }) => {
+ createComponent({ workItemsResolver: resolver });
+
+ fillWorkItemInput(input);
+
+ await waitForPromises();
+
+ expect(resolver).toHaveBeenCalledWith({
+ fullPath: 'test-project-path',
+ types: [WORK_ITEM_TYPE_ENUM_TASK],
+ searchTerm,
+ in: 'TITLE',
+ iid,
+ searchByIid,
+ searchByText,
+ });
+ expect(findTokenSelector().props('dropdownItems')).toHaveLength(length);
+ },
+ );
+
+ it.each`
+ inputType | input | searchTerm | iid | searchByText | searchByIid
+ ${'iid'} | ${nonExistentIid} | ${nonExistentIid} | ${nonExistentIid} | ${true} | ${true}
+ ${'text'} | ${nonExistentWorkItem} | ${nonExistentWorkItem} | ${null} | ${true} | ${false}
+ ${'url'} | ${invalidWorkItemUrl} | ${invalidWorkItemUrl} | ${null} | ${true} | ${false}
+ `(
+ 'list is empty when $inputType is invalid',
+ async ({ input, searchTerm, iid, searchByIid, searchByText }) => {
+ createComponent({ workItemsResolver: emptyWorkItemResolver });
+
+ fillWorkItemInput(input);
+
+ await waitForPromises();
+
+ expect(emptyWorkItemResolver).toHaveBeenCalledWith({
+ fullPath: 'test-project-path',
+ types: [WORK_ITEM_TYPE_ENUM_TASK],
+ searchTerm,
+ in: 'TITLE',
+ iid,
+ searchByIid,
+ searchByText,
+ });
+ expect(findTokenSelector().props('dropdownItems')).toHaveLength(0);
+ },
+ );
+
+ it.each`
+ inputType | input | refs | length
+ ${'iid with wildcard'} | ${validWildCardIid} | ${[validWildCardIid]} | ${1}
+ ${'url'} | ${validWorkItemUrl} | ${[validWorkItemUrl]} | ${1}
+ ${'reference'} | ${validWorkItemReference} | ${[validWorkItemReference]} | ${1}
+ `('lists work items when valid $inputType is pasted', async ({ input, refs, length }) => {
+ createComponent({ workItemsResolver: workItemReferencesQueryResolver });
+
+ fillWorkItemInput(input);
+
await waitForPromises();
- expect(resolver).toHaveBeenCalledWith({
- searchTerm,
- in: 'TITLE',
- iid,
- isNumber,
+ expect(workItemReferencesQueryResolver).toHaveBeenCalledWith({
+ contextNamespacePath: 'test-project-path',
+ refs,
});
expect(findTokenSelector().props('dropdownItems')).toHaveLength(length);
- },
- );
-
- it('renders red border around token selector input when work item is not valid', () => {
- createComponent({
- areWorkItemsToAddValid: false,
});
- expect(findTokenSelector().props('containerClass')).toBe('gl-inset-border-1-red-500!');
+ it('shows proper message if provided with cross project URL', async () => {
+ createComponent({ workItemsResolver: emptyWorkItemResolver });
+
+ fillWorkItemInput('http://localhost/gitlab-org/cross-project-path/-/work_items/101');
+
+ await waitForPromises();
+
+ expect(findNoMatchFoundMessage().text()).toBe('No matches found');
+ });
});
describe('when project context', () => {
diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js
index 196e19791df..6c0042bdad7 100644
--- a/spec/frontend/work_items/components/work_item_assignees_spec.js
+++ b/spec/frontend/work_items/components/work_item_assignees_spec.js
@@ -103,6 +103,9 @@ describe('WorkItemAssignees component', () => {
},
attachTo: document.body,
apolloProvider,
+ stubs: {
+ GlEmoji: { template: '<div/>' },
+ },
});
};
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 48ec84ceb85..43f7027406f 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
@@ -3,7 +3,8 @@ 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 WorkItemMilestoneInline from '~/work_items/components/work_item_milestone_inline.vue';
+import WorkItemMilestoneWithEdit from '~/work_items/components/work_item_milestone_with_edit.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';
@@ -24,7 +25,8 @@ describe('WorkItemAttributesWrapper component', () => {
const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate);
const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
- const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone);
+ const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestoneWithEdit);
+ const findWorkItemMilestoneInline = () => wrapper.findComponent(WorkItemMilestoneInline);
const findWorkItemParentInline = () => wrapper.findComponent(WorkItemParentInline);
const findWorkItemParent = () => wrapper.findComponent(WorkItemParent);
@@ -110,6 +112,26 @@ describe('WorkItemAttributesWrapper component', () => {
expect(findWorkItemMilestone().exists()).toBe(exists);
});
+
+ it.each`
+ description | milestoneWidgetInlinePresent | milestoneWidgetWithEditPresent | workItemsMvc2FlagEnabled
+ ${'renders WorkItemMilestone when workItemsMvc2 enabled'} | ${false} | ${true} | ${true}
+ ${'renders WorkItemMilestoneInline when workItemsMvc2 disabled'} | ${true} | ${false} | ${false}
+ `(
+ '$description',
+ async ({
+ milestoneWidgetInlinePresent,
+ milestoneWidgetWithEditPresent,
+ workItemsMvc2FlagEnabled,
+ }) => {
+ createComponent({ workItemsMvc2: workItemsMvc2FlagEnabled });
+
+ await waitForPromises();
+
+ expect(findWorkItemMilestone().exists()).toBe(milestoneWidgetWithEditPresent);
+ expect(findWorkItemMilestoneInline().exists()).toBe(milestoneWidgetInlinePresent);
+ },
+ );
});
describe('parent widget', () => {
diff --git a/spec/frontend/work_items/components/work_item_description_rendered_spec.js b/spec/frontend/work_items/components/work_item_description_rendered_spec.js
index 4f1d49ee2e5..c4c88c7643f 100644
--- a/spec/frontend/work_items/components/work_item_description_rendered_spec.js
+++ b/spec/frontend/work_items/components/work_item_description_rendered_spec.js
@@ -20,11 +20,13 @@ describe('WorkItemDescription', () => {
const createComponent = ({
workItemDescription = defaultWorkItemDescription,
canEdit = false,
+ disableInlineEditing = false,
} = {}) => {
wrapper = shallowMount(WorkItemDescriptionRendered, {
propsData: {
workItemDescription,
canEdit,
+ disableInlineEditing,
},
});
};
@@ -81,8 +83,8 @@ describe('WorkItemDescription', () => {
});
describe('Edit button', () => {
- it('is not visible when canUpdate = false', async () => {
- await createComponent({
+ it('is not visible when canUpdate = false', () => {
+ createComponent({
canUpdate: false,
});
@@ -100,5 +102,14 @@ describe('WorkItemDescription', () => {
expect(wrapper.emitted('startEditing')).toEqual([[]]);
});
+
+ it('is not visible when `disableInlineEditing` is true and the user can edit', () => {
+ createComponent({
+ disableInlineEditing: true,
+ canEdit: true,
+ });
+
+ expect(findEditButton().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js
index 1d25bb74986..3b137008b5b 100644
--- a/spec/frontend/work_items/components/work_item_description_spec.js
+++ b/spec/frontend/work_items/components/work_item_description_spec.js
@@ -56,6 +56,8 @@ describe('WorkItemDescription', () => {
isEditing = false,
isGroup = false,
workItemIid = '1',
+ disableInlineEditing = false,
+ editMode = false,
} = {}) => {
workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
groupWorkItemResponseHandler = jest
@@ -73,6 +75,8 @@ describe('WorkItemDescription', () => {
fullPath: 'test-project-path',
workItemId: id,
workItemIid,
+ disableInlineEditing,
+ editMode,
},
provide: {
isGroup,
@@ -283,4 +287,36 @@ describe('WorkItemDescription', () => {
expect(groupWorkItemResponseHandler).toHaveBeenCalled();
});
});
+
+ describe('when inline editing is disabled', () => {
+ describe('when edit mode is inactive', () => {
+ beforeEach(() => {
+ createComponent({ disableInlineEditing: true });
+ });
+
+ it('passes the correct props for work item rendered description', () => {
+ expect(findRenderedDescription().props('disableInlineEditing')).toBe(true);
+ });
+
+ it('does not show edit mode of markdown editor in default mode', () => {
+ expect(findMarkdownEditor().exists()).toBe(false);
+ });
+ });
+
+ describe('when edit mode is active', () => {
+ beforeEach(() => {
+ createComponent({ disableInlineEditing: true, editMode: true });
+ });
+
+ it('shows markdown editor in edit mode only when the correct props are passed', () => {
+ expect(findMarkdownEditor().exists()).toBe(true);
+ });
+
+ it('emits the `updateDraft` event when clicked on submit button in edit mode', () => {
+ const updatedDesc = 'updated desc with inline editing disabled';
+ findMarkdownEditor().vm.$emit('input', updatedDesc);
+ expect(wrapper.emitted('updateDraft')).toEqual([[updatedDesc]]);
+ });
+ });
+ });
});
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 d63bb94c3f0..45c8c66cebf 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -19,6 +19,7 @@ import WorkItemRelationships from '~/work_items/components/work_item_relationshi
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 WorkItemTitleWithEdit from '~/work_items/components/work_item_title_with_edit.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';
@@ -32,6 +33,7 @@ import {
mockParent,
workItemByIidResponseFactory,
objectiveType,
+ epicType,
mockWorkItemCommentNote,
mockBlockingLinkedItem,
} from '../mock_data';
@@ -81,6 +83,8 @@ describe('WorkItemDetail component', () => {
const findStickyHeader = () => wrapper.findComponent(WorkItemStickyHeader);
const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview');
const findRightSidebar = () => wrapper.findByTestId('work-item-overview-right-sidebar');
+ const findEditButton = () => wrapper.findByTestId('work-item-edit-form-button');
+ const findWorkItemTitleWithEdit = () => wrapper.findComponent(WorkItemTitleWithEdit);
const createComponent = ({
isGroup = false,
@@ -426,9 +430,18 @@ describe('WorkItemDetail component', () => {
workItemType: objectiveType,
confidential: true,
});
- const handler = jest.fn().mockResolvedValue(objectiveWorkItem);
+ const objectiveHandler = jest.fn().mockResolvedValue(objectiveWorkItem);
- it('renders children tree when work item is an Objective', async () => {
+ const epicWorkItem = workItemByIidResponseFactory({
+ workItemType: epicType,
+ });
+ const epicHandler = jest.fn().mockResolvedValue(epicWorkItem);
+
+ it.each`
+ type | handler
+ ${'Objective'} | ${objectiveHandler}
+ ${'Epic'} | ${epicHandler}
+ `('renders children tree when work item type is $type', async ({ handler }) => {
createComponent({ handler });
await waitForPromises();
@@ -436,14 +449,14 @@ describe('WorkItemDetail component', () => {
});
it('renders a modal', async () => {
- createComponent({ handler });
+ createComponent({ handler: objectiveHandler });
await waitForPromises();
expect(findModal().exists()).toBe(true);
});
it('opens the modal with the child when `show-modal` is emitted', async () => {
- createComponent({ handler, workItemsMvc2Enabled: true });
+ createComponent({ handler: objectiveHandler, workItemsMvc2Enabled: true });
await waitForPromises();
const event = {
@@ -466,7 +479,7 @@ describe('WorkItemDetail component', () => {
beforeEach(async () => {
createComponent({
isModal: true,
- handler,
+ handler: objectiveHandler,
workItemsMvc2Enabled: true,
});
@@ -686,4 +699,65 @@ describe('WorkItemDetail component', () => {
});
});
});
+
+ describe('edit button for work item title and description', () => {
+ describe('when `workItemsMvc2Enabled` is false', () => {
+ beforeEach(async () => {
+ createComponent({ workItemsMvc2Enabled: false });
+ await waitForPromises();
+ });
+
+ it('does not show the edit button', () => {
+ expect(findEditButton().exists()).toBe(false);
+ });
+
+ it('renders the work item title inline editable component', () => {
+ expect(findWorkItemTitle().exists()).toBe(true);
+ });
+
+ it('does not render the work item title with edit component', () => {
+ expect(findWorkItemTitleWithEdit().exists()).toBe(false);
+ });
+ });
+
+ describe('when `workItemsMvc2Enabled` is true', () => {
+ beforeEach(async () => {
+ createComponent({ workItemsMvc2Enabled: true });
+ await waitForPromises();
+ });
+
+ it('shows the edit button', () => {
+ expect(findEditButton().exists()).toBe(true);
+ });
+
+ it('does not render the work item title inline editable component', () => {
+ expect(findWorkItemTitle().exists()).toBe(false);
+ });
+
+ it('renders the work item title with edit component', () => {
+ expect(findWorkItemTitleWithEdit().exists()).toBe(true);
+ expect(findWorkItemTitleWithEdit().props('isEditing')).toBe(false);
+ });
+
+ it('work item description is not shown in edit mode by default', () => {
+ expect(findWorkItemDescription().props('editMode')).toBe(false);
+ });
+
+ describe('when edit is clicked', () => {
+ beforeEach(async () => {
+ findEditButton().vm.$emit('click');
+ await nextTick();
+ });
+
+ it('work item title component shows in edit mode', () => {
+ expect(findWorkItemTitleWithEdit().props('isEditing')).toBe(true);
+ });
+
+ it('work item description component shows in edit mode', () => {
+ expect(findWorkItemDescription().props('disableInlineEditing')).toBe(true);
+ expect(findWorkItemDescription().props('editMode')).toBe(true);
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
index 0a9da17d284..ba09c7e9ce2 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
@@ -15,12 +15,14 @@ import {
I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP,
} from '~/work_items/constants';
import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql';
+import groupWorkItemTypesQuery from '~/work_items/graphql/group_work_item_types.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import {
availableWorkItemsResponse,
projectWorkItemTypesQueryResponse,
+ groupWorkItemTypesQueryResponse,
createWorkItemMutationResponse,
updateWorkItemMutationResponse,
mockIterationWidgetResponse,
@@ -34,22 +36,27 @@ describe('WorkItemLinksForm', () => {
const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const createMutationResolver = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
const availableWorkItemsResolver = jest.fn().mockResolvedValue(availableWorkItemsResponse);
+ const projectWorkItemTypesResolver = jest
+ .fn()
+ .mockResolvedValue(projectWorkItemTypesQueryResponse);
+ const groupWorkItemTypesResolver = jest.fn().mockResolvedValue(groupWorkItemTypesQueryResponse);
const mockParentIteration = mockIterationWidgetResponse;
const createComponent = async ({
- typesResponse = projectWorkItemTypesQueryResponse,
parentConfidential = false,
hasIterationsFeature = false,
parentIteration = null,
formType = FORM_TYPES.create,
parentWorkItemType = WORK_ITEM_TYPE_VALUE_ISSUE,
childrenType = WORK_ITEM_TYPE_ENUM_TASK,
+ isGroup = false,
} = {}) => {
wrapper = shallowMountExtended(WorkItemLinksForm, {
apolloProvider: createMockApollo([
[projectWorkItemsQuery, availableWorkItemsResolver],
- [projectWorkItemTypesQuery, jest.fn().mockResolvedValue(typesResponse)],
+ [projectWorkItemTypesQuery, projectWorkItemTypesResolver],
+ [groupWorkItemTypesQuery, groupWorkItemTypesResolver],
[updateWorkItemMutation, updateMutationResolver],
[createWorkItemMutation, createMutationResolver],
]),
@@ -64,7 +71,7 @@ describe('WorkItemLinksForm', () => {
},
provide: {
hasIterationsFeature,
- isGroup: false,
+ isGroup,
},
});
@@ -79,6 +86,19 @@ describe('WorkItemLinksForm', () => {
const findAddChildButton = () => wrapper.findByTestId('add-child-button');
const findValidationElement = () => wrapper.findByTestId('work-items-invalid');
+ it.each`
+ workspace | isGroup | queryResolver
+ ${'project'} | ${false} | ${projectWorkItemTypesResolver}
+ ${'group'} | ${true} | ${groupWorkItemTypesResolver}
+ `(
+ 'fetches $workspace work item types when isGroup is $isGroup',
+ async ({ isGroup, queryResolver }) => {
+ await createComponent({ isGroup });
+
+ expect(queryResolver).toHaveBeenCalled();
+ },
+ );
+
describe('creating a new work item', () => {
beforeEach(async () => {
await createComponent();
diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_inline_spec.js
index fc2c5eb2af2..75c5763914a 100644
--- a/spec/frontend/work_items/components/work_item_milestone_spec.js
+++ b/spec/frontend/work_items/components/work_item_milestone_inline_spec.js
@@ -2,7 +2,9 @@ import { GlCollapsibleListbox, GlListboxItem, GlSkeletonLoader, GlFormGroup } fr
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import WorkItemMilestone, { noMilestoneId } from '~/work_items/components/work_item_milestone.vue';
+import WorkItemMilestoneInline, {
+ noMilestoneId,
+} from '~/work_items/components/work_item_milestone_inline.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -18,7 +20,7 @@ import {
updateWorkItemMutationResponse,
} from '../mock_data';
-describe('WorkItemMilestone component', () => {
+describe('WorkItemMilestoneInline component', () => {
Vue.use(VueApollo);
let wrapper;
@@ -51,7 +53,7 @@ describe('WorkItemMilestone component', () => {
searchQueryHandler = successSearchQueryHandler,
mutationHandler = successUpdateWorkItemMutationHandler,
} = {}) => {
- wrapper = shallowMountExtended(WorkItemMilestone, {
+ wrapper = shallowMountExtended(WorkItemMilestoneInline, {
apolloProvider: createMockApollo([
[projectMilestonesQuery, searchQueryHandler],
[updateWorkItemMutation, mutationHandler],
@@ -73,13 +75,13 @@ describe('WorkItemMilestone component', () => {
createComponent();
expect(findInputGroup().exists()).toBe(true);
- expect(findInputGroup().attributes('label')).toBe(WorkItemMilestone.i18n.MILESTONE);
+ expect(findInputGroup().attributes('label')).toBe(WorkItemMilestoneInline.i18n.MILESTONE);
});
describe('Default text with canUpdate false and milestone value', () => {
describe.each`
description | milestone | value
- ${'when no milestone'} | ${null} | ${WorkItemMilestone.i18n.NONE}
+ ${'when no milestone'} | ${null} | ${WorkItemMilestoneInline.i18n.NONE}
${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title}
`('$description', ({ milestone, value }) => {
it(`has a value of "${value}"`, () => {
@@ -95,7 +97,9 @@ describe('WorkItemMilestone component', () => {
it(`has a value of "Add to milestone"`, () => {
createComponent({ canUpdate: true, milestone: null });
- expect(findDropdown().props('toggleText')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER);
+ expect(findDropdown().props('toggleText')).toBe(
+ WorkItemMilestoneInline.i18n.MILESTONE_PLACEHOLDER,
+ );
});
});
@@ -111,7 +115,7 @@ describe('WorkItemMilestone component', () => {
searchQueryHandler: successSearchWithNoMatchingMilestones,
});
- expect(findNoResultsText().text()).toBe(WorkItemMilestone.i18n.NO_MATCHING_RESULTS);
+ expect(findNoResultsText().text()).toBe(WorkItemMilestoneInline.i18n.NO_MATCHING_RESULTS);
expect(findDropdownItems()).toHaveLength(1);
});
});
@@ -162,7 +166,7 @@ describe('WorkItemMilestone component', () => {
await waitForPromises();
expect(findDropdown().props()).toMatchObject({
loading: false,
- toggleText: WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER,
+ toggleText: WorkItemMilestoneInline.i18n.MILESTONE_PLACEHOLDER,
toggleClass: expect.arrayContaining(['gl-text-gray-500!']),
});
});
diff --git a/spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js b/spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js
new file mode 100644
index 00000000000..58a57978126
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js
@@ -0,0 +1,209 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import WorkItemMilestone from '~/work_items/components/work_item_milestone_with_edit.vue';
+import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
+import {
+ projectMilestonesResponse,
+ projectMilestonesResponseWithNoMilestones,
+ mockMilestoneWidgetResponse,
+ updateWorkItemMutationErrorResponse,
+ updateWorkItemMutationResponse,
+} from '../mock_data';
+
+describe('WorkItemMilestoneWithEdit component', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+
+ const workItemId = 'gid://gitlab/WorkItem/1';
+ const workItemType = 'Task';
+
+ const findSidebarDropdownWidget = () =>
+ wrapper.findComponent(WorkItemSidebarDropdownWidgetWithEdit);
+
+ const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse);
+ const successSearchWithNoMatchingMilestones = jest
+ .fn()
+ .mockResolvedValue(projectMilestonesResponseWithNoMilestones);
+ const successUpdateWorkItemMutationHandler = jest
+ .fn()
+ .mockResolvedValue(updateWorkItemMutationResponse);
+
+ const showDropdown = () => findSidebarDropdownWidget().vm.$emit('dropdownShown');
+
+ const createComponent = ({
+ mountFn = shallowMountExtended,
+ canUpdate = true,
+ milestone = mockMilestoneWidgetResponse,
+ searchQueryHandler = successSearchQueryHandler,
+ mutationHandler = successUpdateWorkItemMutationHandler,
+ } = {}) => {
+ wrapper = mountFn(WorkItemMilestone, {
+ apolloProvider: createMockApollo([
+ [projectMilestonesQuery, searchQueryHandler],
+ [updateWorkItemMutation, mutationHandler],
+ ]),
+ propsData: {
+ fullPath: 'full-path',
+ canUpdate,
+ workItemMilestone: milestone,
+ workItemId,
+ workItemType,
+ },
+ });
+ };
+
+ it('has "Milestone" label', () => {
+ createComponent();
+
+ expect(findSidebarDropdownWidget().props('dropdownLabel')).toBe('Milestone');
+ });
+
+ describe('Default text with canUpdate false and milestone value', () => {
+ describe.each`
+ description | milestone | value
+ ${'when no milestone'} | ${null} | ${'None'}
+ ${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title}
+ `('$description', ({ milestone, value }) => {
+ it(`has a value of "${value}"`, () => {
+ createComponent({ mountFn: mountExtended, canUpdate: false, milestone });
+
+ expect(findSidebarDropdownWidget().props('canUpdate')).toBe(false);
+ expect(wrapper.text()).toContain(value);
+ });
+ });
+ });
+
+ describe('Dropdown search', () => {
+ it('shows no matching results when no items', () => {
+ createComponent({
+ searchQueryHandler: successSearchWithNoMatchingMilestones,
+ });
+
+ expect(findSidebarDropdownWidget().props('listItems')).toHaveLength(0);
+ });
+ });
+
+ describe('Dropdown options', () => {
+ beforeEach(() => {
+ createComponent({ canUpdate: true });
+ });
+
+ it('calls successSearchQueryHandler with variables when dropdown is opened', async () => {
+ showDropdown();
+ await nextTick();
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+
+ await waitForPromises();
+
+ expect(successSearchQueryHandler).toHaveBeenCalledWith({
+ first: 20,
+ fullPath: 'full-path',
+ state: 'active',
+ title: '',
+ });
+ });
+
+ it('shows the skeleton loader when the items are being fetched on click', async () => {
+ showDropdown();
+ await nextTick();
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+
+ await nextTick();
+
+ expect(findSidebarDropdownWidget().props('loading')).toBe(true);
+ });
+
+ it('shows the milestones in dropdown when the items have finished fetching', async () => {
+ showDropdown();
+ await nextTick();
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+
+ await waitForPromises();
+
+ expect(findSidebarDropdownWidget().props('loading')).toBe(false);
+ expect(findSidebarDropdownWidget().props('listItems')).toHaveLength(
+ projectMilestonesResponse.data.workspace.attributes.nodes.length,
+ );
+ });
+
+ it('changes the milestone to null when clicked on no milestone', async () => {
+ showDropdown();
+ await nextTick();
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+
+ findSidebarDropdownWidget().vm.$emit('updateValue', null);
+
+ await nextTick();
+ expect(findSidebarDropdownWidget().props('updateInProgress')).toBe(true);
+
+ await waitForPromises();
+ expect(findSidebarDropdownWidget().props('updateInProgress')).toBe(false);
+ expect(findSidebarDropdownWidget().props('itemValue')).toBe(null);
+ });
+
+ it('changes the milestone to the selected milestone', async () => {
+ const milestoneAtIndex = projectMilestonesResponse.data.workspace.attributes.nodes[0];
+
+ showDropdown();
+ await nextTick();
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+
+ await waitForPromises();
+ findSidebarDropdownWidget().vm.$emit('updateValue', milestoneAtIndex.id);
+
+ await nextTick();
+
+ expect(findSidebarDropdownWidget().props('itemValue').title).toBe(milestoneAtIndex.title);
+ });
+ });
+
+ describe('Error handlers', () => {
+ it.each`
+ errorType | expectedErrorMessage | mockValue | resolveFunction
+ ${'graphql error'} | ${'Something went wrong while updating the task. Please try again.'} | ${updateWorkItemMutationErrorResponse} | ${'mockResolvedValue'}
+ ${'network error'} | ${'Something went wrong while updating the task. Please try again.'} | ${new Error()} | ${'mockRejectedValue'}
+ `(
+ 'emits an error when there is a $errorType',
+ async ({ mockValue, expectedErrorMessage, resolveFunction }) => {
+ createComponent({
+ mutationHandler: jest.fn()[resolveFunction](mockValue),
+ canUpdate: true,
+ });
+
+ showDropdown();
+ findSidebarDropdownWidget().vm.$emit('updateValue', null);
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]);
+ },
+ );
+ });
+
+ describe('Tracking event', () => {
+ it('tracks updating the milestone', async () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ createComponent({ canUpdate: true });
+
+ showDropdown();
+ findSidebarDropdownWidget().vm.$emit('updateValue', null);
+
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_milestone', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_milestone',
+ property: 'type_Task',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_parent_inline_spec.js b/spec/frontend/work_items/components/work_item_parent_inline_spec.js
index 3e4f99d5935..0cd314c377a 100644
--- a/spec/frontend/work_items/components/work_item_parent_inline_spec.js
+++ b/spec/frontend/work_items/components/work_item_parent_inline_spec.js
@@ -157,7 +157,8 @@ describe('WorkItemParentInline component', () => {
types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE],
in: undefined,
iid: null,
- isNumber: false,
+ searchByIid: false,
+ searchByText: true,
});
await findCollapsibleListbox().vm.$emit('search', 'Objective 101');
@@ -168,7 +169,8 @@ describe('WorkItemParentInline component', () => {
types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE],
in: 'TITLE',
iid: null,
- isNumber: false,
+ searchByIid: false,
+ searchByText: true,
});
await nextTick();
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
index 61e43456479..d5fab9353ac 100644
--- 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
@@ -290,6 +290,8 @@ describe('WorkItemParent component', () => {
in: undefined,
iid: null,
isNumber: false,
+ searchByIid: false,
+ searchByText: true,
});
await findCollapsibleListbox().vm.$emit('search', 'Objective 101');
@@ -301,6 +303,8 @@ describe('WorkItemParent component', () => {
in: 'TITLE',
iid: null,
isNumber: false,
+ searchByIid: false,
+ searchByText: true,
});
await nextTick();
diff --git a/spec/frontend/work_items/components/work_item_state_toggle_spec.js b/spec/frontend/work_items/components/work_item_state_toggle_spec.js
index a210bd50422..988df402d60 100644
--- a/spec/frontend/work_items/components/work_item_state_toggle_spec.js
+++ b/spec/frontend/work_items/components/work_item_state_toggle_spec.js
@@ -32,6 +32,7 @@ describe('Work Item State toggle button component', () => {
canUpdate = true,
workItemState = STATE_OPEN,
workItemType = 'Task',
+ hasComment = false,
} = {}) => {
wrapper = shallowMount(WorkItemStateToggle, {
apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
@@ -40,6 +41,7 @@ describe('Work Item State toggle button component', () => {
workItemState,
workItemType,
canUpdate,
+ hasComment,
},
});
};
@@ -61,6 +63,23 @@ describe('Work Item State toggle button component', () => {
expect(findStateToggleButton().text()).toBe(buttonText);
},
);
+
+ it.each`
+ workItemState | workItemType | buttonText
+ ${STATE_OPEN} | ${'Task'} | ${'Comment & close task'}
+ ${STATE_CLOSED} | ${'Task'} | ${'Comment & reopen task'}
+ ${STATE_OPEN} | ${'Objective'} | ${'Comment & close objective'}
+ ${STATE_CLOSED} | ${'Objective'} | ${'Comment & reopen objective'}
+ ${STATE_OPEN} | ${'Key result'} | ${'Comment & close key result'}
+ ${STATE_CLOSED} | ${'Key result'} | ${'Comment & reopen key result'}
+ `(
+ 'is "$buttonText" when "$workItemType" state is "$workItemState" and hasComment is true',
+ ({ workItemState, workItemType, buttonText }) => {
+ createComponent({ workItemState, workItemType, hasComment: true });
+
+ expect(findStateToggleButton().text()).toBe(buttonText);
+ },
+ );
});
describe('when updating the state', () => {
@@ -92,6 +111,15 @@ describe('Work Item State toggle button component', () => {
});
});
+ it('emits `submit-comment` when hasComment is true', async () => {
+ createComponent({ hasComment: true });
+
+ findStateToggleButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(wrapper.emitted('submit-comment')).toBeDefined();
+ });
+
it('emits an error message when the mutation was unsuccessful', async () => {
createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') });
diff --git a/spec/frontend/work_items/components/work_item_title_with_edit_spec.js b/spec/frontend/work_items/components/work_item_title_with_edit_spec.js
new file mode 100644
index 00000000000..7868e241821
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_title_with_edit_spec.js
@@ -0,0 +1,59 @@
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WorkItemTitleWithEdit from '~/work_items/components/work_item_title_with_edit.vue';
+
+describe('Work Item title with edit', () => {
+ let wrapper;
+ const mockTitle = 'Work Item title';
+
+ const createComponent = ({ isEditing = false } = {}) => {
+ wrapper = shallowMountExtended(WorkItemTitleWithEdit, {
+ propsData: {
+ title: mockTitle,
+ isEditing,
+ },
+ });
+ };
+
+ const findTitle = () => wrapper.findByTestId('work-item-title');
+ const findEditableTitleForm = () => wrapper.findComponent(GlFormGroup);
+ const findEditableTitleInput = () => wrapper.findComponent(GlFormInput);
+
+ describe('Default mode', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders title', () => {
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe(mockTitle);
+ });
+
+ it('does not render edit mode', () => {
+ expect(findEditableTitleForm().exists()).toBe(false);
+ });
+ });
+
+ describe('Edit mode', () => {
+ beforeEach(() => {
+ createComponent({ isEditing: true });
+ });
+
+ it('does not render read only title', () => {
+ expect(findTitle().exists()).toBe(false);
+ });
+
+ it('renders the editable title with label', () => {
+ expect(findEditableTitleForm().exists()).toBe(true);
+ expect(findEditableTitleForm().attributes('label')).toBe(
+ WorkItemTitleWithEdit.i18n.titleLabel,
+ );
+ });
+
+ it('emits `updateDraft` event on change of the input', () => {
+ findEditableTitleInput().vm.$emit('input', 'updated title');
+
+ expect(wrapper.emitted('updateDraft')).toEqual([['updated title']]);
+ });
+ });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 9d4606eb95a..aade1ed4735 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -229,6 +229,7 @@ export const updateWorkItemMutationResponse = {
state: 'OPEN',
description: 'description',
confidential: false,
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
createdAt: '2022-08-03T12:41:54Z',
updatedAt: '2022-08-08T12:41:54Z',
closedAt: null,
@@ -339,6 +340,7 @@ export const convertWorkItemMutationResponse = {
title: 'Updated title',
state: 'OPEN',
description: 'description',
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
confidential: false,
createdAt: '2022-08-03T12:41:54Z',
updatedAt: '2022-08-08T12:41:54Z',
@@ -473,6 +475,13 @@ export const issueType = {
iconName: 'issue-type-issue',
};
+export const epicType = {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/2411',
+ name: 'Epic',
+ iconName: 'issue-type-epic',
+};
+
export const mockEmptyLinkedItems = {
type: WIDGET_TYPE_LINKED_ITEMS,
blocked: false,
@@ -644,6 +653,7 @@ export const workItemResponseFactory = ({
title: 'Updated title',
state,
description: 'description',
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
confidential,
createdAt,
updatedAt,
@@ -726,6 +736,12 @@ export const workItemResponseFactory = ({
title: 'Iteration default title',
startDate: '2022-09-22',
dueDate: '2022-09-30',
+ webUrl: 'http://127.0.0.1:3000/groups/flightjs/-/iterations/23205',
+ iterationCadence: {
+ id: 'gid://gitlab/Iterations::Cadence/5852',
+ title: 'A dolores assumenda harum non facilis similique delectus quod.',
+ __typename: 'IterationCadence',
+ },
},
}
: { type: 'MOCK TYPE' },
@@ -907,6 +923,18 @@ export const projectWorkItemTypesQueryResponse = {
},
};
+export const groupWorkItemTypesQueryResponse = {
+ data: {
+ workspace: {
+ __typename: 'Group',
+ id: 'gid://gitlab/Group/2',
+ workItemTypes: {
+ nodes: [{ id: 'gid://gitlab/WorkItems::Type/6', name: 'Epic' }],
+ },
+ },
+ },
+};
+
export const createWorkItemMutationResponse = {
data: {
workItemCreate: {
@@ -923,6 +951,7 @@ export const createWorkItemMutationResponse = {
createdAt: '2022-08-03T12:41:54Z',
updatedAt: null,
closedAt: null,
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
author: {
...mockAssignees[0],
},
@@ -1010,6 +1039,7 @@ export const workItemHierarchyEmptyResponse = {
__typename: 'WorkItemType',
},
title: 'New title',
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
description: '',
createdAt: '2022-08-03T12:41:54Z',
updatedAt: null,
@@ -1229,6 +1259,7 @@ export const workItemHierarchyResponse = {
__typename: 'WorkItemType',
},
title: 'New title',
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
userPermissions: {
deleteWorkItem: true,
updateWorkItem: true,
@@ -1495,6 +1526,7 @@ export const changeIndirectWorkItemParentMutationResponse = {
__typename: 'WorkItemPermissions',
},
description: null,
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
id: 'gid://gitlab/WorkItem/13',
iid: '13',
archived: false,
@@ -1564,6 +1596,7 @@ export const changeWorkItemParentMutationResponse = {
__typename: 'WorkItemPermissions',
},
description: null,
+ webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1',
id: 'gid://gitlab/WorkItem/2',
iid: '2',
archived: false,
@@ -1734,6 +1767,28 @@ export const searchWorkItemsIidResponse = {
},
};
+export const searchWorkItemsURLRefResponse = {
+ data: {
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/2',
+ workItems: {
+ nodes: [],
+ },
+ workItemsByIid: {
+ nodes: [
+ {
+ id: 'gid://gitlab/WorkItem/460',
+ iid: '101',
+ title: 'Task 3',
+ __typename: 'WorkItem',
+ },
+ ],
+ },
+ },
+ },
+};
+
export const searchWorkItemsTextIidResponse = {
data: {
workspace: {
@@ -1765,6 +1820,23 @@ export const searchWorkItemsTextIidResponse = {
},
};
+export const searchWorkItemsResponse = ({ workItems = [], workItemsByIid = [] } = {}) => {
+ return {
+ data: {
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/2',
+ workItems: {
+ nodes: workItems,
+ },
+ workItemsByIid: {
+ nodes: workItemsByIid,
+ },
+ },
+ },
+ };
+};
+
export const projectMembersResponseWithCurrentUser = {
data: {
workspace: {
diff --git a/spec/frontend/work_items/utils_spec.js b/spec/frontend/work_items/utils_spec.js
index aa24b80cf08..166712de20b 100644
--- a/spec/frontend/work_items/utils_spec.js
+++ b/spec/frontend/work_items/utils_spec.js
@@ -1,4 +1,4 @@
-import { autocompleteDataSources, markdownPreviewPath } from '~/work_items/utils';
+import { autocompleteDataSources, markdownPreviewPath, isReference } from '~/work_items/utils';
describe('autocompleteDataSources', () => {
beforeEach(() => {
@@ -25,3 +25,25 @@ describe('markdownPreviewPath', () => {
);
});
});
+
+describe('isReference', () => {
+ it.each`
+ referenceId | result
+ ${'#101'} | ${true}
+ ${'&101'} | ${true}
+ ${'101'} | ${false}
+ ${'#'} | ${false}
+ ${'&'} | ${false}
+ ${' &101'} | ${false}
+ ${'gitlab-org&101'} | ${true}
+ ${'gitlab-org/project-path#101'} | ${true}
+ ${'gitlab-org/sub-group/project-path#101'} | ${true}
+ ${'gitlab-org'} | ${false}
+ ${'gitlab-org101#'} | ${false}
+ ${'gitlab-org101&'} | ${false}
+ ${'#gitlab-org101'} | ${false}
+ ${'&gitlab-org101'} | ${false}
+ `('returns $result for $referenceId', ({ referenceId, result }) => {
+ expect(isReference(referenceId)).toEqual(result);
+ });
+});