From f8dfaa8d4140328fa63282d3d6ac159d571dab84 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Fri, 11 Feb 2022 21:12:17 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../Feature Proposal - lean.md | 2 +- .../Feature proposal - detailed.md | 16 +- .../components/invite_group_trigger.vue | 2 +- .../components/invite_groups_modal.vue | 146 ++++++ .../components/invite_members_modal.vue | 439 +++++----------- .../components/invite_modal_base.vue | 276 ++++++++++ .../javascripts/invite_members/constants.js | 95 ++-- .../init_invite_groups_modal.js | 44 ++ .../init_invite_members_modal.js | 3 - .../pages/groups/group_members/index.js | 2 + .../pages/projects/project_members/index.js | 2 + .../permissions/components/settings_panel.vue | 19 - app/controllers/projects_controller.rb | 1 - app/graphql/graphql_triggers.rb | 4 + app/graphql/types/issuable_type.rb | 4 +- app/graphql/types/subscription_type.rb | 3 + app/helpers/invite_members_helper.rb | 15 +- app/helpers/projects_helper.rb | 1 - .../concerns/cross_database_modification.rb | 7 +- app/models/project.rb | 1 - app/models/project_setting.rb | 4 + app/policies/project_policy.rb | 1 - app/services/work_items/update_service.rb | 7 + app/views/devise/shared/_signup_box.html.haml | 3 +- .../groups/_invite_groups_modal.html.haml | 3 + .../groups/_invite_members_modal.html.haml | 3 +- .../groups/group_members/index.html.haml | 1 + app/views/notify/_note_email.html.haml | 3 +- app/views/notify/_note_email.text.erb | 2 +- .../projects/_invite_groups_modal.html.haml | 3 + .../projects/project_members/index.html.haml | 1 + data/deprecations/14-8-Elasticsearch-6-8.yml | 18 + ..._cluster_image_scanning_vulnerabilities.rb | 16 + db/schema_migrations/20220209111007 | 1 + db/structure.sql | 2 + .../img/custom_hooks_error_msg.png | Bin 31281 -> 0 bytes .../performance/request_profiling.md | 8 +- doc/administration/server_hooks.md | 188 +++---- doc/api/graphql/reference/index.md | 1 + doc/ci/environments/deployment_approvals.md | 12 +- doc/ci/environments/protected_environments.md | 5 +- doc/integration/elasticsearch.md | 23 +- doc/update/deprecations.md | 16 + doc/user/infrastructure/iac/index.md | 61 +-- .../infrastructure/iac/troubleshooting.md | 68 +++ ...reduce_container_registry_data_transfer.md | 20 +- locale/gitlab.pot | 12 +- qa/qa/page/component/invite_members_modal.rb | 2 +- .../settings/project_settings_spec.rb | 30 -- .../components/invite_group_trigger_spec.js | 2 +- .../components/invite_groups_modal_spec.js | 143 +++++ .../components/invite_members_modal_spec.js | 496 ++++++------------ .../components/invite_modal_base_spec.js | 103 ++++ .../invite_members/mock_data/group_modal.js | 11 + .../invite_members/mock_data/member_modal.js | 36 ++ .../invite_members/mock_data/modal_base.js | 11 + .../components/settings_panel_spec.js | 11 - spec/graphql/graphql_triggers_spec.rb | 14 + spec/graphql/types/issuable_type_spec.rb | 6 +- spec/graphql/types/subscription_type_spec.rb | 1 + spec/helpers/invite_members_helper_spec.rb | 19 +- spec/helpers/projects_helper_spec.rb | 1 - spec/requests/api/project_attributes.yml | 1 - .../work_items/update_service_spec.rb | 20 + .../features/invite_members_modal_helper.rb | 2 +- .../shared/_signup_box.html.haml_spec.rb | 16 + 66 files changed, 1485 insertions(+), 1004 deletions(-) create mode 100644 app/assets/javascripts/invite_members/components/invite_groups_modal.vue create mode 100644 app/assets/javascripts/invite_members/components/invite_modal_base.vue create mode 100644 app/assets/javascripts/invite_members/init_invite_groups_modal.js create mode 100644 app/views/groups/_invite_groups_modal.html.haml create mode 100644 app/views/projects/_invite_groups_modal.html.haml create mode 100644 data/deprecations/14-8-Elasticsearch-6-8.yml create mode 100644 db/post_migrate/20220209111007_add_partial_index_for_batching_active_cluster_image_scanning_vulnerabilities.rb create mode 100644 db/schema_migrations/20220209111007 delete mode 100644 doc/administration/img/custom_hooks_error_msg.png create mode 100644 doc/user/infrastructure/iac/troubleshooting.md create mode 100644 spec/frontend/invite_members/components/invite_groups_modal_spec.js create mode 100644 spec/frontend/invite_members/components/invite_modal_base_spec.js create mode 100644 spec/frontend/invite_members/mock_data/group_modal.js create mode 100644 spec/frontend/invite_members/mock_data/member_modal.js create mode 100644 spec/frontend/invite_members/mock_data/modal_base.js diff --git a/.gitlab/issue_templates/Feature Proposal - lean.md b/.gitlab/issue_templates/Feature Proposal - lean.md index c826abe7e10..53ad17bbf39 100644 --- a/.gitlab/issue_templates/Feature Proposal - lean.md +++ b/.gitlab/issue_templates/Feature Proposal - lean.md @@ -38,7 +38,7 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma --> -### Metrics +### Feature Usage Metrics -### Metrics - - - ### User experience goal +### Feature Usage Metrics + + + ### What does success look like, and how can we measure that? -| GitLab version | Elasticsearch version | -|---------------------------------------------|-------------------------------| -| GitLab Enterprise Edition 13.9 or greater | Elasticsearch 6.8 through 7.x | -| GitLab Enterprise Edition 13.3 through 13.8 | Elasticsearch 6.4 through 7.x | -| GitLab Enterprise Edition 12.7 through 13.2 | Elasticsearch 6.x through 7.x | -| GitLab Enterprise Edition 11.5 through 12.6 | Elasticsearch 5.6 through 6.x | -| GitLab Enterprise Edition 9.0 through 11.4 | Elasticsearch 5.1 through 5.5 | -| GitLab Enterprise Edition 8.4 through 8.17 | Elasticsearch 2.4 with [Delete By Query Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/plugins-delete-by-query.html) installed | +| GitLab version | Elasticsearch version | +|------------------------------------------------|--------------------------------| +| GitLab Enterprise Edition 14.8 or later | Elasticsearch 7.x through 7.17 | +| GitLab Enterprise Edition 13.9 through 14.7 | Elasticsearch 6.8 through 7.17 | +| GitLab Enterprise Edition 13.3 through 13.8 | Elasticsearch 6.4 through 7.x | +| GitLab Enterprise Edition 12.7 through 13.2 | Elasticsearch 6.x through 7.x | +| GitLab Enterprise Edition 11.5 through 12.6 | Elasticsearch 5.6 through 6.x | +| GitLab Enterprise Edition 9.0 through 11.4 | Elasticsearch 5.1 through 5.5 | +| GitLab Enterprise Edition 8.4 through 8.17 | Elasticsearch 2.4 with [Delete By Query Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/plugins-delete-by-query.html) installed | The Elasticsearch Integration works with supported versions of Elasticsearch and follows Elasticsearch's [End of Life Policy](https://www.elastic.co/support/eol). @@ -35,9 +38,9 @@ before we remove them. GitLab does not support: - [Amazon's OpenSearch](https://aws.amazon.com/blogs/opensource/opensearch-1-0-launches/) -(a [fork of Elasticsearch](https://www.elastic.co/what-is/opensearch)). +(a [fork of Elasticsearch](https://www.elastic.co/what-is/opensearch)). Use AWS Elasticsearch Service 7.10 instead. For updates, see [issue #327560](https://gitlab.com/gitlab-org/gitlab/-/issues/327560). -- Elasticsearch 8.0. For updates, see [issue #350600](https://gitlab.com/gitlab-org/gitlab/-/issues/350600). +- Elasticsearch 8.0. For updates, see [issue #350600](https://gitlab.com/gitlab-org/gitlab/-/issues/350600). Use Elasticsearch 7.17 instead. ## System requirements diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index ad70fb80d73..e92f480da0a 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -809,6 +809,22 @@ The following `geo:db:*` tasks will be replaced with their corresponding `db:*:g **Planned removal milestone: 15.0 (2022-05-22)** +### Elasticsearch 6.8 + +WARNING: +This feature will be changed or removed in 15.0 +as a [breaking change](https://docs.gitlab.com/ee/development/contributing/#breaking-changes). +Before updating GitLab, review the details carefully to determine if you need to make any +changes to your code, settings, or workflow. + +Elasticsearch 6.8 is deprecated in GitLab 14.8 and scheduled for removal in GitLab 15.0. +Customers using Elasticsearch 6.8 need to upgrade their Elasticsearch version to 7.x prior to upgrading to GitLab 15.0. +We recommend using the latest version of Elasticsearch 7 to benefit from all Elasticsearch improvements. + +Elasticsearch 6.8 is also incompatible with Amazon OpenSearch, which we [plan to support in GitLab 15.0](https://gitlab.com/gitlab-org/gitlab/-/issues/327560). + +**Planned removal milestone: 15.0 (2022-05-22)** + ### External status check API breaking changes WARNING: diff --git a/doc/user/infrastructure/iac/index.md b/doc/user/infrastructure/iac/index.md index cb6ec8bc438..6fef1aa7879 100644 --- a/doc/user/infrastructure/iac/index.md +++ b/doc/user/infrastructure/iac/index.md @@ -113,66 +113,9 @@ NOTE: The linked tutorial connects the cluster to GitLab through cluster certificates, and this method was [deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8) in GitLab 14.5. You can still create a cluster through IaC and then connect it to GitLab -through the [Agent](../../clusters/agent/index.md), the default and fully supported +through the [agent](../../clusters/agent/index.md), the default and fully supported method to connect clusters to GitLab. ## Troubleshooting -### `gitlab_group_share_group` resources not detected when subgroup state is refreshed - -The GitLab Terraform provider can fail to detect existing `gitlab_group_share_group` resources -due to the issue ["User with permissions cannot retrieve `share_with_groups` from the API"](https://gitlab.com/gitlab-org/gitlab/-/issues/328428). -This results in an error when running `terraform apply` because Terraform attempts to recreate an -existing resource. - -For example, consider the following group/subgroup configuration: - -```plaintext -parent-group -├── subgroup-A -└── subgroup-B -``` - -Where: - -- User `user-1` creates `parent-group`, `subgroup-A`, and `subgroup-B`. -- `subgroup-A` is shared with `subgroup-B`. -- User `terraform-user` is member of `parent-group` with inherited `owner` access to both subgroups. - -When the Terraform state is refreshed, the API query `GET /groups/:subgroup-A_id` issued by the provider does not return the -details of `subgroup-B` in the `shared_with_groups` array. This leads to the error. - -To workaround this issue, make sure to apply one of the following conditions: - -1. The `terraform-user` creates all subgroup resources. -1. Grant Maintainer or Owner role to the `terraform-user` user on `subgroup-B`. -1. The `terraform-user` inherited access to `subgroup-B` and `subgroup-B` contains at least one project. - -### Invalid CI/CD syntax error when using the "latest" base template - -On GitLab 14.2 and later, you might get a CI/CD syntax error when using the -`latest` Base Terraform template: - -```yaml -include: - - template: Terraform/Base.latest.gitlab-ci.yml - -my-Terraform-job: - extends: .init -``` - -The base template's [jobs were renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67719/) -with better Terraform-specific names. To resolve the syntax error, you can: - -- Use the stable `Terraform/Base.gitlab-ci.yml` template, which has not changed. -- Update your pipeline configuration to use the new job names in - `https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml`. - For example: - - ```yaml - include: - - template: Terraform/Base.latest.gitlab-ci.yml - - my-Terraform-job: - extends: .terraform:init # The updated name. - ``` +See the [troubleshooting](troubleshooting.md) documentation. diff --git a/doc/user/infrastructure/iac/troubleshooting.md b/doc/user/infrastructure/iac/troubleshooting.md new file mode 100644 index 00000000000..ecefa20db99 --- /dev/null +++ b/doc/user/infrastructure/iac/troubleshooting.md @@ -0,0 +1,68 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Troubleshooting the Terraform integration with GitLab + +When you are using the integration with Terraform and GitLab, you might experience issues you need to troubleshoot. + +## `gitlab_group_share_group` resources not detected when subgroup state is refreshed + +The GitLab Terraform provider can fail to detect existing `gitlab_group_share_group` resources +due to the issue ["User with permissions cannot retrieve `share_with_groups` from the API"](https://gitlab.com/gitlab-org/gitlab/-/issues/328428). +This results in an error when running `terraform apply` because Terraform attempts to recreate an +existing resource. + +For example, consider the following group/subgroup configuration: + +```plaintext +parent-group +├── subgroup-A +└── subgroup-B +``` + +Where: + +- User `user-1` creates `parent-group`, `subgroup-A`, and `subgroup-B`. +- `subgroup-A` is shared with `subgroup-B`. +- User `terraform-user` is member of `parent-group` with inherited `owner` access to both subgroups. + +When the Terraform state is refreshed, the API query `GET /groups/:subgroup-A_id` issued by the provider does not return the +details of `subgroup-B` in the `shared_with_groups` array. This leads to the error. + +To workaround this issue, make sure to apply one of the following conditions: + +1. The `terraform-user` creates all subgroup resources. +1. Grant Maintainer or Owner role to the `terraform-user` user on `subgroup-B`. +1. The `terraform-user` inherited access to `subgroup-B` and `subgroup-B` contains at least one project. + +### Invalid CI/CD syntax error when using the `latest` base template + +On GitLab 14.2 and later, you might get a CI/CD syntax error when using the +`latest` Base Terraform template: + +```yaml +include: + - template: Terraform/Base.latest.gitlab-ci.yml + +my-Terraform-job: + extends: .init +``` + +The base template's [jobs were renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67719/) +with better Terraform-specific names. To resolve the syntax error, you can: + +- Use the stable `Terraform/Base.gitlab-ci.yml` template, which has not changed. +- Update your pipeline configuration to use the new job names in + `https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml`. + For example: + + ```yaml + include: + - template: Terraform/Base.latest.gitlab-ci.yml + + my-Terraform-job: + extends: .terraform:init # The updated name. + ``` diff --git a/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md b/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md index b3311cb45c9..5f678a661f8 100644 --- a/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md +++ b/doc/user/packages/container_registry/reduce_container_registry_data_transfer.md @@ -99,10 +99,26 @@ base, and each begins a new build stage. You can selectively copy artifacts from another, leaving behind everything you don't want in the final image. This is especially useful when you need to install build dependencies, but you don't need them to be present in your final image. -## Move to GitLab Ultimate +## Use an image pull policy + +When using the `docker` or `docker+machine` executors, you can set a [`pull_policy`](https://docs.gitlab.com/runner/executors/docker.html#using-the-if-not-present-pull-policy) +parameter in your runner `config.toml` that defines how the runner works when pulling Docker images. +To avoid transferring data when using large and rarely updated images, consider using the +`if-not-present` pull policy when pulling images from remote registries. + +## Use Docker layer caching + +When running `docker build`, each command in `Dockerfile` results in a layer. These layers are kept +as a cache and can be reused if there haven't been any changes. You can specify a tagged image to be +used as a cache source for the `docker build` command by using the `--cache-from` argument. Multiple +images can be specified as a cache source by using multiple `--cache-from` arguments. This can speed +up your builds and reduce the amount of data transferred. For more information, see the +[documentation on Docker layer caching](../../../ci/docker/using_docker_build.md#make-docker-in-docker-builds-faster-with-docker-layer-caching). + +## Move to GitLab Premium or Ultimate GitLab data transfer limits are set at the tier level. If you need a higher limit, consider -upgrading to GitLab Ultimate. +upgrading to [GitLab Premium or Ultimate](https://about.gitlab.com/upgrade/). ## Purchase additional data transfer diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 21f673cd197..25395dc6852 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6351,6 +6351,9 @@ msgstr "" msgid "CICDAnalytics|Shared Runners Usage" msgstr "" +msgid "CICDAnalytics|Shared runner pipeline minute duration by month" +msgstr "" + msgid "CICDAnalytics|Shared runner usage" msgstr "" @@ -28437,12 +28440,6 @@ msgstr "" msgid "ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts." msgstr "" -msgid "ProjectSettings|Include diff preview in merge request notification emails" -msgstr "" - -msgid "ProjectSettings|Include the code diff preview on comment threads in merge request notification emails." -msgstr "" - msgid "ProjectSettings|Internal" msgstr "" @@ -29343,6 +29340,9 @@ msgstr "" msgid "ProtectedEnvironment|Protected Environment (%{protected_environments_count})" msgstr "" +msgid "ProtectedEnvironment|Required approvals" +msgstr "" + msgid "ProtectedEnvironment|Select an environment" msgstr "" diff --git a/qa/qa/page/component/invite_members_modal.rb b/qa/qa/page/component/invite_members_modal.rb index 8fa87afa304..7c536ff651b 100644 --- a/qa/qa/page/component/invite_members_modal.rb +++ b/qa/qa/page/component/invite_members_modal.rb @@ -9,7 +9,7 @@ module QA def self.included(base) super - base.view 'app/assets/javascripts/invite_members/components/invite_members_modal.vue' do + base.view 'app/assets/javascripts/invite_members/components/invite_modal_base.vue' do element :invite_button element :access_level_dropdown element :invite_members_modal_content diff --git a/spec/features/projects/settings/project_settings_spec.rb b/spec/features/projects/settings/project_settings_spec.rb index 0e1e6bacd16..a0d44b579a8 100644 --- a/spec/features/projects/settings/project_settings_spec.rb +++ b/spec/features/projects/settings/project_settings_spec.rb @@ -85,36 +85,6 @@ RSpec.describe 'Projects settings' do end end - context 'show diffs in emails', :js do - it 'does not hide diffs by default' do - visit edit_project_path(project) - - show_diff_preview_in_email_input = find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"]', visible: :hidden) - - expect(show_diff_preview_in_email_input.value).to eq('true') - end - - it 'hides diffs in emails when toggled' do - visit edit_project_path(project) - - show_diff_preview_in_email_input = find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"]', visible: :hidden) - show_diff_preview_in_email_checkbox = find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"][type=checkbox]') - - expect(show_diff_preview_in_email_input.value).to eq('true') - - show_diff_preview_in_email_checkbox.click - - expect(show_diff_preview_in_email_input.value).to eq('false') - - page.within('.sharing-permissions') do - find('[data-testid="project-features-save-button"]').click - end - wait_for_requests - - expect(show_diff_preview_in_email_input.value).to eq('false') - end - end - def expect_toggle_state(state) is_collapsed = state == :collapsed diff --git a/spec/frontend/invite_members/components/invite_group_trigger_spec.js b/spec/frontend/invite_members/components/invite_group_trigger_spec.js index cb9967ebe8c..84ddb779a9e 100644 --- a/spec/frontend/invite_members/components/invite_group_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_group_trigger_spec.js @@ -44,7 +44,7 @@ describe('InviteGroupTrigger', () => { }); it('emits event that triggers opening the modal', () => { - expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' }); + expect(eventHub.$emit).toHaveBeenLastCalledWith('openGroupModal'); }); }); }); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js new file mode 100644 index 00000000000..49c55d56080 --- /dev/null +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -0,0 +1,143 @@ +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Api from '~/api'; +import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue'; +import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; +import GroupSelect from '~/invite_members/components/group_select.vue'; +import { stubComponent } from 'helpers/stub_component'; +import { propsData, sharedGroup } from '../mock_data/group_modal'; + +describe('InviteGroupsModal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(InviteGroupsModal, { + propsData: { + ...propsData, + ...props, + }, + stubs: { + InviteModalBase, + GlSprintf, + GlModal: stubComponent(GlModal, { + template: '
', + }), + }, + }); + }; + + const createInviteGroupToProjectWrapper = () => { + createComponent({ isProject: true }); + }; + + const createInviteGroupToGroupWrapper = () => { + createComponent({ isProject: false }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGroupSelect = () => wrapper.findComponent(GroupSelect); + const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findInviteButton = () => wrapper.findByTestId('invite-button'); + const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); + const membersFormGroupInvalidFeedback = () => + findMembersFormGroup().attributes('invalid-feedback'); + const clickInviteButton = () => findInviteButton().vm.$emit('click'); + const clickCancelButton = () => findCancelButton().vm.$emit('click'); + const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val); + + describe('displaying the correct introText and form group description', () => { + describe('when inviting to a project', () => { + it('includes the correct type, and formatted intro text', () => { + createInviteGroupToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name project."); + }); + }); + + describe('when inviting to a group', () => { + it('includes the correct type, and formatted intro text', () => { + createInviteGroupToGroupWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the test name group."); + }); + }); + }); + + describe('submitting the invite form', () => { + describe('when sharing the group is successful', () => { + const groupPostData = { + group_id: sharedGroup.id, + group_access: propsData.defaultAccessLevel, + expires_at: undefined, + format: 'json', + }; + + beforeEach(() => { + createComponent(); + triggerGroupSelect(sharedGroup); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); + + clickInviteButton(); + }); + + it('calls Api groupShareWithGroup with the correct params', () => { + expect(Api.groupShareWithGroup).toHaveBeenCalledWith(propsData.id, groupPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { + onComplete: expect.any(Function), + }); + }); + }); + + describe('when sharing the group fails', () => { + beforeEach(() => { + createInviteGroupToGroupWrapper(); + triggerGroupSelect(sharedGroup); + + wrapper.vm.$toast = { show: jest.fn() }; + + jest + .spyOn(Api, 'groupShareWithGroup') + .mockRejectedValue({ response: { data: { success: false } } }); + + clickInviteButton(); + }); + + it('does not show the toast message on failure', () => { + expect(wrapper.vm.$toast.show).not.toHaveBeenCalled(); + }); + + it('displays the generic error for http server error', () => { + expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); + }); + + describe('clearing the invalid state and message', () => { + it('clears the error when the cancel button is clicked', async () => { + clickCancelButton(); + + await nextTick(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + }); + + it('clears the error when the modal is hidden', async () => { + wrapper.findComponent(GlModal).vm.$emit('hide'); + + await nextTick(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + }); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 72db6904c31..090efc4d4c3 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -1,12 +1,4 @@ -import { - GlDropdown, - GlDropdownItem, - GlDatepicker, - GlFormGroup, - GlSprintf, - GlLink, - GlModal, -} from '@gitlab/ui'; +import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { stubComponent } from 'helpers/stub_component'; @@ -15,15 +7,13 @@ import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; +import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; import ModalConfetti from '~/invite_members/components/confetti.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { INVITE_MEMBERS_FOR_TASK, - CANCEL_BUTTON_TEXT, - INVITE_BUTTON_TEXT, MEMBERS_MODAL_CELEBRATE_INTRO, MEMBERS_MODAL_CELEBRATE_TITLE, - MEMBERS_MODAL_DEFAULT_TITLE, MEMBERS_PLACEHOLDER, MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, LEARN_GITLAB, @@ -33,9 +23,16 @@ import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { getParameterValues } from '~/lib/utils/url_utility'; import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; - -let wrapper; -let mock; +import { + propsData, + inviteSource, + newProjectPath, + user1, + user2, + user3, + user4, + GlEmoji, +} from '../mock_data/member_modal'; jest.mock('~/experimentation/experiment_tracking'); jest.mock('~/lib/utils/url_utility', () => ({ @@ -43,213 +40,125 @@ jest.mock('~/lib/utils/url_utility', () => ({ getParameterValues: jest.fn(() => []), })); -const id = '1'; -const name = 'test name'; -const isProject = false; -const invalidGroups = []; -const inviteeType = 'members'; -const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; -const defaultAccessLevel = 10; -const inviteSource = 'unknown'; -const helpLink = 'https://example.com'; -const tasksToBeDoneOptions = [ - { text: 'First task', value: 'first' }, - { text: 'Second task', value: 'second' }, -]; -const newProjectPath = 'projects/new'; -const projects = [ - { text: 'First project', value: '1' }, - { text: 'Second project', value: '2' }, -]; - -const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; -const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; -const user3 = { - id: 'user-defined-token', - name: 'email@example.com', - username: 'one_2', - avatar_url: '', -}; -const user4 = { - id: 'user-defined-token', - name: 'email4@example.com', - username: 'one_4', - avatar_url: '', -}; -const sharedGroup = { id: '981' }; -const GlEmoji = { template: '' }; - -const createComponent = (data = {}, props = {}) => { - wrapper = shallowMountExtended(InviteMembersModal, { - provide: { - newProjectPath, - }, - propsData: { - id, - name, - isProject, - inviteeType, - accessLevels, - defaultAccessLevel, - tasksToBeDoneOptions, - projects, - helpLink, - invalidGroups, - ...props, - }, - data() { - return data; - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: - '
', - }), - GlDropdown: true, - GlDropdownItem: true, - GlEmoji, - GlSprintf, - GlFormGroup: stubComponent(GlFormGroup, { - props: ['state', 'invalidFeedback', 'description'], - }), - }, - }); -}; - -const createInviteMembersToProjectWrapper = () => { - createComponent({ inviteeType: 'members' }, { isProject: true }); -}; - -const createInviteMembersToGroupWrapper = () => { - createComponent({ inviteeType: 'members' }, { isProject: false }); -}; - -const createInviteGroupToProjectWrapper = () => { - createComponent({ inviteeType: 'group' }, { isProject: true }); -}; - -const createInviteGroupToGroupWrapper = () => { - createComponent({ inviteeType: 'group' }, { isProject: false }); -}; - -beforeEach(() => { - gon.api_version = 'v4'; - mock = new MockAdapter(axios); -}); - -afterEach(() => { - wrapper.destroy(); - wrapper = null; - mock.restore(); -}); - describe('InviteMembersModal', () => { - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); - const findDatepicker = () => wrapper.findComponent(GlDatepicker); - const findLink = () => wrapper.findComponent(GlLink); - const findIntroText = () => wrapper.find({ ref: 'introText' }).text(); + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(InviteMembersModal, { + provide: { + newProjectPath, + }, + propsData: { + ...propsData, + ...props, + }, + stubs: { + InviteModalBase, + GlSprintf, + GlModal: stubComponent(GlModal, { + template: '
', + }), + GlEmoji, + }, + }); + }; + + const createInviteMembersToProjectWrapper = () => { + createComponent({ isProject: true }); + }; + + const createInviteMembersToGroupWrapper = () => { + createComponent({ isProject: false }); + }; + + beforeEach(() => { + gon.api_version = 'v4'; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + mock.restore(); + }); + + const findBase = () => wrapper.findComponent(InviteModalBase); + const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const findInviteButton = () => wrapper.findByTestId('invite-button'); const clickInviteButton = () => findInviteButton().vm.$emit('click'); const clickCancelButton = () => findCancelButton().vm.$emit('click'); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); - const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback'); - const membersFormGroupDescription = () => findMembersFormGroup().props('description'); + const membersFormGroupInvalidFeedback = () => + findMembersFormGroup().attributes('invalid-feedback'); + const membersFormGroupDescription = () => findMembersFormGroup().attributes('description'); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done'); const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks'); const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select'); const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert'); - const findCelebrationEmoji = () => wrapper.findComponent(GlModal).find(GlEmoji); - - describe('rendering the modal', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the modal with the correct title', () => { - expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_DEFAULT_TITLE); - }); - - it('renders the Cancel button text correctly', () => { - expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); - }); - - it('renders the Invite button text correctly', () => { - expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT); - }); - - it('renders the Invite button modal without isLoading', () => { - expect(findInviteButton().props('loading')).toBe(false); - }); - - describe('rendering the access levels dropdown', () => { - it('sets the default dropdown text to the default access level name', () => { - expect(findDropdown().attributes('text')).toBe('Guest'); - }); - - it('renders dropdown items for each accessLevel', () => { - expect(findDropdownItems()).toHaveLength(5); - }); - }); - - describe('rendering the help link', () => { - it('renders the correct link', () => { - expect(findLink().attributes('href')).toBe(helpLink); - }); - }); - - describe('rendering the access expiration date field', () => { - it('renders the datepicker', () => { - expect(findDatepicker().exists()).toBe(true); - }); - }); - }); + const findCelebrationEmoji = () => wrapper.findComponent(GlEmoji); + const triggerOpenModal = async ({ mode = 'default', source }) => { + eventHub.$emit('openModal', { mode, source }); + await nextTick(); + }; + const triggerMembersTokenSelect = async (val) => { + findMembersSelect().vm.$emit('input', val); + await nextTick(); + }; + const triggerTasks = async (val) => { + findTasks().vm.$emit('input', val); + await nextTick(); + }; + const triggerAccessLevel = async (val) => { + findBase().vm.$emit('access-level', val); + await nextTick(); + }; describe('rendering the tasks to be done', () => { - const setupComponent = ( - extraData = {}, - props = {}, - urlParameter = ['invite_members_for_task'], - ) => { - const data = { - selectedAccessLevel: 30, - selectedTasksToBeDone: ['ci', 'code'], - ...extraData, - }; + const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => { getParameterValues.mockImplementation(() => urlParameter); - createComponent(data, props); + createComponent(props); + + await triggerAccessLevel(30); + }; + + const setupComponentWithTasks = async (...args) => { + await setupComponent(...args); + await triggerTasks(['ci', 'code']); }; afterAll(() => { getParameterValues.mockImplementation(() => []); }); - it('renders the tasks to be done', () => { - setupComponent(); + it('renders the tasks to be done', async () => { + await setupComponent(); expect(findTasksToBeDone().exists()).toBe(true); }); describe('when the selected access level is lower than 30', () => { - it('does not render the tasks to be done', () => { - setupComponent({ selectedAccessLevel: 20 }); + it('does not render the tasks to be done', async () => { + await setupComponent(); + await triggerAccessLevel(20); expect(findTasksToBeDone().exists()).toBe(false); }); }); describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => { - it('does not render the tasks to be done', () => { - setupComponent({}, {}, []); + it('does not render the tasks to be done', async () => { + await setupComponent({}, []); expect(findTasksToBeDone().exists()).toBe(false); }); describe('when opened from the Learn GitLab page', () => { - it('does render the tasks to be done', () => { - setupComponent({ source: LEARN_GITLAB }, {}, []); + it('does render the tasks to be done', async () => { + await setupComponent({}, []); + await triggerOpenModal({ source: LEARN_GITLAB }); expect(findTasksToBeDone().exists()).toBe(true); }); @@ -257,27 +166,27 @@ describe('InviteMembersModal', () => { }); describe('rendering the tasks', () => { - it('renders the tasks', () => { - setupComponent(); + it('renders the tasks', async () => { + await setupComponent(); expect(findTasks().exists()).toBe(true); }); - it('does not render an alert', () => { - setupComponent(); + it('does not render an alert', async () => { + await setupComponent(); expect(findNoProjectsAlert().exists()).toBe(false); }); describe('when there are no projects passed in the data', () => { - it('does not render the tasks', () => { - setupComponent({}, { projects: [] }); + it('does not render the tasks', async () => { + await setupComponent({ projects: [] }); expect(findTasks().exists()).toBe(false); }); - it('renders an alert with a link to the new projects path', () => { - setupComponent({}, { projects: [] }); + it('renders an alert with a link to the new projects path', async () => { + await setupComponent({ projects: [] }); expect(findNoProjectsAlert().exists()).toBe(true); expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe( @@ -288,23 +197,23 @@ describe('InviteMembersModal', () => { }); describe('rendering the project dropdown', () => { - it('renders the project select', () => { - setupComponent(); + it('renders the project select', async () => { + await setupComponentWithTasks(); expect(findProjectSelect().exists()).toBe(true); }); describe('when the modal is shown for a project', () => { - it('does not render the project select', () => { - setupComponent({}, { isProject: true }); + it('does not render the project select', async () => { + await setupComponentWithTasks({ isProject: true }); expect(findProjectSelect().exists()).toBe(false); }); }); describe('when no tasks are selected', () => { - it('does not render the project select', () => { - setupComponent({ selectedTasksToBeDone: [] }); + it('does not render the project select', async () => { + await setupComponent(); expect(findProjectSelect().exists()).toBe(false); }); @@ -312,8 +221,8 @@ describe('InviteMembersModal', () => { }); describe('tracking events', () => { - it('tracks the view for invite_members_for_task', () => { - setupComponent(); + it('tracks the view for invite_members_for_task', async () => { + await setupComponentWithTasks(); expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name); expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( @@ -321,8 +230,8 @@ describe('InviteMembersModal', () => { ); }); - it('tracks the submit for invite_members_for_task', () => { - setupComponent(); + it('tracks the submit for invite_members_for_task', async () => { + await setupComponentWithTasks(); clickInviteButton(); expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, { @@ -355,8 +264,9 @@ describe('InviteMembersModal', () => { }); describe('when inviting members with celebration', () => { - beforeEach(() => { - createComponent({ mode: 'celebrate', inviteeType: 'members' }, { isProject: true }); + beforeEach(async () => { + createComponent({ isProject: true }); + await triggerOpenModal({ mode: 'celebrate' }); }); it('renders the modal with confetti', () => { @@ -375,34 +285,14 @@ describe('InviteMembersModal', () => { expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); }); }); - - describe('when sharing with a group', () => { - it('includes the correct invitee, type, and formatted name', () => { - createInviteGroupToProjectWrapper(); - - expect(findIntroText()).toBe("You're inviting a group to the test name project."); - expect(membersFormGroupDescription()).toBe(''); - }); - }); }); describe('when inviting to a group', () => { - describe('when inviting members', () => { - it('includes the correct invitee, type, and formatted name', () => { - createInviteMembersToGroupWrapper(); + it('includes the correct invitee, type, and formatted name', () => { + createInviteMembersToGroupWrapper(); - expect(findIntroText()).toBe("You're inviting members to the test name group."); - expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); - }); - }); - - describe('when sharing with a group', () => { - it('includes the correct invitee, type, and formatted name', () => { - createInviteGroupToGroupWrapper(); - - expect(findIntroText()).toBe("You're inviting a group to the test name group."); - expect(membersFormGroupDescription()).toBe(''); - }); + expect(findIntroText()).toBe("You're inviting members to the test name group."); + expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER); }); }); }); @@ -422,7 +312,7 @@ describe('InviteMembersModal', () => { describe('when inviting an existing user to group by user ID', () => { const postData = { user_id: '1,2', - access_level: defaultAccessLevel, + access_level: propsData.defaultAccessLevel, expires_at: undefined, invite_source: inviteSource, format: 'json', @@ -431,8 +321,9 @@ describe('InviteMembersModal', () => { }; describe('when member is added successfully', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user1, user2] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user1, user2]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); @@ -448,19 +339,17 @@ describe('InviteMembersModal', () => { }); it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData); + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); }); describe('when opened from a Learn GitLab page', () => { it('emits the `showSuccessfulInvitationsAlert` event', async () => { - eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB }); + await triggerOpenModal({ source: LEARN_GITLAB }); jest.spyOn(eventHub, '$emit').mockImplementation(); @@ -474,12 +363,10 @@ describe('InviteMembersModal', () => { }); describe('when member is not added successfully', () => { - beforeEach(() => { + beforeEach(async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user1] }); + await triggerMembersTokenSelect([user1]); }); it('displays "Member already exists" api message for http status conflict', async () => { @@ -490,7 +377,6 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); expect(findInviteButton().props('loading')).toBe(false); }); @@ -506,7 +392,6 @@ describe('InviteMembersModal', () => { it('clears the error when the list of members to invite is cleared', async () => { expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); findMembersSelect().vm.$emit('clear'); @@ -514,7 +399,6 @@ describe('InviteMembersModal', () => { await nextTick(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); expect(findMembersSelect().props('validationState')).not.toBe(false); }); @@ -524,7 +408,6 @@ describe('InviteMembersModal', () => { await nextTick(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); expect(findMembersSelect().props('validationState')).not.toBe(false); }); @@ -534,7 +417,6 @@ describe('InviteMembersModal', () => { await nextTick(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); expect(findMembersSelect().props('validationState')).not.toBe(false); }); }); @@ -547,7 +429,6 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); - expect(findMembersFormGroup().props('state')).toBe(false); expect(findMembersSelect().props('validationState')).toBe(false); expect(findInviteButton().props('loading')).toBe(false); @@ -556,8 +437,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe(''); - expect(findMembersFormGroup().props('state')).not.toBe(false); - expect(findMembersSelect().props('validationState')).not.toBe(false); + expect(findMembersSelect().props('validationState')).toBe(null); expect(findInviteButton().props('loading')).toBe(false); }); @@ -611,7 +491,7 @@ describe('InviteMembersModal', () => { describe('when inviting a new user by email address', () => { const postData = { - access_level: defaultAccessLevel, + access_level: propsData.defaultAccessLevel, expires_at: undefined, email: 'email@example.com', invite_source: inviteSource, @@ -621,8 +501,9 @@ describe('InviteMembersModal', () => { }; describe('when invites are sent successfully', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user3] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); @@ -634,24 +515,20 @@ describe('InviteMembersModal', () => { }); it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData); + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData); }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); }); }); describe('when invites are not sent successfully', () => { - beforeEach(() => { + beforeEach(async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user3] }); + await triggerMembersTokenSelect([user3]); }); it('displays the api error for invalid email syntax', async () => { @@ -686,9 +563,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); expect(findMembersSelect().props('validationState')).toBe(null); }); @@ -719,9 +594,7 @@ describe('InviteMembersModal', () => { it('displays the invalid syntax error if one of the emails is invalid', async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user3, user4] }); + await triggerMembersTokenSelect([user3, user4]); mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID); clickInviteButton(); @@ -736,7 +609,7 @@ describe('InviteMembersModal', () => { describe('when inviting members and non-members in same click', () => { const postData = { - access_level: defaultAccessLevel, + access_level: propsData.defaultAccessLevel, expires_at: undefined, invite_source: inviteSource, format: 'json', @@ -748,8 +621,9 @@ describe('InviteMembersModal', () => { const idPostData = { ...postData, user_id: '1' }; describe('when invites are sent successfully', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user1, user3] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user1, user3]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); @@ -762,30 +636,28 @@ describe('InviteMembersModal', () => { }); it('calls Api inviteGroupMembersByEmail with the correct params', () => { - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData); + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData); }); it('calls Api addGroupMembersByUserId with the correct params', () => { - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData); + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData); }); it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); }); - it('calls Apis with the invite source passed through to openModal', () => { - eventHub.$emit('openModal', { inviteeType: 'members', source: '_invite_source_' }); + it('calls Apis with the invite source passed through to openModal', async () => { + await triggerOpenModal({ source: '_invite_source_' }); clickInviteButton(); - expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, { + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, { ...emailPostData, invite_source: '_invite_source_', }); - expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, { + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, { ...idPostData, invite_source: '_invite_source_', }); @@ -793,12 +665,10 @@ describe('InviteMembersModal', () => { }); describe('when any invite failed for any reason', () => { - beforeEach(() => { + beforeEach(async () => { createInviteMembersToGroupWrapper(); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ newUsersToInvite: [user1, user3] }); + await triggerMembersTokenSelect([user1, user3]); mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); mockMembersApi(httpStatus.OK, '200 OK'); @@ -814,64 +684,10 @@ describe('InviteMembersModal', () => { }); }); - describe('when inviting a group to share', () => { - describe('when sharing the group is successful', () => { - const groupPostData = { - group_id: sharedGroup.id, - group_access: defaultAccessLevel, - expires_at: undefined, - format: 'json', - }; - - beforeEach(() => { - createComponent({ groupToBeSharedWith: sharedGroup }); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ inviteeType: 'group' }); - wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); - - clickInviteButton(); - }); - - it('calls Api groupShareWithGroup with the correct params', () => { - expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData); - }); - - it('displays the successful toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', { - onComplete: expect.any(Function), - }); - }); - }); - - describe('when sharing the group fails', () => { - beforeEach(() => { - createInviteGroupToGroupWrapper(); - - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ groupToBeSharedWith: sharedGroup }); - wrapper.vm.$toast = { show: jest.fn() }; - - jest - .spyOn(Api, 'groupShareWithGroup') - .mockRejectedValue({ response: { data: { success: false } } }); - - clickInviteButton(); - }); - - it('displays the generic error message', () => { - expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); - expect(membersFormGroupDescription()).toBe(''); - }); - }); - }); - describe('tracking', () => { - beforeEach(() => { - createComponent({ newUsersToInvite: [user3] }); + beforeEach(async () => { + createComponent(); + await triggerMembersTokenSelect([user3]); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js new file mode 100644 index 00000000000..4b183bfd670 --- /dev/null +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -0,0 +1,103 @@ +import { + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlFormGroup, + GlSprintf, + GlLink, + GlModal, +} from '@gitlab/ui'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; +import { CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT } from '~/invite_members/constants'; +import { propsData } from '../mock_data/modal_base'; + +describe('InviteModalBase', () => { + let wrapper; + + const createComponent = (data = {}, props = {}) => { + wrapper = shallowMountExtended(InviteModalBase, { + propsData: { + ...propsData, + ...props, + }, + data() { + return data; + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: + '
', + }), + GlDropdown: true, + GlDropdownItem: true, + GlSprintf, + GlFormGroup: stubComponent(GlFormGroup, { + props: ['state', 'invalidFeedback', 'description'], + }), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + const findLink = () => wrapper.findComponent(GlLink); + const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findInviteButton = () => wrapper.findByTestId('invite-button'); + + describe('rendering the modal', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the modal with the correct title', () => { + expect(wrapper.findComponent(GlModal).props('title')).toBe(propsData.modalTitle); + }); + + it('displays the introText', () => { + expect(findIntroText()).toBe(propsData.labelIntroText); + }); + + it('renders the Cancel button text correctly', () => { + expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT); + }); + + it('renders the Invite button text correctly', () => { + expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT); + }); + + it('renders the Invite button modal without isLoading', () => { + expect(findInviteButton().props('loading')).toBe(false); + }); + + describe('rendering the access levels dropdown', () => { + it('sets the default dropdown text to the default access level name', () => { + expect(findDropdown().attributes('text')).toBe('Guest'); + }); + + it('renders dropdown items for each accessLevel', () => { + expect(findDropdownItems()).toHaveLength(5); + }); + }); + + describe('rendering the help link', () => { + it('renders the correct link', () => { + expect(findLink().attributes('href')).toBe(propsData.helpLink); + }); + }); + + describe('rendering the access expiration date field', () => { + it('renders the datepicker', () => { + expect(findDatepicker().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/mock_data/group_modal.js b/spec/frontend/invite_members/mock_data/group_modal.js new file mode 100644 index 00000000000..c05c4edb7d0 --- /dev/null +++ b/spec/frontend/invite_members/mock_data/group_modal.js @@ -0,0 +1,11 @@ +export const propsData = { + id: '1', + name: 'test name', + isProject: false, + invalidGroups: [], + accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, + defaultAccessLevel: 10, + helpLink: 'https://example.com', +}; + +export const sharedGroup = { id: '981' }; diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js new file mode 100644 index 00000000000..590502909b2 --- /dev/null +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -0,0 +1,36 @@ +export const propsData = { + id: '1', + name: 'test name', + isProject: false, + accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, + defaultAccessLevel: 30, + helpLink: 'https://example.com', + tasksToBeDoneOptions: [ + { text: 'First task', value: 'first' }, + { text: 'Second task', value: 'second' }, + ], + projects: [ + { text: 'First project', value: '1' }, + { text: 'Second project', value: '2' }, + ], +}; + +export const inviteSource = 'unknown'; +export const newProjectPath = 'projects/new'; + +export const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; +export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; +export const user3 = { + id: 'user-defined-token', + name: 'email@example.com', + username: 'one_2', + avatar_url: '', +}; +export const user4 = { + id: 'user-defined-token', + name: 'email4@example.com', + username: 'one_4', + avatar_url: '', +}; + +export const GlEmoji = { template: '' }; diff --git a/spec/frontend/invite_members/mock_data/modal_base.js b/spec/frontend/invite_members/mock_data/modal_base.js new file mode 100644 index 00000000000..ea5a8d2b00d --- /dev/null +++ b/spec/frontend/invite_members/mock_data/modal_base.js @@ -0,0 +1,11 @@ +export const propsData = { + modalTitle: '_modal_title_', + modalId: '_modal_id_', + name: '_name_', + accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, + defaultAccessLevel: 10, + helpLink: 'https://example.com', + labelIntroText: '_label_intro_text_', + labelSearchField: '_label_search_field_', + formGroupDescription: '_form_group_description_', +}; diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 776788da879..305dce51971 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -28,7 +28,6 @@ const defaultProps = { emailsDisabled: false, packagesEnabled: true, showDefaultAwardEmojis: true, - showDiffPreviewInEmail: true, warnAboutPotentiallyUnwantedCharacters: true, }, isGitlabCom: true, @@ -102,9 +101,6 @@ describe('Settings Panel', () => { const findEmailSettings = () => wrapper.find({ ref: 'email-settings' }); const findShowDefaultAwardEmojis = () => wrapper.find('input[name="project[project_setting_attributes][show_default_award_emojis]"]'); - - const findShowDiffPreviewInEmail = () => - wrapper.find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"]'); const findWarnAboutPuc = () => wrapper.find( 'input[name="project[project_setting_attributes][warn_about_potentially_unwanted_characters]"]', @@ -589,13 +585,6 @@ describe('Settings Panel', () => { expect(findShowDefaultAwardEmojis().exists()).toBe(true); }); }); - describe('Hide diffs in email', () => { - it('should show the "Hide Diffs in email" input', () => { - wrapper = mountComponent(); - - expect(findShowDiffPreviewInEmail().exists()).toBe(true); - }); - }); describe('Warn about potentially unwanted characters', () => { it('should have a "Warn about Potentially Unwanted Characters" input', () => { diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb index 0b53c633077..2d83edca363 100644 --- a/spec/graphql/graphql_triggers_spec.rb +++ b/spec/graphql/graphql_triggers_spec.rb @@ -17,4 +17,18 @@ RSpec.describe GraphqlTriggers do GraphqlTriggers.issuable_assignees_updated(issue) end end + + describe '.issuable_title_updated' do + it 'triggers the issuableTitleUpdated subscription' do + work_item = create(:work_item) + + expect(GitlabSchema.subscriptions).to receive(:trigger).with( + 'issuableTitleUpdated', + { issuable_id: work_item.to_gid }, + work_item + ).and_call_original + + GraphqlTriggers.issuable_title_updated(work_item) + end + end end diff --git a/spec/graphql/types/issuable_type_spec.rb b/spec/graphql/types/issuable_type_spec.rb index 992a58f524b..cb18bbe2eab 100644 --- a/spec/graphql/types/issuable_type_spec.rb +++ b/spec/graphql/types/issuable_type_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['Issuable'] do it 'returns possible types' do - expect(described_class.possible_types).to include(Types::IssueType, Types::MergeRequestType) + expect(described_class.possible_types).to include(Types::IssueType, Types::MergeRequestType, Types::WorkItemType) end describe '.resolve_type' do @@ -16,6 +16,10 @@ RSpec.describe GitlabSchema.types['Issuable'] do expect(described_class.resolve_type(build(:merge_request), {})).to eq(Types::MergeRequestType) end + it 'resolves work items' do + expect(described_class.resolve_type(build(:work_item), {})).to eq(Types::WorkItemType) + end + it 'raises an error for invalid types' do expect { described_class.resolve_type(build(:user), {}) }.to raise_error 'Unsupported issuable type' end diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb index bf933945a31..593795de004 100644 --- a/spec/graphql/types/subscription_type_spec.rb +++ b/spec/graphql/types/subscription_type_spec.rb @@ -7,6 +7,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do expected_fields = %i[ issuable_assignees_updated issue_crm_contacts_updated + issuable_title_updated ] expect(described_class).to have_graphql_fields(*expected_fields).only diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb index c032c7875a9..6a854a65920 100644 --- a/spec/helpers/invite_members_helper_spec.rb +++ b/spec/helpers/invite_members_helper_spec.rb @@ -15,13 +15,28 @@ RSpec.describe InviteMembersHelper do helper.extend(Gitlab::Experimentation::ControllerConcern) end - describe '#common_invite_modal_dataset' do + describe '#common_invite_group_modal_data' do it 'has expected common attributes' do attributes = { id: project.id, name: project.name, default_access_level: Gitlab::Access::GUEST, - invalid_groups: project.related_group_ids + invalid_groups: project.related_group_ids, + help_link: help_page_url('user/permissions'), + is_project: 'true', + access_levels: ProjectMember.access_level_roles.to_json + } + + expect(helper.common_invite_group_modal_data(project, ProjectMember, 'true')).to include(attributes) + end + end + + describe '#common_invite_modal_dataset' do + it 'has expected common attributes' do + attributes = { + id: project.id, + name: project.name, + default_access_level: Gitlab::Access::GUEST } expect(helper.common_invite_modal_dataset(project)).to include(attributes) diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 0d785dcabca..1d78778c757 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -964,7 +964,6 @@ RSpec.describe ProjectsHelper do metricsDashboardAccessLevel: project.project_feature.metrics_dashboard_access_level, operationsAccessLevel: project.project_feature.operations_access_level, showDefaultAwardEmojis: project.show_default_award_emojis?, - showDiffPreviewInEmail: project.show_diff_preview_in_email?, securityAndComplianceAccessLevel: project.security_and_compliance_access_level, containerRegistryAccessLevel: project.project_feature.container_registry_access_level ) diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml index ef47263ad78..01d2fb18f00 100644 --- a/spec/requests/api/project_attributes.yml +++ b/spec/requests/api/project_attributes.yml @@ -145,7 +145,6 @@ project_setting: - project_id - push_rule_id - show_default_award_emojis - - show_diff_preview_in_email - updated_at - cve_id_request_enabled - mr_default_target_self diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb index 70dce26d63e..f71f1060e40 100644 --- a/spec/services/work_items/update_service_spec.rb +++ b/spec/services/work_items/update_service_spec.rb @@ -18,6 +18,26 @@ RSpec.describe WorkItems::UpdateService do stub_spam_services end + context 'when title is changed' do + let(:opts) { { title: 'changed' } } + + it 'triggers issuable_title_updated graphql subscription' do + expect(GraphqlTriggers).to receive(:issuable_title_updated).with(work_item).and_call_original + + update_work_item + end + end + + context 'when title is not changed' do + let(:opts) { { description: 'changed' } } + + it 'does not trigger issuable_title_updated graphql subscription' do + expect(GraphqlTriggers).not_to receive(:issuable_title_updated) + + update_work_item + end + end + context 'when updating state_event' do context 'when state_event is close' do let(:opts) { { state_event: 'close' } } diff --git a/spec/support/helpers/features/invite_members_modal_helper.rb b/spec/support/helpers/features/invite_members_modal_helper.rb index 11040562b49..2a4f78ca57f 100644 --- a/spec/support/helpers/features/invite_members_modal_helper.rb +++ b/spec/support/helpers/features/invite_members_modal_helper.rb @@ -8,7 +8,7 @@ module Spec def invite_member(name, role: 'Guest', expires_at: nil) click_on 'Invite members' - page.within '[data-testid="invite-members-modal"]' do + page.within '[data-testid="invite-modal"]' do find('[data-testid="members-token-select-input"]').set(name) wait_for_requests diff --git a/spec/views/devise/shared/_signup_box.html.haml_spec.rb b/spec/views/devise/shared/_signup_box.html.haml_spec.rb index 6efb2730964..37dbfd39f2d 100644 --- a/spec/views/devise/shared/_signup_box.html.haml_spec.rb +++ b/spec/views/devise/shared/_signup_box.html.haml_spec.rb @@ -63,6 +63,22 @@ RSpec.describe 'devise/shared/_signup_box' do end end + context 'using the borderless option' do + let(:border_css_classes) { '.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base' } + + it 'renders with a border by default' do + render + + expect(rendered).to have_selector(border_css_classes) + end + + it 'renders without a border when borderless is truthy' do + render('devise/shared/signup_box', borderless: true) + + expect(rendered).not_to have_selector(border_css_classes) + end + end + def stub_devise allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user]) allow(view).to receive(:resource).and_return(spy)