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 = {
labels: true,
snippets: true,
vulnerabilities: true,
contacts: true,
};
class GfmAutoComplete {
......@@ -127,6 +128,7 @@ class GfmAutoComplete {
if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($input);
if (this.enableMap.snippets) this.setupSnippets($input);
if (this.enableMap.contacts) this.setupContacts($input);
$input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/',
......@@ -174,9 +176,16 @@ class GfmAutoComplete {
let tpl = '/${name} ';
let referencePrefix = null;
if (value.params.length > 0) {
[[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) {
const regexp = /\[[a-z]+:/;
const match = regexp.exec(value.params);
if (match) {
[referencePrefix] = match;
tpl += '<%- referencePrefix %>';
} else {
[[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) {
tpl += '<%- referencePrefix %>';
}
}
}
return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
......@@ -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() {
const self = this;
......@@ -790,6 +835,7 @@ GfmAutoComplete.atTypeMap = {
'/': 'commands',
'[vulnerability:': 'vulnerabilities',
$: 'snippets',
'[contact:': 'contacts',
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
......@@ -883,6 +929,11 @@ GfmAutoComplete.Milestones = {
return `<li>${escape(title)}</li>`;
},
};
GfmAutoComplete.Contacts = {
templateFunction({ email, firstName, lastName }) {
return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
},
};
GfmAutoComplete.Loading = {
template:
'<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';
import { stripHtml } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
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 MarkdownToolbar from './toolbar.vue';
......@@ -23,6 +24,7 @@ export default {
GlIcon,
Suggestions,
},
mixins: [glFeatureFlagsMixin()],
props: {
/**
* This prop should be bound to the value of the `<textarea>` element
......@@ -217,6 +219,7 @@ export default {
labels: this.enableAutocomplete,
snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
contacts: this.enableAutocomplete && this.glFeatures.contactsAutocomplete,
},
true,
);
......
......@@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController
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(:iteration_cadences, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:contacts_autocomplete, project&.group, default_enabled: :yaml)
end
before_action only: :show do
......
......@@ -396,7 +396,8 @@ module ApplicationHelper
labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_project_autocomplete_sources_path(object),
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
......
......@@ -26,6 +26,18 @@ class CustomerRelations::Contact < ApplicationRecord
validate :validate_email_format
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)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
......
......@@ -48,10 +48,16 @@ module Issues
end
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)
end
def emails(key)
params[key].map do |email|
extract_email_from_request_param(email)
end
end
def add_by_id(contact_ids)
contact_ids -= existing_ids
contact_ids.uniq.each do |contact_id|
......@@ -69,7 +75,7 @@ module Issues
end
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)
end
......@@ -80,6 +86,13 @@ module Issues
.delete_all
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?
current_user&.can?(:set_issue_crm_contacts, issue)
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
# 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
(individuals) and organizations (companies) and relate them to issues.
......@@ -133,7 +137,7 @@ API.
### 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).
You can also add, remove, or replace issue contacts using the
......@@ -142,9 +146,25 @@ API.
### 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).
You can also add, remove, or replace issue contacts using the
[GraphQL](../../api/graphql/reference/index.md#mutationissuesetcrmcontacts)
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:
| repository file references | `[README](doc/README.md)` | | |
| repository file line references | `[README](doc/README.md#L13)` | | |
| [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.
......
......@@ -49,7 +49,7 @@ threads. Some quick actions might not be available to all subscription tiers.
| 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. |
| `/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. |
......@@ -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. |
| `/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_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_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. |
......
......@@ -177,19 +177,19 @@ RSpec.describe ApplicationHelper do
end
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
it 'returns paths for autocomplete_sources_controller including epics and vulnerabilities for group projects' do
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
context 'when epics and vulnerabilities are disabled' 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
......
......@@ -287,7 +287,7 @@ module Gitlab
desc _('Add customer relation contacts')
explanation _('Add customer relation contact(s).')
params 'contact@example.com person@example.org'
params '[contact:contact@example.com] [contact:person@example.org]'
types Issue
condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) &&
......@@ -302,7 +302,7 @@ module Gitlab
desc _('Remove customer relation contacts')
explanation _('Remove customer relation contact(s).')
params 'contact@example.com person@example.org'
params '[contact:contact@example.com] [contact:person@example.org]'
types Issue
condition do
current_user.can?(:set_issue_crm_contacts, quick_action_target) &&
......
......@@ -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(: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(:label) { create(:label, project: project, title: 'special+') }
let_it_be(:label_scoped) { create(:label, project: project, title: 'scoped::label') }
......@@ -19,9 +20,9 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:label_xss) { create(:label, project: project, title: label_xss_title) }
before_all do
project.add_maintainer(user)
project.add_maintainer(user_xss)
project.add_maintainer(user2)
group.add_maintainer(user)
group.add_maintainer(user_xss)
group.add_maintainer(user2)
end
describe 'new issue page' do
......@@ -381,6 +382,30 @@ RSpec.describe 'GFM autocomplete', :js do
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
private
......
......@@ -63,6 +63,11 @@ describe('Markdown field component', () => {
textareaValue,
lines,
},
provide: {
glFeatures: {
contactsAutocomplete: true,
},
},
},
);
}
......
......@@ -289,7 +289,7 @@ RSpec.describe ApplicationHelper do
it 'returns paths for autocomplete_sources_controller' do
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|
expect(sources[key]).not_to be_nil
end
......
......@@ -26,6 +26,18 @@ RSpec.describe CustomerRelations::Contact, type: :model do
it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email
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
let_it_be(:parent) { create(:group) }
let_it_be(:group) { create(:group, parent: parent) }
......
......@@ -7,20 +7,31 @@ RSpec.describe Issues::SetCrmContactsService do
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:project) { create(:project, 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(: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" }
before do
create(:issue_customer_relations_contact, issue: issue, contact: contacts[0])
create(:issue_customer_relations_contact, issue: issue, contact: contacts[1])
let_it_be(:issue_contact_2) do
create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]).contact
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
described_class.new(project: project, current_user: user, params: params).execute(issue)
end
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
let(:params) { { replace_ids: [contacts[1].id, contacts[2].id] } }
......@@ -67,56 +78,56 @@ RSpec.describe Issues::SetCrmContactsService do
context 'replace' do
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
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[1], contacts[2]])
end
it_behaves_like 'setting contacts'
end
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
response = set_crm_contacts
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]])
end
it_behaves_like 'setting contacts'
end
context 'add by email' do
let(:params) { { add_emails: [contacts[3].email] } }
let(:added_contact) { contacts[3] }
let(:expected_contacts) { [issue_contact_1, issue_contact_2, added_contact] }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
context 'with pure emails in params' do
let(:params) { { add_emails: [contacts[3].email] } }
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]])
it_behaves_like 'setting contacts'
end
context 'with autocomplete prefix emails in params' do
let(:params) { { add_emails: ["[\"contact:\"#{contacts[3].email}\"]"] } }
it_behaves_like 'setting contacts'
end
end
context 'remove' do
let(:params) { { remove_ids: [contacts[0].id] } }
let(:expected_contacts) { [contacts[1]] }
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([contacts[1]])
end
it_behaves_like 'setting contacts'
end
context 'remove by email' do
let(:params) { { remove_emails: [contacts[0].email] } }
let(:expected_contacts) { [contacts[1]] }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
context 'with pure email in params' do
let(:params) { { remove_emails: [contacts[0].email] } }
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[1]])
it_behaves_like 'setting contacts'
end
context 'with autocomplete prefix and suffix email in params' do
let(:params) { { remove_emails: ["[contact:#{contacts[0].email}]"] } }
it_behaves_like 'setting contacts'
end
end
......@@ -145,15 +156,19 @@ RSpec.describe Issues::SetCrmContactsService do
context 'when combining params' do
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
let(:params) { { remove_ids: [contacts[1].id], add_ids: [contacts[3].id] } }
context 'with contact ids' do
let(:params) { { remove_ids: [contacts[1].id], add_ids: [contacts[3].id] } }
it 'updates the issue with correct contacts' do
response = set_crm_contacts
it_behaves_like 'setting contacts'
end
context 'with contact emails' do
let(:params) { { remove_emails: [contacts[1].email], add_emails: ["[\"contact:#{contacts[3].email}]"] } }
expect(response).to be_success
expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[3]])
it_behaves_like 'setting contacts'
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