Commit c388c0d3 authored by James Fargher's avatar James Fargher

Merge branch '2256-add-feat-edit-crm-contact' into 'master'

Add edit CRM contact UI

See merge request gitlab-org/gitlab!76610
parents 921fd6ab 3b4adf96
...@@ -4,7 +4,8 @@ import { produce } from 'immer'; ...@@ -4,7 +4,8 @@ import { produce } from 'immer';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_GROUP } from '~/graphql_shared/constants'; import { TYPE_GROUP } from '~/graphql_shared/constants';
import createContact from './queries/create_contact.mutation.graphql'; import createContactMutation from './queries/create_contact.mutation.graphql';
import updateContactMutation from './queries/update_contact.mutation.graphql';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
export default { export default {
...@@ -21,6 +22,11 @@ export default { ...@@ -21,6 +22,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
contact: {
type: Object,
required: false,
default: () => {},
},
}, },
data() { data() {
return { return {
...@@ -35,66 +41,111 @@ export default { ...@@ -35,66 +41,111 @@ export default {
}, },
computed: { computed: {
invalid() { invalid() {
return this.firstName === '' || this.lastName === '' || this.email === ''; const { firstName, lastName, email } = this;
return firstName.trim() === '' || lastName.trim() === '' || email.trim() === '';
},
editMode() {
return Boolean(this.contact);
},
title() {
return this.editMode ? this.$options.i18n.editTitle : this.$options.i18n.newTitle;
},
buttonLabel() {
return this.editMode
? this.$options.i18n.editButtonLabel
: this.$options.i18n.createButtonLabel;
},
mutation() {
return this.editMode ? updateContactMutation : createContactMutation;
},
variables() {
const { contact, firstName, lastName, phone, email, description, editMode, groupId } = this;
const variables = {
input: {
firstName,
lastName,
phone,
email,
description,
}, },
};
if (editMode) {
variables.input.id = contact.id;
} else {
variables.input.groupId = convertToGraphQLId(TYPE_GROUP, groupId);
}
return variables;
},
},
mounted() {
if (this.editMode) {
const { contact } = this;
this.firstName = contact.firstName || '';
this.lastName = contact.lastName || '';
this.phone = contact.phone || '';
this.email = contact.email || '';
this.description = contact.description || '';
}
}, },
methods: { methods: {
save() { save() {
const { mutation, variables, updateCache, close } = this;
this.submitting = true; this.submitting = true;
return this.$apollo return this.$apollo
.mutate({ .mutate({
mutation: createContact, mutation,
variables: { variables,
input: { update: updateCache,
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 }) => { .then(({ data }) => {
if (data.customerRelationsContactCreate.errors.length === 0) this.close(true); if (
data.customerRelationsContactCreate?.errors.length === 0 ||
data.customerRelationsContactUpdate?.errors.length === 0
) {
close(true);
}
this.submitting = false; this.submitting = false;
}) })
.catch(() => { .catch(() => {
this.errorMessages = [__('Something went wrong. Please try again.')]; this.errorMessages = [this.$options.i18n.somethingWentWrong];
this.submitting = false; this.submitting = false;
}); });
}, },
close(success) { close(success) {
this.$emit('close', success); this.$emit('close', success);
}, },
updateCache(store, { data: { customerRelationsContactCreate } }) { updateCache(store, { data }) {
if (customerRelationsContactCreate.errors.length > 0) { const mutationData =
this.errorMessages = customerRelationsContactCreate.errors; data.customerRelationsContactCreate || data.customerRelationsContactUpdate;
if (mutationData?.errors.length > 0) {
this.errorMessages = mutationData.errors;
return; return;
} }
const variables = { const queryArgs = {
groupFullPath: this.groupFullPath,
};
const sourceData = store.readQuery({
query: getGroupContactsQuery, query: getGroupContactsQuery,
variables, variables: { groupFullPath: this.groupFullPath },
}); };
const data = produce(sourceData, (draftState) => { const sourceData = store.readQuery(queryArgs);
queryArgs.data = produce(sourceData, (draftState) => {
draftState.group.contacts.nodes = [ draftState.group.contacts.nodes = [
...sourceData.group.contacts.nodes, ...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id),
customerRelationsContactCreate.contact, mutationData.contact,
]; ];
}); });
store.writeQuery({ store.writeQuery(queryArgs);
query: getGroupContactsQuery,
variables,
data,
});
}, },
getDrawerHeaderHeight() { getDrawerHeaderHeight() {
const wrapperEl = document.querySelector('.content-wrapper'); const wrapperEl = document.querySelector('.content-wrapper');
...@@ -107,14 +158,17 @@ export default { ...@@ -107,14 +158,17 @@ export default {
}, },
}, },
i18n: { i18n: {
buttonLabel: s__('Crm|Create new contact'), createButtonLabel: s__('Crm|Create new contact'),
editButtonLabel: __('Save changes'),
cancel: __('Cancel'), cancel: __('Cancel'),
firstName: s__('Crm|First name'), firstName: s__('Crm|First name'),
lastName: s__('Crm|Last name'), lastName: s__('Crm|Last name'),
email: s__('Crm|Email'), email: s__('Crm|Email'),
phone: s__('Crm|Phone number (optional)'), phone: s__('Crm|Phone number (optional)'),
description: s__('Crm|Description (optional)'), description: s__('Crm|Description (optional)'),
title: s__('Crm|New Contact'), newTitle: s__('Crm|New contact'),
editTitle: s__('Crm|Edit contact'),
somethingWentWrong: __('Something went wrong. Please try again.'),
}, },
}; };
</script> </script>
...@@ -127,7 +181,7 @@ export default { ...@@ -127,7 +181,7 @@ export default {
@close="close(false)" @close="close(false)"
> >
<template #title> <template #title>
<h4>{{ $options.i18n.title }}</h4> <h3>{{ title }}</h3>
</template> </template>
<gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
<ul class="gl-mb-0! gl-ml-5"> <ul class="gl-mb-0! gl-ml-5">
...@@ -160,9 +214,9 @@ export default { ...@@ -160,9 +214,9 @@ export default {
variant="confirm" variant="confirm"
:disabled="invalid" :disabled="invalid"
:loading="submitting" :loading="submitting"
data-testid="create-new-contact-button" data-testid="save-contact-button"
type="submit" type="submit"
>{{ $options.i18n.buttonLabel }}</gl-button >{{ buttonLabel }}</gl-button
> >
</span> </span>
</form> </form>
......
...@@ -2,9 +2,11 @@ ...@@ -2,9 +2,11 @@
import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants';
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
import NewContactForm from './new_contact_form.vue'; import ContactForm from './contact_form.vue';
export default { export default {
components: { components: {
...@@ -12,7 +14,7 @@ export default { ...@@ -12,7 +14,7 @@ export default {
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
NewContactForm, ContactForm,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -47,11 +49,19 @@ export default { ...@@ -47,11 +49,19 @@ export default {
return this.$apollo.queries.contacts.loading; return this.$apollo.queries.contacts.loading;
}, },
showNewForm() { showNewForm() {
return this.$route.path.startsWith('/new'); return this.$route.name === NEW_ROUTE_NAME;
}, },
canCreateNew() { showEditForm() {
return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME;
},
canAdmin() {
return parseBoolean(this.canAdminCrmContact); return parseBoolean(this.canAdminCrmContact);
}, },
editingContact() {
return this.contacts.find(
(contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id),
);
},
}, },
methods: { methods: {
extractContacts(data) { extractContacts(data) {
...@@ -61,16 +71,28 @@ export default { ...@@ -61,16 +71,28 @@ export default {
displayNewForm() { displayNewForm() {
if (this.showNewForm) return; if (this.showNewForm) return;
this.$router.push({ path: '/new' }); this.$router.push({ name: NEW_ROUTE_NAME });
}, },
hideNewForm(success) { hideNewForm(success) {
if (success) this.$toast.show(s__('Crm|Contact has been added')); if (success) this.$toast.show(s__('Crm|Contact has been added'));
this.$router.replace({ path: '/' }); this.$router.replace({ name: INDEX_ROUTE_NAME });
},
hideEditForm(success) {
if (success) this.$toast.show(s__('Crm|Contact has been updated'));
this.editingContactId = 0;
this.$router.replace({ name: INDEX_ROUTE_NAME });
}, },
getIssuesPath(path, value) { getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_contact_id=${value}`; return `${path}?scope=all&state=opened&crm_contact_id=${value}`;
}, },
edit(value) {
if (this.showEditForm) return;
this.editingContactId = value;
this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } });
},
}, },
fields: [ fields: [
{ key: 'firstName', sortable: true }, { key: 'firstName', sortable: true },
...@@ -87,7 +109,7 @@ export default { ...@@ -87,7 +109,7 @@ export default {
}, },
{ {
key: 'id', key: 'id',
label: __('Issues'), label: '',
formatter: (id) => { formatter: (id) => {
return getIdFromGraphQLId(id); return getIdFromGraphQLId(id);
}, },
...@@ -96,6 +118,7 @@ export default { ...@@ -96,6 +118,7 @@ export default {
i18n: { i18n: {
emptyText: s__('Crm|No contacts found'), emptyText: s__('Crm|No contacts found'),
issuesButtonLabel: __('View issues'), issuesButtonLabel: __('View issues'),
editButtonLabel: __('Edit'),
title: s__('Crm|Customer Relations Contacts'), title: s__('Crm|Customer Relations Contacts'),
newContact: s__('Crm|New contact'), newContact: s__('Crm|New contact'),
errorText: __('Something went wrong. Please try again.'), errorText: __('Something went wrong. Please try again.'),
...@@ -116,7 +139,7 @@ export default { ...@@ -116,7 +139,7 @@ export default {
</h2> </h2>
<div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"> <div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end">
<gl-button <gl-button
v-if="canCreateNew" v-if="canAdmin"
variant="confirm" variant="confirm"
data-testid="new-contact-button" data-testid="new-contact-button"
@click="displayNewForm" @click="displayNewForm"
...@@ -125,7 +148,13 @@ export default { ...@@ -125,7 +148,13 @@ export default {
</gl-button> </gl-button>
</div> </div>
</div> </div>
<new-contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> <contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" />
<contact-form
v-if="showEditForm"
:contact="editingContact"
:drawer-open="showEditForm"
@close="hideEditForm"
/>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table <gl-table
v-else v-else
...@@ -138,11 +167,20 @@ export default { ...@@ -138,11 +167,20 @@ export default {
<template #cell(id)="data"> <template #cell(id)="data">
<gl-button <gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
class="gl-mr-3"
data-testid="issues-link" data-testid="issues-link"
icon="issues" icon="issues"
:aria-label="$options.i18n.issuesButtonLabel" :aria-label="$options.i18n.issuesButtonLabel"
:href="getIssuesPath(groupIssuesPath, data.value)" :href="getIssuesPath(groupIssuesPath, data.value)"
/> />
<gl-button
v-if="canAdmin"
v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
data-testid="edit-contact-button"
icon="pencil"
:aria-label="$options.i18n.editButtonLabel"
@click="edit(data.value)"
/>
</template> </template>
</gl-table> </gl-table>
</div> </div>
......
#import "./crm_contact_fields.fragment.graphql"
mutation updateContact($input: CustomerRelationsContactUpdateInput!) {
customerRelationsContactUpdate(input: $input) {
contact {
...ContactFragment
}
errors
}
}
export const INDEX_ROUTE_NAME = 'index';
export const NEW_ROUTE_NAME = 'new';
export const EDIT_ROUTE_NAME = 'edit';
...@@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo'; ...@@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import CrmContactsRoot from './components/contacts_root.vue'; import CrmContactsRoot from './components/contacts_root.vue';
import routes from './routes';
Vue.use(VueApollo); Vue.use(VueApollo);
Vue.use(VueRouter); Vue.use(VueRouter);
...@@ -25,14 +26,7 @@ export default () => { ...@@ -25,14 +26,7 @@ export default () => {
const router = new VueRouter({ const router = new VueRouter({
base: basePath, base: basePath,
mode: 'history', mode: 'history',
routes: [ routes,
{
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Contacts List',
path: '/',
component: CrmContactsRoot,
},
],
}); });
return new Vue({ return new Vue({
......
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants';
import CrmContactsRoot from './components/contacts_root.vue';
export default [
{
name: INDEX_ROUTE_NAME,
path: '/',
component: CrmContactsRoot,
},
{
name: NEW_ROUTE_NAME,
path: '/new',
component: CrmContactsRoot,
},
{
name: EDIT_ROUTE_NAME,
path: '/:id/edit',
component: CrmContactsRoot,
},
];
export const MINIMUM_SEARCH_LENGTH = 3; export const MINIMUM_SEARCH_LENGTH = 3;
export const TYPE_CI_RUNNER = 'Ci::Runner'; export const TYPE_CI_RUNNER = 'Ci::Runner';
export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_EPIC = 'Epic'; export const TYPE_EPIC = 'Epic';
export const TYPE_GROUP = 'Group'; export const TYPE_GROUP = 'Group';
export const TYPE_ISSUE = 'Issue'; export const TYPE_ISSUE = 'Issue';
...@@ -8,11 +10,10 @@ export const TYPE_ITERATION = 'Iteration'; ...@@ -8,11 +10,10 @@ export const TYPE_ITERATION = 'Iteration';
export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence';
export const TYPE_MERGE_REQUEST = 'MergeRequest'; export const TYPE_MERGE_REQUEST = 'MergeRequest';
export const TYPE_MILESTONE = 'Milestone'; export const TYPE_MILESTONE = 'Milestone';
export const TYPE_NOTE = 'Note';
export const TYPE_PACKAGES_PACKAGE = 'Packages::Package';
export const TYPE_PROJECT = 'Project'; export const TYPE_PROJECT = 'Project';
export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile';
export const TYPE_USER = 'User'; export const TYPE_USER = 'User';
export const TYPE_VULNERABILITY = 'Vulnerability'; export const TYPE_VULNERABILITY = 'Vulnerability';
export const TYPE_NOTE = 'Note';
export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_PACKAGES_PACKAGE = 'Packages::Package';
...@@ -9,6 +9,10 @@ class Groups::Crm::ContactsController < Groups::ApplicationController ...@@ -9,6 +9,10 @@ class Groups::Crm::ContactsController < Groups::ApplicationController
render action: "index" render action: "index"
end end
def edit
render action: "index"
end
private private
def authorize_read_crm_contact! def authorize_read_crm_contact!
......
...@@ -127,7 +127,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -127,7 +127,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end end
namespace :crm do namespace :crm do
resources :contacts, only: [:index, :new] resources :contacts, only: [:index, :new, :edit]
resources :organizations, only: [:index] resources :organizations, only: [:index]
end end
end end
......
...@@ -10282,6 +10282,9 @@ msgstr "" ...@@ -10282,6 +10282,9 @@ msgstr ""
msgid "Crm|Contact has been added" msgid "Crm|Contact has been added"
msgstr "" msgstr ""
msgid "Crm|Contact has been updated"
msgstr ""
msgid "Crm|Create new contact" msgid "Crm|Create new contact"
msgstr "" msgstr ""
...@@ -10291,6 +10294,9 @@ msgstr "" ...@@ -10291,6 +10294,9 @@ msgstr ""
msgid "Crm|Description (optional)" msgid "Crm|Description (optional)"
msgstr "" msgstr ""
msgid "Crm|Edit contact"
msgstr ""
msgid "Crm|Email" msgid "Crm|Email"
msgstr "" msgstr ""
...@@ -10300,9 +10306,6 @@ msgstr "" ...@@ -10300,9 +10306,6 @@ msgstr ""
msgid "Crm|Last name" msgid "Crm|Last name"
msgstr "" msgstr ""
msgid "Crm|New Contact"
msgstr ""
msgid "Crm|New contact" msgid "Crm|New contact"
msgstr "" msgstr ""
......
...@@ -4,41 +4,49 @@ import VueApollo from 'vue-apollo'; ...@@ -4,41 +4,49 @@ import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import NewContactForm from '~/crm/components/new_contact_form.vue'; import ContactForm from '~/crm/components/contact_form.vue';
import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql';
import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql';
import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
import { import {
createContactMutationErrorResponse, createContactMutationErrorResponse,
createContactMutationResponse, createContactMutationResponse,
getGroupContactsQueryResponse, getGroupContactsQueryResponse,
updateContactMutationErrorResponse,
updateContactMutationResponse,
} from './mock_data'; } from './mock_data';
describe('Customer relations contacts root app', () => { describe('Customer relations contact form component', () => {
Vue.use(VueApollo); Vue.use(VueApollo);
let wrapper; let wrapper;
let fakeApollo; let fakeApollo;
let mutation;
let queryHandler; let queryHandler;
const findCreateNewContactButton = () => wrapper.findByTestId('create-new-contact-button'); const findSaveContactButton = () => wrapper.findByTestId('save-contact-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button'); const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findForm = () => wrapper.find('form'); const findForm = () => wrapper.find('form');
const findError = () => wrapper.findComponent(GlAlert); const findError = () => wrapper.findComponent(GlAlert);
const mountComponent = ({ mountFunction = shallowMountExtended } = {}) => { const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => {
fakeApollo = createMockApollo([[createContactMutation, queryHandler]]); fakeApollo = createMockApollo([[mutation, queryHandler]]);
fakeApollo.clients.defaultClient.cache.writeQuery({ fakeApollo.clients.defaultClient.cache.writeQuery({
query: getGroupContactsQuery, query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' }, variables: { groupFullPath: 'flightjs' },
data: getGroupContactsQueryResponse.data, data: getGroupContactsQueryResponse.data,
}); });
wrapper = mountFunction(NewContactForm, { const propsData = { drawerOpen: true };
if (editForm)
propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' };
wrapper = mountFunction(ContactForm, {
provide: { groupId: 26, groupFullPath: 'flightjs' }, provide: { groupId: 26, groupFullPath: 'flightjs' },
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
propsData: { drawerOpen: true }, propsData,
}); });
}; };
beforeEach(() => { beforeEach(() => {
mutation = createContactMutation;
queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse); queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse);
}); });
...@@ -47,14 +55,14 @@ describe('Customer relations contacts root app', () => { ...@@ -47,14 +55,14 @@ describe('Customer relations contacts root app', () => {
fakeApollo = null; fakeApollo = null;
}); });
describe('Create new contact button', () => { describe('Save contact button', () => {
it('should be disabled by default', () => { it('should be disabled when required fields are empty', () => {
mountComponent(); mountComponent();
expect(findCreateNewContactButton().attributes('disabled')).toBeTruthy(); expect(findSaveContactButton().props('disabled')).toBe(true);
}); });
it('should not be disabled when first, last and email have values', async () => { it('should not be disabled when required fields have values', async () => {
mountComponent(); mountComponent();
wrapper.find('#contact-first-name').vm.$emit('input', 'A'); wrapper.find('#contact-first-name').vm.$emit('input', 'A');
...@@ -62,7 +70,7 @@ describe('Customer relations contacts root app', () => { ...@@ -62,7 +70,7 @@ describe('Customer relations contacts root app', () => {
wrapper.find('#contact-email').vm.$emit('input', 'C'); wrapper.find('#contact-email').vm.$emit('input', 'C');
await waitForPromises(); await waitForPromises();
expect(findCreateNewContactButton().attributes('disabled')).toBeFalsy(); expect(findSaveContactButton().props('disabled')).toBe(false);
}); });
}); });
...@@ -74,7 +82,7 @@ describe('Customer relations contacts root app', () => { ...@@ -74,7 +82,7 @@ describe('Customer relations contacts root app', () => {
expect(wrapper.emitted().close).toBeTruthy(); expect(wrapper.emitted().close).toBeTruthy();
}); });
describe('when query is successful', () => { describe('when create mutation is successful', () => {
it("should emit 'close'", async () => { it("should emit 'close'", async () => {
mountComponent(); mountComponent();
...@@ -85,7 +93,7 @@ describe('Customer relations contacts root app', () => { ...@@ -85,7 +93,7 @@ describe('Customer relations contacts root app', () => {
}); });
}); });
describe('when query fails', () => { describe('when create mutation fails', () => {
it('should show error on reject', async () => { it('should show error on reject', async () => {
queryHandler = jest.fn().mockRejectedValue('ERROR'); queryHandler = jest.fn().mockRejectedValue('ERROR');
mountComponent(); mountComponent();
...@@ -107,4 +115,43 @@ describe('Customer relations contacts root app', () => { ...@@ -107,4 +115,43 @@ describe('Customer relations contacts root app', () => {
expect(findError().text()).toBe('Phone is invalid.'); expect(findError().text()).toBe('Phone is invalid.');
}); });
}); });
describe('when update mutation is successful', () => {
it("should emit 'close'", async () => {
mutation = updateContactMutation;
queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse);
mountComponent({ editForm: true });
findForm().trigger('submit');
await waitForPromises();
expect(wrapper.emitted().close).toBeTruthy();
});
});
describe('when update mutation fails', () => {
beforeEach(() => {
mutation = updateContactMutation;
});
it('should show error on reject', async () => {
queryHandler = jest.fn().mockRejectedValue('ERROR');
mountComponent({ editForm: true });
findForm().trigger('submit');
await waitForPromises();
expect(findError().exists()).toBe(true);
});
it('should show error on error response', async () => {
queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse);
mountComponent({ editForm: true });
findForm().trigger('submit');
await waitForPromises();
expect(findError().exists()).toBe(true);
expect(findError().text()).toBe('Email is invalid.');
});
});
}); });
...@@ -6,8 +6,10 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help ...@@ -6,8 +6,10 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import ContactsRoot from '~/crm/components/contacts_root.vue'; import ContactsRoot from '~/crm/components/contacts_root.vue';
import NewContactForm from '~/crm/components/new_contact_form.vue'; import ContactForm from '~/crm/components/contact_form.vue';
import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants';
import routes from '~/crm/routes';
import { getGroupContactsQueryResponse } from './mock_data'; import { getGroupContactsQueryResponse } from './mock_data';
describe('Customer relations contacts root app', () => { describe('Customer relations contacts root app', () => {
...@@ -21,7 +23,8 @@ describe('Customer relations contacts root app', () => { ...@@ -21,7 +23,8 @@ describe('Customer relations contacts root app', () => {
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const findNewContactButton = () => wrapper.findByTestId('new-contact-button'); const findNewContactButton = () => wrapper.findByTestId('new-contact-button');
const findNewContactForm = () => wrapper.findComponent(NewContactForm); const findEditContactButton = () => wrapper.findByTestId('edit-contact-button');
const findContactForm = () => wrapper.findComponent(ContactForm);
const findError = () => wrapper.findComponent(GlAlert); const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse); const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
...@@ -49,7 +52,7 @@ describe('Customer relations contacts root app', () => { ...@@ -49,7 +52,7 @@ describe('Customer relations contacts root app', () => {
router = new VueRouter({ router = new VueRouter({
base: basePath, base: basePath,
mode: 'history', mode: 'history',
routes: [], routes,
}); });
}); });
...@@ -79,12 +82,12 @@ describe('Customer relations contacts root app', () => { ...@@ -79,12 +82,12 @@ describe('Customer relations contacts root app', () => {
}); });
}); });
describe('new contact form', () => { describe('contact form', () => {
it('should not exist by default', async () => { it('should not exist by default', async () => {
mountComponent(); mountComponent();
await waitForPromises(); await waitForPromises();
expect(findNewContactForm().exists()).toBe(false); expect(findContactForm().exists()).toBe(false);
}); });
it('should exist when user clicks new contact button', async () => { it('should exist when user clicks new contact button', async () => {
...@@ -93,25 +96,54 @@ describe('Customer relations contacts root app', () => { ...@@ -93,25 +96,54 @@ describe('Customer relations contacts root app', () => {
findNewContactButton().vm.$emit('click'); findNewContactButton().vm.$emit('click');
await waitForPromises(); await waitForPromises();
expect(findNewContactForm().exists()).toBe(true); expect(findContactForm().exists()).toBe(true);
}); });
it('should exist when user navigates directly to /new', async () => { it('should exist when user navigates directly to `new` route', async () => {
router.replace({ path: '/new' }); router.replace({ name: NEW_ROUTE_NAME });
mountComponent(); mountComponent();
await waitForPromises(); await waitForPromises();
expect(findNewContactForm().exists()).toBe(true); expect(findContactForm().exists()).toBe(true);
}); });
it('should not exist when form emits close', async () => { it('should exist when user clicks edit contact button', async () => {
router.replace({ path: '/new' }); mountComponent({ mountFunction: mountExtended });
await waitForPromises();
findEditContactButton().vm.$emit('click');
await waitForPromises();
expect(findContactForm().exists()).toBe(true);
});
it('should exist when user navigates directly to `edit` route', async () => {
router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
mountComponent(); mountComponent();
await waitForPromises();
expect(findContactForm().exists()).toBe(true);
});
it('should not exist when new form emits close', async () => {
router.replace({ name: NEW_ROUTE_NAME });
mountComponent();
findContactForm().vm.$emit('close');
await waitForPromises();
expect(findContactForm().exists()).toBe(false);
});
it('should not exist when edit form emits close', async () => {
router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
mountComponent();
await waitForPromises();
findNewContactForm().vm.$emit('close'); findContactForm().vm.$emit('close');
await waitForPromises(); await waitForPromises();
expect(findNewContactForm().exists()).toBe(false); expect(findContactForm().exists()).toBe(false);
}); });
}); });
......
...@@ -106,3 +106,31 @@ export const createContactMutationErrorResponse = { ...@@ -106,3 +106,31 @@ export const createContactMutationErrorResponse = {
}, },
}, },
}; };
export const updateContactMutationResponse = {
data: {
customerRelationsContactUpdate: {
__typeName: 'CustomerRelationsContactCreatePayload',
contact: {
__typename: 'CustomerRelationsContact',
id: 'gid://gitlab/CustomerRelations::Contact/1',
firstName: 'First',
lastName: 'Last',
email: 'email@example.com',
phone: null,
description: null,
organization: null,
},
errors: [],
},
},
};
export const updateContactMutationErrorResponse = {
data: {
customerRelationsContactUpdate: {
contact: null,
errors: ['Email is invalid.'],
},
},
};
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::Crm::ContactsController do
let_it_be(:user) { create(:user) }
shared_examples 'response with 404 status' do
it 'returns 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
shared_examples 'ok response with index template' do
it 'renders the index template' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
end
end
shared_examples 'ok response with index template if authorized' do
context 'private group' do
let(:group) { create(:group, :private) }
context 'with authorized user' do
before do
group.add_reporter(user)
sign_in(user)
end
context 'when feature flag is enabled' do
it_behaves_like 'ok response with index template'
end
context 'when feature flag is not enabled' do
before do
stub_feature_flags(customer_relations: false)
end
it_behaves_like 'response with 404 status'
end
end
context 'with unauthorized user' do
before do
sign_in(user)
end
it_behaves_like 'response with 404 status'
end
context 'with anonymous user' do
it 'blah' do
subject
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to(new_user_session_path)
end
end
end
context 'public group' do
let(:group) { create(:group, :public) }
context 'with anonymous user' do
it_behaves_like 'ok response with index template'
end
end
end
describe 'GET #index' do
subject do
get group_crm_contacts_path(group)
response
end
it_behaves_like 'ok response with index template if authorized'
end
describe 'GET #new' do
subject do
get new_group_crm_contact_path(group)
response
end
it_behaves_like 'ok response with index template if authorized'
end
describe 'GET #edit' do
subject do
get edit_group_crm_contact_path(group, id: 1)
response
end
it_behaves_like 'ok response with index template if authorized'
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