Commit be62d061 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '207954-requirement-create-support' into 'master'

Add support for creating Requirements

Closes #207954

See merge request gitlab-org/gitlab!28795
parents b9299863 a3ceca88
<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';
import RequirementsLoading from './requirements_loading.vue';
import RequirementsEmptyState from './requirements_empty_state.vue';
import RequirementItem from './requirement_item.vue';
import RequirementForm from './requirement_form.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql';
import createRequirement from '../queries/createRequirement.mutation.graphql';
import { FilterState, DEFAULT_PAGE_SIZE } from '../constants';
......@@ -20,6 +23,7 @@ export default {
RequirementsLoading,
RequirementsEmptyState,
RequirementItem,
RequirementForm,
},
props: {
projectPath: {
......@@ -50,10 +54,6 @@ export default {
required: false,
default: '',
},
showCreateRequirement: {
type: Boolean,
required: true,
},
emptyStatePath: {
type: String,
required: true,
......@@ -105,6 +105,8 @@ export default {
},
data() {
return {
showCreateForm: false,
createRequirementRequestActive: false,
currentPage: this.page,
prevPageCursor: this.prev,
nextPageCursor: this.next,
......@@ -136,6 +138,16 @@ export default {
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: {
/**
* Update browser URL with updated query-param values
......@@ -166,6 +178,40 @@ export default {
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) {
const { startCursor, endCursor } = this.requirements.pageInfo;
......@@ -202,6 +248,12 @@ export default {
:current-tab-count="totalRequirements"
:current-page="currentPage"
/>
<requirement-form
v-if="showCreateForm"
:requirement-request-active="createRequirementRequestActive"
@save="handleNewRequirementSave"
@cancel="handleNewRequirementCancel"
/>
<ul
v-if="!requirementsListLoading && !requirementsListEmpty"
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';
Vue.use(VueApollo);
export default () => {
const btnNewRequirement = document.querySelector('.js-new-requirement');
const el = document.getElementById('js-requirements-app');
if (!el) {
......@@ -43,7 +42,6 @@ export default () => {
const ARCHIVED = parseInt(archived, 10);
return {
showCreateRequirement: false,
filterBy: stateFilterBy,
requirementsCount: {
OPENED,
......@@ -57,17 +55,6 @@ export default () => {
projectPath,
};
},
mounted() {
btnNewRequirement.addEventListener('click', this.handleClickNewRequirement);
},
beforeDestroy() {
btnNewRequirement.removeEventListener('click', this.handleClickNewRequirement);
},
methods: {
handleClickNewRequirement() {
this.showCreateRequirement = !this.showCreateRequirement;
},
},
render(createElement) {
return createElement('requirements-root', {
props: {
......@@ -77,7 +64,6 @@ export default () => {
page: parseInt(this.page, 10) || 1,
prev: this.prev,
next: this.next,
showCreateRequirement: this.showCreateRequirement,
emptyStatePath: this.emptyStatePath,
},
});
......
......@@ -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 {
......
......@@ -45,10 +45,42 @@ describe 'Requirements list', :js do
end
end
it 'shows button "New requirement"' do
page.within('.nav-controls') do
expect(page).to have_selector('button.js-new-requirement')
expect(find('button.js-new-requirement')).to have_content('New requirement')
context 'new requirement' do
it 'shows button "New requirement"' do
page.within('.nav-controls') do
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
......
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 { GlPagination } from '@gitlab/ui';
import createFlash from '~/flash';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.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 {
FilterState,
......@@ -17,6 +22,8 @@ jest.mock('ee/requirements/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
}));
jest.mock('~/flash');
const createComponent = ({
projectPath = 'gitlab-org/gitlab-shell',
filterBy = FilterState.opened,
......@@ -41,8 +48,10 @@ const createComponent = ({
list: [],
pageInfo: {},
count: {},
refetch: jest.fn(),
},
},
mutate: jest.fn(),
},
},
});
......@@ -51,6 +60,7 @@ describe('RequirementsRoot', () => {
let wrapper;
beforeEach(() => {
setFixtures('<button class="js-new-requirement">New requirement</button>');
wrapper = createComponent();
});
......@@ -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', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn());
......@@ -208,6 +304,16 @@ describe('RequirementsRoot', () => {
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', () => {
wrapper.setData({
requirements: {
......
......@@ -5937,6 +5937,9 @@ msgstr ""
msgid "Create project label"
msgstr ""
msgid "Create requirement"
msgstr ""
msgid "Create wildcard: %{searchTerm}"
msgstr ""
......@@ -6852,6 +6855,9 @@ msgstr ""
msgid "Describe the goal of the changes and what reviewers should be aware of."
msgstr ""
msgid "Describe the requirement here"
msgstr ""
msgid "Description"
msgstr ""
......@@ -17009,6 +17015,9 @@ msgstr ""
msgid "Require users to prove ownership of custom domains"
msgstr ""
msgid "Requirement"
msgstr ""
msgid "Requirements"
msgstr ""
......@@ -18668,6 +18677,9 @@ msgstr ""
msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr ""
msgid "Something went wrong while creating a requirement."
msgstr ""
msgid "Something went wrong while deleting description changes. Please try again."
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