Commit db0b9254 authored by Lee Tickett's avatar Lee Tickett

Display issue crm contacts in UI

Changelog: added
parent c74db77d
...@@ -44,6 +44,7 @@ export function initIssuableApp(issuableData, store) { ...@@ -44,6 +44,7 @@ export function initIssuableApp(issuableData, store) {
isConfidential: this.getNoteableData?.confidential, isConfidential: this.getNoteableData?.confidential,
isLocked: this.getNoteableData?.discussion_locked, isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state, issuableStatus: this.getNoteableData?.state,
id: this.getNoteableData?.id,
}, },
}); });
}, },
......
<script>
import { GlIcon, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
import createFlash from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql';
import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql';
export default {
components: {
GlIcon,
GlPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
issueId: {
type: String,
required: true,
},
},
data() {
return {
contacts: [],
};
},
apollo: {
contacts: {
query: getIssueCrmContactsQuery,
variables() {
return this.queryVariables;
},
update(data) {
return data?.issue?.customerRelationsContacts?.nodes;
},
error(error) {
createFlash({
message: __('Something went wrong trying to load issue contacts.'),
error,
captureError: true,
});
},
subscribeToMore: {
document: issueCrmContactsSubscription,
variables() {
return this.queryVariables;
},
updateQuery(prev, { subscriptionData }) {
const draftData = subscriptionData?.data?.issueCrmContactsUpdated;
if (prev && draftData) return { issue: draftData };
return prev;
},
},
},
},
computed: {
shouldShowContacts() {
return this.contacts?.length;
},
queryVariables() {
return { id: convertToGraphQLId(TYPE_ISSUE, this.issueId) };
},
contactsLabel() {
return sprintf(n__('%{count} contact', '%{count} contacts', this.contactCount), {
count: this.contactCount,
});
},
contactCount() {
return this.contacts?.length || 0;
},
},
methods: {
shouldShowPopover(contact) {
return this.popOverData(contact).length > 0;
},
divider(index) {
if (index < this.contactCount - 1) return ',';
return '';
},
popOverData(contact) {
return [contact.organization?.name, contact.email, contact.phone, contact.description].filter(
Boolean,
);
},
},
i18n: {
help: __('Work in progress- click here to find out more'),
},
};
</script>
<template>
<div>
<div v-gl-tooltip.left.viewport :title="contactsLabel" class="sidebar-collapsed-icon">
<gl-icon name="users" />
<span> {{ contactCount }} </span>
</div>
<div
v-gl-tooltip.left.viewport="$options.i18n.help"
class="hide-collapsed help-button float-right"
>
<a href="https://gitlab.com/gitlab-org/gitlab/-/issues/2256"><gl-icon name="question-o" /></a>
</div>
<div class="title hide-collapsed gl-mb-2 gl-line-height-20">
{{ contactsLabel }}
</div>
<div class="hide-collapsed gl-display-flex gl-flex-wrap">
<div
v-for="(contact, index) in contacts"
:id="`contact_container_${index}`"
:key="index"
class="gl-pr-2"
>
<span :id="`contact_${index}`" class="gl-font-weight-bold"
>{{ contact.firstName }} {{ contact.lastName }}{{ divider(index) }}</span
>
<gl-popover
v-if="shouldShowPopover(contact)"
:target="`contact_${index}`"
:container="`contact_container_${index}`"
triggers="hover focus"
placement="top"
>
<div v-for="row in popOverData(contact)" :key="row">{{ row }}</div>
</gl-popover>
</div>
</div>
</div>
</template>
#import "./issue_crm_contacts.fragment.graphql"
query issueCrmContacts($id: IssueID!) {
issue(id: $id) {
...CrmContacts
}
}
fragment CrmContacts on Issue {
id
customerRelationsContacts {
nodes {
id
firstName
lastName
email
phone
description
organization {
id
name
}
}
}
}
#import "./issue_crm_contacts.fragment.graphql"
subscription issueCrmContactsUpdated($id: IssuableID!) {
issueCrmContactsUpdated(issuableId: $id) {
... on Issue {
...CrmContacts
}
}
}
...@@ -34,6 +34,7 @@ import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subsc ...@@ -34,6 +34,7 @@ import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subsc
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import { IssuableAttributeType } from './constants'; import { IssuableAttributeType } from './constants';
import SidebarMoveIssue from './lib/sidebar_move_issue'; import SidebarMoveIssue from './lib/sidebar_move_issue';
import CrmContacts from './components/crm_contacts/crm_contacts.vue';
Vue.use(Translate); Vue.use(Translate);
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -205,6 +206,28 @@ function mountReviewersComponent(mediator) { ...@@ -205,6 +206,28 @@ function mountReviewersComponent(mediator) {
} }
} }
function mountCrmContactsComponent() {
const el = document.getElementById('js-issue-crm-contacts');
if (!el) return;
const { issueId } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
apolloProvider,
components: {
CrmContacts,
},
render: (createElement) =>
createElement('crm-contacts', {
props: {
issueId,
},
}),
});
}
function mountMilestoneSelect() { function mountMilestoneSelect() {
const el = document.querySelector('.js-milestone-select'); const el = document.querySelector('.js-milestone-select');
...@@ -535,6 +558,7 @@ export function mountSidebar(mediator, store) { ...@@ -535,6 +558,7 @@ export function mountSidebar(mediator, store) {
mountAssigneesComponentDeprecated(mediator); mountAssigneesComponentDeprecated(mediator);
} }
mountReviewersComponent(mediator); mountReviewersComponent(mediator);
mountCrmContactsComponent();
mountSidebarLabels(); mountSidebarLabels();
mountMilestoneSelect(); mountMilestoneSelect();
mountConfidentialComponent(mediator); mountConfidentialComponent(mediator);
......
...@@ -17,6 +17,7 @@ module IssuableActions ...@@ -17,6 +17,7 @@ module IssuableActions
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
@show_crm_contacts = issuable.is_a?(Issue) && can?(current_user, :read_crm_contact, issuable.project.group) # rubocop:disable Gitlab/ModuleWithInstanceVariables
@issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables @issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables
render 'show' render 'show'
end end
......
...@@ -4,4 +4,8 @@ module GraphqlTriggers ...@@ -4,4 +4,8 @@ module GraphqlTriggers
def self.issuable_assignees_updated(issuable) def self.issuable_assignees_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable) GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable)
end end
def self.issue_crm_contacts_updated(issue)
GitlabSchema.subscriptions.trigger('issueCrmContactsUpdated', { issuable_id: issue.to_gid }, issue)
end
end end
...@@ -6,5 +6,8 @@ module Types ...@@ -6,5 +6,8 @@ module Types
field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the assignees of an issuable are updated.' description: 'Triggered when the assignees of an issuable are updated.'
field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the crm contacts of an issuable are updated.'
end end
end end
...@@ -23,6 +23,7 @@ module Issues ...@@ -23,6 +23,7 @@ module Issues
remove_by_email if params[:remove_emails].present? remove_by_email if params[:remove_emails].present?
if issue.valid? if issue.valid?
GraphqlTriggers.issue_crm_contacts_updated(issue)
ServiceResponse.success(payload: issue) ServiceResponse.success(payload: issue)
else else
# The default error isn't very helpful: "Issue customer relations contacts is invalid" # The default error isn't very helpful: "Issue customer relations contacts is invalid"
......
...@@ -36,6 +36,10 @@ ...@@ -36,6 +36,10 @@
.block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }< .block{ class: 'gl-pt-0! gl-collapse-empty', data: { qa_selector: 'iteration_container', testid: 'iteration_container' } }<
= render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable.to_s, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if @show_crm_contacts
.block.contact
#js-issue-crm-contacts{ data: { issue_id: issuable_sidebar[:id] } }
- if issuable_sidebar[:supports_time_tracking] - if issuable_sidebar[:supports_time_tracking]
#issuable-time-tracker.block #issuable-time-tracker.block
// Fallback while content is loading // Fallback while content is loading
......
...@@ -495,6 +495,11 @@ msgstr[1] "" ...@@ -495,6 +495,11 @@ msgstr[1] ""
msgid "%{count} approvals from %{name}" msgid "%{count} approvals from %{name}"
msgstr "" msgstr ""
msgid "%{count} contact"
msgid_plural "%{count} contacts"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} files touched" msgid "%{count} files touched"
msgstr "" msgstr ""
...@@ -32379,6 +32384,9 @@ msgstr "" ...@@ -32379,6 +32384,9 @@ msgstr ""
msgid "Something went wrong trying to change the locked state of this %{issuableDisplayName}" msgid "Something went wrong trying to change the locked state of this %{issuableDisplayName}"
msgstr "" msgstr ""
msgid "Something went wrong trying to load issue contacts."
msgstr ""
msgid "Something went wrong when creating a work item. Please try again" msgid "Something went wrong when creating a work item. Please try again"
msgstr "" msgstr ""
...@@ -39335,6 +39343,9 @@ msgstr "" ...@@ -39335,6 +39343,9 @@ msgstr ""
msgid "Work in progress Limit" msgid "Work in progress Limit"
msgstr "" msgstr ""
msgid "Work in progress- click here to find out more"
msgstr ""
msgid "WorkItem|Work Items" msgid "WorkItem|Work Items"
msgstr "" msgstr ""
......
...@@ -25,7 +25,7 @@ RSpec.describe 'ActionCable logging', :js do ...@@ -25,7 +25,7 @@ RSpec.describe 'ActionCable logging', :js do
username: user.username username: user.username
) )
expect(ActiveSupport::Notifications).to receive(:instrument).with('subscribe.action_cable', subscription_data) expect(ActiveSupport::Notifications).to receive(:instrument).with('subscribe.action_cable', subscription_data).at_least(:once)
gitlab_sign_in(user) gitlab_sign_in(user)
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import CrmContacts from '~/sidebar/components/crm_contacts/crm_contacts.vue';
import getIssueCrmContactsQuery from '~/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql';
import issueCrmContactsSubscription from '~/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql';
import {
getIssueCrmContactsQueryResponse,
issueCrmContactsUpdateResponse,
issueCrmContactsUpdateNullResponse,
} from './mock_data';
jest.mock('~/flash');
describe('Issue crm contacts component', () => {
Vue.use(VueApollo);
let wrapper;
let fakeApollo;
const successQueryHandler = jest.fn().mockResolvedValue(getIssueCrmContactsQueryResponse);
const successSubscriptionHandler = jest.fn().mockResolvedValue(issueCrmContactsUpdateResponse);
const nullSubscriptionHandler = jest.fn().mockResolvedValue(issueCrmContactsUpdateNullResponse);
const mountComponent = ({
queryHandler = successQueryHandler,
subscriptionHandler = successSubscriptionHandler,
} = {}) => {
fakeApollo = createMockApollo([
[getIssueCrmContactsQuery, queryHandler],
[issueCrmContactsSubscription, subscriptionHandler],
]);
wrapper = shallowMountExtended(CrmContacts, {
propsData: { issueId: '123' },
apolloProvider: fakeApollo,
});
};
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
it('should render error message on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
it('calls the query with correct variables', () => {
mountComponent();
expect(successQueryHandler).toHaveBeenCalledWith({
id: 'gid://gitlab/Issue/123',
});
});
it('calls the subscription with correct variable for issue', () => {
mountComponent();
expect(successSubscriptionHandler).toHaveBeenCalledWith({
id: 'gid://gitlab/Issue/123',
});
});
it('renders correct initial results', async () => {
mountComponent({ subscriptionHandler: nullSubscriptionHandler });
await waitForPromises();
expect(wrapper.find('#contact_0').text()).toContain('Someone Important');
expect(wrapper.find('#contact_container_0').text()).toContain('si@gitlab.com');
expect(wrapper.find('#contact_1').text()).toContain('Marty McFly');
});
it('renders correct results after subscription update', async () => {
mountComponent();
await waitForPromises();
const contact = ['Dave Davies', 'dd@gitlab.com', '+44 20 1111 2222', 'Vice President'];
contact.forEach((property) => {
expect(wrapper.find('#contact_container_0').text()).toContain(property);
});
});
});
export const getIssueCrmContactsQueryResponse = {
data: {
issue: {
id: 'gid://gitlab/Issue/123',
customerRelationsContacts: {
nodes: [
{
id: 'gid://gitlab/CustomerRelations::Contact/1',
firstName: 'Someone',
lastName: 'Important',
email: 'si@gitlab.com',
phone: null,
description: null,
organization: null,
},
{
id: 'gid://gitlab/CustomerRelations::Contact/5',
firstName: 'Marty',
lastName: 'McFly',
email: null,
phone: null,
description: null,
organization: null,
},
],
},
},
},
};
export const issueCrmContactsUpdateNullResponse = {
data: {
issueCrmContactsUpdated: null,
},
};
export const issueCrmContactsUpdateResponse = {
data: {
issueCrmContactsUpdated: {
id: 'gid://gitlab/Issue/123',
customerRelationsContacts: {
nodes: [
{
id: 'gid://gitlab/CustomerRelations::Contact/13',
firstName: 'Dave',
lastName: 'Davies',
email: 'dd@gitlab.com',
phone: '+44 20 1111 2222',
description: 'Vice President',
organization: null,
},
],
},
},
},
};
...@@ -6,6 +6,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do ...@@ -6,6 +6,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %i[ expected_fields = %i[
issuable_assignees_updated issuable_assignees_updated
issue_crm_contacts_updated
] ]
expect(described_class).to have_graphql_fields(*expected_fields).only expect(described_class).to have_graphql_fields(*expected_fields).only
......
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