Commit 9094a287 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'create-generic-form-component' into 'master'

Add generic/re-useable form vue component

See merge request gitlab-org/gitlab!77201
parents ed67f0e8 73c5df25
<script>
import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { get as getPropValueByPath, isEmpty } from 'lodash';
import { produce } from 'immer';
import { MountingPortal } from 'portal-vue';
import { __ } from '~/locale';
import { logError } from '~/lib/logger';
import { getFirstPropertyValue } from '~/lib/utils/common_utils';
import { INDEX_ROUTE_NAME } from '../constants';
const MSG_SAVE_CHANGES = __('Save changes');
const MSG_ERROR = __('Something went wrong. Please try again.');
const MSG_OPTIONAL = __('(optional)');
const MSG_CANCEL = __('Cancel');
/**
* This component is a first iteration towards a general reusable Create/Update component
*
* There's some opportunity to improve cohesion of this module which we are planning
* to address after solidifying the abstraction's requirements.
*
* Please see https://gitlab.com/gitlab-org/gitlab/-/issues/349441
*/
export default {
components: {
GlAlert,
GlButton,
GlDrawer,
GlFormGroup,
GlFormInput,
MountingPortal,
},
props: {
drawerOpen: {
type: Boolean,
required: true,
},
fields: {
type: Array,
required: true,
},
title: {
type: String,
required: true,
},
successMessage: {
type: String,
required: true,
},
mutation: {
type: Object,
required: true,
},
getQuery: {
type: Object,
required: false,
default: null,
},
getQueryNodePath: {
type: String,
required: false,
default: null,
},
existingModel: {
type: Object,
required: false,
default: () => ({}),
},
additionalCreateParams: {
type: Object,
required: false,
default: () => ({}),
},
buttonLabel: {
type: String,
required: false,
default: () => MSG_SAVE_CHANGES,
},
},
data() {
const initialModel = this.fields.reduce(
(map, field) =>
Object.assign(map, {
[field.name]: this.existingModel ? this.existingModel[field.name] : null,
}),
{},
);
return {
model: initialModel,
submitting: false,
errorMessages: [],
};
},
computed: {
isEditMode() {
return this.existingModel?.id;
},
isInvalid() {
const { fields, model } = this;
return fields.some((field) => {
return field.required && isEmpty(model[field.name]);
});
},
variables() {
const { additionalCreateParams, fields, isEditMode, model } = this;
const variables = fields.reduce(
(map, field) =>
Object.assign(map, {
[field.name]: this.formatValue(model, field),
}),
{},
);
if (isEditMode) {
return { input: { id: this.existingModel.id, ...variables } };
}
return { input: { ...additionalCreateParams, ...variables } };
},
},
methods: {
formatValue(model, field) {
if (!isEmpty(model[field.name]) && field.input?.type === 'number') {
return parseFloat(model[field.name]);
}
return model[field.name];
},
save() {
const { mutation, variables, close } = this;
this.submitting = true;
return this.$apollo
.mutate({
mutation,
variables,
update: (store, { data }) => {
const { errors, ...result } = getFirstPropertyValue(data);
if (errors?.length) {
this.errorMessages = errors;
} else {
this.updateCache(store, result);
close(true);
}
},
})
.catch((e) => {
logError(e);
this.errorMessages = [MSG_ERROR];
})
.finally(() => {
this.submitting = false;
});
},
close(success) {
if (success) {
// This is needed so toast perists when route is changed
this.$root.$toast.show(this.successMessage);
}
this.$router.replace({ name: this.$options.INDEX_ROUTE_NAME });
},
updateCache(store, result) {
const { getQuery, isEditMode, getQueryNodePath } = this;
if (isEditMode || !getQuery) return;
const sourceData = store.readQuery(getQuery);
const newData = produce(sourceData, (draftState) => {
getPropValueByPath(draftState, getQueryNodePath).nodes.push(getFirstPropertyValue(result));
});
store.writeQuery({
...getQuery,
data: newData,
});
},
getFieldLabel(field) {
const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`;
return field.label + optionalSuffix;
},
},
MSG_CANCEL,
INDEX_ROUTE_NAME,
};
</script>
<template>
<mounting-portal 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>
</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
v-for="field in fields"
:key="field.name"
:label="getFieldLabel(field)"
:label-for="field.name"
>
<gl-form-input :id="field.name" v-bind="field.input" v-model="model[field.name]" />
</gl-form-group>
<span class="gl-float-right">
<gl-button data-testid="cancel-button" @click="close(false)">
{{ $options.MSG_CANCEL }}
</gl-button>
<gl-button
variant="confirm"
:disabled="isInvalid"
:loading="submitting"
data-testid="save-button"
type="submit"
>{{ buttonLabel }}</gl-button
>
</span>
</form>
</gl-drawer>
</mounting-portal>
</template>
...@@ -746,3 +746,12 @@ export const isLoggedIn = () => Boolean(window.gon?.current_user_id); ...@@ -746,3 +746,12 @@ export const isLoggedIn = () => Boolean(window.gon?.current_user_id);
*/ */
export const convertArrayOfObjectsToCamelCase = (array) => export const convertArrayOfObjectsToCamelCase = (array) =>
array.map((o) => convertObjectPropsToCamelCase(o)); array.map((o) => convertObjectPropsToCamelCase(o));
export const getFirstPropertyValue = (data) => {
if (!data) return null;
const [key] = Object.keys(data);
if (!key) return null;
return data[key];
};
...@@ -1160,6 +1160,9 @@ msgstr "" ...@@ -1160,6 +1160,9 @@ msgstr ""
msgid "(max size 15 MB)" msgid "(max size 15 MB)"
msgstr "" msgstr ""
msgid "(optional)"
msgstr ""
msgid "(removed)" msgid "(removed)"
msgstr "" msgstr ""
......
...@@ -112,7 +112,7 @@ describe('Customer relations contact form component', () => { ...@@ -112,7 +112,7 @@ describe('Customer relations contact form component', () => {
await waitForPromises(); await waitForPromises();
expect(findError().exists()).toBe(true); expect(findError().exists()).toBe(true);
expect(findError().text()).toBe('Phone is invalid.'); expect(findError().text()).toBe('create contact is invalid.');
}); });
}); });
...@@ -151,7 +151,7 @@ describe('Customer relations contact form component', () => { ...@@ -151,7 +151,7 @@ describe('Customer relations contact form component', () => {
await waitForPromises(); await waitForPromises();
expect(findError().exists()).toBe(true); expect(findError().exists()).toBe(true);
expect(findError().text()).toBe('Email is invalid.'); expect(findError().text()).toBe('update contact is invalid.');
}); });
}); });
}); });
import { GlAlert } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
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 {
createContactMutationErrorResponse,
createContactMutationResponse,
getGroupContactsQueryResponse,
updateContactMutationErrorResponse,
updateContactMutationResponse,
createOrganizationMutationErrorResponse,
createOrganizationMutationResponse,
getGroupOrganizationsQueryResponse,
} from './mock_data';
const FORM_CREATE_CONTACT = 'create contact';
const FORM_UPDATE_CONTACT = 'update contact';
const FORM_CREATE_ORG = 'create organization';
describe('Reusable form component', () => {
Vue.use(VueApollo);
Vue.use(VueRouter);
const DEFAULT_RESPONSES = {
createContact: Promise.resolve(createContactMutationResponse),
updateContact: Promise.resolve(updateContactMutationResponse),
createOrg: Promise.resolve(createOrganizationMutationResponse),
};
let wrapper;
let handler;
let fakeApollo;
let router;
beforeEach(() => {
router = new VueRouter({
base: '',
mode: 'history',
routes,
});
router.push('/test');
handler = jest.fn().mockImplementation((key) => DEFAULT_RESPONSES[key]);
const hanlderWithKey = (key) => (...args) => handler(key, ...args);
fakeApollo = createMockApollo([
[createContactMutation, hanlderWithKey('createContact')],
[updateContactMutation, hanlderWithKey('updateContact')],
[createOrganizationMutation, hanlderWithKey('createOrg')],
]);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getGroupContactsQuery,
variables: { groupFullPath: 'flightjs' },
data: getGroupContactsQueryResponse.data,
});
fakeApollo.clients.defaultClient.cache.writeQuery({
query: getGroupOrganizationsQuery,
variables: { groupFullPath: 'flightjs' },
data: getGroupOrganizationsQueryResponse.data,
});
});
const mockToastShow = jest.fn();
const findSaveButton = () => wrapper.findByTestId('save-button');
const findForm = () => wrapper.find('form');
const findError = () => wrapper.findComponent(GlAlert);
const mountComponent = (propsData) => {
wrapper = shallowMountExtended(Form, {
router,
apolloProvider: fakeApollo,
propsData: { drawerOpen: true, ...propsData },
mocks: {
$toast: {
show: mockToastShow,
},
},
});
};
const mountContact = ({ propsData } = {}) => {
mountComponent({
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' },
],
...propsData,
});
};
const mountContactCreate = () => {
const propsData = {
title: 'New contact',
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' },
};
mountContact({ propsData });
};
const mountContactUpdate = () => {
const propsData = {
title: 'Edit contact',
successMessage: 'Contact has been updated',
mutation: updateContactMutation,
existingModel: {
id: 'gid://gitlab/CustomerRelations::Contact/12',
firstName: 'First',
lastName: 'Last',
email: 'email@example.com',
},
};
mountContact({ propsData });
};
const mountOrganization = ({ propsData } = {}) => {
mountComponent({
fields: [
{ name: 'name', label: 'Name', required: true },
{ name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } },
{ name: 'description', label: 'Description' },
],
...propsData,
});
};
const mountOrganizationCreate = () => {
const propsData = {
title: 'New organization',
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' },
};
mountOrganization({ propsData });
};
const forms = {
[FORM_CREATE_CONTACT]: {
mountFunction: mountContactCreate,
mutationErrorResponse: createContactMutationErrorResponse,
toastMessage: 'Contact has been added',
},
[FORM_UPDATE_CONTACT]: {
mountFunction: mountContactUpdate,
mutationErrorResponse: updateContactMutationErrorResponse,
toastMessage: 'Contact has been updated',
},
[FORM_CREATE_ORG]: {
mountFunction: mountOrganizationCreate,
mutationErrorResponse: createOrganizationMutationErrorResponse,
toastMessage: 'Organization has been added',
},
};
const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]);
afterEach(() => {
wrapper.destroy();
});
describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT))(
'%s form save button',
(name, { mountFunction }) => {
beforeEach(() => {
mountFunction();
});
it('should be disabled when required fields are empty', async () => {
wrapper.find('#firstName').vm.$emit('input', '');
await waitForPromises();
expect(findSaveButton().props('disabled')).toBe(true);
});
it('should not be disabled when required fields have values', async () => {
wrapper.find('#firstName').vm.$emit('input', 'A');
wrapper.find('#lastName').vm.$emit('input', 'B');
wrapper.find('#email').vm.$emit('input', 'C');
await waitForPromises();
expect(findSaveButton().props('disabled')).toBe(false);
});
},
);
describe.each(asTestParams(FORM_CREATE_ORG))('%s form save button', (name, { mountFunction }) => {
beforeEach(() => {
mountFunction();
});
it('should be disabled when required field is empty', async () => {
wrapper.find('#name').vm.$emit('input', '');
await waitForPromises();
expect(findSaveButton().props('disabled')).toBe(true);
});
it('should not be disabled when required field has a value', async () => {
wrapper.find('#name').vm.$emit('input', 'A');
await waitForPromises();
expect(findSaveButton().props('disabled')).toBe(false);
});
});
describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT, FORM_CREATE_ORG))(
'when %s mutation is successful',
(name, { mountFunction, toastMessage }) => {
it('form should display correct toast message', async () => {
mountFunction();
findForm().trigger('submit');
await waitForPromises();
expect(mockToastShow).toHaveBeenCalledWith(toastMessage);
});
},
);
describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT, FORM_CREATE_ORG))(
'when %s mutation fails',
(formName, { mutationErrorResponse, mountFunction }) => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation();
});
it('should show error on reject', async () => {
handler.mockRejectedValue('ERROR');
mountFunction();
findForm().trigger('submit');
await waitForPromises();
expect(findError().text()).toBe('Something went wrong. Please try again.');
});
it('should show error on error response', async () => {
handler.mockResolvedValue(mutationErrorResponse);
mountFunction();
findForm().trigger('submit');
await waitForPromises();
expect(findError().text()).toBe(`${formName} is invalid.`);
});
},
);
});
...@@ -82,7 +82,6 @@ export const getGroupOrganizationsQueryResponse = { ...@@ -82,7 +82,6 @@ export const getGroupOrganizationsQueryResponse = {
export const createContactMutationResponse = { export const createContactMutationResponse = {
data: { data: {
customerRelationsContactCreate: { customerRelationsContactCreate: {
__typeName: 'CustomerRelationsContactCreatePayload',
contact: { contact: {
__typename: 'CustomerRelationsContact', __typename: 'CustomerRelationsContact',
id: 'gid://gitlab/CustomerRelations::Contact/1', id: 'gid://gitlab/CustomerRelations::Contact/1',
...@@ -102,7 +101,7 @@ export const createContactMutationErrorResponse = { ...@@ -102,7 +101,7 @@ export const createContactMutationErrorResponse = {
data: { data: {
customerRelationsContactCreate: { customerRelationsContactCreate: {
contact: null, contact: null,
errors: ['Phone is invalid.'], errors: ['create contact is invalid.'],
}, },
}, },
}; };
...@@ -130,7 +129,7 @@ export const updateContactMutationErrorResponse = { ...@@ -130,7 +129,7 @@ export const updateContactMutationErrorResponse = {
data: { data: {
customerRelationsContactUpdate: { customerRelationsContactUpdate: {
contact: null, contact: null,
errors: ['Email is invalid.'], errors: ['update contact is invalid.'],
}, },
}, },
}; };
...@@ -138,7 +137,6 @@ export const updateContactMutationErrorResponse = { ...@@ -138,7 +137,6 @@ export const updateContactMutationErrorResponse = {
export const createOrganizationMutationResponse = { export const createOrganizationMutationResponse = {
data: { data: {
customerRelationsOrganizationCreate: { customerRelationsOrganizationCreate: {
__typeName: 'CustomerRelationsOrganizationCreatePayload',
organization: { organization: {
__typename: 'CustomerRelationsOrganization', __typename: 'CustomerRelationsOrganization',
id: 'gid://gitlab/CustomerRelations::Organization/2', id: 'gid://gitlab/CustomerRelations::Organization/2',
...@@ -155,7 +153,7 @@ export const createOrganizationMutationErrorResponse = { ...@@ -155,7 +153,7 @@ export const createOrganizationMutationErrorResponse = {
data: { data: {
customerRelationsOrganizationCreate: { customerRelationsOrganizationCreate: {
organization: null, organization: null,
errors: ['Name cannot be blank.'], errors: ['create organization is invalid.'],
}, },
}, },
}; };
...@@ -103,7 +103,7 @@ describe('Customer relations organizations root app', () => { ...@@ -103,7 +103,7 @@ describe('Customer relations organizations root app', () => {
await waitForPromises(); await waitForPromises();
expect(findError().exists()).toBe(true); expect(findError().exists()).toBe(true);
expect(findError().text()).toBe('Name cannot be blank.'); expect(findError().text()).toBe('create organization is invalid.');
}); });
}); });
}); });
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