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 {
required: false,
default: null,
},
existingModel: {
type: Object,
required: false,
default: () => ({}),
},
additionalCreateParams: {
type: Object,
required: false,
......@@ -76,25 +71,42 @@ export default {
required: false,
default: () => MSG_SAVE_CHANGES,
},
existingId: {
type: String,
required: false,
default: null,
},
},
data() {
const initialModel = this.fields.reduce(
(map, field) =>
Object.assign(map, {
[field.name]: this.existingModel ? this.existingModel[field.name] : null,
}),
{},
);
return {
model: initialModel,
model: null,
submitting: false,
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: {
isEditMode() {
return this.existingModel?.id;
return this.existingId;
},
isInvalid() {
const { fields, model } = this;
......@@ -115,13 +127,24 @@ export default {
);
if (isEditMode) {
return { input: { id: this.existingModel.id, ...variables } };
return { input: { id: this.existingId, ...variables } };
}
return { input: { ...additionalCreateParams, ...variables } };
},
},
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) {
if (!isEmpty(model[field.name]) && field.input?.type === 'number') {
return parseFloat(model[field.name]);
......@@ -173,7 +196,7 @@ export default {
const sourceData = store.readQuery(getQuery);
const newData = produce(sourceData, (draftState) => {
getPropValueByPath(draftState, getQueryNodePath).nodes.push(getFirstPropertyValue(result));
getPropValueByPath(draftState, getQueryNodePath).nodes.push(this.getPayload(result));
});
store.writeQuery({
......@@ -185,6 +208,14 @@ export default {
const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`;
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,
INDEX_ROUTE_NAME,
......@@ -192,7 +223,7 @@ export default {
</script>
<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)">
<template #title>
<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 @@
import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
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 ContactForm from './contact_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
export default {
components: {
......@@ -14,12 +12,11 @@ export default {
GlButton,
GlLoadingIcon,
GlTable,
ContactForm,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'],
inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath'],
data() {
return {
contacts: [],
......@@ -48,50 +45,20 @@ export default {
isLoading() {
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() {
return parseBoolean(this.canAdminCrmContact);
},
editingContact() {
return this.contacts.find(
(contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id),
);
},
},
methods: {
extractContacts(data) {
const contacts = data?.group?.contacts?.nodes || [];
return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
},
displayNewForm() {
if (this.showNewForm) return;
this.$router.push({ 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) {
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 } });
getEditRoute(id) {
return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
},
},
fields: [
......@@ -119,10 +86,12 @@ export default {
emptyText: s__('Crm|No contacts found'),
issuesButtonLabel: __('View issues'),
editButtonLabel: __('Edit'),
title: s__('Crm|Customer Relations Contacts'),
title: s__('Crm|Customer relations contacts'),
newContact: s__('Crm|New contact'),
errorText: __('Something went wrong. Please try again.'),
},
EDIT_ROUTE_NAME,
NEW_ROUTE_NAME,
};
</script>
......@@ -137,24 +106,15 @@ export default {
<h2 class="gl-font-size-h2 gl-my-0">
{{ $options.i18n.title }}
</h2>
<div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end">
<gl-button
v-if="canAdmin"
variant="confirm"
data-testid="new-contact-button"
@click="displayNewForm"
>
{{ $options.i18n.newContact }}
</gl-button>
<div v-if="canAdmin">
<router-link :to="{ name: $options.NEW_ROUTE_NAME }">
<gl-button variant="confirm" data-testid="new-contact-button">
{{ $options.i18n.newContact }}
</gl-button>
</router-link>
</div>
</div>
<contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" />
<contact-form
v-if="showEditForm"
:contact="editingContact"
:drawer-open="showEditForm"
@close="hideEditForm"
/>
<router-view />
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
<gl-table
v-else
......@@ -164,23 +124,24 @@ export default {
:empty-text="$options.i18n.emptyText"
show-empty
>
<template #cell(id)="data">
<template #cell(id)="{ value: id }">
<gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
class="gl-mr-3"
data-testid="issues-link"
icon="issues"
:aria-label="$options.i18n.issuesButtonLabel"
: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)"
: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-contact-button"
icon="pencil"
:aria-label="$options.i18n.editButtonLabel"
/>
</router-link>
</template>
</gl-table>
</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 [
{
......@@ -8,9 +9,12 @@ export default [
{
name: NEW_ROUTE_NAME,
path: '/new',
component: ContactFormWrapper,
},
{
name: EDIT_ROUTE_NAME,
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 '@
import { parseBoolean } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants';
import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql';
import NewOrganizationForm from './new_organization_form.vue';
import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
export default {
components: {
......@@ -13,7 +12,6 @@ export default {
GlButton,
GlLoadingIcon,
GlTable,
NewOrganizationForm,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -21,8 +19,8 @@ export default {
inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'],
data() {
return {
error: false,
organizations: [],
error: false,
};
},
apollo: {
......@@ -47,10 +45,7 @@ export default {
isLoading() {
return this.$apollo.queries.organizations.loading;
},
showNewForm() {
return this.$route.name === NEW_ROUTE_NAME;
},
canCreateNew() {
canAdmin() {
return parseBoolean(this.canAdminCrmOrganization);
},
},
......@@ -62,15 +57,8 @@ export default {
getIssuesPath(path, value) {
return `${path}?scope=all&state=opened&crm_organization_id=${value}`;
},
displayNewForm() {
if (this.showNewForm) return;
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 });
getEditRoute(id) {
return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
},
},
fields: [
......@@ -79,7 +67,7 @@ export default {
{ key: 'description', sortable: true },
{
key: 'id',
label: __('Issues'),
label: '',
formatter: (id) => {
return getIdFromGraphQLId(id);
},
......@@ -88,11 +76,13 @@ export default {
i18n: {
emptyText: s__('Crm|No organizations found'),
issuesButtonLabel: __('View issues'),
title: s__('Crm|Customer Relations Organizations'),
editButtonLabel: __('Edit'),
title: s__('Crm|Customer relations organizations'),
newOrganization: s__('Crm|New organization'),
errorText: __('Something went wrong. Please try again.'),
organizationAdded: s__('Crm|Organization has been added'),
},
EDIT_ROUTE_NAME,
NEW_ROUTE_NAME,
};
</script>
......@@ -108,15 +98,17 @@ export default {
{{ $options.i18n.title }}
</h2>
<div
v-if="canCreateNew"
v-if="canAdmin"
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">
{{ $options.i18n.newOrganization }}
</gl-button>
<router-link :to="{ name: $options.NEW_ROUTE_NAME }">
<gl-button variant="confirm" data-testid="new-organization-button">
{{ $options.i18n.newOrganization }}
</gl-button>
</router-link>
</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-table
v-else
......@@ -126,14 +118,24 @@ export default {
:empty-text="$options.i18n.emptyText"
show-empty
>
<template #cell(id)="data">
<template #cell(id)="{ value: id }">
<gl-button
v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
class="gl-mr-3"
data-testid="issues-link"
icon="issues"
: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>
</gl-table>
</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;
export const TYPE_BOARD = 'Board';
export const TYPE_CI_RUNNER = 'Ci::Runner';
export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
export const TYPE_CRM_ORGANIZATION = 'CustomerRelations::Organization';
export const TYPE_DISCUSSION = 'Discussion';
export const TYPE_EPIC = 'Epic';
export const TYPE_EPIC_BOARD = 'Boards::EpicBoard';
......
import initCrmContactsApp from '~/crm/contacts_bundle';
import initCrmContactsApp from '~/crm/contacts/bundle';
initCrmContactsApp();
import initCrmOrganizationsApp from '~/crm/organizations_bundle';
import initCrmOrganizationsApp from '~/crm/organizations/bundle';
initCrmOrganizationsApp();
......@@ -10,6 +10,10 @@ class Groups::Crm::OrganizationsController < Groups::ApplicationController
render action: "index"
end
def edit
render action: "index"
end
private
def authorize_read_crm_organization!
......
- breadcrumb_title _('Customer Relations Contacts')
- page_title _('Customer Relations Contacts')
- breadcrumb_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) } }
- breadcrumb_title _('Customer Relations Organizations')
- page_title _('Customer Relations Organizations')
- breadcrumb_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) } }
......@@ -135,7 +135,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
namespace :crm do
resources :contacts, only: [:index, :new, :edit]
resources :organizations, only: [:index, :new]
resources :organizations, only: [:index, :new, :edit]
end
end
......
......@@ -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 left sidebar, select **Customer relations > Contacts**.
![Contacts list](crm_contacts_v14_6.png)
![Contacts list](crm_contacts_v14_10.png)
### Create a contact
......@@ -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 left sidebar, select **Customer relations > Organizations**.
![Organizations list](crm_organizations_v14_6.png)
![Organizations list](crm_organizations_v14_10.png)
### Create an organization
......
......@@ -10829,43 +10829,25 @@ msgstr ""
msgid "Critical vulnerabilities present"
msgstr ""
msgid "Crm|Contact has been added"
msgid "Crm|Contact has been added."
msgstr ""
msgid "Crm|Contact has been updated"
msgid "Crm|Contact has been updated."
msgstr ""
msgid "Crm|Create new contact"
msgid "Crm|Customer relations contacts"
msgstr ""
msgid "Crm|Create organization"
msgid "Crm|Customer relations organizations"
msgstr ""
msgid "Crm|Customer Relations Contacts"
msgstr ""
msgid "Crm|Customer Relations Organizations"
msgstr ""
msgid "Crm|Default rate (optional)"
msgstr ""
msgid "Crm|Description (optional)"
msgid "Crm|Default rate"
msgstr ""
msgid "Crm|Edit contact"
msgstr ""
msgid "Crm|Email"
msgstr ""
msgid "Crm|First name"
msgstr ""
msgid "Crm|Last name"
msgstr ""
msgid "Crm|New Organization"
msgid "Crm|Edit organization"
msgstr ""
msgid "Crm|New contact"
......@@ -10880,10 +10862,10 @@ msgstr ""
msgid "Crm|No organizations found"
msgstr ""
msgid "Crm|Organization has been added"
msgid "Crm|Organization has been added."
msgstr ""
msgid "Crm|Phone number (optional)"
msgid "Crm|Organization has been updated."
msgstr ""
msgid "Cron Timezone"
......@@ -10994,16 +10976,16 @@ msgstr ""
msgid "Custom range (UTC)"
msgstr ""
msgid "Customer Relations Contacts"
msgid "Customer experience improvement and third-party offers"
msgstr ""
msgid "Customer Relations Organizations"
msgid "Customer relations"
msgstr ""
msgid "Customer experience improvement and third-party offers"
msgid "Customer relations contacts"
msgstr ""
msgid "Customer relations"
msgid "Customer relations organizations"
msgstr ""
msgid "Customize CI/CD settings, including Auto DevOps, shared runners, and job artifacts."
......@@ -27272,6 +27254,9 @@ msgstr ""
msgid "Phabricator Tasks"
msgstr ""
msgid "Phone"
msgstr ""
msgid "Pick a name"
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';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ContactsRoot from '~/crm/components/contacts_root.vue';
import ContactForm from '~/crm/components/contact_form.vue';
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 ContactsRoot from '~/crm/contacts/components/contacts_root.vue';
import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import routes from '~/crm/contacts/routes';
import { getGroupContactsQueryResponse } from './mock_data';
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 findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
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 successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
......@@ -40,8 +36,8 @@ describe('Customer relations contacts root app', () => {
router,
provide: {
groupFullPath: 'flightjs',
groupIssuesPath: '/issues',
groupId: 26,
groupIssuesPath: '/issues',
canAdminCrmContact,
},
apolloProvider: fakeApollo,
......@@ -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', () => {
it('should exist on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
......
......@@ -6,12 +6,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Form from '~/crm/components/form.vue';
import routes from '~/crm/routes';
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 createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import routes from '~/crm/contacts/routes';
import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql';
import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
import {
createContactMutationErrorResponse,
createContactMutationResponse,
......@@ -101,6 +101,11 @@ describe('Reusable form component', () => {
{ name: 'phone', label: 'Phone' },
{ name: 'description', label: 'Description' },
],
getQuery: {
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.contacts',
...propsData,
});
};
......@@ -108,13 +113,8 @@ describe('Reusable form component', () => {
const mountContactCreate = () => {
const propsData = {
title: 'New contact',
successMessage: 'Contact has been added',
successMessage: 'Contact has been added.',
buttonLabel: 'Create contact',
getQuery: {
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.contacts',
mutation: createContactMutation,
additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
};
......@@ -124,14 +124,9 @@ describe('Reusable form component', () => {
const mountContactUpdate = () => {
const propsData = {
title: 'Edit contact',
successMessage: 'Contact has been updated',
successMessage: 'Contact has been updated.',
mutation: updateContactMutation,
existingModel: {
id: 'gid://gitlab/CustomerRelations::Contact/12',
firstName: 'First',
lastName: 'Last',
email: 'email@example.com',
},
existingId: 'gid://gitlab/CustomerRelations::Contact/12',
};
mountContact({ propsData });
};
......@@ -143,6 +138,11 @@ describe('Reusable form component', () => {
{ name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } },
{ name: 'description', label: 'Description' },
],
getQuery: {
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.organizations',
...propsData,
});
};
......@@ -150,13 +150,8 @@ describe('Reusable form component', () => {
const mountOrganizationCreate = () => {
const propsData = {
title: 'New organization',
successMessage: 'Organization has been added',
successMessage: 'Organization has been added.',
buttonLabel: 'Create organization',
getQuery: {
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
},
getQueryNodePath: 'group.organizations',
mutation: createOrganizationMutation,
additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
};
......@@ -167,17 +162,17 @@ describe('Reusable form component', () => {
[FORM_CREATE_CONTACT]: {
mountFunction: mountContactCreate,
mutationErrorResponse: createContactMutationErrorResponse,
toastMessage: 'Contact has been added',
toastMessage: 'Contact has been added.',
},
[FORM_UPDATE_CONTACT]: {
mountFunction: mountContactUpdate,
mutationErrorResponse: updateContactMutationErrorResponse,
toastMessage: 'Contact has been updated',
toastMessage: 'Contact has been updated.',
},
[FORM_CREATE_ORG]: {
mountFunction: mountOrganizationCreate,
mutationErrorResponse: createOrganizationMutationErrorResponse,
toastMessage: 'Organization has been added',
toastMessage: 'Organization has been added.',
},
};
const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]);
......
......@@ -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';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import OrganizationsRoot from '~/crm/components/organizations_root.vue';
import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
import { NEW_ROUTE_NAME } from '~/crm/constants';
import routes from '~/crm/routes';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import OrganizationsRoot from '~/crm/organizations/components/organizations_root.vue';
import routes from '~/crm/organizations/routes';
import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
import { getGroupOrganizationsQueryResponse } from './mock_data';
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 findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button');
const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm);
const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
......@@ -37,7 +34,11 @@ describe('Customer relations organizations root app', () => {
fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
wrapper = mountFunction(OrganizationsRoot, {
router,
provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
provide: {
canAdminCrmOrganization,
groupFullPath: 'flightjs',
groupIssuesPath: '/issues',
},
apolloProvider: fakeApollo,
});
};
......@@ -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 () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
......
......@@ -85,28 +85,19 @@ RSpec.describe Groups::Crm::ContactsController do
end
describe 'GET #index' do
subject do
get group_crm_contacts_path(group)
response
end
subject { get group_crm_contacts_path(group) }
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
subject { get new_group_crm_contact_path(group) }
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
subject { get edit_group_crm_contact_path(group, id: 1) }
it_behaves_like 'ok response with index template if authorized'
end
......
......@@ -85,18 +85,19 @@ RSpec.describe Groups::Crm::OrganizationsController do
end
describe 'GET #index' do
subject do
get group_crm_organizations_path(group)
response
end
subject { get group_crm_organizations_path(group) }
it_behaves_like 'ok response with index template if authorized'
end
describe 'GET #new' do
subject do
get new_group_crm_organization_path(group)
end
subject { get new_group_crm_organization_path(group) }
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'
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