Commit 0161ad78 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '2256-add-create-crm-contact-component' into 'master'

Add create crm contact component

See merge request gitlab-org/gitlab!75047
parents 68f5cfc3 38c481df
<script>
import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
import NewContactForm from './new_contact_form.vue';
export default {
components: {
GlAlert,
GlButton,
GlLoadingIcon,
GlTable,
NewContactForm,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['groupFullPath', 'groupIssuesPath'],
inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'],
data() {
return { contacts: [] };
return {
contacts: [],
error: false,
errorMessages: [],
};
},
apollo: {
contacts: {
......@@ -31,12 +37,8 @@ export default {
update(data) {
return this.extractContacts(data);
},
error(error) {
createFlash({
message: __('Something went wrong. Please try again.'),
error,
captureError: true,
});
error() {
this.error = true;
},
},
},
......@@ -44,12 +46,31 @@ export default {
isLoading() {
return this.$apollo.queries.contacts.loading;
},
showNewForm() {
return this.$route.path.startsWith('/new');
},
},
methods: {
extractContacts(data) {
const contacts = data?.group?.contacts?.nodes || [];
return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
},
displayNewForm() {
if (this.showNewForm) return;
this.$router.push({ path: '/new' });
},
hideNewForm() {
this.$router.replace({ path: '/' });
},
handleError(errors) {
this.error = true;
if (errors) this.errorMessages = errors;
},
dismissError() {
this.error = false;
this.errorMessages = [];
},
},
fields: [
{ key: 'firstName', sortable: true },
......@@ -75,15 +96,41 @@ export default {
i18n: {
emptyText: s__('Crm|No contacts found'),
issuesButtonLabel: __('View issues'),
title: s__('Crm|Customer Relations Contacts'),
newContact: s__('Crm|New contact'),
errorText: __('Something went wrong. Please try again.'),
},
};
</script>
<template>
<div>
<gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="dismissError">
<div v-if="errorMessages.length == 0">{{ $options.i18n.errorText }}</div>
<div v-for="(message, index) in errorMessages" :key="index">{{ message }}</div>
</gl-alert>
<div
class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
>
<h2 class="gl-font-size-h2 gl-my-0">
{{ $options.i18n.title }}
</h2>
<div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end">
<gl-button
v-if="canAdminCrmContact"
variant="confirm"
data-testid="new-contact-button"
@click="displayNewForm"
>
{{ $options.i18n.newContact }}
</gl-button>
</div>
</div>
<new-contact-form v-if="showNewForm" @close="hideNewForm" @error="handleError" />
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
class="gl-mt-5"
:items="contacts"
:fields="$options.fields"
:empty-text="$options.i18n.emptyText"
......
<script>
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { produce } from 'immer';
import { __, s__ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_GROUP } from '~/graphql_shared/constants';
import createContact from './queries/create_contact.mutation.graphql';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
export default {
components: {
GlButton,
GlFormGroup,
GlFormInput,
},
inject: ['groupFullPath', 'groupId'],
data() {
return {
firstName: '',
lastName: '',
phone: '',
email: '',
description: '',
submitting: false,
};
},
computed: {
invalid() {
return this.firstName === '' || this.lastName === '' || this.email === '';
},
},
methods: {
save() {
this.submitting = true;
return this.$apollo
.mutate({
mutation: createContact,
variables: {
input: {
groupId: convertToGraphQLId(TYPE_GROUP, this.groupId),
firstName: this.firstName,
lastName: this.lastName,
phone: this.phone,
email: this.email,
description: this.description,
},
},
update: this.updateCache,
})
.then(({ data }) => {
if (data.customerRelationsContactCreate.errors.length === 0) this.close();
this.submitting = false;
})
.catch(() => {
this.error();
this.submitting = false;
});
},
close() {
this.$emit('close');
},
error(errors = null) {
this.$emit('error', errors);
},
updateCache(store, { data: { customerRelationsContactCreate } }) {
if (customerRelationsContactCreate.errors.length > 0) {
this.error(customerRelationsContactCreate.errors);
return;
}
const variables = {
groupFullPath: this.groupFullPath,
};
const sourceData = store.readQuery({
query: getGroupContactsQuery,
variables,
});
const data = produce(sourceData, (draftState) => {
draftState.group.contacts.nodes = [
...sourceData.group.contacts.nodes,
customerRelationsContactCreate.contact,
];
});
store.writeQuery({
query: getGroupContactsQuery,
variables,
data,
});
},
},
i18n: {
buttonLabel: s__('Crm|Create new contact'),
cancel: __('Cancel'),
firstName: s__('Crm|First name'),
lastName: s__('Crm|Last name'),
email: s__('Crm|Email'),
phone: s__('Crm|Phone number (optional)'),
description: s__('Crm|Description (optional)'),
},
};
</script>
<template>
<div class="col-md-4">
<form @submit.prevent="save">
<gl-form-group :label="$options.i18n.firstName" label-for="contact-first-name">
<gl-form-input id="contact-first-name" v-model="firstName" />
</gl-form-group>
<gl-form-group :label="$options.i18n.lastName" label-for="contact-last-name">
<gl-form-input id="contact-last-name" v-model="lastName" />
</gl-form-group>
<gl-form-group :label="$options.i18n.email" label-for="contact-email">
<gl-form-input id="contact-email" v-model="email" />
</gl-form-group>
<gl-form-group :label="$options.i18n.phone" label-for="contact-phone">
<gl-form-input id="contact-phone" v-model="phone" />
</gl-form-group>
<gl-form-group :label="$options.i18n.description" label-for="contact-description">
<gl-form-input id="contact-description" v-model="description" />
</gl-form-group>
<div class="form-actions">
<gl-button
variant="confirm"
:disabled="invalid"
:loading="submitting"
data-testid="create-new-contact-button"
type="submit"
>{{ $options.i18n.buttonLabel }}</gl-button
>
<gl-button data-testid="cancel-button" @click="close">
{{ $options.i18n.cancel }}
</gl-button>
</div>
</form>
<div class="gl-pb-5"></div>
</div>
</template>
#import "./crm_contact_fields.fragment.graphql"
mutation createContact($input: CustomerRelationsContactCreateInput!) {
customerRelationsContactCreate(input: $input) {
contact {
...ContactFragment
}
errors
}
}
fragment ContactFragment on CustomerRelationsContact {
__typename
id
firstName
lastName
email
phone
description
organization {
__typename
id
name
}
}
#import "./crm_contact_fields.fragment.graphql"
query contacts($groupFullPath: ID!) {
group(fullPath: $groupFullPath) {
__typename
id
contacts {
nodes {
__typename
id
firstName
lastName
email
phone
description
organization {
__typename
id
name
}
...ContactFragment
}
}
}
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import CrmContactsRoot from './components/contacts_root.vue';
Vue.use(VueApollo);
Vue.use(VueRouter);
export default () => {
const el = document.getElementById('js-crm-contacts-app');
......@@ -16,12 +18,26 @@ export default () => {
return false;
}
const { groupFullPath, groupIssuesPath } = el.dataset;
const { basePath, groupFullPath, groupIssuesPath, canAdminCrmContact, groupId } = el.dataset;
const router = new VueRouter({
base: basePath,
mode: 'history',
routes: [
{
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Contacts List',
path: '/',
component: CrmContactsRoot,
},
],
});
return new Vue({
el,
router,
apolloProvider,
provide: { groupFullPath, groupIssuesPath },
provide: { groupFullPath, groupIssuesPath, canAdminCrmContact, groupId },
render(createElement) {
return createElement(CrmContactsRoot);
},
......
# frozen_string_literal: true
class Groups::Crm::ContactsController < Groups::ApplicationController
feature_category :team_planning
before_action :authorize_read_crm_contact!
def new
render action: "index"
end
private
def authorize_read_crm_contact!
render_404 unless can?(current_user, :read_crm_contact, group)
end
end
# frozen_string_literal: true
class Groups::CrmController < Groups::ApplicationController
class Groups::Crm::OrganizationsController < Groups::ApplicationController
feature_category :team_planning
before_action :authorize_read_crm_contact!, only: [:contacts]
before_action :authorize_read_crm_organization!, only: [:organizations]
def contacts
respond_to do |format|
format.html
end
end
def organizations
respond_to do |format|
format.html
end
end
before_action :authorize_read_crm_organization!
private
def authorize_read_crm_contact!
render_404 unless can?(current_user, :read_crm_contact, group)
end
def authorize_read_crm_organization!
render_404 unless can?(current_user, :read_crm_organization, group)
end
......
- breadcrumb_title _('Customer Relations Contacts')
- page_title _('Customer Relations Contacts')
#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group) } }
#js-crm-contacts-app{ data: { group_full_path: @group.full_path, group_issues_path: issues_group_path(@group), group_id: @group.id, can_admin_crm_contact: can?(current_user, :admin_crm_contact, @group).to_s, base_path: group_crm_contacts_path(@group) } }
......@@ -126,11 +126,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end
end
resources :crm, only: [] do
collection do
get 'contacts'
get 'organizations'
end
namespace :crm do
resources :contacts, only: [:index, :new]
resources :organizations, only: [:index]
end
end
......
......@@ -32,7 +32,7 @@ module Sidebars
def contacts_menu_item
::Sidebars::MenuItem.new(
title: _('Contacts'),
link: contacts_group_crm_index_path(context.group),
link: group_crm_contacts_path(context.group),
active_routes: { path: 'groups/crm#contacts' },
item_id: :crm_contacts
)
......@@ -41,7 +41,7 @@ module Sidebars
def organizations_menu_item
::Sidebars::MenuItem.new(
title: _('Organizations'),
link: organizations_group_crm_index_path(context.group),
link: group_crm_organizations_path(context.group),
active_routes: { path: 'groups/crm#organizations' },
item_id: :crm_organizations
)
......
......@@ -10154,12 +10154,36 @@ msgstr ""
msgid "Critical vulnerabilities present"
msgstr ""
msgid "Crm|Create new contact"
msgstr ""
msgid "Crm|Customer Relations Contacts"
msgstr ""
msgid "Crm|Description (optional)"
msgstr ""
msgid "Crm|Email"
msgstr ""
msgid "Crm|First name"
msgstr ""
msgid "Crm|Last name"
msgstr ""
msgid "Crm|New contact"
msgstr ""
msgid "Crm|No contacts found"
msgstr ""
msgid "Crm|No organizations found"
msgstr ""
msgid "Crm|Phone number (optional)"
msgstr ""
msgid "Cron Timezone"
msgstr ""
......
import { GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { mountExtended, 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 ContactsRoot from '~/crm/components/contacts_root.vue';
import NewContactForm from '~/crm/components/new_contact_form.vue';
import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
import { getGroupContactsQueryResponse } from './mock_data';
jest.mock('~/flash');
describe('Customer relations contacts root app', () => {
Vue.use(VueApollo);
Vue.use(VueRouter);
let wrapper;
let fakeApollo;
let router;
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const findNewContactButton = () => wrapper.findByTestId('new-contact-button');
const findNewContactForm = () => wrapper.findComponent(NewContactForm);
const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
const basePath = '/groups/flightjs/-/crm/contacts';
const mountComponent = ({
queryHandler = successQueryHandler,
mountFunction = shallowMountExtended,
canAdminCrmContact = true,
} = {}) => {
fakeApollo = createMockApollo([[getGroupContactsQuery, queryHandler]]);
wrapper = mountFunction(ContactsRoot, {
provide: { groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
router,
provide: {
groupFullPath: 'flightjs',
groupIssuesPath: '/issues',
groupId: 26,
canAdminCrmContact,
},
apolloProvider: fakeApollo,
});
};
beforeEach(() => {
router = new VueRouter({
base: basePath,
mode: 'history',
routes: [],
});
});
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
router = null;
});
it('should render loading spinner', () => {
......@@ -43,11 +65,81 @@ describe('Customer relations contacts root app', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('should render error message on reject', async () => {
describe('new contact button', () => {
it('should exist when user has permission', () => {
mountComponent();
expect(findNewContactButton().exists()).toBe(true);
});
it('should not exist when user has no permission', () => {
mountComponent({ canAdminCrmContact: false });
expect(findNewContactButton().exists()).toBe(false);
});
});
describe('new contact form', () => {
it('should not exist by default', async () => {
mountComponent();
await waitForPromises();
expect(findNewContactForm().exists()).toBe(false);
});
it('should exist when user clicks new contact button', async () => {
mountComponent();
findNewContactButton().vm.$emit('click');
await waitForPromises();
expect(findNewContactForm().exists()).toBe(true);
});
it('should exist when user navigates directly to /new', async () => {
router.replace({ path: '/new' });
mountComponent();
await waitForPromises();
expect(findNewContactForm().exists()).toBe(true);
});
it('should not exist when form emits close', async () => {
router.replace({ path: '/new' });
mountComponent();
findNewContactForm().vm.$emit('close');
await waitForPromises();
expect(findNewContactForm().exists()).toBe(false);
});
});
describe('error', () => {
it('should exist on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
expect(findError().exists()).toBe(true);
});
it('should exist when new contact form emits error', async () => {
router.replace({ path: '/new' });
mountComponent();
findNewContactForm().vm.$emit('error');
await waitForPromises();
expect(findError().exists()).toBe(true);
});
});
describe('on successful load', () => {
it('should not render error', async () => {
mountComponent();
await waitForPromises();
expect(findError().exists()).toBe(false);
});
it('renders correct results', async () => {
......@@ -62,4 +154,5 @@ describe('Customer relations contacts root app', () => {
expect(issueLink.exists()).toBe(true);
expect(issueLink.attributes('href')).toBe('/issues?scope=all&state=opened&crm_contact_id=16');
});
});
});
......@@ -40,7 +40,6 @@ export const getGroupContactsQueryResponse = {
organization: null,
},
],
__typename: 'CustomerRelationsContactConnection',
},
},
},
......@@ -79,3 +78,31 @@ export const getGroupOrganizationsQueryResponse = {
},
},
};
export const createContactMutationResponse = {
data: {
customerRelationsContactCreate: {
__typeName: 'CustomerRelationsContactCreatePayload',
contact: {
__typename: 'CustomerRelationsContact',
id: 'gid://gitlab/CustomerRelations::Contact/1',
firstName: 'A',
lastName: 'B',
email: 'C',
phone: null,
description: null,
organization: null,
},
errors: [],
},
},
};
export const createContactMutationErrorResponse = {
data: {
customerRelationsContactCreate: {
contact: null,
errors: ['Phone is invalid.'],
},
},
};
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 NewContactForm from '~/crm/components/new_contact_form.vue';
import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql';
import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
import {
createContactMutationErrorResponse,
createContactMutationResponse,
getGroupContactsQueryResponse,
} from './mock_data';
describe('Customer relations contacts root app', () => {
Vue.use(VueApollo);
let wrapper;
let fakeApollo;
let queryHandler;
const findCreateNewContactButton = () => wrapper.findByTestId('create-new-contact-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findForm = () => wrapper.find('form');
const mountComponent = ({ mountFunction = shallowMountExtended } = {}) => {
fakeApollo = createMockApollo([[createContactMutation, queryHandler]]);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
data: getGroupContactsQueryResponse.data,
});
wrapper = mountFunction(NewContactForm, {
provide: { groupId: 26, groupFullPath: 'flightjs' },
apolloProvider: fakeApollo,
});
};
beforeEach(() => {
queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse);
});
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
describe('Create new contact button', () => {
it('should be disabled by default', () => {
mountComponent();
expect(findCreateNewContactButton().attributes('disabled')).toBeTruthy();
});
it('should not be disabled when first, last and email have values', async () => {
mountComponent();
wrapper.find('#contact-first-name').vm.$emit('input', 'A');
wrapper.find('#contact-last-name').vm.$emit('input', 'B');
wrapper.find('#contact-email').vm.$emit('input', 'C');
await waitForPromises();
expect(findCreateNewContactButton().attributes('disabled')).toBeFalsy();
});
});
it("should emit 'close' when cancel button is clicked", () => {
mountComponent();
findCancelButton().vm.$emit('click');
expect(wrapper.emitted().close).toBeTruthy();
});
describe('when query is successful', () => {
it("should emit 'close'", async () => {
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(wrapper.emitted().close).toBeTruthy();
});
});
describe('when query fails', () => {
it('should emit error on reject', async () => {
queryHandler = jest.fn().mockRejectedValue('ERROR');
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(wrapper.emitted().error).toBeTruthy();
});
it('should emit error on error response', async () => {
queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse);
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(wrapper.emitted().error[0][0]).toEqual(
createContactMutationErrorResponse.data.customerRelationsContactCreate.errors,
);
});
});
});
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