Commit 7de49906 authored by Matthias Käppler's avatar Matthias Käppler

Merge branch '350135-contact-autocomplete' into 'master'

Add support for contacts autocompletion

See merge request gitlab-org/gitlab!79639
parents b83ea43d 543067dd
...@@ -86,6 +86,7 @@ export const defaultAutocompleteConfig = { ...@@ -86,6 +86,7 @@ export const defaultAutocompleteConfig = {
labels: true, labels: true,
snippets: true, snippets: true,
vulnerabilities: true, vulnerabilities: true,
contacts: true,
}; };
class GfmAutoComplete { class GfmAutoComplete {
...@@ -127,6 +128,7 @@ class GfmAutoComplete { ...@@ -127,6 +128,7 @@ class GfmAutoComplete {
if (this.enableMap.mergeRequests) this.setupMergeRequests($input); if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($input); if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input); if (this.enableMap.snippets) this.setupSnippets($input);
if (this.enableMap.contacts) this.setupContacts($input);
$input.filter('[data-supports-quick-actions="true"]').atwho({ $input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/', at: '/',
...@@ -174,11 +176,18 @@ class GfmAutoComplete { ...@@ -174,11 +176,18 @@ class GfmAutoComplete {
let tpl = '/${name} '; let tpl = '/${name} ';
let referencePrefix = null; let referencePrefix = null;
if (value.params.length > 0) { if (value.params.length > 0) {
const regexp = /\[[a-z]+:/;
const match = regexp.exec(value.params);
if (match) {
[referencePrefix] = match;
tpl += '<%- referencePrefix %>';
} else {
[[referencePrefix]] = value.params; [[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) { if (/^[@%~]/.test(referencePrefix)) {
tpl += '<%- referencePrefix %>'; tpl += '<%- referencePrefix %>';
} }
} }
}
return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix }); return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
}, },
suffix: '', suffix: '',
...@@ -619,6 +628,42 @@ class GfmAutoComplete { ...@@ -619,6 +628,42 @@ class GfmAutoComplete {
}); });
} }
setupContacts($input) {
$input.atwho({
at: '[contact:',
suffix: ']',
alias: 'contacts',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.email != null) {
tmpl = GfmAutoComplete.Contacts.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${email}',
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(contacts) {
return $.map(contacts, (m) => {
if (m.email == null) {
return m;
}
return {
id: m.id,
email: m.email,
firstName: m.first_name,
lastName: m.last_name,
search: `${m.email}`,
};
});
},
},
});
}
getDefaultCallbacks() { getDefaultCallbacks() {
const self = this; const self = this;
...@@ -790,6 +835,7 @@ GfmAutoComplete.atTypeMap = { ...@@ -790,6 +835,7 @@ GfmAutoComplete.atTypeMap = {
'/': 'commands', '/': 'commands',
'[vulnerability:': 'vulnerabilities', '[vulnerability:': 'vulnerabilities',
$: 'snippets', $: 'snippets',
'[contact:': 'contacts',
}; };
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
...@@ -883,6 +929,11 @@ GfmAutoComplete.Milestones = { ...@@ -883,6 +929,11 @@ GfmAutoComplete.Milestones = {
return `<li>${escape(title)}</li>`; return `<li>${escape(title)}</li>`;
}, },
}; };
GfmAutoComplete.Contacts = {
templateFunction({ email, firstName, lastName }) {
return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
},
};
GfmAutoComplete.Loading = { GfmAutoComplete.Loading = {
template: template:
'<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>', '<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',
......
...@@ -9,6 +9,7 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -9,6 +9,7 @@ import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility'; import { stripHtml } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue'; import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue'; import MarkdownToolbar from './toolbar.vue';
...@@ -23,6 +24,7 @@ export default { ...@@ -23,6 +24,7 @@ export default {
GlIcon, GlIcon,
Suggestions, Suggestions,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
/** /**
* This prop should be bound to the value of the `<textarea>` element * This prop should be bound to the value of the `<textarea>` element
...@@ -217,6 +219,7 @@ export default { ...@@ -217,6 +219,7 @@ export default {
labels: this.enableAutocomplete, labels: this.enableAutocomplete,
snippets: this.enableAutocomplete, snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete, vulnerabilities: this.enableAutocomplete,
contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete,
}, },
true, true,
); );
......
...@@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:contacts_autocomplete, project&.group, default_enabled: :yaml)
end end
before_action only: :show do before_action only: :show do
......
...@@ -396,7 +396,8 @@ module ApplicationHelper ...@@ -396,7 +396,8 @@ module ApplicationHelper
labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_project_autocomplete_sources_path(object), milestones: milestones_project_autocomplete_sources_path(object),
commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
snippets: snippets_project_autocomplete_sources_path(object) snippets: snippets_project_autocomplete_sources_path(object),
contacts: contacts_project_autocomplete_sources_path(object)
} }
end end
end end
......
...@@ -26,6 +26,18 @@ class CustomerRelations::Contact < ApplicationRecord ...@@ -26,6 +26,18 @@ class CustomerRelations::Contact < ApplicationRecord
validate :validate_email_format validate :validate_email_format
validate :unique_email_for_group_hierarchy validate :unique_email_for_group_hierarchy
def self.reference_prefix
'[contact:'
end
def self.reference_prefix_quoted
'["contact:'
end
def self.reference_postfix
']'
end
def self.find_ids_by_emails(group, emails) def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
......
...@@ -48,10 +48,16 @@ module Issues ...@@ -48,10 +48,16 @@ module Issues
end end
def add_by_email def add_by_email
contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, params[:add_emails]) contact_ids = ::CustomerRelations::Contact.find_ids_by_emails(project_group, emails(:add_emails))
add_by_id(contact_ids) add_by_id(contact_ids)
end end
def emails(key)
params[key].map do |email|
extract_email_from_request_param(email)
end
end
def add_by_id(contact_ids) def add_by_id(contact_ids)
contact_ids -= existing_ids contact_ids -= existing_ids
contact_ids.uniq.each do |contact_id| contact_ids.uniq.each do |contact_id|
...@@ -69,7 +75,7 @@ module Issues ...@@ -69,7 +75,7 @@ module Issues
end end
def remove_by_email def remove_by_email
contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, params[:remove_emails]) contact_ids = ::CustomerRelations::IssueContact.find_contact_ids_by_emails(issue.id, emails(:remove_emails))
remove_by_id(contact_ids) remove_by_id(contact_ids)
end end
...@@ -80,6 +86,13 @@ module Issues ...@@ -80,6 +86,13 @@ module Issues
.delete_all .delete_all
end end
def extract_email_from_request_param(email_param)
email_param.delete_prefix(::CustomerRelations::Contact.reference_prefix_quoted)
.delete_prefix(::CustomerRelations::Contact.reference_prefix)
.delete_suffix(::CustomerRelations::Contact.reference_postfix)
.tr('"', '')
end
def allowed? def allowed?
current_user&.can?(:set_issue_crm_contacts, issue) current_user&.can?(:set_issue_crm_contacts, issue)
end end
......
---
name: contacts_autocomplete
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79639
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/352123
milestone: '14.8'
type: development
group: group::product planning
default_enabled: false
...@@ -6,7 +6,11 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -6,7 +6,11 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Customer relations management (CRM) **(FREE)** # Customer relations management (CRM) **(FREE)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `customer_relations`.
On GitLab.com, this feature is not available.
With customer relations management (CRM) you can create a record of contacts With customer relations management (CRM) you can create a record of contacts
(individuals) and organizations (companies) and relate them to issues. (individuals) and organizations (companies) and relate them to issues.
...@@ -133,7 +137,7 @@ API. ...@@ -133,7 +137,7 @@ API.
### Add contacts to an issue ### Add contacts to an issue
To add contacts to an issue use the `/add_contacts` To add contacts to an issue use the `/add_contacts [contact:address@example.com]`
[quick action](../project/quick_actions.md). [quick action](../project/quick_actions.md).
You can also add, remove, or replace issue contacts using the You can also add, remove, or replace issue contacts using the
...@@ -142,9 +146,25 @@ API. ...@@ -142,9 +146,25 @@ API.
### Remove contacts from an issue ### Remove contacts from an issue
To remove contacts from an issue use the `/remove_contacts` To remove contacts from an issue use the `/remove_contacts [contact:address@example.com]`
[quick action](../project/quick_actions.md). [quick action](../project/quick_actions.md).
You can also add, remove, or replace issue contacts using the You can also add, remove, or replace issue contacts using the
[GraphQL](../../api/graphql/reference/index.md#mutationissuesetcrmcontacts) [GraphQL](../../api/graphql/reference/index.md#mutationissuesetcrmcontacts)
API. API.
## Autocomplete contacts **(FREE SELF)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.8 [with a flag](../../administration/feature_flags.md) named `contacts_autocomplete`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `contacts_autocomplete`.
On GitLab.com, this feature is not available.
This feature is not ready for production use.
When you use the `/add_contacts` or `/remove_contacts` quick actions, follow them with `[contact:` and an autocomplete list appears:
```plaintext
/add_contacts [contact:
/remove_contacts [contact:
```
...@@ -539,6 +539,7 @@ GitLab Flavored Markdown recognizes the following: ...@@ -539,6 +539,7 @@ GitLab Flavored Markdown recognizes the following:
| repository file references | `[README](doc/README.md)` | | | | repository file references | `[README](doc/README.md)` | | |
| repository file line references | `[README](doc/README.md#L13)` | | | | repository file line references | `[README](doc/README.md#L13)` | | |
| [alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` | | [alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` |
| contact | `[contact:test@example.com]` | | |
1. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/222483) in GitLab 13.7. 1. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/222483) in GitLab 13.7.
......
...@@ -49,7 +49,7 @@ threads. Some quick actions might not be available to all subscription tiers. ...@@ -49,7 +49,7 @@ threads. Some quick actions might not be available to all subscription tiers.
| Command | Issue | Merge request | Epic | Action | | Command | Issue | Merge request | Epic | Action |
|:-------------------------------------------------------------------------------------------------|:-----------------------|:-----------------------|:-----------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |:-------------------------------------------------------------------------------------------------|:-----------------------|:-----------------------|:-----------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `/add_contacts email1 email2` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Add one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). | | `/add_contacts [contact:email1@example.com] [contact:email2@example.com]` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Add one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). |
| `/approve` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Approve the merge request. | | `/approve` | **{dotted-circle}** No | **{check-circle}** Yes | **{dotted-circle}** No | Approve the merge request. |
| `/assign @user1 @user2` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Assign one or more users. | | `/assign @user1 @user2` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Assign one or more users. |
| `/assign me` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Assign yourself. | | `/assign me` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Assign yourself. |
...@@ -89,7 +89,7 @@ threads. Some quick actions might not be available to all subscription tiers. ...@@ -89,7 +89,7 @@ threads. Some quick actions might not be available to all subscription tiers.
| `/relabel ~label1 ~label2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Replace current labels with those specified. | | `/relabel ~label1 ~label2` | **{check-circle}** Yes | **{check-circle}** Yes | **{check-circle}** Yes | Replace current labels with those specified. |
| `/relate #issue1 #issue2` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Mark issues as related. | | `/relate #issue1 #issue2` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Mark issues as related. |
| `/remove_child_epic <epic>` | **{dotted-circle}** No | **{dotted-circle}** No | **{check-circle}** Yes | Remove child epic from `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic ([introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/-/issues/7330)). | | `/remove_child_epic <epic>` | **{dotted-circle}** No | **{dotted-circle}** No | **{check-circle}** Yes | Remove child epic from `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic ([introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/-/issues/7330)). |
| `/remove_contacts email1 email2` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). | | `/remove_contacts [contact:email1@example.com] [contact:email2@example.com]` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove one or more [CRM contacts](../crm/index.md) ([introduced in GitLab 14.6](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73413)). |
| `/remove_due_date` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove due date. | | `/remove_due_date` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove due date. |
| `/remove_epic` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove from epic. | | `/remove_epic` | **{check-circle}** Yes | **{dotted-circle}** No | **{dotted-circle}** No | Remove from epic. |
| `/remove_estimate` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Remove time estimate. | | `/remove_estimate` | **{check-circle}** Yes | **{check-circle}** Yes | **{dotted-circle}** No | Remove time estimate. |
......
...@@ -177,19 +177,19 @@ RSpec.describe ApplicationHelper do ...@@ -177,19 +177,19 @@ RSpec.describe ApplicationHelper do
end end
it 'returns paths for autocomplete_sources_controller for personal projects' do it 'returns paths for autocomplete_sources_controller for personal projects' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :vulnerabilities]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :vulnerabilities, :contacts])
end end
it 'returns paths for autocomplete_sources_controller including epics and vulnerabilities for group projects' do it 'returns paths for autocomplete_sources_controller including epics and vulnerabilities for group projects' do
object.update!(group: create(:group)) object.update!(group: create(:group))
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :epics, :vulnerabilities]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :epics, :vulnerabilities, :contacts])
end end
end end
context 'when epics and vulnerabilities are disabled' do context 'when epics and vulnerabilities are disabled' do
it 'returns paths for autocomplete_sources_controller' do it 'returns paths for autocomplete_sources_controller' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :contacts])
end end
end end
end end
......
...@@ -287,7 +287,7 @@ module Gitlab ...@@ -287,7 +287,7 @@ module Gitlab
desc _('Add customer relation contacts') desc _('Add customer relation contacts')
explanation _('Add customer relation contact(s).') explanation _('Add customer relation contact(s).')
params 'contact@example.com person@example.org' params '[contact:contact@example.com] [contact:person@example.org]'
types Issue types Issue
condition do condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) && current_user.can?(:set_issue_crm_contacts, quick_action_target) &&
...@@ -302,7 +302,7 @@ module Gitlab ...@@ -302,7 +302,7 @@ module Gitlab
desc _('Remove customer relation contacts') desc _('Remove customer relation contacts')
explanation _('Remove customer relation contact(s).') explanation _('Remove customer relation contact(s).')
params 'contact@example.com person@example.org' params '[contact:contact@example.com] [contact:person@example.org]'
types Issue types Issue
condition do condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) && current_user.can?(:set_issue_crm_contacts, quick_action_target) &&
......
...@@ -6,7 +6,8 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -6,7 +6,8 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') } let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') } let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
let_it_be(:project) { create(:project) } let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) } let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
let_it_be(:label) { create(:label, project: project, title: 'special+') } let_it_be(:label) { create(:label, project: project, title: 'special+') }
let_it_be(:label_scoped) { create(:label, project: project, title: 'scoped::label') } let_it_be(:label_scoped) { create(:label, project: project, title: 'scoped::label') }
...@@ -19,9 +20,9 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -19,9 +20,9 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:label_xss) { create(:label, project: project, title: label_xss_title) } let_it_be(:label_xss) { create(:label, project: project, title: label_xss_title) }
before_all do before_all do
project.add_maintainer(user) group.add_maintainer(user)
project.add_maintainer(user_xss) group.add_maintainer(user_xss)
project.add_maintainer(user2) group.add_maintainer(user2)
end end
describe 'new issue page' do describe 'new issue page' do
...@@ -381,6 +382,30 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -381,6 +382,30 @@ RSpec.describe 'GFM autocomplete', :js do
end end
end end
end end
context 'contact' do
let_it_be(:contacts) { create_list(:contact, 2, group: group) }
before do
fill_in 'Comment', with: '/add_contacts [contact:'
wait_for_requests
end
it 'shows contacts list in the autocomplete menu' do
page.within(find_autocomplete_menu) do
expect(page).to have_selector('li', count: 2)
end
end
it 'shows all contacts' do
page.within(find_autocomplete_menu) do
expected_data = contacts.map { |c| "#{c.first_name} #{c.last_name} #{c.email}"}
expect(page.all('li').map(&:text)).to match_array(expected_data)
end
end
end
end end
private private
......
...@@ -63,6 +63,11 @@ describe('Markdown field component', () => { ...@@ -63,6 +63,11 @@ describe('Markdown field component', () => {
textareaValue, textareaValue,
lines, lines,
}, },
provide: {
glFeatures: {
contactsAutocomplete: true,
},
},
}, },
); );
} }
......
...@@ -289,7 +289,7 @@ RSpec.describe ApplicationHelper do ...@@ -289,7 +289,7 @@ RSpec.describe ApplicationHelper do
it 'returns paths for autocomplete_sources_controller' do it 'returns paths for autocomplete_sources_controller' do
sources = helper.autocomplete_data_sources(project, noteable_type) sources = helper.autocomplete_data_sources(project, noteable_type)
expect(sources.keys).to match_array([:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets]) expect(sources.keys).to match_array([:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :contacts])
sources.keys.each do |key| sources.keys.each do |key|
expect(sources[key]).not_to be_nil expect(sources[key]).not_to be_nil
end end
......
...@@ -26,6 +26,18 @@ RSpec.describe CustomerRelations::Contact, type: :model do ...@@ -26,6 +26,18 @@ RSpec.describe CustomerRelations::Contact, type: :model do
it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email
end end
describe '.reference_prefix' do
it { expect(described_class.reference_prefix).to eq('[contact:') }
end
describe '.reference_prefix_quoted' do
it { expect(described_class.reference_prefix_quoted).to eq('["contact:') }
end
describe '.reference_postfix' do
it { expect(described_class.reference_postfix).to eq(']') }
end
describe '#unique_email_for_group_hierarchy' do describe '#unique_email_for_group_hierarchy' do
let_it_be(:parent) { create(:group) } let_it_be(:parent) { create(:group) }
let_it_be(:group) { create(:group, parent: parent) } let_it_be(:group) { create(:group, parent: parent) }
......
...@@ -7,20 +7,31 @@ RSpec.describe Issues::SetCrmContactsService do ...@@ -7,20 +7,31 @@ RSpec.describe Issues::SetCrmContactsService do
let_it_be(:group) { create(:group, :crm_enabled) } let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, group: group) } let_it_be(:project) { create(:project, group: group) }
let_it_be(:contacts) { create_list(:contact, 4, group: group) } let_it_be(:contacts) { create_list(:contact, 4, group: group) }
let_it_be(:issue, reload: true) { create(:issue, project: project) }
let_it_be(:issue_contact_1) do
create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]).contact
end
let(:issue) { create(:issue, project: project) } let_it_be(:issue_contact_2) do
let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]).contact
before do
create(:issue_customer_relations_contact, issue: issue, contact: contacts[0])
create(:issue_customer_relations_contact, issue: issue, contact: contacts[1])
end end
let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" }
subject(:set_crm_contacts) do subject(:set_crm_contacts) do
described_class.new(project: project, current_user: user, params: params).execute(issue) described_class.new(project: project, current_user: user, params: params).execute(issue)
end end
describe '#execute' do describe '#execute' do
shared_examples 'setting contacts' do
it 'updates the issue with correct contacts' do
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array(expected_contacts)
end
end
context 'when the user has no permission' do context 'when the user has no permission' do
let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } } let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } }
...@@ -67,56 +78,56 @@ RSpec.describe Issues::SetCrmContactsService do ...@@ -67,56 +78,56 @@ RSpec.describe Issues::SetCrmContactsService do
context 'replace' do context 'replace' do
let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } } let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } }
let(:expected_contacts) { [contacts[1], contacts[2]] }
it 'updates the issue with correct contacts' do it_behaves_like 'setting contacts'
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[1], contacts[2]])
end
end end
context 'add' do context 'add' do
let(:params) { { add_ids: [contacts[3].id] } } let(:added_contact) { contacts[3] }
let(:params) { { add_ids: [added_contact.id] } }
let(:expected_contacts) { [issue_contact_1, issue_contact_2, added_contact] }
it 'updates the issue with correct contacts' do it_behaves_like 'setting contacts'
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]])
end
end end
context 'add by email' do context 'add by email' do
let(:added_contact) { contacts[3] }
let(:expected_contacts) { [issue_contact_1, issue_contact_2, added_contact] }
context 'with pure emails in params' do
let(:params) { { add_emails: [contacts[3].email] } } let(:params) { { add_emails: [contacts[3].email] } }
it 'updates the issue with correct contacts' do it_behaves_like 'setting contacts'
response = set_crm_contacts end
expect(response).to be_success context 'with autocomplete prefix emails in params' do
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]]) let(:params) { { add_emails: ["[\"contact:\"#{contacts[3].email}\"]"] } }
it_behaves_like 'setting contacts'
end end
end end
context 'remove' do context 'remove' do
let(:params) { { remove_ids: [contacts[0].id] } } let(:params) { { remove_ids: [contacts[0].id] } }
let(:expected_contacts) { [contacts[1]] }
it 'updates the issue with correct contacts' do it_behaves_like 'setting contacts'
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[1]])
end
end end
context 'remove by email' do context 'remove by email' do
let(:expected_contacts) { [contacts[1]] }
context 'with pure email in params' do
let(:params) { { remove_emails: [contacts[0].email] } } let(:params) { { remove_emails: [contacts[0].email] } }
it 'updates the issue with correct contacts' do it_behaves_like 'setting contacts'
response = set_crm_contacts end
expect(response).to be_success context 'with autocomplete prefix and suffix email in params' do
expect(issue.customer_relations_contacts).to match_array([contacts[1]]) let(:params) { { remove_emails: ["[contact:#{contacts[0].email}]"] } }
it_behaves_like 'setting contacts'
end end
end end
...@@ -145,15 +156,19 @@ RSpec.describe Issues::SetCrmContactsService do ...@@ -145,15 +156,19 @@ RSpec.describe Issues::SetCrmContactsService do
context 'when combining params' do context 'when combining params' do
let(:error_invalid_params) { 'You cannot combine replace_ids with add_ids or remove_ids' } let(:error_invalid_params) { 'You cannot combine replace_ids with add_ids or remove_ids' }
let(:expected_contacts) { [contacts[0], contacts[3]] }
context 'add and remove' do context 'add and remove' do
context 'with contact ids' do
let(:params) { { remove_ids: [contacts[1].id], add_ids: [contacts[3].id] } } let(:params) { { remove_ids: [contacts[1].id], add_ids: [contacts[3].id] } }
it 'updates the issue with correct contacts' do it_behaves_like 'setting contacts'
response = set_crm_contacts end
expect(response).to be_success context 'with contact emails' do
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[3]]) let(:params) { { remove_emails: [contacts[1].email], add_emails: ["[\"contact:#{contacts[3].email}]"] } }
it_behaves_like 'setting contacts'
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment