Commit 3042e325 authored by Peter Hegman's avatar Peter Hegman

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

Implement generic CRM form and add "edit crm organization" UI

See merge request gitlab-org/gitlab!76949
parents 67b5ee8e 62da0d43
<script>
import { GlAlert, GlButton, GlDrawer, 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 createContactMutation from './queries/create_contact.mutation.graphql';
import updateContactMutation from './queries/update_contact.mutation.graphql';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
export default {
components: {
GlAlert,
GlButton,
GlDrawer,
GlFormGroup,
GlFormInput,
},
inject: ['groupFullPath', 'groupId'],
props: {
drawerOpen: {
type: Boolean,
required: true,
},
contact: {
type: Object,
required: false,
default: () => {},
},
},
data() {
return {
firstName: '',
lastName: '',
phone: '',
email: '',
description: '',
submitting: false,
errorMessages: [],
};
},
computed: {
invalid() {
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: {
save() {
const { mutation, variables, updateCache, close } = this;
this.submitting = true;
return this.$apollo
.mutate({
mutation,
variables,
update: updateCache,
})
.then(({ data }) => {
if (
data.customerRelationsContactCreate?.errors.length === 0 ||
data.customerRelationsContactUpdate?.errors.length === 0
) {
close(true);
}
this.submitting = false;
})
.catch(() => {
this.errorMessages = [this.$options.i18n.somethingWentWrong];
this.submitting = false;
});
},
close(success) {
this.$emit('close', success);
},
updateCache(store, { data }) {
const mutationData =
data.customerRelationsContactCreate || data.customerRelationsContactUpdate;
if (mutationData?.errors.length > 0) {
this.errorMessages = mutationData.errors;
return;
}
const queryArgs = {
query: getGroupContactsQuery,
variables: { groupFullPath: this.groupFullPath },
};
const sourceData = store.readQuery(queryArgs);
queryArgs.data = produce(sourceData, (draftState) => {
draftState.group.contacts.nodes = [
...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id),
mutationData.contact,
];
});
store.writeQuery(queryArgs);
},
getDrawerHeaderHeight() {
const wrapperEl = document.querySelector('.content-wrapper');
if (wrapperEl) {
return `${wrapperEl.offsetTop}px`;
}
return '';
},
},
i18n: {
createButtonLabel: s__('Crm|Create new contact'),
editButtonLabel: __('Save changes'),
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)'),
newTitle: s__('Crm|New contact'),
editTitle: s__('Crm|Edit contact'),
somethingWentWrong: __('Something went wrong. Please try again.'),
},
};
</script>
<template>
<gl-drawer
class="gl-drawer-responsive"
:open="drawerOpen"
:header-height="getDrawerHeaderHeight()"
@close="close(false)"
>
<template #title>
<h3>{{ title }}</h3>
</template>
<gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
<ul class="gl-mb-0! gl-ml-5">
<li v-for="error in errorMessages" :key="error">
{{ error }}
</li>
</ul>
</gl-alert>
<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>
<span class="gl-float-right">
<gl-button data-testid="cancel-button" @click="close(false)">
{{ $options.i18n.cancel }}
</gl-button>
<gl-button
variant="confirm"
:disabled="invalid"
:loading="submitting"
data-testid="save-contact-button"
type="submit"
>{{ buttonLabel }}</gl-button
>
</span>
</form>
</gl-drawer>
</template>
...@@ -61,11 +61,6 @@ export default { ...@@ -61,11 +61,6 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
existingModel: {
type: Object,
required: false,
default: () => ({}),
},
additionalCreateParams: { additionalCreateParams: {
type: Object, type: Object,
required: false, required: false,
...@@ -76,25 +71,42 @@ export default { ...@@ -76,25 +71,42 @@ export default {
required: false, required: false,
default: () => MSG_SAVE_CHANGES, default: () => MSG_SAVE_CHANGES,
}, },
existingId: {
type: String,
required: false,
default: null,
},
}, },
data() { data() {
const initialModel = this.fields.reduce(
(map, field) =>
Object.assign(map, {
[field.name]: this.existingModel ? this.existingModel[field.name] : null,
}),
{},
);
return { return {
model: initialModel, model: null,
submitting: false, submitting: false,
errorMessages: [], errorMessages: [],
records: [],
loading: true,
}; };
}, },
apollo: {
records: {
query() {
return this.getQuery.query;
},
variables() {
return this.getQuery.variables;
},
update(data) {
this.records = getPropValueByPath(data, this.getQueryNodePath).nodes || [];
this.setInitialModel();
this.loading = false;
},
error() {
this.errorMessages = [MSG_ERROR];
},
},
},
computed: { computed: {
isEditMode() { isEditMode() {
return this.existingModel?.id; return this.existingId;
}, },
isInvalid() { isInvalid() {
const { fields, model } = this; const { fields, model } = this;
...@@ -115,13 +127,24 @@ export default { ...@@ -115,13 +127,24 @@ export default {
); );
if (isEditMode) { if (isEditMode) {
return { input: { id: this.existingModel.id, ...variables } }; return { input: { id: this.existingId, ...variables } };
} }
return { input: { ...additionalCreateParams, ...variables } }; return { input: { ...additionalCreateParams, ...variables } };
}, },
}, },
methods: { methods: {
setInitialModel() {
const existingModel = this.records.find(({ id }) => id === this.existingId);
this.model = this.fields.reduce(
(map, field) =>
Object.assign(map, {
[field.name]: !this.isEditMode || !existingModel ? null : existingModel[field.name],
}),
{},
);
},
formatValue(model, field) { formatValue(model, field) {
if (!isEmpty(model[field.name]) && field.input?.type === 'number') { if (!isEmpty(model[field.name]) && field.input?.type === 'number') {
return parseFloat(model[field.name]); return parseFloat(model[field.name]);
...@@ -173,7 +196,7 @@ export default { ...@@ -173,7 +196,7 @@ export default {
const sourceData = store.readQuery(getQuery); const sourceData = store.readQuery(getQuery);
const newData = produce(sourceData, (draftState) => { const newData = produce(sourceData, (draftState) => {
getPropValueByPath(draftState, getQueryNodePath).nodes.push(getFirstPropertyValue(result)); getPropValueByPath(draftState, getQueryNodePath).nodes.push(this.getPayload(result));
}); });
store.writeQuery({ store.writeQuery({
...@@ -185,6 +208,14 @@ export default { ...@@ -185,6 +208,14 @@ export default {
const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`; const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`;
return field.label + optionalSuffix; return field.label + optionalSuffix;
}, },
getPayload(data) {
if (!data) return null;
const keys = Object.keys(data);
if (keys[0] === '__typename') return data[keys[1]];
return data[keys[0]];
},
}, },
MSG_CANCEL, MSG_CANCEL,
INDEX_ROUTE_NAME, INDEX_ROUTE_NAME,
...@@ -192,7 +223,7 @@ export default { ...@@ -192,7 +223,7 @@ export default {
</script> </script>
<template> <template>
<mounting-portal mount-to="#js-crm-form-portal" append> <mounting-portal v-if="!loading" mount-to="#js-crm-form-portal" append>
<gl-drawer class="gl-drawer-responsive gl-absolute" :open="drawerOpen" @close="close(false)"> <gl-drawer class="gl-drawer-responsive gl-absolute" :open="drawerOpen" @close="close(false)">
<template #title> <template #title>
<h3>{{ title }}</h3> <h3>{{ title }}</h3>
......
<script>
import { GlAlert, GlButton, GlDrawer, 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 createOrganization from './queries/create_organization.mutation.graphql';
import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
export default {
components: {
GlAlert,
GlButton,
GlDrawer,
GlFormGroup,
GlFormInput,
},
inject: ['groupFullPath', 'groupId'],
props: {
drawerOpen: {
type: Boolean,
required: true,
},
},
data() {
return {
name: '',
defaultRate: null,
description: '',
submitting: false,
errorMessages: [],
};
},
computed: {
invalid() {
return this.name.trim() === '';
},
},
methods: {
save() {
this.submitting = true;
return this.$apollo
.mutate({
mutation: createOrganization,
variables: {
input: {
groupId: convertToGraphQLId(TYPE_GROUP, this.groupId),
name: this.name,
defaultRate: this.defaultRate ? parseFloat(this.defaultRate) : null,
description: this.description,
},
},
update: this.updateCache,
})
.then(({ data }) => {
if (data.customerRelationsOrganizationCreate.errors.length === 0) this.close(true);
this.submitting = false;
})
.catch(() => {
this.errorMessages = [this.$options.i18n.somethingWentWrong];
this.submitting = false;
});
},
close(success) {
this.$emit('close', success);
},
updateCache(store, { data: { customerRelationsOrganizationCreate } }) {
if (customerRelationsOrganizationCreate.errors.length > 0) {
this.errorMessages = customerRelationsOrganizationCreate.errors;
return;
}
const variables = {
groupFullPath: this.groupFullPath,
};
const sourceData = store.readQuery({
query: getGroupOrganizationsQuery,
variables,
});
const data = produce(sourceData, (draftState) => {
draftState.group.organizations.nodes = [
...sourceData.group.organizations.nodes,
customerRelationsOrganizationCreate.organization,
];
});
store.writeQuery({
query: getGroupOrganizationsQuery,
variables,
data,
});
},
getDrawerHeaderHeight() {
const wrapperEl = document.querySelector('.content-wrapper');
if (wrapperEl) {
return `${wrapperEl.offsetTop}px`;
}
return '';
},
},
i18n: {
buttonLabel: s__('Crm|Create organization'),
cancel: __('Cancel'),
name: __('Name'),
defaultRate: s__('Crm|Default rate (optional)'),
description: __('Description (optional)'),
title: s__('Crm|New Organization'),
somethingWentWrong: __('Something went wrong. Please try again.'),
},
};
</script>
<template>
<gl-drawer
class="gl-drawer-responsive"
:open="drawerOpen"
:header-height="getDrawerHeaderHeight()"
@close="close(false)"
>
<template #title>
<h4>{{ $options.i18n.title }}</h4>
</template>
<gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []">
<ul class="gl-mb-0! gl-ml-5">
<li v-for="error in errorMessages" :key="error">
{{ error }}
</li>
</ul>
</gl-alert>
<form @submit.prevent="save">
<gl-form-group :label="$options.i18n.name" label-for="organization-name">
<gl-form-input id="organization-name" v-model="name" />
</gl-form-group>
<gl-form-group :label="$options.i18n.defaultRate" label-for="organization-default-rate">
<gl-form-input
id="organization-default-rate"
v-model="defaultRate"
type="number"
step="0.01"
/>
</gl-form-group>
<gl-form-group :label="$options.i18n.description" label-for="organization-description">
<gl-form-input id="organization-description" v-model="description" />
</gl-form-group>
<span class="gl-float-right">
<gl-button data-testid="cancel-button" @click="close(false)">
{{ $options.i18n.cancel }}
</gl-button>
<gl-button
variant="confirm"
:disabled="invalid"
:loading="submitting"
data-testid="create-new-organization-button"
type="submit"
>{{ $options.i18n.buttonLabel }}</gl-button
>
</span>
</form>
</gl-drawer>
</template>
<script>
import { s__, __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants';
import ContactForm from '../../components/form.vue';
import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
import createContactMutation from './graphql/create_contact.mutation.graphql';
import updateContactMutation from './graphql/update_contact.mutation.graphql';
export default {
components: {
ContactForm,
},
inject: ['groupFullPath', 'groupId'],
props: {
isEditMode: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
contactGraphQLId() {
if (!this.isEditMode) return null;
return convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id);
},
groupGraphQLId() {
return convertToGraphQLId(TYPE_GROUP, this.groupId);
},
mutation() {
if (this.isEditMode) return updateContactMutation;
return createContactMutation;
},
getQuery() {
return {
query: getGroupContactsQuery,
variables: { groupFullPath: this.groupFullPath },
};
},
title() {
if (this.isEditMode) return s__('Crm|Edit contact');
return s__('Crm|New contact');
},
successMessage() {
if (this.isEditMode) return s__('Crm|Contact has been updated.');
return s__('Crm|Contact has been added.');
},
additionalCreateParams() {
return { groupId: this.groupGraphQLId };
},
},
fields: [
{ name: 'firstName', label: __('First name'), required: true },
{ name: 'lastName', label: __('Last name'), required: true },
{ name: 'email', label: __('Email'), required: true },
{ name: 'phone', label: __('Phone') },
{ name: 'description', label: __('Description') },
],
};
</script>
<template>
<contact-form
:drawer-open="true"
:get-query="getQuery"
get-query-node-path="group.contacts"
:mutation="mutation"
:additional-create-params="additionalCreateParams"
:existing-id="contactGraphQLId"
:fields="$options.fields"
:title="title"
:success-message="successMessage"
/>
</template>
...@@ -2,11 +2,9 @@ ...@@ -2,11 +2,9 @@
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 { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants'; import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants'; import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
import getGroupContactsQuery from './queries/get_group_contacts.query.graphql';
import ContactForm from './contact_form.vue';
export default { export default {
components: { components: {
...@@ -14,12 +12,11 @@ export default { ...@@ -14,12 +12,11 @@ export default {
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
ContactForm,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'], inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath'],
data() { data() {
return { return {
contacts: [], contacts: [],
...@@ -48,50 +45,20 @@ export default { ...@@ -48,50 +45,20 @@ export default {
isLoading() { isLoading() {
return this.$apollo.queries.contacts.loading; return this.$apollo.queries.contacts.loading;
}, },
showNewForm() {
return this.$route.name === NEW_ROUTE_NAME;
},
showEditForm() {
return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME;
},
canAdmin() { 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) {
const contacts = data?.group?.contacts?.nodes || []; const contacts = data?.group?.contacts?.nodes || [];
return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName)); return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
}, },
displayNewForm() {
if (this.showNewForm) return;
this.$router.push({ name: NEW_ROUTE_NAME });
},
hideNewForm(success) {
if (success) this.$toast.show(s__('Crm|Contact has been added'));
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) { getEditRoute(id) {
if (this.showEditForm) return; return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
this.editingContactId = value;
this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } });
}, },
}, },
fields: [ fields: [
...@@ -119,10 +86,12 @@ export default { ...@@ -119,10 +86,12 @@ export default {
emptyText: s__('Crm|No contacts found'), emptyText: s__('Crm|No contacts found'),
issuesButtonLabel: __('View issues'), issuesButtonLabel: __('View issues'),
editButtonLabel: __('Edit'), 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.'),
}, },
EDIT_ROUTE_NAME,
NEW_ROUTE_NAME,
}; };
</script> </script>
...@@ -137,24 +106,15 @@ export default { ...@@ -137,24 +106,15 @@ export default {
<h2 class="gl-font-size-h2 gl-my-0"> <h2 class="gl-font-size-h2 gl-my-0">
{{ $options.i18n.title }} {{ $options.i18n.title }}
</h2> </h2>
<div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"> <div v-if="canAdmin">
<gl-button <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
v-if="canAdmin" <gl-button variant="confirm" data-testid="new-contact-button">
variant="confirm" {{ $options.i18n.newContact }}
data-testid="new-contact-button" </gl-button>
@click="displayNewForm" </router-link>
>
{{ $options.i18n.newContact }}
</gl-button>
</div> </div>
</div> </div>
<contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> <router-view />
<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
...@@ -164,23 +124,24 @@ export default { ...@@ -164,23 +124,24 @@ export default {
:empty-text="$options.i18n.emptyText" :empty-text="$options.i18n.emptyText"
show-empty show-empty
> >
<template #cell(id)="data"> <template #cell(id)="{ value: id }">
<gl-button <gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
class="gl-mr-3" 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, id)"
/>
<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)"
/> />
<router-link :to="getEditRoute(id)">
<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"
/>
</router-link>
</template> </template>
</gl-table> </gl-table>
</div> </div>
......
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants'; import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants';
import ContactFormWrapper from './components/contact_form_wrapper.vue';
export default [ export default [
{ {
...@@ -8,9 +9,12 @@ export default [ ...@@ -8,9 +9,12 @@ export default [
{ {
name: NEW_ROUTE_NAME, name: NEW_ROUTE_NAME,
path: '/new', path: '/new',
component: ContactFormWrapper,
}, },
{ {
name: EDIT_ROUTE_NAME, name: EDIT_ROUTE_NAME,
path: '/:id/edit', path: '/:id/edit',
component: ContactFormWrapper,
props: { isEditMode: true },
}, },
]; ];
#import "./crm_organization_fields.fragment.graphql"
mutation updateOrganization($input: CustomerRelationsOrganizationUpdateInput!) {
customerRelationsOrganizationUpdate(input: $input) {
organization {
...OrganizationFragment
}
errors
}
}
<script>
import { s__, __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants';
import OrganizationForm from '../../components/form.vue';
import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
import createOrganizationMutation from './graphql/create_organization.mutation.graphql';
import updateOrganizationMutation from './graphql/update_organization.mutation.graphql';
export default {
components: {
OrganizationForm,
},
inject: ['groupFullPath', 'groupId'],
props: {
isEditMode: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
organizationGraphQLId() {
if (!this.isEditMode) return null;
return convertToGraphQLId(TYPE_CRM_ORGANIZATION, this.$route.params.id);
},
groupGraphQLId() {
return convertToGraphQLId(TYPE_GROUP, this.groupId);
},
mutation() {
if (this.isEditMode) return updateOrganizationMutation;
return createOrganizationMutation;
},
getQuery() {
return {
query: getGroupOrganizationsQuery,
variables: { groupFullPath: this.groupFullPath },
};
},
title() {
if (this.isEditMode) return s__('Crm|Edit organization');
return s__('Crm|New organization');
},
successMessage() {
if (this.isEditMode) return s__('Crm|Organization has been updated.');
return s__('Crm|Organization has been added.');
},
additionalCreateParams() {
return { groupId: this.groupGraphQLId };
},
},
fields: [
{ name: 'name', label: __('Name'), required: true },
{
name: 'defaultRate',
label: s__('Crm|Default rate'),
input: { type: 'number', step: '0.01' },
},
{ name: 'description', label: __('Description') },
],
};
</script>
<template>
<organization-form
:drawer-open="true"
:get-query="getQuery"
get-query-node-path="group.organizations"
:mutation="mutation"
:additional-create-params="additionalCreateParams"
:existing-id="organizationGraphQLId"
:fields="$options.fields"
:title="title"
:success-message="successMessage"
/>
</template>
...@@ -3,9 +3,8 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@ ...@@ -3,9 +3,8 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@
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 { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants'; import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
import NewOrganizationForm from './new_organization_form.vue';
export default { export default {
components: { components: {
...@@ -13,7 +12,6 @@ export default { ...@@ -13,7 +12,6 @@ export default {
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
NewOrganizationForm,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -21,8 +19,8 @@ export default { ...@@ -21,8 +19,8 @@ export default {
inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'], inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'],
data() { data() {
return { return {
error: false,
organizations: [], organizations: [],
error: false,
}; };
}, },
apollo: { apollo: {
...@@ -47,10 +45,7 @@ export default { ...@@ -47,10 +45,7 @@ export default {
isLoading() { isLoading() {
return this.$apollo.queries.organizations.loading; return this.$apollo.queries.organizations.loading;
}, },
showNewForm() { canAdmin() {
return this.$route.name === NEW_ROUTE_NAME;
},
canCreateNew() {
return parseBoolean(this.canAdminCrmOrganization); return parseBoolean(this.canAdminCrmOrganization);
}, },
}, },
...@@ -62,15 +57,8 @@ export default { ...@@ -62,15 +57,8 @@ export default {
getIssuesPath(path, value) { getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_organization_id=${value}`; return `${path}?scope=all&state=opened&crm_organization_id=${value}`;
}, },
displayNewForm() { getEditRoute(id) {
if (this.showNewForm) return; return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
this.$router.push({ name: NEW_ROUTE_NAME });
},
hideNewForm(success) {
if (success) this.$toast.show(this.$options.i18n.organizationAdded);
this.$router.replace({ name: INDEX_ROUTE_NAME });
}, },
}, },
fields: [ fields: [
...@@ -79,7 +67,7 @@ export default { ...@@ -79,7 +67,7 @@ export default {
{ key: 'description', sortable: true }, { key: 'description', sortable: true },
{ {
key: 'id', key: 'id',
label: __('Issues'), label: '',
formatter: (id) => { formatter: (id) => {
return getIdFromGraphQLId(id); return getIdFromGraphQLId(id);
}, },
...@@ -88,11 +76,13 @@ export default { ...@@ -88,11 +76,13 @@ export default {
i18n: { i18n: {
emptyText: s__('Crm|No organizations found'), emptyText: s__('Crm|No organizations found'),
issuesButtonLabel: __('View issues'), issuesButtonLabel: __('View issues'),
title: s__('Crm|Customer Relations Organizations'), editButtonLabel: __('Edit'),
title: s__('Crm|Customer relations organizations'),
newOrganization: s__('Crm|New organization'), newOrganization: s__('Crm|New organization'),
errorText: __('Something went wrong. Please try again.'), errorText: __('Something went wrong. Please try again.'),
organizationAdded: s__('Crm|Organization has been added'),
}, },
EDIT_ROUTE_NAME,
NEW_ROUTE_NAME,
}; };
</script> </script>
...@@ -108,15 +98,17 @@ export default { ...@@ -108,15 +98,17 @@ export default {
{{ $options.i18n.title }} {{ $options.i18n.title }}
</h2> </h2>
<div <div
v-if="canCreateNew" v-if="canAdmin"
class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end" class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"
> >
<gl-button variant="confirm" data-testid="new-organization-button" @click="displayNewForm"> <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
{{ $options.i18n.newOrganization }} <gl-button variant="confirm" data-testid="new-organization-button">
</gl-button> {{ $options.i18n.newOrganization }}
</gl-button>
</router-link>
</div> </div>
</div> </div>
<new-organization-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> <router-view />
<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
...@@ -126,14 +118,24 @@ export default { ...@@ -126,14 +118,24 @@ export default {
:empty-text="$options.i18n.emptyText" :empty-text="$options.i18n.emptyText"
show-empty show-empty
> >
<template #cell(id)="data"> <template #cell(id)="{ value: id }">
<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, id)"
/> />
<router-link :to="getEditRoute(id)">
<gl-button
v-if="canAdmin"
v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
data-testid="edit-organization-button"
icon="pencil"
:aria-label="$options.i18n.editButtonLabel"
/>
</router-link>
</template> </template>
</gl-table> </gl-table>
</div> </div>
......
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants';
import OrganizationFormWrapper from './components/organization_form_wrapper.vue';
export default [
{
name: INDEX_ROUTE_NAME,
path: '/',
},
{
name: NEW_ROUTE_NAME,
path: '/new',
component: OrganizationFormWrapper,
},
{
name: EDIT_ROUTE_NAME,
path: '/:id/edit',
component: OrganizationFormWrapper,
props: { isEditMode: true },
},
];
...@@ -3,6 +3,7 @@ export const MINIMUM_SEARCH_LENGTH = 3; ...@@ -3,6 +3,7 @@ export const MINIMUM_SEARCH_LENGTH = 3;
export const TYPE_BOARD = 'Board'; export const TYPE_BOARD = 'Board';
export const TYPE_CI_RUNNER = 'Ci::Runner'; export const TYPE_CI_RUNNER = 'Ci::Runner';
export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
export const TYPE_CRM_ORGANIZATION = 'CustomerRelations::Organization';
export const TYPE_DISCUSSION = 'Discussion'; export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_EPIC = 'Epic'; export const TYPE_EPIC = 'Epic';
export const TYPE_EPIC_BOARD = 'Boards::EpicBoard'; export const TYPE_EPIC_BOARD = 'Boards::EpicBoard';
......
import initCrmContactsApp from '~/crm/contacts_bundle'; import initCrmContactsApp from '~/crm/contacts/bundle';
initCrmContactsApp(); initCrmContactsApp();
import initCrmOrganizationsApp from '~/crm/organizations_bundle'; import initCrmOrganizationsApp from '~/crm/organizations/bundle';
initCrmOrganizationsApp(); initCrmOrganizationsApp();
...@@ -10,6 +10,10 @@ class Groups::Crm::OrganizationsController < Groups::ApplicationController ...@@ -10,6 +10,10 @@ class Groups::Crm::OrganizationsController < Groups::ApplicationController
render action: "index" render action: "index"
end end
def edit
render action: "index"
end
private private
def authorize_read_crm_organization! def authorize_read_crm_organization!
......
- breadcrumb_title _('Customer Relations Contacts') - breadcrumb_title _('Customer relations contacts')
- page_title _('Customer Relations Contacts') - page_title _('Customer relations contacts')
- @content_wrapper_class = "gl-relative"
= content_for :after_content do
#js-crm-form-portal
#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) } } #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) } }
- breadcrumb_title _('Customer Relations Organizations') - breadcrumb_title _('Customer relations organizations')
- page_title _('Customer Relations Organizations') - page_title _('Customer relations organizations')
- @content_wrapper_class = "gl-relative"
= content_for :after_content do
#js-crm-form-portal
#js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } } #js-crm-organizations-app{ data: { base_path: group_crm_organizations_path(@group), can_admin_crm_organization: can?(current_user, :admin_crm_organization, @group).to_s, group_full_path: @group.full_path, group_id: @group.id, group_issues_path: issues_group_path(@group) } }
...@@ -135,7 +135,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -135,7 +135,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
namespace :crm do namespace :crm do
resources :contacts, only: [:index, :new, :edit] resources :contacts, only: [:index, :new, :edit]
resources :organizations, only: [:index, :new] resources :organizations, only: [:index, :new, :edit]
end end
end end
......
...@@ -49,7 +49,7 @@ To view a group's contacts: ...@@ -49,7 +49,7 @@ To view a group's contacts:
1. On the top bar, select **Menu > Groups** and find your group. 1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Customer relations > Contacts**. 1. On the left sidebar, select **Customer relations > Contacts**.
![Contacts list](crm_contacts_v14_6.png) ![Contacts list](crm_contacts_v14_10.png)
### Create a contact ### Create a contact
...@@ -86,7 +86,7 @@ To view a group's organizations: ...@@ -86,7 +86,7 @@ To view a group's organizations:
1. On the top bar, select **Menu > Groups** and find your group. 1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Customer relations > Organizations**. 1. On the left sidebar, select **Customer relations > Organizations**.
![Organizations list](crm_organizations_v14_6.png) ![Organizations list](crm_organizations_v14_10.png)
### Create an organization ### Create an organization
......
...@@ -10829,43 +10829,25 @@ msgstr "" ...@@ -10829,43 +10829,25 @@ msgstr ""
msgid "Critical vulnerabilities present" msgid "Critical vulnerabilities present"
msgstr "" msgstr ""
msgid "Crm|Contact has been added" msgid "Crm|Contact has been added."
msgstr "" msgstr ""
msgid "Crm|Contact has been updated" msgid "Crm|Contact has been updated."
msgstr "" msgstr ""
msgid "Crm|Create new contact" msgid "Crm|Customer relations contacts"
msgstr "" msgstr ""
msgid "Crm|Create organization" msgid "Crm|Customer relations organizations"
msgstr "" msgstr ""
msgid "Crm|Customer Relations Contacts" msgid "Crm|Default rate"
msgstr ""
msgid "Crm|Customer Relations Organizations"
msgstr ""
msgid "Crm|Default rate (optional)"
msgstr ""
msgid "Crm|Description (optional)"
msgstr "" msgstr ""
msgid "Crm|Edit contact" msgid "Crm|Edit contact"
msgstr "" msgstr ""
msgid "Crm|Email" msgid "Crm|Edit organization"
msgstr ""
msgid "Crm|First name"
msgstr ""
msgid "Crm|Last name"
msgstr ""
msgid "Crm|New Organization"
msgstr "" msgstr ""
msgid "Crm|New contact" msgid "Crm|New contact"
...@@ -10880,10 +10862,10 @@ msgstr "" ...@@ -10880,10 +10862,10 @@ msgstr ""
msgid "Crm|No organizations found" msgid "Crm|No organizations found"
msgstr "" msgstr ""
msgid "Crm|Organization has been added" msgid "Crm|Organization has been added."
msgstr "" msgstr ""
msgid "Crm|Phone number (optional)" msgid "Crm|Organization has been updated."
msgstr "" msgstr ""
msgid "Cron Timezone" msgid "Cron Timezone"
...@@ -10994,16 +10976,16 @@ msgstr "" ...@@ -10994,16 +10976,16 @@ msgstr ""
msgid "Custom range (UTC)" msgid "Custom range (UTC)"
msgstr "" msgstr ""
msgid "Customer Relations Contacts" msgid "Customer experience improvement and third-party offers"
msgstr "" msgstr ""
msgid "Customer Relations Organizations" msgid "Customer relations"
msgstr "" msgstr ""
msgid "Customer experience improvement and third-party offers" msgid "Customer relations contacts"
msgstr "" msgstr ""
msgid "Customer relations" msgid "Customer relations organizations"
msgstr "" msgstr ""
msgid "Customize CI/CD settings, including Auto DevOps, shared runners, and job artifacts." msgid "Customize CI/CD settings, including Auto DevOps, shared runners, and job artifacts."
...@@ -27272,6 +27254,9 @@ msgstr "" ...@@ -27272,6 +27254,9 @@ msgstr ""
msgid "Phabricator Tasks" msgid "Phabricator Tasks"
msgstr "" msgstr ""
msgid "Phone"
msgstr ""
msgid "Pick a name" msgid "Pick a name"
msgstr "" msgstr ""
......
import { GlAlert } from '@gitlab/ui';
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 ContactForm from '~/crm/components/contact_form.vue';
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 {
createContactMutationErrorResponse,
createContactMutationResponse,
getGroupContactsQueryResponse,
updateContactMutationErrorResponse,
updateContactMutationResponse,
} from './mock_data';
describe('Customer relations contact form component', () => {
Vue.use(VueApollo);
let wrapper;
let fakeApollo;
let mutation;
let queryHandler;
const findSaveContactButton = () => wrapper.findByTestId('save-contact-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findForm = () => wrapper.find('form');
const findError = () => wrapper.findComponent(GlAlert);
const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => {
fakeApollo = createMockApollo([[mutation, queryHandler]]);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
data: getGroupContactsQueryResponse.data,
});
const propsData = { drawerOpen: true };
if (editForm)
propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' };
wrapper = mountFunction(ContactForm, {
provide: { groupId: 26, groupFullPath: 'flightjs' },
apolloProvider: fakeApollo,
propsData,
});
};
beforeEach(() => {
mutation = createContactMutation;
queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse);
});
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
describe('Save contact button', () => {
it('should be disabled when required fields are empty', () => {
mountComponent();
expect(findSaveContactButton().props('disabled')).toBe(true);
});
it('should not be disabled when required fields 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(findSaveContactButton().props('disabled')).toBe(false);
});
});
it("should emit 'close' when cancel button is clicked", () => {
mountComponent();
findCancelButton().vm.$emit('click');
expect(wrapper.emitted().close).toBeTruthy();
});
describe('when create mutation is successful', () => {
it("should emit 'close'", async () => {
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(wrapper.emitted().close).toBeTruthy();
});
});
describe('when create mutation fails', () => {
it('should show error on reject', async () => {
queryHandler = jest.fn().mockRejectedValue('ERROR');
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(findError().exists()).toBe(true);
});
it('should show error on error response', async () => {
queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse);
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(findError().exists()).toBe(true);
expect(findError().text()).toBe('create contact 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('update contact is invalid.');
});
});
});
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue';
import ContactForm from '~/crm/components/form.vue';
import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
describe('Customer relations contact form wrapper', () => {
let wrapper;
const findContactForm = () => wrapper.findComponent(ContactForm);
const $apollo = {
queries: {
contacts: {
loading: false,
},
},
};
const $route = {
params: {
id: 7,
},
};
const contacts = [{ id: 'gid://gitlab/CustomerRelations::Contact/7' }];
const mountComponent = ({ isEditMode = false } = {}) => {
wrapper = shallowMountExtended(ContactFormWrapper, {
propsData: {
isEditMode,
},
provide: {
groupFullPath: 'flightjs',
groupId: 26,
},
mocks: {
$apollo,
$route,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('in edit mode', () => {
it('should render contact form with correct props', () => {
mountComponent({ isEditMode: true });
const contactForm = findContactForm();
expect(contactForm.props('fields')).toHaveLength(5);
expect(contactForm.props('title')).toBe('Edit contact');
expect(contactForm.props('successMessage')).toBe('Contact has been updated.');
expect(contactForm.props('mutation')).toBe(updateContactMutation);
expect(contactForm.props('getQuery')).toMatchObject({
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
});
expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
expect(contactForm.props('existingId')).toBe(contacts[0].id);
expect(contactForm.props('additionalCreateParams')).toMatchObject({
groupId: 'gid://gitlab/Group/26',
});
});
});
describe('in create mode', () => {
it('should render contact form with correct props', () => {
mountComponent();
const contactForm = findContactForm();
expect(contactForm.props('fields')).toHaveLength(5);
expect(contactForm.props('title')).toBe('New contact');
expect(contactForm.props('successMessage')).toBe('Contact has been added.');
expect(contactForm.props('mutation')).toBe(createContactMutation);
expect(contactForm.props('getQuery')).toMatchObject({
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
});
expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
expect(contactForm.props('existingId')).toBeNull();
expect(contactForm.props('additionalCreateParams')).toMatchObject({
groupId: 'gid://gitlab/Group/26',
});
});
});
});
...@@ -5,11 +5,9 @@ import VueRouter from 'vue-router'; ...@@ -5,11 +5,9 @@ import VueRouter from 'vue-router';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, 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 ContactsRoot from '~/crm/components/contacts_root.vue'; import ContactsRoot from '~/crm/contacts/components/contacts_root.vue';
import ContactForm from '~/crm/components/contact_form.vue'; import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; import routes from '~/crm/contacts/routes';
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', () => {
...@@ -23,8 +21,6 @@ describe('Customer relations contacts root app', () => { ...@@ -23,8 +21,6 @@ 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 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);
...@@ -40,8 +36,8 @@ describe('Customer relations contacts root app', () => { ...@@ -40,8 +36,8 @@ describe('Customer relations contacts root app', () => {
router, router,
provide: { provide: {
groupFullPath: 'flightjs', groupFullPath: 'flightjs',
groupIssuesPath: '/issues',
groupId: 26, groupId: 26,
groupIssuesPath: '/issues',
canAdminCrmContact, canAdminCrmContact,
}, },
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
...@@ -82,71 +78,6 @@ describe('Customer relations contacts root app', () => { ...@@ -82,71 +78,6 @@ describe('Customer relations contacts root app', () => {
}); });
}); });
describe('contact form', () => {
it('should not exist by default', async () => {
mountComponent();
await waitForPromises();
expect(findContactForm().exists()).toBe(false);
});
it('should exist when user clicks new contact button', async () => {
mountComponent();
findNewContactButton().vm.$emit('click');
await waitForPromises();
expect(findContactForm().exists()).toBe(true);
});
it('should exist when user navigates directly to `new` route', async () => {
router.replace({ name: NEW_ROUTE_NAME });
mountComponent();
await waitForPromises();
expect(findContactForm().exists()).toBe(true);
});
it('should exist when user clicks edit contact button', async () => {
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();
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();
findContactForm().vm.$emit('close');
await waitForPromises();
expect(findContactForm().exists()).toBe(false);
});
});
describe('error', () => { describe('error', () => {
it('should exist on reject', async () => { it('should exist on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
......
...@@ -6,12 +6,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; ...@@ -6,12 +6,12 @@ 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 Form from '~/crm/components/form.vue'; import Form from '~/crm/components/form.vue';
import routes from '~/crm/routes'; import routes from '~/crm/contacts/routes';
import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
import { import {
createContactMutationErrorResponse, createContactMutationErrorResponse,
createContactMutationResponse, createContactMutationResponse,
...@@ -101,6 +101,11 @@ describe('Reusable form component', () => { ...@@ -101,6 +101,11 @@ describe('Reusable form component', () => {
{ name: 'phone', label: 'Phone' }, { name: 'phone', label: 'Phone' },
{ name: 'description', label: 'Description' }, { name: 'description', label: 'Description' },
], ],
getQuery: {
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.contacts',
...propsData, ...propsData,
}); });
}; };
...@@ -108,13 +113,8 @@ describe('Reusable form component', () => { ...@@ -108,13 +113,8 @@ describe('Reusable form component', () => {
const mountContactCreate = () => { const mountContactCreate = () => {
const propsData = { const propsData = {
title: 'New contact', title: 'New contact',
successMessage: 'Contact has been added', successMessage: 'Contact has been added.',
buttonLabel: 'Create contact', buttonLabel: 'Create contact',
getQuery: {
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.contacts',
mutation: createContactMutation, mutation: createContactMutation,
additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
}; };
...@@ -124,14 +124,9 @@ describe('Reusable form component', () => { ...@@ -124,14 +124,9 @@ describe('Reusable form component', () => {
const mountContactUpdate = () => { const mountContactUpdate = () => {
const propsData = { const propsData = {
title: 'Edit contact', title: 'Edit contact',
successMessage: 'Contact has been updated', successMessage: 'Contact has been updated.',
mutation: updateContactMutation, mutation: updateContactMutation,
existingModel: { existingId: 'gid://gitlab/CustomerRelations::Contact/12',
id: 'gid://gitlab/CustomerRelations::Contact/12',
firstName: 'First',
lastName: 'Last',
email: 'email@example.com',
},
}; };
mountContact({ propsData }); mountContact({ propsData });
}; };
...@@ -143,6 +138,11 @@ describe('Reusable form component', () => { ...@@ -143,6 +138,11 @@ describe('Reusable form component', () => {
{ name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } }, { name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } },
{ name: 'description', label: 'Description' }, { name: 'description', label: 'Description' },
], ],
getQuery: {
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.organizations',
...propsData, ...propsData,
}); });
}; };
...@@ -150,13 +150,8 @@ describe('Reusable form component', () => { ...@@ -150,13 +150,8 @@ describe('Reusable form component', () => {
const mountOrganizationCreate = () => { const mountOrganizationCreate = () => {
const propsData = { const propsData = {
title: 'New organization', title: 'New organization',
successMessage: 'Organization has been added', successMessage: 'Organization has been added.',
buttonLabel: 'Create organization', buttonLabel: 'Create organization',
getQuery: {
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.organizations',
mutation: createOrganizationMutation, mutation: createOrganizationMutation,
additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
}; };
...@@ -167,17 +162,17 @@ describe('Reusable form component', () => { ...@@ -167,17 +162,17 @@ describe('Reusable form component', () => {
[FORM_CREATE_CONTACT]: { [FORM_CREATE_CONTACT]: {
mountFunction: mountContactCreate, mountFunction: mountContactCreate,
mutationErrorResponse: createContactMutationErrorResponse, mutationErrorResponse: createContactMutationErrorResponse,
toastMessage: 'Contact has been added', toastMessage: 'Contact has been added.',
}, },
[FORM_UPDATE_CONTACT]: { [FORM_UPDATE_CONTACT]: {
mountFunction: mountContactUpdate, mountFunction: mountContactUpdate,
mutationErrorResponse: updateContactMutationErrorResponse, mutationErrorResponse: updateContactMutationErrorResponse,
toastMessage: 'Contact has been updated', toastMessage: 'Contact has been updated.',
}, },
[FORM_CREATE_ORG]: { [FORM_CREATE_ORG]: {
mountFunction: mountOrganizationCreate, mountFunction: mountOrganizationCreate,
mutationErrorResponse: createOrganizationMutationErrorResponse, mutationErrorResponse: createOrganizationMutationErrorResponse,
toastMessage: 'Organization has been added', toastMessage: 'Organization has been added.',
}, },
}; };
const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]); const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]);
......
...@@ -157,3 +157,28 @@ export const createOrganizationMutationErrorResponse = { ...@@ -157,3 +157,28 @@ export const createOrganizationMutationErrorResponse = {
}, },
}, },
}; };
export const updateOrganizationMutationResponse = {
data: {
customerRelationsOrganizationUpdate: {
__typeName: 'CustomerRelationsOrganizationUpdatePayload',
organization: {
__typename: 'CustomerRelationsOrganization',
id: 'gid://gitlab/CustomerRelations::Organization/2',
name: 'A',
defaultRate: null,
description: null,
},
errors: [],
},
},
};
export const updateOrganizationMutationErrorResponse = {
data: {
customerRelationsOrganizationUpdate: {
organization: null,
errors: ['Description is invalid.'],
},
},
};
import { GlAlert } from '@gitlab/ui';
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 NewOrganizationForm from '~/crm/components/new_organization_form.vue';
import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import {
createOrganizationMutationErrorResponse,
createOrganizationMutationResponse,
getGroupOrganizationsQueryResponse,
} from './mock_data';
describe('Customer relations organizations root app', () => {
Vue.use(VueApollo);
let wrapper;
let fakeApollo;
let queryHandler;
const findCreateNewOrganizationButton = () =>
wrapper.findByTestId('create-new-organization-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findForm = () => wrapper.find('form');
const findError = () => wrapper.findComponent(GlAlert);
const mountComponent = () => {
fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
data: getGroupOrganizationsQueryResponse.data,
});
wrapper = shallowMountExtended(NewOrganizationForm, {
provide: { groupId: 26, groupFullPath: 'flightjs' },
apolloProvider: fakeApollo,
propsData: { drawerOpen: true },
});
};
beforeEach(() => {
queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse);
});
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
});
describe('Create new organization button', () => {
it('should be disabled by default', () => {
mountComponent();
expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy();
});
it('should not be disabled when first, last and email have values', async () => {
mountComponent();
wrapper.find('#organization-name').vm.$emit('input', 'A');
await waitForPromises();
expect(findCreateNewOrganizationButton().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 show error on reject', async () => {
queryHandler = jest.fn().mockRejectedValue('ERROR');
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(findError().exists()).toBe(true);
});
it('should show error on error response', async () => {
queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse);
mountComponent();
findForm().trigger('submit');
await waitForPromises();
expect(findError().exists()).toBe(true);
expect(findError().text()).toBe('create organization is invalid.');
});
});
});
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import OrganizationFormWrapper from '~/crm/organizations/components/organization_form_wrapper.vue';
import OrganizationForm from '~/crm/components/form.vue';
import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql';
import updateOrganizationMutation from '~/crm/organizations/components/graphql/update_organization.mutation.graphql';
describe('Customer relations organization form wrapper', () => {
let wrapper;
const findOrganizationForm = () => wrapper.findComponent(OrganizationForm);
const $apollo = {
queries: {
organizations: {
loading: false,
},
},
};
const $route = {
params: {
id: 7,
},
};
const organizations = [{ id: 'gid://gitlab/CustomerRelations::Organization/7' }];
const mountComponent = ({ isEditMode = false } = {}) => {
wrapper = shallowMountExtended(OrganizationFormWrapper, {
propsData: {
isEditMode,
},
provide: {
groupFullPath: 'flightjs',
groupId: 26,
},
mocks: {
$apollo,
$route,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('in edit mode', () => {
it('should render organization form with correct props', () => {
mountComponent({ isEditMode: true });
const organizationForm = findOrganizationForm();
expect(organizationForm.props('fields')).toHaveLength(3);
expect(organizationForm.props('title')).toBe('Edit organization');
expect(organizationForm.props('successMessage')).toBe('Organization has been updated.');
expect(organizationForm.props('mutation')).toBe(updateOrganizationMutation);
expect(organizationForm.props('getQuery')).toMatchObject({
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
});
expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations');
expect(organizationForm.props('existingId')).toBe(organizations[0].id);
expect(organizationForm.props('additionalCreateParams')).toMatchObject({
groupId: 'gid://gitlab/Group/26',
});
});
});
describe('in create mode', () => {
it('should render organization form with correct props', () => {
mountComponent();
const organizationForm = findOrganizationForm();
expect(organizationForm.props('fields')).toHaveLength(3);
expect(organizationForm.props('title')).toBe('New organization');
expect(organizationForm.props('successMessage')).toBe('Organization has been added.');
expect(organizationForm.props('mutation')).toBe(createOrganizationMutation);
expect(organizationForm.props('getQuery')).toMatchObject({
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
});
expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations');
expect(organizationForm.props('existingId')).toBeNull();
expect(organizationForm.props('additionalCreateParams')).toMatchObject({
groupId: 'gid://gitlab/Group/26',
});
});
});
});
...@@ -5,11 +5,9 @@ import VueRouter from 'vue-router'; ...@@ -5,11 +5,9 @@ import VueRouter from 'vue-router';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, 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 OrganizationsRoot from '~/crm/components/organizations_root.vue'; import OrganizationsRoot from '~/crm/organizations/components/organizations_root.vue';
import NewOrganizationForm from '~/crm/components/new_organization_form.vue'; import routes from '~/crm/organizations/routes';
import { NEW_ROUTE_NAME } from '~/crm/constants'; import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
import routes from '~/crm/routes';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import { getGroupOrganizationsQueryResponse } from './mock_data'; import { getGroupOrganizationsQueryResponse } from './mock_data';
describe('Customer relations organizations root app', () => { describe('Customer relations organizations root app', () => {
...@@ -23,7 +21,6 @@ describe('Customer relations organizations root app', () => { ...@@ -23,7 +21,6 @@ describe('Customer relations organizations 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 findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button'); const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button');
const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm);
const findError = () => wrapper.findComponent(GlAlert); const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse); const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
...@@ -37,7 +34,11 @@ describe('Customer relations organizations root app', () => { ...@@ -37,7 +34,11 @@ describe('Customer relations organizations root app', () => {
fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]); fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
wrapper = mountFunction(OrganizationsRoot, { wrapper = mountFunction(OrganizationsRoot, {
router, router,
provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' }, provide: {
canAdminCrmOrganization,
groupFullPath: 'flightjs',
groupIssuesPath: '/issues',
},
apolloProvider: fakeApollo, apolloProvider: fakeApollo,
}); });
}; };
...@@ -76,42 +77,6 @@ describe('Customer relations organizations root app', () => { ...@@ -76,42 +77,6 @@ describe('Customer relations organizations root app', () => {
}); });
}); });
describe('new organization form', () => {
it('should not exist by default', async () => {
mountComponent();
await waitForPromises();
expect(findNewOrganizationForm().exists()).toBe(false);
});
it('should exist when user clicks new contact button', async () => {
mountComponent();
findNewOrganizationButton().vm.$emit('click');
await waitForPromises();
expect(findNewOrganizationForm().exists()).toBe(true);
});
it('should exist when user navigates directly to /new', async () => {
router.replace({ name: NEW_ROUTE_NAME });
mountComponent();
await waitForPromises();
expect(findNewOrganizationForm().exists()).toBe(true);
});
it('should not exist when form emits close', async () => {
router.replace({ name: NEW_ROUTE_NAME });
mountComponent();
findNewOrganizationForm().vm.$emit('close');
await waitForPromises();
expect(findNewOrganizationForm().exists()).toBe(false);
});
});
it('should render error message on reject', async () => { it('should render error message on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') }); mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises(); await waitForPromises();
......
...@@ -85,28 +85,19 @@ RSpec.describe Groups::Crm::ContactsController do ...@@ -85,28 +85,19 @@ RSpec.describe Groups::Crm::ContactsController do
end end
describe 'GET #index' do describe 'GET #index' do
subject do subject { get group_crm_contacts_path(group) }
get group_crm_contacts_path(group)
response
end
it_behaves_like 'ok response with index template if authorized' it_behaves_like 'ok response with index template if authorized'
end end
describe 'GET #new' do describe 'GET #new' do
subject do subject { get new_group_crm_contact_path(group) }
get new_group_crm_contact_path(group)
response
end
it_behaves_like 'ok response with index template if authorized' it_behaves_like 'ok response with index template if authorized'
end end
describe 'GET #edit' do describe 'GET #edit' do
subject do subject { get edit_group_crm_contact_path(group, id: 1) }
get edit_group_crm_contact_path(group, id: 1)
response
end
it_behaves_like 'ok response with index template if authorized' it_behaves_like 'ok response with index template if authorized'
end end
......
...@@ -85,18 +85,19 @@ RSpec.describe Groups::Crm::OrganizationsController do ...@@ -85,18 +85,19 @@ RSpec.describe Groups::Crm::OrganizationsController do
end end
describe 'GET #index' do describe 'GET #index' do
subject do subject { get group_crm_organizations_path(group) }
get group_crm_organizations_path(group)
response
end
it_behaves_like 'ok response with index template if authorized' it_behaves_like 'ok response with index template if authorized'
end end
describe 'GET #new' do describe 'GET #new' do
subject do subject { get new_group_crm_organization_path(group) }
get new_group_crm_organization_path(group)
end it_behaves_like 'ok response with index template if authorized'
end
describe 'GET #edit' do
subject { get edit_group_crm_organization_path(group, id: 1) }
it_behaves_like 'ok response with index template if authorized' 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