Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-07-01 09:08:29 +00:00
parent a6dce21d91
commit 4def415fbf
20 changed files with 1098 additions and 11 deletions

View File

@ -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,
});

View File

@ -0,0 +1,6 @@
fragment ContactFragment on CustomerRelationsContact {
id
firstName
lastName
email
}

View File

@ -0,0 +1,4 @@
fragment OrganizationFragment on CustomerRelationsOrganization {
id
name
}

View File

@ -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
}
}
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -0,0 +1,131 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import { ITEM_TYPE } from '~/groups/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql';
import { DEFAULT_NONE_ANY } from '../constants';
import BaseToken from './base_token.vue';
export default {
components: {
BaseToken,
GlFilteredSearchSuggestion,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
},
data() {
return {
contacts: this.config.initialContacts || [],
loading: false,
};
},
computed: {
defaultContacts() {
return this.config.defaultContacts || DEFAULT_NONE_ANY;
},
namespace() {
return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
},
methods: {
getActiveContact(contacts, data) {
return contacts.find((contact) => {
return `${this.formatContactId(contact)}` === data;
});
},
getContactName(contact) {
return `${contact.firstName} ${contact.lastName}`;
},
fetchContacts(searchTerm) {
let searchString = null;
let searchId = null;
if (isPositiveInteger(searchTerm)) {
searchId = this.formatContactGraphQLId(searchTerm);
} else {
searchString = searchTerm;
}
this.loading = true;
this.$apollo
.query({
query: searchCrmContactsQuery,
variables: {
fullPath: this.config.fullPath,
searchString,
searchIds: searchId ? [searchId] : null,
isProject: this.config.isProject,
},
})
.then(({ data }) => {
this.contacts = this.config.isProject
? data[this.namespace]?.group.contacts.nodes
: data[this.namespace]?.contacts.nodes;
})
.catch(() =>
createFlash({
message: __('There was a problem fetching CRM contacts.'),
}),
)
.finally(() => {
this.loading = false;
});
},
formatContactId(contact) {
return `${getIdFromGraphQLId(contact.id)}`;
},
formatContactGraphQLId(id) {
return convertToGraphQLId('CustomerRelations::Contact', id);
},
},
};
</script>
<template>
<base-token
:config="config"
:value="value"
:active="active"
:suggestions-loading="loading"
:suggestions="contacts"
:get-active-token-value="getActiveContact"
:default-suggestions="defaultContacts"
v-bind="$attrs"
@fetch-suggestions="fetchContacts"
v-on="$listeners"
>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
{{ activeTokenValue ? getContactName(activeTokenValue) : inputValue }}
</template>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="contact in suggestions"
:key="formatContactId(contact)"
:value="formatContactId(contact)"
>
<div>
<div>{{ getContactName(contact) }}</div>
<div class="gl-font-sm">{{ contact.email }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
</base-token>
</template>

View File

@ -0,0 +1,125 @@
<script>
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import { ITEM_TYPE } from '~/groups/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql';
import { DEFAULT_NONE_ANY } from '../constants';
import BaseToken from './base_token.vue';
export default {
components: {
BaseToken,
GlFilteredSearchSuggestion,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
},
data() {
return {
organizations: this.config.initialOrganizations || [],
loading: false,
};
},
computed: {
defaultOrganizations() {
return this.config.defaultOrganizations || DEFAULT_NONE_ANY;
},
namespace() {
return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
},
methods: {
getActiveOrganization(organizations, data) {
return organizations.find((organization) => {
return `${this.formatOrganizationId(organization)}` === data;
});
},
fetchOrganizations(searchTerm) {
let searchString = null;
let searchId = null;
if (isPositiveInteger(searchTerm)) {
searchId = this.formatOrganizationGraphQLId(searchTerm);
} else {
searchString = searchTerm;
}
this.loading = true;
this.$apollo
.query({
query: searchCrmOrganizationsQuery,
variables: {
fullPath: this.config.fullPath,
searchString,
searchIds: searchId ? [searchId] : null,
isProject: this.config.isProject,
},
})
.then(({ data }) => {
this.organizations = this.config.isProject
? data[this.namespace]?.group.organizations.nodes
: data[this.namespace]?.organizations.nodes;
})
.catch(() =>
createFlash({
message: __('There was a problem fetching CRM organizations.'),
}),
)
.finally(() => {
this.loading = false;
});
},
formatOrganizationId(organization) {
return `${getIdFromGraphQLId(organization.id)}`;
},
formatOrganizationGraphQLId(id) {
return convertToGraphQLId('CustomerRelations::Organization', id);
},
},
};
</script>
<template>
<base-token
:config="config"
:value="value"
:active="active"
:suggestions-loading="loading"
:suggestions="organizations"
:get-active-token-value="getActiveOrganization"
:default-suggestions="defaultOrganizations"
v-bind="$attrs"
@fetch-suggestions="fetchOrganizations"
v-on="$listeners"
>
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
{{ activeTokenValue ? activeTokenValue.name : inputValue }}
</template>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="organization in suggestions"
:key="formatOrganizationId(organization)"
:value="formatOrganizationId(organization)"
>
{{ organization.name }}
</gl-filtered-search-suggestion>
</template>
</base-token>
</template>

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
aeaa386b52a2a5e30b59fbe57e9c701298fea45219b3ec419866d40c6d2a5e5d

View File

@ -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.**

View File

@ -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)

View File

@ -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

View File

@ -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|

View File

@ -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 ""

View File

@ -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',

View File

@ -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: '<div></div>',
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: '=' }]);
});
});
});

View File

@ -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: '<div></div>',
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: '=' }]);
});
});
});

View File

@ -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

View File

@ -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=