Commit 93d54410 authored by Lee Tickett's avatar Lee Tickett Committed by Heinrich Lee Yu

Add ability to search issues by crm contact

parent bd86b61a
<script> <script>
import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
export default { export default {
components: { components: {
GlButton,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
}, },
inject: ['groupFullPath'], directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['groupFullPath', 'groupIssuesPath'],
data() { data() {
return { contacts: [] }; return { contacts: [] };
}, },
...@@ -59,9 +64,17 @@ export default { ...@@ -59,9 +64,17 @@ export default {
}, },
sortable: true, sortable: true,
}, },
{
key: 'id',
label: __('Issues'),
formatter: (id) => {
return getIdFromGraphQLId(id);
},
},
], ],
i18n: { i18n: {
emptyText: s__('Crm|No contacts found'), emptyText: s__('Crm|No contacts found'),
issuesButtonLabel: __('View issues'),
}, },
}; };
</script> </script>
...@@ -75,6 +88,16 @@ export default { ...@@ -75,6 +88,16 @@ export default {
:fields="$options.fields" :fields="$options.fields"
:empty-text="$options.i18n.emptyText" :empty-text="$options.i18n.emptyText"
show-empty show-empty
/> >
<template #cell(id)="data">
<gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
data-testid="issues-link"
icon="issues"
:aria-label="$options.i18n.issuesButtonLabel"
:href="`${groupIssuesPath}?scope=all&state=opened&crm_contact_id=${data.value}`"
/>
</template>
</gl-table>
</div> </div>
</template> </template>
...@@ -16,10 +16,12 @@ export default () => { ...@@ -16,10 +16,12 @@ export default () => {
return false; return false;
} }
const { groupFullPath, groupIssuesPath } = el.dataset;
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
provide: { groupFullPath: el.dataset.groupFullPath }, provide: { groupFullPath, groupIssuesPath },
render(createElement) { render(createElement) {
return createElement(CrmContactsRoot); return createElement(CrmContactsRoot);
}, },
......
...@@ -35,6 +35,7 @@ ...@@ -35,6 +35,7 @@
# updated_before: datetime # updated_before: datetime
# attempt_group_search_optimizations: boolean # attempt_group_search_optimizations: boolean
# attempt_project_search_optimizations: boolean # attempt_project_search_optimizations: boolean
# crm_contact_id: integer
# #
class IssuableFinder class IssuableFinder
prepend FinderWithCrossProjectAccess prepend FinderWithCrossProjectAccess
...@@ -59,6 +60,7 @@ class IssuableFinder ...@@ -59,6 +60,7 @@ class IssuableFinder
assignee_username assignee_username
author_id author_id
author_username author_username
crm_contact_id
label_name label_name
milestone_title milestone_title
release_tag release_tag
...@@ -138,7 +140,8 @@ class IssuableFinder ...@@ -138,7 +140,8 @@ class IssuableFinder
items = by_milestone(items) items = by_milestone(items)
items = by_release(items) items = by_release(items)
items = by_label(items) items = by_label(items)
by_my_reaction_emoji(items) items = by_my_reaction_emoji(items)
by_crm_contact(items)
end end
def should_filter_negated_args? def should_filter_negated_args?
...@@ -463,6 +466,10 @@ class IssuableFinder ...@@ -463,6 +466,10 @@ class IssuableFinder
params[:non_archived].present? ? items.non_archived : items params[:non_archived].present? ? items.non_archived : items
end end
def by_crm_contact(items)
Issuables::CrmContactFilter.new(params: original_params).filter(items)
end
def or_filters_enabled? def or_filters_enabled?
strong_memoize(:or_filters_enabled) do strong_memoize(:or_filters_enabled) do
Feature.enabled?(:or_issuable_queries, feature_flag_scope, default_enabled: :yaml) Feature.enabled?(:or_issuable_queries, feature_flag_scope, default_enabled: :yaml)
......
# frozen_string_literal: true
module Issuables
class CrmContactFilter < BaseFilter
def filter(issuables)
by_crm_contact(issuables)
end
# rubocop: disable CodeReuse/ActiveRecord
def by_crm_contact(issuables)
return issuables if params[:crm_contact_id].blank?
condition = CustomerRelations::IssueContact
.where(contact_id: params[:crm_contact_id])
.where(Arel.sql("issue_id = issues.id"))
issuables.where(condition.arel.exists)
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
- breadcrumb_title _('Customer Relations Contacts') - breadcrumb_title _('Customer Relations Contacts')
- page_title _('Customer Relations Contacts') - page_title _('Customer Relations Contacts')
#js-crm-contacts-app{ data: { group_full_path: @group.full_path } } #js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group) } }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Issuables::CrmContactFilter do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:contact1) { create(:contact, group: group) }
let_it_be(:contact2) { create(:contact, group: group) }
let_it_be(:contact1_issue1) { create(:issue, project: project) }
let_it_be(:contact1_issue2) { create(:issue, project: project) }
let_it_be(:contact2_issue1) { create(:issue, project: project) }
let_it_be(:issues) { Issue.where(id: [contact1_issue1.id, contact1_issue2.id, contact2_issue1.id]) }
before_all do
create(:issue_customer_relations_contact, issue: contact1_issue1, contact: contact1)
create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1)
create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2)
end
describe 'when a contact has issues' do
it 'returns all contact1 issues' do
params = { crm_contact_id: contact1.id }
expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact1_issue1, contact1_issue2)
end
it 'returns all contact2 issues' do
params = { crm_contact_id: contact2.id }
expect(described_class.new(params: params).filter(issues)).to contain_exactly(contact2_issue1)
end
end
describe 'when a contact has no issues' do
it 'returns no issues' do
contact3 = create(:contact, group: group)
params = { crm_contact_id: contact3.id }
expect(described_class.new(params: params).filter(issues)).to be_empty
end
end
end
...@@ -910,6 +910,25 @@ RSpec.describe IssuesFinder do ...@@ -910,6 +910,25 @@ RSpec.describe IssuesFinder do
end end
end end
context 'filtering by crm contact' do
let_it_be(:contact1) { create(:contact, group: group) }
let_it_be(:contact2) { create(:contact, group: group) }
let_it_be(:contact1_issue1) { create(:issue, project: project1) }
let_it_be(:contact1_issue2) { create(:issue, project: project1) }
let_it_be(:contact2_issue1) { create(:issue, project: project1) }
let(:params) { { crm_contact_id: contact1.id } }
it 'returns issues with that label' do
create(:issue_customer_relations_contact, issue: contact1_issue1, contact: contact1)
create(:issue_customer_relations_contact, issue: contact1_issue2, contact: contact1)
create(:issue_customer_relations_contact, issue: contact2_issue1, contact: contact2)
expect(issues).to contain_exactly(contact1_issue1, contact1_issue2)
end
end
context 'when the user is unauthorized' do context 'when the user is unauthorized' do
let(:search_user) { nil } let(:search_user) { nil }
......
...@@ -18,6 +18,7 @@ describe('Customer relations contacts root app', () => { ...@@ -18,6 +18,7 @@ describe('Customer relations contacts root app', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse); const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
const mountComponent = ({ const mountComponent = ({
...@@ -26,7 +27,7 @@ describe('Customer relations contacts root app', () => { ...@@ -26,7 +27,7 @@ describe('Customer relations contacts root app', () => {
} = {}) => { } = {}) => {
fakeApollo = createMockApollo([[getGroupContactsQuery, queryHandler]]); fakeApollo = createMockApollo([[getGroupContactsQuery, queryHandler]]);
wrapper = mountFunction(ContactsRoot, { wrapper = mountFunction(ContactsRoot, {
provide: { groupFullPath: 'flightjs' }, provide: { groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
}); });
}; };
...@@ -56,5 +57,9 @@ describe('Customer relations contacts root app', () => { ...@@ -56,5 +57,9 @@ describe('Customer relations contacts root app', () => {
expect(findRowByName(/Marty/i)).toHaveLength(1); expect(findRowByName(/Marty/i)).toHaveLength(1);
expect(findRowByName(/George/i)).toHaveLength(1); expect(findRowByName(/George/i)).toHaveLength(1);
expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1); expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1);
const issueLink = findIssuesLinks().at(0);
expect(issueLink.exists()).toBe(true);
expect(issueLink.attributes('href')).toBe('/issues?scope=all&state=opened&crm_contact_id=16');
}); });
}); });
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