Commit a3ceca88 authored by Kushal Pandya's avatar Kushal Pandya

Add support for creating Requirements

Adds UI support for creating requirements from
Requirements List page.
parent 20b5232c
<script>
import { GlFormGroup, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __ } from '~/locale';
export default {
components: {
GlFormGroup,
GlFormTextarea,
GlDeprecatedButton,
},
props: {
requirement: {
type: Object,
required: false,
default: null,
},
requirementRequestActive: {
type: Boolean,
required: true,
},
},
data() {
return {
isCreate: isEmpty(this.requirement),
title: this.requirement?.title || '',
};
},
computed: {
fieldLabel() {
return this.isCreate ? __('New requirement') : __('Requirement');
},
saveButtonLabel() {
return this.isCreate ? __('Create requirement') : __('Save changes');
},
disableSaveButton() {
return this.title === '' || this.requirementRequestActive;
},
},
methods: {
handleSave() {
if (this.isCreate) {
this.$emit('save', this.title);
} else {
this.$emit('save', {
iid: this.requirement.iid,
title: this.title,
});
}
},
},
};
</script>
<template>
<div class="requirement-form" :class="{ 'p-3 border-bottom': isCreate }">
<gl-form-group :label="fieldLabel" label-for="requirementTitle">
<gl-form-textarea
id="requirementTitle"
v-model.trim="title"
autofocus
resize
:disabled="requirementRequestActive"
:placeholder="__('Describe the requirement here')"
max-rows="25"
class="requirement-form-textarea"
@keyup.escape.exact="$emit('cancel')"
/>
</gl-form-group>
<div class="d-flex requirement-form-actions">
<gl-deprecated-button
:disabled="disableSaveButton"
:loading="requirementRequestActive"
category="primary"
variant="success"
class="mr-auto js-requirement-save"
@click="handleSave"
>{{ saveButtonLabel }}</gl-deprecated-button
>
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">{{
__('Cancel')
}}</gl-deprecated-button>
</div>
</div>
</template>
...@@ -9,7 +9,10 @@ import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; ...@@ -9,7 +9,10 @@ import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import RequirementsLoading from './requirements_loading.vue'; import RequirementsLoading from './requirements_loading.vue';
import RequirementsEmptyState from './requirements_empty_state.vue'; import RequirementsEmptyState from './requirements_empty_state.vue';
import RequirementItem from './requirement_item.vue'; import RequirementItem from './requirement_item.vue';
import RequirementForm from './requirement_form.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql'; import projectRequirements from '../queries/projectRequirements.query.graphql';
import createRequirement from '../queries/createRequirement.mutation.graphql';
import { FilterState, DEFAULT_PAGE_SIZE } from '../constants'; import { FilterState, DEFAULT_PAGE_SIZE } from '../constants';
...@@ -20,6 +23,7 @@ export default { ...@@ -20,6 +23,7 @@ export default {
RequirementsLoading, RequirementsLoading,
RequirementsEmptyState, RequirementsEmptyState,
RequirementItem, RequirementItem,
RequirementForm,
}, },
props: { props: {
projectPath: { projectPath: {
...@@ -50,10 +54,6 @@ export default { ...@@ -50,10 +54,6 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
showCreateRequirement: {
type: Boolean,
required: true,
},
emptyStatePath: { emptyStatePath: {
type: String, type: String,
required: true, required: true,
...@@ -105,6 +105,8 @@ export default { ...@@ -105,6 +105,8 @@ export default {
}, },
data() { data() {
return { return {
showCreateForm: false,
createRequirementRequestActive: false,
currentPage: this.page, currentPage: this.page,
prevPageCursor: this.prev, prevPageCursor: this.prev,
nextPageCursor: this.next, nextPageCursor: this.next,
...@@ -136,6 +138,16 @@ export default { ...@@ -136,6 +138,16 @@ export default {
return nextPage > Math.ceil(this.totalRequirements / DEFAULT_PAGE_SIZE) ? null : nextPage; return nextPage > Math.ceil(this.totalRequirements / DEFAULT_PAGE_SIZE) ? null : nextPage;
}, },
}, },
mounted() {
document
.querySelector('.js-new-requirement')
.addEventListener('click', this.handleNewRequirementClick);
},
beforeDestroy() {
document
.querySelector('.js-new-requirement')
.removeEventListener('click', this.handleNewRequirementClick);
},
methods: { methods: {
/** /**
* Update browser URL with updated query-param values * Update browser URL with updated query-param values
...@@ -166,6 +178,40 @@ export default { ...@@ -166,6 +178,40 @@ export default {
replace: true, replace: true,
}); });
}, },
handleNewRequirementClick() {
this.showCreateForm = true;
},
handleNewRequirementSave(title) {
this.createRequirementRequestActive = true;
return this.$apollo
.mutate({
mutation: createRequirement,
variables: {
createRequirementInput: {
projectPath: this.projectPath,
title,
},
},
})
.then(({ data }) => {
if (!data.createRequirement.errors.length) {
this.showCreateForm = false;
this.$apollo.queries.requirements.refetch();
} else {
throw new Error(`Error creating a requirement`);
}
})
.catch(e => {
createFlash(__('Something went wrong while creating a requirement.'));
Sentry.captureException(e);
})
.finally(() => {
this.createRequirementRequestActive = false;
});
},
handleNewRequirementCancel() {
this.showCreateForm = false;
},
handlePageChange(page) { handlePageChange(page) {
const { startCursor, endCursor } = this.requirements.pageInfo; const { startCursor, endCursor } = this.requirements.pageInfo;
...@@ -202,6 +248,12 @@ export default { ...@@ -202,6 +248,12 @@ export default {
:current-tab-count="totalRequirements" :current-tab-count="totalRequirements"
:current-page="currentPage" :current-page="currentPage"
/> />
<requirement-form
v-if="showCreateForm"
:requirement-request-active="createRequirementRequestActive"
@save="handleNewRequirementSave"
@cancel="handleNewRequirementCancel"
/>
<ul <ul
v-if="!requirementsListLoading && !requirementsListEmpty" v-if="!requirementsListLoading && !requirementsListEmpty"
class="content-list issuable-list issues-list requirements-list" class="content-list issuable-list issues-list requirements-list"
......
mutation createRequirement($createRequirementInput: CreateRequirementInput!) {
createRequirement(input: $createRequirementInput) {
clientMutationId
errors
}
}
...@@ -9,7 +9,6 @@ import { FilterState } from './constants'; ...@@ -9,7 +9,6 @@ import { FilterState } from './constants';
Vue.use(VueApollo); Vue.use(VueApollo);
export default () => { export default () => {
const btnNewRequirement = document.querySelector('.js-new-requirement');
const el = document.getElementById('js-requirements-app'); const el = document.getElementById('js-requirements-app');
if (!el) { if (!el) {
...@@ -43,7 +42,6 @@ export default () => { ...@@ -43,7 +42,6 @@ export default () => {
const ARCHIVED = parseInt(archived, 10); const ARCHIVED = parseInt(archived, 10);
return { return {
showCreateRequirement: false,
filterBy: stateFilterBy, filterBy: stateFilterBy,
requirementsCount: { requirementsCount: {
OPENED, OPENED,
...@@ -57,17 +55,6 @@ export default () => { ...@@ -57,17 +55,6 @@ export default () => {
projectPath, projectPath,
}; };
}, },
mounted() {
btnNewRequirement.addEventListener('click', this.handleClickNewRequirement);
},
beforeDestroy() {
btnNewRequirement.removeEventListener('click', this.handleClickNewRequirement);
},
methods: {
handleClickNewRequirement() {
this.showCreateRequirement = !this.showCreateRequirement;
},
},
render(createElement) { render(createElement) {
return createElement('requirements-root', { return createElement('requirements-root', {
props: { props: {
...@@ -77,7 +64,6 @@ export default () => { ...@@ -77,7 +64,6 @@ export default () => {
page: parseInt(this.page, 10) || 1, page: parseInt(this.page, 10) || 1,
prev: this.prev, prev: this.prev,
next: this.next, next: this.next,
showCreateRequirement: this.showCreateRequirement,
emptyStatePath: this.emptyStatePath, emptyStatePath: this.emptyStatePath,
}, },
}); });
......
...@@ -11,6 +11,15 @@ ...@@ -11,6 +11,15 @@
} }
} }
} }
.requirement-form {
.requirement-form-textarea {
line-height: $gl-line-height-24;
// We need `!important` here as GlFormTextarea (based on `BFormTextarea`)
// somehow applies inline styles ¯\_(ツ)_/¯.
overflow-y: auto !important;
}
}
} }
.requirements-list-container { .requirements-list-container {
......
...@@ -45,10 +45,42 @@ describe 'Requirements list', :js do ...@@ -45,10 +45,42 @@ describe 'Requirements list', :js do
end end
end end
it 'shows button "New requirement"' do context 'new requirement' do
page.within('.nav-controls') do it 'shows button "New requirement"' do
expect(page).to have_selector('button.js-new-requirement') page.within('.nav-controls') do
expect(find('button.js-new-requirement')).to have_content('New requirement') expect(page).to have_selector('button.js-new-requirement')
expect(find('button.js-new-requirement')).to have_content('New requirement')
end
end
it 'shows requirement create form when "New requirement" button is clicked' do
page.within('.nav-controls') do
find('button.js-new-requirement').click
end
page.within('.requirements-list-container') do
expect(page).to have_selector('.requirement-form')
end
end
it 'creates new requirement' do
page.within('.nav-controls') do
find('button.js-new-requirement').click
end
page.within('.requirements-list-container') do
requirement_title = 'Foobar'
find('textarea#requirementTitle').native.send_keys requirement_title
find('button.js-requirement-save').click
wait_for_all_requests
expect(page).to have_selector('li.requirement', count: 4)
page.within('.requirements-list li.requirement', match: :first) do
expect(page.find('.issue-title-text')).to have_content(requirement_title)
end
end
end end
end end
......
import { shallowMount } from '@vue/test-utils';
import { GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import { mockRequirementsOpen } from '../mock_data';
const createComponent = ({ requirement = null, requirementRequestActive = false } = {}) =>
shallowMount(RequirementForm, {
propsData: {
requirement,
requirementRequestActive,
},
});
describe('RequirementForm', () => {
let wrapper;
let wrapperWithRequirement;
beforeEach(() => {
wrapper = createComponent();
wrapperWithRequirement = createComponent({
requirement: mockRequirementsOpen[0],
});
});
afterEach(() => {
wrapper.destroy();
wrapperWithRequirement.destroy();
});
describe('computed', () => {
describe('fieldLabel', () => {
it('returns string "New requirement" when `requirement` prop is null', () => {
expect(wrapper.vm.fieldLabel).toBe('New requirement');
});
it('returns string "Requirement" when `requirement` prop is defined', () => {
expect(wrapperWithRequirement.vm.fieldLabel).toBe('Requirement');
});
});
describe('saveButtonLabel', () => {
it('returns string "Create requirement" when `requirement` prop is null', () => {
expect(wrapper.vm.saveButtonLabel).toBe('Create requirement');
});
it('returns string "Save changes" when `requirement` prop is defined', () => {
expect(wrapperWithRequirement.vm.saveButtonLabel).toBe('Save changes');
});
});
});
describe('methods', () => {
describe('handleSave', () => {
it('emits `save` event on component with `title` as param when form is in create mode', () => {
wrapper.setData({
title: 'foo',
});
wrapper.vm.handleSave();
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('save')).toBeTruthy();
expect(wrapper.emitted('save')[0]).toEqual(['foo']);
});
});
it('emits `save` event on component with object as param containing `iid` & `title` when form is in update mode', () => {
wrapperWithRequirement.vm.handleSave();
return wrapperWithRequirement.vm.$nextTick(() => {
expect(wrapperWithRequirement.emitted('save')).toBeTruthy();
expect(wrapperWithRequirement.emitted('save')[0]).toEqual([
{
iid: mockRequirementsOpen[0].iid,
title: mockRequirementsOpen[0].title,
},
]);
});
});
});
});
describe('template', () => {
it('renders component container element with classes `p-3 border-bottom` when form is in create mode', () => {
const wrapperClasses = wrapper.classes();
expect(wrapperClasses).toContain('p-3');
expect(wrapperClasses).toContain('border-bottom');
});
it('renders component container element without classes `p-3 border-bottom` when form is in edit mode', () => {
const wrapperClasses = wrapperWithRequirement.classes();
expect(wrapperClasses).not.toContain('p-3');
expect(wrapperClasses).not.toContain('border-bottom');
});
it('renders gl-form-group component', () => {
const glFormGroup = wrapper.find(GlFormGroup);
expect(glFormGroup.exists()).toBe(true);
expect(glFormGroup.attributes('label')).toBe('New requirement');
expect(glFormGroup.attributes('label-for')).toBe('requirementTitle');
});
it('renders gl-form-textarea component', () => {
const glFormTextarea = wrapper.find(GlFormTextarea);
expect(glFormTextarea.exists()).toBe(true);
expect(glFormTextarea.attributes('id')).toBe('requirementTitle');
expect(glFormTextarea.attributes('placeholder')).toBe('Describe the requirement here');
expect(glFormTextarea.attributes('max-rows')).toBe('25');
});
it('renders gl-form-textarea component populated with `requirement.title` when `requirement` prop is defined', () => {
expect(wrapperWithRequirement.find(GlFormTextarea).attributes('value')).toBe(
mockRequirementsOpen[0].title,
);
});
it('renders save button component', () => {
const saveButton = wrapper.find('.js-requirement-save');
expect(saveButton.exists()).toBe(true);
expect(saveButton.text()).toBe('Create requirement');
});
it('renders cancel button component', () => {
const cancelButton = wrapper.find('.js-requirement-cancel');
expect(cancelButton.exists()).toBe(true);
expect(cancelButton.text()).toBe('Cancel');
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import createFlash from '~/flash';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue'; import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue'; import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue'; import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue';
import RequirementItem from 'ee/requirements/components/requirement_item.vue'; import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import createRequirement from 'ee/requirements/queries/createRequirement.mutation.graphql';
import { import {
FilterState, FilterState,
...@@ -17,6 +22,8 @@ jest.mock('ee/requirements/constants', () => ({ ...@@ -17,6 +22,8 @@ jest.mock('ee/requirements/constants', () => ({
DEFAULT_PAGE_SIZE: 2, DEFAULT_PAGE_SIZE: 2,
})); }));
jest.mock('~/flash');
const createComponent = ({ const createComponent = ({
projectPath = 'gitlab-org/gitlab-shell', projectPath = 'gitlab-org/gitlab-shell',
filterBy = FilterState.opened, filterBy = FilterState.opened,
...@@ -41,8 +48,10 @@ const createComponent = ({ ...@@ -41,8 +48,10 @@ const createComponent = ({
list: [], list: [],
pageInfo: {}, pageInfo: {},
count: {}, count: {},
refetch: jest.fn(),
}, },
}, },
mutate: jest.fn(),
}, },
}, },
}); });
...@@ -51,6 +60,7 @@ describe('RequirementsRoot', () => { ...@@ -51,6 +60,7 @@ describe('RequirementsRoot', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
setFixtures('<button class="js-new-requirement">New requirement</button>');
wrapper = createComponent(); wrapper = createComponent();
}); });
...@@ -148,6 +158,92 @@ describe('RequirementsRoot', () => { ...@@ -148,6 +158,92 @@ describe('RequirementsRoot', () => {
}); });
}); });
describe('handleNewRequirementClick', () => {
it('sets `showCreateForm` prop to `true`', () => {
wrapper.vm.handleNewRequirementClick();
expect(wrapper.vm.showCreateForm).toBe(true);
});
});
describe('handleNewRequirementSave', () => {
const mockMutationResult = {
data: {
createRequirement: {
errors: [],
},
},
};
it('sets `createRequirementRequestActive` prop to `true`', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult));
wrapper.vm.handleNewRequirementSave('foo');
expect(wrapper.vm.createRequirementRequestActive).toBe(true);
});
it('calls `$apollo.mutate` with createRequirement mutation and `projectPath` & `title` as variables', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult));
wrapper.vm.handleNewRequirementSave('foo');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createRequirement,
variables: {
createRequirementInput: {
projectPath: 'gitlab-org/gitlab-shell',
title: 'foo',
},
},
}),
);
});
it('sets `showCreateForm` and `createRequirementRequestActive` props to `false` and calls `$apollo.queries.requirements.refetch()` when request is successful', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult));
jest
.spyOn(wrapper.vm.$apollo.queries.requirements, 'refetch')
.mockImplementation(jest.fn());
return wrapper.vm.handleNewRequirementSave('foo').then(() => {
expect(wrapper.vm.showCreateForm).toBe(false);
expect(wrapper.vm.$apollo.queries.requirements.refetch).toHaveBeenCalled();
expect(wrapper.vm.createRequirementRequestActive).toBe(false);
});
});
it('sets `createRequirementRequestActive` prop to `false` and calls `createFlash` when `$apollo.mutate` request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
return wrapper.vm.handleNewRequirementSave('foo').then(() => {
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while creating a requirement.',
);
expect(wrapper.vm.createRequirementRequestActive).toBe(false);
});
});
});
describe('handleNewRequirementCancel', () => {
it('sets `showCreateForm` prop to `false`', () => {
wrapper.setData({
showCreateForm: true,
});
wrapper.vm.handleNewRequirementCancel();
expect(wrapper.vm.showCreateForm).toBe(false);
});
});
describe('handlePageChange', () => { describe('handlePageChange', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn()); jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn());
...@@ -208,6 +304,16 @@ describe('RequirementsRoot', () => { ...@@ -208,6 +304,16 @@ describe('RequirementsRoot', () => {
wrapperLoading.destroy(); wrapperLoading.destroy();
}); });
it('renders requirement-form component when `showCreateForm` prop is `true`', () => {
wrapper.setData({
showCreateForm: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementForm).exists()).toBe(true);
});
});
it('renders requirement items for all the requirements', () => { it('renders requirement items for all the requirements', () => {
wrapper.setData({ wrapper.setData({
requirements: { requirements: {
......
...@@ -5931,6 +5931,9 @@ msgstr "" ...@@ -5931,6 +5931,9 @@ msgstr ""
msgid "Create project label" msgid "Create project label"
msgstr "" msgstr ""
msgid "Create requirement"
msgstr ""
msgid "Create wildcard: %{searchTerm}" msgid "Create wildcard: %{searchTerm}"
msgstr "" msgstr ""
...@@ -6846,6 +6849,9 @@ msgstr "" ...@@ -6846,6 +6849,9 @@ msgstr ""
msgid "Describe the goal of the changes and what reviewers should be aware of." msgid "Describe the goal of the changes and what reviewers should be aware of."
msgstr "" msgstr ""
msgid "Describe the requirement here"
msgstr ""
msgid "Description" msgid "Description"
msgstr "" msgstr ""
...@@ -16973,6 +16979,9 @@ msgstr "" ...@@ -16973,6 +16979,9 @@ msgstr ""
msgid "Require users to prove ownership of custom domains" msgid "Require users to prove ownership of custom domains"
msgstr "" msgstr ""
msgid "Requirement"
msgstr ""
msgid "Requirements" msgid "Requirements"
msgstr "" msgstr ""
...@@ -18632,6 +18641,9 @@ msgstr "" ...@@ -18632,6 +18641,9 @@ msgstr ""
msgid "Something went wrong while closing the %{issuable}. Please try again later" msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr "" msgstr ""
msgid "Something went wrong while creating a requirement."
msgstr ""
msgid "Something went wrong while deleting description changes. Please try again." msgid "Something went wrong while deleting description changes. Please try again."
msgstr "" msgstr ""
......
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