From 4def415fbf45e0693b17ea418d378d62ab03a146 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Fri, 1 Jul 2022 09:08:29 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../list/components/issues_list_app.vue | 16 +- .../queries/crm_contact.fragment.graphql | 6 + .../queries/crm_organization.fragment.graphql | 4 + .../queries/search_crm_contacts.query.graphql | 28 ++ .../search_crm_organizations.query.graphql | 28 ++ .../tokens/crm_contact_token.vue | 131 ++++++++ .../tokens/crm_organization_token.vue | 125 ++++++++ config/puma.example.development.rb | 3 +- ...ame_builds_sidekiq_queues_to_namespaces.rb | 22 ++ db/schema_migrations/20220628122622 | 1 + doc/administration/geo/setup/index.md | 3 + .../bitbucket_integration.md | 7 +- .../sidekiq/compatibility_across_updates.md | 5 +- .../cluster/puma_worker_killer_initializer.rb | 8 +- locale/gitlab.pot | 6 + .../filtered_search_bar/mock_data.js | 146 +++++++++ .../tokens/crm_contact_token_spec.js | 283 ++++++++++++++++++ .../tokens/crm_organization_token_spec.js | 282 +++++++++++++++++ workhorse/go.mod | 2 +- workhorse/go.sum | 3 +- 20 files changed, 1098 insertions(+), 11 deletions(-) create mode 100644 app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql create mode 100644 app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql create mode 100644 app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql create mode 100644 app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql create mode 100644 app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue create mode 100644 app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue create mode 100644 db/post_migrate/20220628122622_rename_builds_sidekiq_queues_to_namespaces.rb create mode 100644 db/schema_migrations/20220628122622 create mode 100644 spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js create mode 100644 spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index fa56c0183b2..c25d3883259 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -98,6 +98,10 @@ const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); const ReleaseToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); +const CrmContactToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'); +const CrmOrganizationToken = () => + import('~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'); export default { i18n, @@ -383,7 +387,11 @@ export default { type: TOKEN_TYPE_CONTACT, title: TOKEN_TITLE_CONTACT, icon: 'user', - token: GlFilteredSearchToken, + token: CrmContactToken, + fullPath: this.fullPath, + isProject: this.isProject, + defaultContacts: DEFAULT_NONE_ANY, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-contacts`, operators: OPERATOR_IS_ONLY, unique: true, }); @@ -394,7 +402,11 @@ export default { type: TOKEN_TYPE_ORGANIZATION, title: TOKEN_TITLE_ORGANIZATION, icon: 'users', - token: GlFilteredSearchToken, + token: CrmOrganizationToken, + fullPath: this.fullPath, + isProject: this.isProject, + defaultOrganizations: DEFAULT_NONE_ANY, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-organizations`, operators: OPERATOR_IS_ONLY, unique: true, }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql new file mode 100644 index 00000000000..38222e4e8c2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_contact.fragment.graphql @@ -0,0 +1,6 @@ +fragment ContactFragment on CustomerRelationsContact { + id + firstName + lastName + email +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql new file mode 100644 index 00000000000..a7de3c7f7af --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/crm_organization.fragment.graphql @@ -0,0 +1,4 @@ +fragment OrganizationFragment on CustomerRelationsOrganization { + id + name +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql new file mode 100644 index 00000000000..647aaa0f7f8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql @@ -0,0 +1,28 @@ +#import "./crm_contact.fragment.graphql" + +query searchCrmContacts( + $isProject: Boolean = false + $fullPath: ID! + $searchString: String + $searchIds: [CustomerRelationsContactID!] +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + contacts(search: $searchString, ids: $searchIds) { + nodes { + ...ContactFragment + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + group { + id + contacts(search: $searchString, ids: $searchIds) { + nodes { + ...ContactFragment + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql new file mode 100644 index 00000000000..c4f4663de45 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql @@ -0,0 +1,28 @@ +#import "./crm_organization.fragment.graphql" + +query searchCrmOrganizations( + $isProject: Boolean = false + $fullPath: ID! + $searchString: String + $searchIds: [CustomerRelationsOrganizationID!] +) { + group(fullPath: $fullPath) @skip(if: $isProject) { + id + organizations(search: $searchString, ids: $searchIds) { + nodes { + ...OrganizationFragment + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + id + group { + id + organizations(search: $searchString, ids: $searchIds) { + nodes { + ...OrganizationFragment + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue new file mode 100644 index 00000000000..adfe0559b62 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -0,0 +1,131 @@ + + + diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue new file mode 100644 index 00000000000..e6ab944449e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -0,0 +1,125 @@ + + + diff --git a/config/puma.example.development.rb b/config/puma.example.development.rb index ad33250011e..3164ffe3ef4 100644 --- a/config/puma.example.development.rb +++ b/config/puma.example.development.rb @@ -54,7 +54,8 @@ end before_fork do # Signal to the puma killer - Gitlab::Cluster::PumaWorkerKillerInitializer.start @config.options unless ENV['DISABLE_PUMA_WORKER_KILLER'] + enable_puma_worker_killer = !Gitlab::Utils.to_boolean(ENV['DISABLE_PUMA_WORKER_KILLER']) + Gitlab::Cluster::PumaWorkerKillerInitializer.start(@config.options) if enable_puma_worker_killer # Signal application hooks that we're about to fork Gitlab::Cluster::LifecycleEvents.do_before_fork diff --git a/db/post_migrate/20220628122622_rename_builds_sidekiq_queues_to_namespaces.rb b/db/post_migrate/20220628122622_rename_builds_sidekiq_queues_to_namespaces.rb new file mode 100644 index 00000000000..f692d1476ce --- /dev/null +++ b/db/post_migrate/20220628122622_rename_builds_sidekiq_queues_to_namespaces.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class RenameBuildsSidekiqQueuesToNamespaces < Gitlab::Database::Migration[2.0] + restrict_gitlab_migration gitlab_schema: :gitlab_main + disable_ddl_transaction! + + BUILD_OLD_QUEUE = 'pipeline_processing:build_finished' + BUILD_NEW_QUEUE = 'pipeline_processing:ci_build_finished' + + TRACE_OLD_QUEUE = 'pipeline_background:archive_trace' + TRACE_NEW_QUEUE = 'pipeline_background:ci_archive_trace' + + def up + sidekiq_queue_migrate BUILD_OLD_QUEUE, to: BUILD_NEW_QUEUE + sidekiq_queue_migrate TRACE_OLD_QUEUE, to: TRACE_NEW_QUEUE + end + + def down + sidekiq_queue_migrate BUILD_NEW_QUEUE, to: BUILD_OLD_QUEUE + sidekiq_queue_migrate TRACE_NEW_QUEUE, to: TRACE_OLD_QUEUE + end +end diff --git a/db/schema_migrations/20220628122622 b/db/schema_migrations/20220628122622 new file mode 100644 index 00000000000..ce29140a862 --- /dev/null +++ b/db/schema_migrations/20220628122622 @@ -0,0 +1 @@ +aeaa386b52a2a5e30b59fbe57e9c701298fea45219b3ec419866d40c6d2a5e5d \ No newline at end of file diff --git a/doc/administration/geo/setup/index.md b/doc/administration/geo/setup/index.md index 4a3f0f41a83..f7d47d82e71 100644 --- a/doc/administration/geo/setup/index.md +++ b/doc/administration/geo/setup/index.md @@ -12,6 +12,9 @@ These instructions assume you have a working instance of GitLab. They guide you 1. Making your existing instance the **primary** site. 1. Adding **secondary** sites. +You must use a [GitLab Premium](https://about.gitlab.com/pricing/) license or higher, +but you only need one license for all the sites. + WARNING: The steps below should be followed in the order they appear. **Make sure the GitLab version is the same on all sites.** diff --git a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md index 40b29ebb6ea..42babf00841 100644 --- a/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md +++ b/doc/ci/ci_cd_for_external_repos/bitbucket_integration.md @@ -18,8 +18,13 @@ To use GitLab CI/CD with a Bitbucket Cloud repository: 1. On the top menu, select **Projects > Create new project**. 1. Select **Run CI/CD for external repository**. 1. Select **Repository by URL**. - + 1. Fill in the fields with information from the repository in Bitbucket: + - For **Git repository URL**, use the URL from the **Clone this repository** panel in Bitbucket. + - Leave the username blank. + - You can generate and use a [Bitbucket App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) for the password field. + GitLab imports the repository and enables [Pull Mirroring](../../user/project/repository/mirror/pull.md). + You can check that mirroring is working in the project by going to **Settings > Repository > Mirroring repositories**. 1. In GitLab, create a [Personal Access Token](../../user/profile/personal_access_tokens.md) diff --git a/doc/development/sidekiq/compatibility_across_updates.md b/doc/development/sidekiq/compatibility_across_updates.md index 35f4b88351e..96a3573d11a 100644 --- a/doc/development/sidekiq/compatibility_across_updates.md +++ b/doc/development/sidekiq/compatibility_across_updates.md @@ -142,7 +142,10 @@ When renaming queues, use the `sidekiq_queue_migrate` helper migration method in a **post-deployment migration**: ```ruby -class MigrateTheRenamedSidekiqQueue < Gitlab::Database::Migration[1.0] +class MigrateTheRenamedSidekiqQueue < Gitlab::Database::Migration[2.0] + restrict_gitlab_migration gitlab_schema: :gitlab_main + disable_ddl_transaction! + def up sidekiq_queue_migrate 'old_queue_name', to: 'new_queue_name' end diff --git a/lib/gitlab/cluster/puma_worker_killer_initializer.rb b/lib/gitlab/cluster/puma_worker_killer_initializer.rb index e634291f894..5908de68687 100644 --- a/lib/gitlab/cluster/puma_worker_killer_initializer.rb +++ b/lib/gitlab/cluster/puma_worker_killer_initializer.rb @@ -5,10 +5,10 @@ module Gitlab class PumaWorkerKillerInitializer def self.start( puma_options, - puma_per_worker_max_memory_mb: 1024, - puma_master_max_memory_mb: 800, - additional_puma_dev_max_memory_mb: 200 - ) + puma_per_worker_max_memory_mb: 1200, + puma_master_max_memory_mb: 950, + additional_puma_dev_max_memory_mb: 200) + require 'puma_worker_killer' PumaWorkerKiller.config do |config| diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1e1307276fe..6779af2f80d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -38935,6 +38935,12 @@ msgstr "" msgid "There was a problem communicating with your device." msgstr "" +msgid "There was a problem fetching CRM contacts." +msgstr "" + +msgid "There was a problem fetching CRM organizations." +msgstr "" + msgid "There was a problem fetching branches." msgstr "" diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index e3e2ef5610d..86d1f21fd04 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -8,6 +8,8 @@ import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; +import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; +import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; export const mockAuthor1 = { id: 1, @@ -62,6 +64,128 @@ export const mockMilestones = [ mockEscapedMilestone, ]; +export const mockCrmContacts = [ + { + id: 'gid://gitlab/CustomerRelations::Contact/1', + firstName: 'John', + lastName: 'Smith', + email: 'john@smith.com', + }, + { + id: 'gid://gitlab/CustomerRelations::Contact/2', + firstName: 'Andy', + lastName: 'Green', + email: 'andy@green.net', + }, +]; + +export const mockCrmOrganizations = [ + { + id: 'gid://gitlab/CustomerRelations::Organization/1', + name: 'First Org Ltd.', + }, + { + id: 'gid://gitlab/CustomerRelations::Organization/2', + name: 'Organizer S.p.a.', + }, +]; + +export const mockProjectCrmContactsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 1, + group: { + __typename: 'Group', + id: 1, + contacts: { + __typename: 'CustomerRelationsContactConnection', + nodes: [ + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[0], + }, + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[1], + }, + ], + }, + }, + }, + }, +}; + +export const mockProjectCrmOrganizationsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 1, + group: { + __typename: 'Group', + id: 1, + organizations: { + __typename: 'CustomerRelationsOrganizationConnection', + nodes: [ + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[0], + }, + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[1], + }, + ], + }, + }, + }, + }, +}; + +export const mockGroupCrmContactsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 1, + contacts: { + __typename: 'CustomerRelationsContactConnection', + nodes: [ + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[0], + }, + { + __typename: 'CustomerRelationsContact', + ...mockCrmContacts[1], + }, + ], + }, + }, + }, +}; + +export const mockGroupCrmOrganizationsQueryResponse = { + data: { + group: { + __typename: 'Group', + id: 1, + organizations: { + __typename: 'CustomerRelationsOrganizationConnection', + nodes: [ + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[0], + }, + { + __typename: 'CustomerRelationsOrganization', + ...mockCrmOrganizations[1], + }, + ], + }, + }, + }, +}; + export const mockEmoji1 = { name: 'thumbsup', }; @@ -134,6 +258,28 @@ export const mockReactionEmojiToken = { fetchEmojis: () => Promise.resolve(mockEmojis), }; +export const mockCrmContactToken = { + type: 'crm_contact', + title: 'Contact', + icon: 'user', + token: CrmContactToken, + isProject: false, + fullPath: 'group', + operators: OPERATOR_IS_ONLY, + unique: true, +}; + +export const mockCrmOrganizationToken = { + type: 'crm_contact', + title: 'Organization', + icon: 'user', + token: CrmOrganizationToken, + isProject: false, + fullPath: 'group', + operators: OPERATOR_IS_ONLY, + unique: true, +}; + export const mockMembershipToken = { type: 'with_inherited_permissions', icon: 'group', diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js new file mode 100644 index 00000000000..157e021fc60 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -0,0 +1,283 @@ +import { + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; +import searchCrmContactsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql'; + +import { + mockCrmContacts, + mockCrmContactToken, + mockGroupCrmContactsQueryResponse, + mockProjectCrmContactsQueryResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + BaseToken, + GlFilteredSearchSuggestionList: { + template: '
', + methods: { + getValue: () => '=', + }, + }, +}; + +describe('CrmContactToken', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const getBaseToken = () => wrapper.findComponent(BaseToken); + + const searchGroupCrmContactsQueryHandler = jest + .fn() + .mockResolvedValue(mockGroupCrmContactsQueryResponse); + const searchProjectCrmContactsQueryHandler = jest + .fn() + .mockResolvedValue(mockProjectCrmContactsQueryResponse); + + const mountComponent = ({ + config = mockCrmContactToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + listeners = {}, + queryHandler = searchGroupCrmContactsQueryHandler, + } = {}) => { + fakeApollo = createMockApollo([[searchCrmContactsQuery, queryHandler]]); + + wrapper = mount(CrmContactToken, { + propsData: { + config, + value, + active, + cursorPosition: 'start', + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: () => 'custom-class', + }, + stubs, + listeners, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('methods', () => { + describe('fetchContacts', () => { + describe('for groups', () => { + beforeEach(() => { + mountComponent(); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Contact/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + }); + + describe('for projects', () => { + beforeEach(() => { + mountComponent({ + config: { + fullPath: 'project', + isProject: true, + }, + queryHandler: searchProjectCrmContactsQueryHandler, + }); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Contact/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts); + }); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching CRM contacts.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + + await waitForPromises(); + + expect(getBaseToken().props('suggestionsLoading')).toBe(false); + }); + }); + }); + + describe('template', () => { + const defaultContacts = DEFAULT_NONE_ANY; + + it('renders base-token component', () => { + mountComponent({ + config: { ...mockCrmContactToken, initialContacts: mockCrmContacts }, + value: { data: '1' }, + }); + + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + suggestions: mockCrmContacts, + getActiveTokenValue: wrapper.vm.getActiveContact, + }); + }); + + it.each(mockCrmContacts)('renders token item when value is selected', (contact) => { + mountComponent({ + config: { ...mockCrmContactToken, initialContacts: mockCrmContacts }, + value: { data: `${getIdFromGraphQLId(contact.id)}` }, + }); + + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Contact, =, Contact name + expect(tokenSegments.at(2).text()).toBe(`${contact.firstName} ${contact.lastName}`); // Contact name + }); + + it('renders provided defaultContacts as suggestions', async () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken, defaultContacts }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultContacts.length); + defaultContacts.forEach((contact, index) => { + expect(suggestions.at(index).text()).toBe(contact.text); + }); + }); + + it('does not render divider when no defaultContacts', async () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken, defaultContacts: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + mountComponent({ + active: true, + config: { ...mockCrmContactToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); + DEFAULT_NONE_ANY.forEach((contact, index) => { + expect(suggestions.at(index).text()).toBe(contact.text); + }); + }); + + it('emits listeners in the base-token', () => { + const mockInput = jest.fn(); + mountComponent({ + listeners: { + input: mockInput, + }, + }); + wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + + expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js new file mode 100644 index 00000000000..977f8bbef61 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -0,0 +1,282 @@ +import { + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; +import searchCrmOrganizationsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql'; + +import { + mockCrmOrganizations, + mockCrmOrganizationToken, + mockGroupCrmOrganizationsQueryResponse, + mockProjectCrmOrganizationsQueryResponse, +} from '../mock_data'; + +jest.mock('~/flash'); + +const defaultStubs = { + Portal: true, + BaseToken, + GlFilteredSearchSuggestionList: { + template: '
', + methods: { + getValue: () => '=', + }, + }, +}; + +describe('CrmOrganizationToken', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const getBaseToken = () => wrapper.findComponent(BaseToken); + + const searchGroupCrmOrganizationsQueryHandler = jest + .fn() + .mockResolvedValue(mockGroupCrmOrganizationsQueryResponse); + const searchProjectCrmOrganizationsQueryHandler = jest + .fn() + .mockResolvedValue(mockProjectCrmOrganizationsQueryResponse); + + const mountComponent = ({ + config = mockCrmOrganizationToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + listeners = {}, + queryHandler = searchGroupCrmOrganizationsQueryHandler, + } = {}) => { + fakeApollo = createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]); + wrapper = mount(CrmOrganizationToken, { + propsData: { + config, + value, + active, + cursorPosition: 'start', + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: () => 'custom-class', + }, + stubs, + listeners, + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('methods', () => { + describe('fetchOrganizations', () => { + describe('for groups', () => { + beforeEach(() => { + mountComponent(); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'group', + isProject: false, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Organization/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + }); + + describe('for projects', () => { + beforeEach(() => { + mountComponent({ + config: { + fullPath: 'project', + isProject: true, + }, + queryHandler: searchProjectCrmOrganizationsQueryHandler, + }); + }); + + it('calls the apollo query providing the searchString when search term is a string', async () => { + getBaseToken().vm.$emit('fetch-suggestions', 'foo'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: 'foo', + searchIds: null, + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + + it('calls the apollo query providing the searchId when search term is a number', async () => { + getBaseToken().vm.$emit('fetch-suggestions', '5'); + await waitForPromises(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ + fullPath: 'project', + isProject: true, + searchString: null, + searchIds: ['gid://gitlab/CustomerRelations::Organization/5'], + }); + expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations); + }); + }); + + it('calls `createFlash` with flash error message when request fails', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching CRM organizations.', + }); + }); + + it('sets `loading` to false when request completes', async () => { + mountComponent(); + + jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); + + getBaseToken().vm.$emit('fetch-suggestions'); + + await waitForPromises(); + + expect(getBaseToken().props('suggestionsLoading')).toBe(false); + }); + }); + }); + + describe('template', () => { + const defaultOrganizations = DEFAULT_NONE_ANY; + + it('renders base-token component', () => { + mountComponent({ + config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations }, + value: { data: '1' }, + }); + + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + suggestions: mockCrmOrganizations, + getActiveTokenValue: wrapper.vm.getActiveOrganization, + }); + }); + + it.each(mockCrmOrganizations)('renders token item when value is selected', (organization) => { + mountComponent({ + config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations }, + value: { data: `${getIdFromGraphQLId(organization.id)}` }, + }); + + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); // Organization, =, Organization name + expect(tokenSegments.at(2).text()).toBe(organization.name); // Organization name + }); + + it('renders provided defaultOrganizations as suggestions', async () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken, defaultOrganizations }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultOrganizations.length); + defaultOrganizations.forEach((organization, index) => { + expect(suggestions.at(index).text()).toBe(organization.text); + }); + }); + + it('does not render divider when no defaultOrganizations', async () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken, defaultOrganizations: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await nextTick(); + + expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); + expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); + }); + + it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + mountComponent({ + active: true, + config: { ...mockCrmOrganizationToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); + DEFAULT_NONE_ANY.forEach((organization, index) => { + expect(suggestions.at(index).text()).toBe(organization.text); + }); + }); + + it('emits listeners in the base-token', () => { + const mockInput = jest.fn(); + mountComponent({ + listeners: { + input: mockInput, + }, + }); + wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]); + + expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]); + }); + }); +}); diff --git a/workhorse/go.mod b/workhorse/go.mod index 7f8760c86e2..39bc1cce6a0 100644 --- a/workhorse/go.mod +++ b/workhorse/go.mod @@ -10,7 +10,7 @@ require ( github.com/aws/aws-sdk-go v1.43.31 github.com/disintegration/imaging v1.6.2 github.com/getsentry/raven-go v0.2.0 - github.com/golang-jwt/jwt/v4 v4.4.1 + github.com/golang-jwt/jwt/v4 v4.4.2 github.com/golang/gddo v0.0.0-20190419222130-af0f2af80721 github.com/golang/protobuf v1.5.2 github.com/gomodule/redigo v2.0.0+incompatible diff --git a/workhorse/go.sum b/workhorse/go.sum index 5811b8bd85e..7b67b5bd402 100644 --- a/workhorse/go.sum +++ b/workhorse/go.sum @@ -451,8 +451,9 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=