From 84d1bd786125c1c14a3ba5f63e38a4cc736a9027 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 16 Jan 2024 10:42:19 +0000 Subject: Add latest changes from gitlab-org/gitlab@16-8-stable-ee --- .../abuse_report/components/user_details_spec.js | 67 +- spec/frontend/admin/abuse_report/mock_data.js | 6 +- .../components/alerts_settings_form_spec.js | 250 +-- spec/frontend/behaviors/secret_values_spec.js | 230 --- spec/frontend/blob/openapi/index_spec.js | 13 +- spec/frontend/boards/board_list_helper.js | 1 - spec/frontend/boards/board_list_spec.js | 83 +- .../components/board_add_new_column_form_spec.js | 22 +- .../board_add_new_column_trigger_spec.js | 7 - spec/frontend/boards/components/board_app_spec.js | 8 +- .../components/board_card_move_to_position_spec.js | 12 +- spec/frontend/boards/components/board_card_spec.js | 1 - .../boards/components/board_content_spec.js | 2 +- .../boards/components/board_top_bar_spec.js | 1 - .../boards/components/boards_selector_spec.js | 1 - .../boards/components/config_toggle_spec.js | 7 - spec/frontend/boards/mock_data.js | 78 - spec/frontend/boards/project_select_spec.js | 6 + spec/frontend/boards/stores/actions_spec.js | 2098 -------------------- spec/frontend/boards/stores/getters_spec.js | 203 -- spec/frontend/boards/stores/state_spec.js | 11 - spec/frontend/captcha/captcha_modal_spec.js | 63 + .../components/details/ci_resource_about_spec.js | 4 +- .../details/ci_resource_components_spec.js | 4 +- .../components/details/ci_resource_header_spec.js | 2 +- .../catalog/components/list/catalog_search_spec.js | 13 +- .../catalog/components/list/catalog_tabs_spec.js | 71 + .../components/list/ci_resources_list_item_spec.js | 4 +- .../components/list/ci_resources_list_spec.js | 97 +- .../components/pages/ci_resources_page_spec.js | 68 +- spec/frontend/ci/catalog/mock.js | 34 +- .../ci_environments_dropdown_spec.js | 215 ++ .../ci/ci_environments_dropdown/utils_spec.js | 33 + .../components/ci_environments_dropdown_spec.js | 180 -- .../components/ci_group_variables_spec.js | 2 +- .../components/ci_project_variables_spec.js | 2 +- .../components/ci_variable_drawer_spec.js | 8 +- .../components/ci_variable_settings_spec.js | 4 +- .../components/ci_variable_shared_spec.js | 6 +- spec/frontend/ci/ci_variable_list/mocks.js | 2 +- spec/frontend/ci/ci_variable_list/utils_spec.js | 27 - .../ci/pipeline_details/test_reports/mock_data.js | 8 +- .../test_reports/test_reports_spec.js | 44 +- .../test_reports/test_suite_table_spec.js | 10 +- .../popovers/walkthrough_popover_spec.js | 23 +- .../empty_state/pipelines_ci_templates_spec.js | 3 + .../components/runner_job_status_badge_spec.js | 3 +- .../clusters/agents/components/show_spec.js | 20 + .../comment_templates/components/form_spec.js | 14 +- .../commit/components/signature_badge_spec.js | 1 + .../components/wrappers/table_cell_base_spec.js | 42 + .../content_editor/extensions/copy_paste_spec.js | 13 +- .../content_editor/extensions/task_item_spec.js | 115 ++ .../services/markdown_serializer_spec.js | 50 + .../services/markdown_sourcemap_spec.js | 33 +- .../components/__snapshots__/list_spec.js.snap | 2 +- spec/frontend/custom_emoji/components/list_spec.js | 3 + .../deploy_keys/components/action_btn_spec.js | 43 +- spec/frontend/deploy_keys/components/app_spec.js | 244 ++- spec/frontend/deploy_keys/components/key_spec.js | 154 +- .../deploy_keys/components/keys_panel_spec.js | 13 +- .../frontend/deploy_keys/graphql/resolvers_spec.js | 7 +- .../__snapshots__/tree_list_spec.js.snap | 160 ++ spec/frontend/diffs/components/app_spec.js | 39 +- .../diffs/components/diff_file_header_spec.js | 27 +- spec/frontend/diffs/components/diff_file_spec.js | 146 +- .../diffs/components/diff_row_utils_spec.js | 39 +- spec/frontend/diffs/components/tree_list_spec.js | 103 +- spec/frontend/diffs/store/actions_spec.js | 137 +- spec/frontend/diffs/store/getters_spec.js | 32 + spec/frontend/diffs/store/mutations_spec.js | 57 +- spec/frontend/diffs/store/utils_spec.js | 11 + spec/frontend/editor/schema/ci/ci_schema_spec.js | 22 +- .../negative_tests/auto_cancel_pipeline.yml | 4 - .../schema/ci/yaml_tests/negative_tests/image.yml | 11 + .../ci/yaml_tests/negative_tests/secrets.yml | 18 + .../ci/yaml_tests/negative_tests/services.yml | 14 + .../workflow/auto_cancel/on_job_failure.yml | 3 + .../workflow/auto_cancel/on_new_commit.yml | 3 + .../workflow/rules/auto_cancel/on_job_failure.yml | 7 + .../workflow/rules/auto_cancel/on_new_commit.yml | 7 + .../auto_cancel_pipeline/on_job_failure/all.yml | 4 - .../auto_cancel_pipeline/on_job_failure/none.yml | 4 - .../schema/ci/yaml_tests/positive_tests/image.yml | 13 + .../ci/yaml_tests/positive_tests/secrets.yml | 29 + .../ci/yaml_tests/positive_tests/services.yml | 15 + .../workflow/auto_cancel/on_job_failure.yml | 3 + .../workflow/auto_cancel/on_new_commit.yml | 3 + .../workflow/rules/auto_cancel/on_job_failure.yml | 7 + .../workflow/rules/auto_cancel/on_new_commit.yml | 7 + spec/frontend/emoji/components/emoji_group_spec.js | 1 + .../helpers/k8s_integration_helper_spec.js | 30 - .../environments/kubernetes_status_bar_spec.js | 53 +- .../components/error_details_info_spec.js | 7 + .../components/error_tracking_list_spec.js | 22 + .../fixtures/static/oauth_remember_me.html | 8 +- spec/frontend/groups/components/app_spec.js | 3 + spec/frontend/groups/components/group_item_spec.js | 61 +- .../groups/components/group_name_and_path_spec.js | 17 +- .../groups/components/overview_tabs_spec.js | 16 +- .../components/more_actions_dropdown_spec.js | 30 +- spec/frontend/ide/lib/alerts/environment_spec.js | 21 - spec/frontend/ide/services/index_spec.js | 33 +- spec/frontend/ide/stores/actions/alert_spec.js | 46 - spec/frontend/ide/stores/getters/alert_spec.js | 46 - spec/frontend/ide/stores/mutations/alert_spec.js | 26 - .../import_groups/components/import_status_spec.js | 1 - .../components/invite_modal_base_spec.js | 17 +- .../invite_members/utils/member_utils_spec.js | 16 +- .../branches/components/project_dropdown_spec.js | 78 +- spec/frontend/jira_connect/branches/mock_data.js | 30 + .../components/workload_table_spec.js | 11 +- .../kubernetes_dashboard/graphql/mock_data.js | 246 +++ .../graphql/resolvers/kubernetes_spec.js | 254 ++- .../helpers/k8s_integration_helper_spec.js | 80 + .../pages/cron_jobs_page_spec.js | 102 + .../kubernetes_dashboard/pages/jobs_page_spec.js | 102 + .../pages/services_page_spec.js | 104 + spec/frontend/lib/utils/number_utility_spec.js | 262 --- spec/frontend/lib/utils/number_utils_spec.js | 256 +++ spec/frontend/lib/utils/secret_detection_spec.js | 5 + spec/frontend/lib/utils/text_utility_spec.js | 8 + spec/frontend/logo_spec.js | 8 +- .../ml/model_registry/apps/index_ml_models_spec.js | 45 +- .../ml/model_registry/apps/new_ml_model_spec.js | 119 ++ .../ml/model_registry/apps/show_ml_model_spec.js | 11 +- .../components/actions_dropdown_spec.js | 39 + .../components/candidate_list_spec.js | 94 +- .../components/model_version_list_spec.js | 90 +- .../components/searchable_list_spec.js | 170 ++ .../ml/model_registry/graphql_mock_data.js | 24 + spec/frontend/ml/model_registry/mock_data.js | 1 + spec/frontend/oauth_remember_me_spec.js | 8 +- spec/frontend/observability/client_spec.js | 32 + .../organizations/new/components/app_spec.js | 27 +- .../components/organization_settings_spec.js | 71 +- .../shared/components/groups_view_spec.js | 10 +- .../shared/components/new_edit_form_spec.js | 71 +- .../shared/components/projects_view_spec.js | 10 +- .../organizations/show/components/app_spec.js | 7 + .../components/organization_description_spec.js | 46 + .../components/details_page/details_header_spec.js | 1 + .../__snapshots__/package_list_row_spec.js.snap | 8 +- .../components/list/package_list_row_spec.js | 2 + .../group/components/group_settings_app_spec.js | 14 +- .../components/packages_protection_rules_spec.js | 97 + .../components/registry_settings_app_spec.js | 19 + .../settings/project/settings/mock_data.js | 33 + .../components/bulk_imports_history_app_spec.js | 112 +- .../components/ci_catalog_settings_spec.js | 6 +- .../sessions/new/preserve_url_fragment_spec.js | 6 +- .../components/performance_bar_app_spec.js | 3 + .../components/request_warning_spec.js | 23 +- .../components/profile_preferences_spec.js | 15 +- .../components/commit_comments_button_spec.js | 42 - .../new/components/new_project_url_select_spec.js | 69 +- .../components/new_access_dropdown_spec.js | 23 +- .../settings/repository/branch_rules/app_spec.js | 123 +- .../settings/repository/branch_rules/mock_data.js | 14 + .../releases/__snapshots__/util_spec.js.snap | 8 +- .../releases/components/app_edit_new_spec.js | 19 + .../frontend/releases/components/app_index_spec.js | 44 +- spec/frontend/releases/mock_data.js | 11 + .../releases/stores/modules/detail/actions_spec.js | 52 + .../releases/stores/modules/detail/getters_spec.js | 15 +- spec/frontend/search/store/actions_spec.js | 25 + .../components/feature_card_spec.js | 2 +- spec/frontend/security_configuration/mock_data.js | 79 +- spec/frontend/security_configuration/utils_spec.js | 109 +- .../dropdown_contents_create_view_spec.js | 45 +- .../labels/labels_select_widget/mock_data.js | 24 - spec/frontend/sidebar/components/mock_data.js | 24 + .../components/sidebar_color_picker_spec.js | 58 + .../super_sidebar/components/create_menu_spec.js | 1 + .../super_sidebar/components/user_menu_spec.js | 58 + spec/frontend/super_sidebar/mock_data.js | 101 +- .../components/namespace_storage_app_spec.js | 51 + .../components/storage_usage_overview_card_spec.js | 44 + .../components/storage_usage_statistics_spec.js | 43 + spec/frontend/usage_quotas/storage/mock_data.js | 2 + .../components/mr_widget_rebase_spec.js | 16 + .../states/mr_widget_ready_to_merge_spec.js | 49 + .../mr_widget_options_spec.js | 4 +- .../vue_shared/components/file_row_spec.js | 13 + .../tokens/daterange_token_spec.js | 170 ++ .../vue_shared/components/gl_countdown_spec.js | 4 + .../groups_list/groups_list_item_spec.js | 5 +- .../components/groups_list/groups_list_spec.js | 7 + .../help_page_link/help_page_link_spec.js | 51 + .../markdown/comment_templates_dropdown_spec.js | 6 +- .../components/markdown/markdown_editor_spec.js | 17 - .../projects_list/projects_list_item_spec.js | 3 +- .../components/projects_list/projects_list_spec.js | 7 + .../runner_instructions_spec.js | 33 - .../segmented_control_button_group_spec.js | 1 + .../source_viewer/source_viewer_new_spec.js | 18 +- .../upload_dropzone/avatar_upload_dropzone_spec.js | 116 ++ .../vue_shared/components/user_select_spec.js | 45 + .../issuable/list/components/issuable_item_spec.js | 79 +- .../notes/work_item_comment_form_spec.js | 25 + ..._item_sidebar_dropdown_widget_with_edit_spec.js | 161 ++ .../shared/work_item_token_input_spec.js | 238 ++- .../components/work_item_assignees_spec.js | 3 + .../work_item_attributes_wrapper_spec.js | 26 +- .../work_item_description_rendered_spec.js | 15 +- .../components/work_item_description_spec.js | 36 + .../work_items/components/work_item_detail_spec.js | 84 +- .../work_item_links/work_item_links_form_spec.js | 26 +- .../components/work_item_milestone_inline_spec.js | 234 +++ .../components/work_item_milestone_spec.js | 230 --- .../work_item_milestone_with_edit_spec.js | 209 ++ .../components/work_item_parent_inline_spec.js | 6 +- .../components/work_item_parent_with_edit_spec.js | 4 + .../components/work_item_state_toggle_spec.js | 28 + .../components/work_item_title_with_edit_spec.js | 59 + spec/frontend/work_items/mock_data.js | 72 + spec/frontend/work_items/utils_spec.js | 24 +- 217 files changed, 7242 insertions(+), 4898 deletions(-) delete mode 100644 spec/frontend/behaviors/secret_values_spec.js delete mode 100644 spec/frontend/boards/stores/actions_spec.js delete mode 100644 spec/frontend/boards/stores/getters_spec.js delete mode 100644 spec/frontend/boards/stores/state_spec.js create mode 100644 spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js create mode 100644 spec/frontend/ci/ci_environments_dropdown/ci_environments_dropdown_spec.js create mode 100644 spec/frontend/ci/ci_environments_dropdown/utils_spec.js delete mode 100644 spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js delete mode 100644 spec/frontend/ci/ci_variable_list/utils_spec.js create mode 100644 spec/frontend/content_editor/extensions/task_item_spec.js create mode 100644 spec/frontend/diffs/components/__snapshots__/tree_list_spec.js.snap delete mode 100644 spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_job_failure.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_new_commit.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_job_failure.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/rules/auto_cancel/on_new_commit.yml delete mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml delete mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_job_failure.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_new_commit.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_job_failure.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/rules/auto_cancel/on_new_commit.yml delete mode 100644 spec/frontend/ide/lib/alerts/environment_spec.js delete mode 100644 spec/frontend/ide/stores/actions/alert_spec.js delete mode 100644 spec/frontend/ide/stores/getters/alert_spec.js delete mode 100644 spec/frontend/ide/stores/mutations/alert_spec.js create mode 100644 spec/frontend/kubernetes_dashboard/pages/cron_jobs_page_spec.js create mode 100644 spec/frontend/kubernetes_dashboard/pages/jobs_page_spec.js create mode 100644 spec/frontend/kubernetes_dashboard/pages/services_page_spec.js delete mode 100644 spec/frontend/lib/utils/number_utility_spec.js create mode 100644 spec/frontend/lib/utils/number_utils_spec.js create mode 100644 spec/frontend/ml/model_registry/apps/new_ml_model_spec.js create mode 100644 spec/frontend/ml/model_registry/components/actions_dropdown_spec.js create mode 100644 spec/frontend/ml/model_registry/components/searchable_list_spec.js create mode 100644 spec/frontend/organizations/show/components/organization_description_spec.js create mode 100644 spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js delete mode 100644 spec/frontend/projects/commit/components/commit_comments_button_spec.js create mode 100644 spec/frontend/sidebar/components/sidebar_color_picker_spec.js create mode 100644 spec/frontend/usage_quotas/storage/components/namespace_storage_app_spec.js create mode 100644 spec/frontend/usage_quotas/storage/components/storage_usage_overview_card_spec.js create mode 100644 spec/frontend/usage_quotas/storage/components/storage_usage_statistics_spec.js create mode 100644 spec/frontend/vue_shared/components/filtered_search_bar/tokens/daterange_token_spec.js create mode 100644 spec/frontend/vue_shared/components/help_page_link/help_page_link_spec.js delete mode 100644 spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js create mode 100644 spec/frontend/vue_shared/components/upload_dropzone/avatar_upload_dropzone_spec.js create mode 100644 spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit_spec.js create mode 100644 spec/frontend/work_items/components/work_item_milestone_inline_spec.js delete mode 100644 spec/frontend/work_items/components/work_item_milestone_spec.js create mode 100644 spec/frontend/work_items/components/work_item_milestone_with_edit_spec.js create mode 100644 spec/frontend/work_items/components/work_item_title_with_edit_spec.js (limited to 'spec/frontend') 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 ` -
- *** -
- - `; -} - -function generateFixtureMarkup(secrets, isRevealed, valueClass, placeholderClass) { - return ` -
- ${secrets.map((secret) => generateValueMarkup(secret, valueClass, placeholderClass)).join('')} - -
- `; -} - -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(`
`); - 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_environments_dropdown/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_environments_dropdown/ci_environments_dropdown_spec.js new file mode 100644 index 00000000000..d26827de57b --- /dev/null +++ b/spec/frontend/ci/ci_environments_dropdown/ci_environments_dropdown_spec.js @@ -0,0 +1,215 @@ +import { GlListboxItem, GlCollapsibleListbox, GlDropdownDivider, GlIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +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: '', + }; + + const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index); + const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListboxText = () => findListbox().props('toggleText'); + const findCreateWildcardButton = () => wrapper.findByTestId('create-wildcard-button'); + const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); + const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice'); + + const createComponent = ({ props = {}, searchTerm = '' } = {}) => { + wrapper = mountExtended(CiEnvironmentsDropdown, { + propsData: { + ...defaultProps, + ...props, + }, + }); + + findListbox().vm.$emit('search', searchTerm); + }; + + 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); + }); + }); + + 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); + }); + }); + }); + + 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'); + }); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent({ props: { environments: envs } }); + }); + + it('prepends * in listbox', () => { + expect(findListboxItemByIndex(0).text()).toBe('*'); + }); + + it('renders all environments', () => { + expect(findListboxItemByIndex(1).text()).toBe(envs[0]); + expect(findListboxItemByIndex(2).text()).toBe(envs[1]); + expect(findListboxItemByIndex(3).text()).toBe(envs[2]); + }); + + 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', () => { + const wildcardScope = '*'; + + beforeEach(() => { + createComponent({ props: { selectedEnvironmentScope: wildcardScope } }); + }); + + it('shows the `All environments` text and not the wildcard', () => { + expect(findListboxText()).toContain('All (default)'); + expect(findListboxText()).not.toContain(wildcardScope); + }); + }); + + describe('when fetching environments', () => { + const currentEnv = envs[2]; + + beforeEach(() => { + createComponent(); + }); + + it('renders dropdown divider', () => { + expect(findDropdownDivider().exists()).toBe(true); + }); + + it('renders environments passed down to it', async () => { + await findListbox().vm.$emit('search', currentEnv); + + expect(findAllListboxItems()).toHaveLength(envs.length); + }); + + it('renders dropdown loading icon while fetch query is loading', () => { + createComponent({ props: { areEnvironmentsLoading: true } }); + + expect(findListbox().props('loading')).toBe(true); + expect(findListbox().props('searching')).toBe(false); + expect(findDropdownDivider().exists()).toBe(false); + }); + + it('renders search loading icon while search query is loading and dropdown is open', async () => { + createComponent({ props: { areEnvironmentsLoading: true } }); + await findListbox().vm.$emit('shown'); + + expect(findListbox().props('loading')).toBe(false); + expect(findListbox().props('searching')).toBe(true); + }); + + it('emits event when searching', async () => { + expect(wrapper.emitted('search-environment-scope')).toHaveLength(1); + + await findListbox().vm.$emit('search', currentEnv); + + expect(wrapper.emitted('search-environment-scope')).toHaveLength(2); + expect(wrapper.emitted('search-environment-scope')[1]).toEqual([currentEnv]); + }); + + it('displays note about max environments', () => { + expect(findMaxEnvNote().exists()).toBe(true); + expect(findMaxEnvNote().text()).toContain('30'); + }); + }); + + describe('Custom events', () => { + describe('when selecting an environment', () => { + const itemIndex = 0; + + beforeEach(() => { + createComponent(); + }); + + it('emits `select-environment` when an environment is clicked', () => { + findListbox().vm.$emit('select', envs[itemIndex]); + + expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); + }); + }); + + describe('when creating a new environment scope from a search term', () => { + const searchTerm = 'new-env'; + beforeEach(() => { + createComponent({ searchTerm }); + }); + + it('sets new environment scope as the selected environment scope', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', searchTerm); + + expect(findListbox().props('selected')).toBe(searchTerm); + }); + + it('includes new environment scope in search if it matches search term', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', searchTerm); + + expect(findAllListboxItems()).toHaveLength(envs.length + 1); + expect(findListboxItemByIndex(1).text()).toBe(searchTerm); + }); + + it('excludes new environment scope in search if it does not match the search term', async () => { + findCreateWildcardButton().trigger('click'); + + await findListbox().vm.$emit('search', 'not-new-env'); + + expect(findAllListboxItems()).toHaveLength(envs.length); + }); + }); + }); +}); diff --git a/spec/frontend/ci/ci_environments_dropdown/utils_spec.js b/spec/frontend/ci/ci_environments_dropdown/utils_spec.js new file mode 100644 index 00000000000..6da0d7cdbca --- /dev/null +++ b/spec/frontend/ci/ci_environments_dropdown/utils_spec.js @@ -0,0 +1,33 @@ +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('All (default)'); + }); + + 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'); + }); + }); + + describe('mapEnvironmentNames', () => { + const envName = 'dev'; + const envName2 = 'prod'; + + const nodes = [ + { name: envName, otherProp: {} }, + { name: envName2, otherProp: {} }, + ]; + it('flatten a nodes array with only their names', () => { + expect(mapEnvironmentNames(nodes)).toEqual([envName, envName2]); + }); + }); +}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js deleted file mode 100644 index 353b5fd3c47..00000000000 --- a/spec/frontend/ci/ci_variable_list/components/ci_environments_dropdown_spec.js +++ /dev/null @@ -1,180 +0,0 @@ -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'; - -describe('Ci environments dropdown', () => { - let wrapper; - - const envs = ['dev', 'prod', 'staging']; - const defaultProps = { - areEnvironmentsLoading: false, - environments: envs, - selectedEnvironmentScope: '', - }; - - const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); - const findListboxItemByIndex = (index) => wrapper.findAllComponents(GlListboxItem).at(index); - const findActiveIconByIndex = (index) => findListboxItemByIndex(index).findComponent(GlIcon); - const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); - const findListboxText = () => findListbox().props('toggleText'); - const findCreateWildcardButton = () => wrapper.findByTestId('create-wildcard-button'); - const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); - const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice'); - - const createComponent = ({ props = {}, searchTerm = '' } = {}) => { - wrapper = mountExtended(CiEnvironmentsDropdown, { - propsData: { - ...defaultProps, - ...props, - }, - }); - - findListbox().vm.$emit('search', searchTerm); - }; - - describe('No environments found', () => { - 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'); - }); - }); - - describe('Search term is empty', () => { - beforeEach(() => { - createComponent({ props: { environments: envs } }); - }); - - it(`prepends * in listbox`, () => { - expect(findListboxItemByIndex(0).text()).toBe('*'); - }); - - it('renders all environments', () => { - expect(findListboxItemByIndex(1).text()).toBe(envs[0]); - expect(findListboxItemByIndex(2).text()).toBe(envs[1]); - expect(findListboxItemByIndex(3).text()).toBe(envs[2]); - }); - - it('does not display active checkmark', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); - }); - }); - - describe('when `*` is the value of selectedEnvironmentScope props', () => { - const wildcardScope = '*'; - - beforeEach(() => { - createComponent({ props: { selectedEnvironmentScope: wildcardScope } }); - }); - - it('shows the `All environments` text and not the wildcard', () => { - expect(findListboxText()).toContain(allEnvironments.text); - expect(findListboxText()).not.toContain(wildcardScope); - }); - }); - - describe('when fetching environments', () => { - const currentEnv = envs[2]; - - beforeEach(() => { - createComponent(); - }); - - it('renders dropdown divider', () => { - expect(findDropdownDivider().exists()).toBe(true); - }); - - it('renders environments passed down to it', async () => { - await findListbox().vm.$emit('search', currentEnv); - - expect(findAllListboxItems()).toHaveLength(envs.length); - }); - - it('renders dropdown loading icon while fetch query is loading', () => { - createComponent({ props: { areEnvironmentsLoading: true } }); - - expect(findListbox().props('loading')).toBe(true); - expect(findListbox().props('searching')).toBe(false); - expect(findDropdownDivider().exists()).toBe(false); - }); - - it('renders search loading icon while search query is loading and dropdown is open', async () => { - createComponent({ props: { areEnvironmentsLoading: true } }); - await findListbox().vm.$emit('shown'); - - expect(findListbox().props('loading')).toBe(false); - expect(findListbox().props('searching')).toBe(true); - }); - - it('emits event when searching', async () => { - expect(wrapper.emitted('search-environment-scope')).toHaveLength(1); - - await findListbox().vm.$emit('search', currentEnv); - - expect(wrapper.emitted('search-environment-scope')).toHaveLength(2); - expect(wrapper.emitted('search-environment-scope')[1]).toEqual([currentEnv]); - }); - - it('displays note about max environments shown', () => { - expect(findMaxEnvNote().exists()).toBe(true); - expect(findMaxEnvNote().text()).toContain(String(ENVIRONMENT_QUERY_LIMIT)); - }); - }); - - describe('Custom events', () => { - describe('when selecting an environment', () => { - const itemIndex = 0; - - beforeEach(() => { - createComponent(); - }); - - it('emits `select-environment` when an environment is clicked', () => { - findListbox().vm.$emit('select', envs[itemIndex]); - - expect(wrapper.emitted('select-environment')).toEqual([[envs[itemIndex]]]); - }); - }); - - describe('when creating a new environment scope from a search term', () => { - const searchTerm = 'new-env'; - beforeEach(() => { - createComponent({ searchTerm }); - }); - - it('sets new environment scope as the selected environment scope', async () => { - findCreateWildcardButton().trigger('click'); - - await findListbox().vm.$emit('search', searchTerm); - - expect(findListbox().props('selected')).toBe(searchTerm); - }); - - it('includes new environment scope in search if it matches search term', async () => { - findCreateWildcardButton().trigger('click'); - - await findListbox().vm.$emit('search', searchTerm); - - expect(findAllListboxItems()).toHaveLength(envs.length + 1); - expect(findListboxItemByIndex(1).text()).toBe(searchTerm); - }); - - it('excludes new environment scope in search if it does not match the search term', async () => { - findCreateWildcardButton().trigger('click'); - - await findListbox().vm.$emit('search', 'not-new-env'); - - expect(findAllListboxItems()).toHaveLength(envs.length); - }); - }); - }); -}); 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/ci_variable_list/utils_spec.js b/spec/frontend/ci/ci_variable_list/utils_spec.js deleted file mode 100644 index fbcf0e7c5a5..00000000000 --- a/spec/frontend/ci/ci_variable_list/utils_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import { convertEnvironmentScope, mapEnvironmentNames } from '~/ci/ci_variable_list/utils'; -import { allEnvironments } from '~/ci/ci_variable_list/constants'; - -describe('utils', () => { - describe('convertEnvironmentScope', () => { - it('converts the * to the `All environments` text', () => { - expect(convertEnvironmentScope('*')).toBe(allEnvironments.text); - }); - - it('returns the environment as is if not the *', () => { - expect(convertEnvironmentScope('prod')).toBe('prod'); - }); - }); - - describe('mapEnvironmentNames', () => { - const envName = 'dev'; - const envName2 = 'prod'; - - const nodes = [ - { name: envName, otherProp: {} }, - { name: envName2, otherProp: {} }, - ]; - it('flatten a nodes array with only their names', () => { - expect(mapEnvironmentNames(nodes)).toEqual([envName, envName2]); - }); - }); -}); 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: '' }, + }, + }); }; 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: '' }, + }, 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': `Workspaces 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 @@ -179,6 +183,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 }} 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(` +
  • + +
    +

    + foo +

    +
    +
  • + `); + }); + + 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(` +
  • + +
    +

    + foo +

    +
    +
  • + `); + }); + + it('ignores any tags in the task item', () => { + tiptapEditor.commands.setContent(` + + `); + + expect(tiptapEditor.view.dom.querySelector('li')).toMatchInlineSnapshot(` +
  • + +
    +

    + foo +

    +
    +
  • + `); + }); +}); 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 = ``; +const MALFORMED_BULLET_LIST_HTML = + ``; + 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" > - ' }, + }, }); } 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/auto_cancel_pipeline.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml deleted file mode 100644 index 0ba3e5632e3..00000000000 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/auto_cancel_pipeline.yml +++ /dev/null @@ -1,4 +0,0 @@ -# invalid workflow:auto-cancel:on-job-failure -workflow: - auto_cancel: - on_job_failure: unexpected_value diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/image.yml 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/workflow/auto_cancel/on_job_failure.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_job_failure.yml new file mode 100644 index 00000000000..2bf9effe1be --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/workflow/auto_cancel/on_job_failure.yml @@ -0,0 +1,3 @@ +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/all.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml deleted file mode 100644 index bf84ff16f42..00000000000 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/all.yml +++ /dev/null @@ -1,4 +0,0 @@ -# valid workflow:auto-cancel:on-job-failure -workflow: - auto_cancel: - on_job_failure: all diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/auto_cancel_pipeline/on_job_failure/none.yml 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/workflow/auto_cancel/on_job_failure.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_job_failure.yml new file mode 100644 index 00000000000..79d18f40721 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/workflow/auto_cancel/on_job_failure.yml @@ -0,0 +1,3 @@ +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: '
    ' }, }, }), ); 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 @@ -
    +
    { 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: '
    ' }, + }, }); }; 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: `
    ` }, + }, + }); + }; + + 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. - + Ever. Best. Release. - + Ever. { 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'; @@ -121,6 +122,38 @@ describe('Release edit/new actions', () => { JSON.stringify(createFrom), ); + return testAction({ + action: actions.loadDraftRelease, + state, + expectedMutations: [ + { 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, @@ -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: '
    ' }, }, 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: ` - ${mockIssuable.state} - `, - }, + describe('status', () => { + it('renders issuable status via slot', () => { + wrapper = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + status: ` + ${mockIssuable.state} + `, + }, + }); + 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: '
    ' }, + }, }); }; 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_inline_spec.js b/spec/frontend/work_items/components/work_item_milestone_inline_spec.js new file mode 100644 index 00000000000..75c5763914a --- /dev/null +++ b/spec/frontend/work_items/components/work_item_milestone_inline_spec.js @@ -0,0 +1,234 @@ +import { GlCollapsibleListbox, GlListboxItem, GlSkeletonLoader, GlFormGroup } from '@gitlab/ui'; + +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +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'; +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('WorkItemMilestoneInline component', () => { + Vue.use(VueApollo); + + let wrapper; + + const workItemId = 'gid://gitlab/WorkItem/1'; + const workItemType = 'Task'; + + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findNoMilestoneDropdownItem = () => wrapper.findByTestId('listbox-item-no-milestone-id'); + const findDropdownItems = () => wrapper.findAllComponents(GlListboxItem); + const findDisabledTextSpan = () => wrapper.findByTestId('disabled-text'); + const findInputGroup = () => wrapper.findComponent(GlFormGroup); + const findNoResultsText = () => wrapper.findByTestId('no-results-text'); + + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse); + const successSearchWithNoMatchingMilestones = jest + .fn() + .mockResolvedValue(projectMilestonesResponseWithNoMilestones); + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); + + const showDropdown = () => findDropdown().vm.$emit('shown'); + const hideDropdown = () => findDropdown().vm.$emit('hide'); + + const createComponent = ({ + canUpdate = true, + milestone = mockMilestoneWidgetResponse, + searchQueryHandler = successSearchQueryHandler, + mutationHandler = successUpdateWorkItemMutationHandler, + } = {}) => { + wrapper = shallowMountExtended(WorkItemMilestoneInline, { + apolloProvider: createMockApollo([ + [projectMilestonesQuery, searchQueryHandler], + [updateWorkItemMutation, mutationHandler], + ]), + propsData: { + fullPath: 'full-path', + canUpdate, + workItemMilestone: milestone, + workItemId, + workItemType, + }, + stubs: { + GlCollapsibleListbox, + }, + }); + }; + + it('has "Milestone" label', () => { + createComponent(); + + expect(findInputGroup().exists()).toBe(true); + 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} | ${WorkItemMilestoneInline.i18n.NONE} + ${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title} + `('$description', ({ milestone, value }) => { + it(`has a value of "${value}"`, () => { + createComponent({ canUpdate: false, milestone }); + + expect(findDisabledTextSpan().text()).toBe(value); + expect(findDropdown().exists()).toBe(false); + }); + }); + }); + + describe('Default text value when canUpdate true and no milestone set', () => { + it(`has a value of "Add to milestone"`, () => { + createComponent({ canUpdate: true, milestone: null }); + + expect(findDropdown().props('toggleText')).toBe( + WorkItemMilestoneInline.i18n.MILESTONE_PLACEHOLDER, + ); + }); + }); + + describe('Dropdown search', () => { + it('has the search box', () => { + createComponent(); + + expect(findDropdown().props('searchable')).toBe(true); + }); + + it('shows no matching results when no items', () => { + createComponent({ + searchQueryHandler: successSearchWithNoMatchingMilestones, + }); + + expect(findNoResultsText().text()).toBe(WorkItemMilestoneInline.i18n.NO_MATCHING_RESULTS); + expect(findDropdownItems()).toHaveLength(1); + }); + }); + + describe('Dropdown options', () => { + beforeEach(() => { + createComponent({ canUpdate: true }); + }); + + it('calls successSearchQueryHandler with variables when dropdown is opened', async () => { + showDropdown(); + await nextTick(); + + 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(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows the milestones in dropdown when the items have finished fetching', async () => { + showDropdown(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findNoMilestoneDropdownItem().exists()).toBe(true); + expect(findDropdownItems()).toHaveLength( + projectMilestonesResponse.data.workspace.attributes.nodes.length + 1, + ); + }); + + it('changes the milestone to null when clicked on no milestone', async () => { + showDropdown(); + findDropdown().vm.$emit('select', noMilestoneId); + + hideDropdown(); + await nextTick(); + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + expect(findDropdown().props()).toMatchObject({ + loading: false, + toggleText: WorkItemMilestoneInline.i18n.MILESTONE_PLACEHOLDER, + toggleClass: expect.arrayContaining(['gl-text-gray-500!']), + }); + }); + + it('changes the milestone to the selected milestone', async () => { + const milestoneIndex = 1; + /** the index is -1 since no matching results is also a dropdown item */ + const milestoneAtIndex = + projectMilestonesResponse.data.workspace.attributes.nodes[milestoneIndex - 1]; + + showDropdown(); + + await waitForPromises(); + findDropdown().vm.$emit('select', milestoneAtIndex.id); + + hideDropdown(); + await waitForPromises(); + + expect(findDropdown().props('toggleText')).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(); + findDropdown().vm.$emit('select', noMilestoneId); + hideDropdown(); + + 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(); + findDropdown().vm.$emit('select', noMilestoneId); + hideDropdown(); + + 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_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js deleted file mode 100644 index fc2c5eb2af2..00000000000 --- a/spec/frontend/work_items/components/work_item_milestone_spec.js +++ /dev/null @@ -1,230 +0,0 @@ -import { GlCollapsibleListbox, GlListboxItem, GlSkeletonLoader, GlFormGroup } from '@gitlab/ui'; - -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import WorkItemMilestone, { noMilestoneId } from '~/work_items/components/work_item_milestone.vue'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { mockTracking } from 'helpers/tracking_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -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('WorkItemMilestone component', () => { - Vue.use(VueApollo); - - let wrapper; - - const workItemId = 'gid://gitlab/WorkItem/1'; - const workItemType = 'Task'; - - const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); - const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findNoMilestoneDropdownItem = () => wrapper.findByTestId('listbox-item-no-milestone-id'); - const findDropdownItems = () => wrapper.findAllComponents(GlListboxItem); - const findDisabledTextSpan = () => wrapper.findByTestId('disabled-text'); - const findInputGroup = () => wrapper.findComponent(GlFormGroup); - const findNoResultsText = () => wrapper.findByTestId('no-results-text'); - - const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse); - const successSearchWithNoMatchingMilestones = jest - .fn() - .mockResolvedValue(projectMilestonesResponseWithNoMilestones); - const successUpdateWorkItemMutationHandler = jest - .fn() - .mockResolvedValue(updateWorkItemMutationResponse); - - const showDropdown = () => findDropdown().vm.$emit('shown'); - const hideDropdown = () => findDropdown().vm.$emit('hide'); - - const createComponent = ({ - canUpdate = true, - milestone = mockMilestoneWidgetResponse, - searchQueryHandler = successSearchQueryHandler, - mutationHandler = successUpdateWorkItemMutationHandler, - } = {}) => { - wrapper = shallowMountExtended(WorkItemMilestone, { - apolloProvider: createMockApollo([ - [projectMilestonesQuery, searchQueryHandler], - [updateWorkItemMutation, mutationHandler], - ]), - propsData: { - fullPath: 'full-path', - canUpdate, - workItemMilestone: milestone, - workItemId, - workItemType, - }, - stubs: { - GlCollapsibleListbox, - }, - }); - }; - - it('has "Milestone" label', () => { - createComponent(); - - expect(findInputGroup().exists()).toBe(true); - expect(findInputGroup().attributes('label')).toBe(WorkItemMilestone.i18n.MILESTONE); - }); - - describe('Default text with canUpdate false and milestone value', () => { - describe.each` - description | milestone | value - ${'when no milestone'} | ${null} | ${WorkItemMilestone.i18n.NONE} - ${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title} - `('$description', ({ milestone, value }) => { - it(`has a value of "${value}"`, () => { - createComponent({ canUpdate: false, milestone }); - - expect(findDisabledTextSpan().text()).toBe(value); - expect(findDropdown().exists()).toBe(false); - }); - }); - }); - - describe('Default text value when canUpdate true and no milestone set', () => { - it(`has a value of "Add to milestone"`, () => { - createComponent({ canUpdate: true, milestone: null }); - - expect(findDropdown().props('toggleText')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER); - }); - }); - - describe('Dropdown search', () => { - it('has the search box', () => { - createComponent(); - - expect(findDropdown().props('searchable')).toBe(true); - }); - - it('shows no matching results when no items', () => { - createComponent({ - searchQueryHandler: successSearchWithNoMatchingMilestones, - }); - - expect(findNoResultsText().text()).toBe(WorkItemMilestone.i18n.NO_MATCHING_RESULTS); - expect(findDropdownItems()).toHaveLength(1); - }); - }); - - describe('Dropdown options', () => { - beforeEach(() => { - createComponent({ canUpdate: true }); - }); - - it('calls successSearchQueryHandler with variables when dropdown is opened', async () => { - showDropdown(); - await nextTick(); - - 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(); - - expect(findSkeletonLoader().exists()).toBe(true); - }); - - it('shows the milestones in dropdown when the items have finished fetching', async () => { - showDropdown(); - await waitForPromises(); - - expect(findSkeletonLoader().exists()).toBe(false); - expect(findNoMilestoneDropdownItem().exists()).toBe(true); - expect(findDropdownItems()).toHaveLength( - projectMilestonesResponse.data.workspace.attributes.nodes.length + 1, - ); - }); - - it('changes the milestone to null when clicked on no milestone', async () => { - showDropdown(); - findDropdown().vm.$emit('select', noMilestoneId); - - hideDropdown(); - await nextTick(); - expect(findDropdown().props('loading')).toBe(true); - - await waitForPromises(); - expect(findDropdown().props()).toMatchObject({ - loading: false, - toggleText: WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER, - toggleClass: expect.arrayContaining(['gl-text-gray-500!']), - }); - }); - - it('changes the milestone to the selected milestone', async () => { - const milestoneIndex = 1; - /** the index is -1 since no matching results is also a dropdown item */ - const milestoneAtIndex = - projectMilestonesResponse.data.workspace.attributes.nodes[milestoneIndex - 1]; - - showDropdown(); - - await waitForPromises(); - findDropdown().vm.$emit('select', milestoneAtIndex.id); - - hideDropdown(); - await waitForPromises(); - - expect(findDropdown().props('toggleText')).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(); - findDropdown().vm.$emit('select', noMilestoneId); - hideDropdown(); - - 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(); - findDropdown().vm.$emit('select', noMilestoneId); - hideDropdown(); - - 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_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); + }); +}); -- cgit v1.2.3