Commit 88b09ea1 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '210306-requirement-update-support' into 'master'

Add support for editing Requirements

Closes #210306

See merge request gitlab-org/gitlab!28905
parents 203d2e31 dca999e1
...@@ -36,6 +36,9 @@ export default { ...@@ -36,6 +36,9 @@ export default {
disableSaveButton() { disableSaveButton() {
return this.title === '' || this.requirementRequestActive; return this.title === '' || this.requirementRequestActive;
}, },
reference() {
return `REQ-${this.requirement?.iid}`;
},
}, },
methods: { methods: {
handleSave() { handleSave() {
...@@ -53,33 +56,39 @@ export default { ...@@ -53,33 +56,39 @@ export default {
</script> </script>
<template> <template>
<div class="requirement-form" :class="{ 'p-3 border-bottom': isCreate }"> <div
<gl-form-group :label="fieldLabel" label-for="requirementTitle"> class="requirement-form"
<gl-form-textarea :class="{ 'p-3 border-bottom': isCreate, 'd-block d-sm-flex': !isCreate }"
id="requirementTitle" >
v-model.trim="title" <span v-if="!isCreate" class="text-muted mr-1">{{ reference }}</span>
autofocus <div class="requirement-form-container" :class="{ 'flex-grow-1 ml-sm-1 mt-1': !isCreate }">
resize <gl-form-group :label="fieldLabel" label-for="requirementTitle">
:disabled="requirementRequestActive" <gl-form-textarea
:placeholder="__('Describe the requirement here')" id="requirementTitle"
max-rows="25" v-model.trim="title"
class="requirement-form-textarea" autofocus
@keyup.escape.exact="$emit('cancel')" resize
/> :disabled="requirementRequestActive"
</gl-form-group> :placeholder="__('Describe the requirement here')"
<div class="d-flex requirement-form-actions"> max-rows="25"
<gl-deprecated-button class="requirement-form-textarea"
:disabled="disableSaveButton" @keyup.escape.exact="$emit('cancel')"
:loading="requirementRequestActive" />
category="primary" </gl-form-group>
variant="success" <div class="d-flex requirement-form-actions">
class="mr-auto js-requirement-save" <gl-deprecated-button
@click="handleSave" :disabled="disableSaveButton"
>{{ saveButtonLabel }}</gl-deprecated-button :loading="requirementRequestActive"
> category="primary"
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">{{ variant="success"
__('Cancel') class="mr-auto js-requirement-save"
}}</gl-deprecated-button> @click="handleSave"
>{{ saveButtonLabel }}</gl-deprecated-button
>
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">
{{ __('Cancel') }}
</gl-deprecated-button>
</div>
</div> </div>
</div> </div>
</template> </template>
...@@ -12,6 +12,8 @@ import { __, sprintf } from '~/locale'; ...@@ -12,6 +12,8 @@ import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility'; import { getTimeago } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import RequirementForm from './requirement_form.vue';
export default { export default {
components: { components: {
GlPopover, GlPopover,
...@@ -19,6 +21,7 @@ export default { ...@@ -19,6 +21,7 @@ export default {
GlAvatar, GlAvatar,
GlDeprecatedButton, GlDeprecatedButton,
GlIcon, GlIcon,
RequirementForm,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -33,6 +36,16 @@ export default { ...@@ -33,6 +36,16 @@ export default {
prop => value[prop], prop => value[prop],
), ),
}, },
showUpdateForm: {
type: Boolean,
required: false,
default: false,
},
updateRequirementRequestActive: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
reference() { reference() {
...@@ -70,13 +83,23 @@ export default { ...@@ -70,13 +83,23 @@ export default {
} }
return ''; return '';
}, },
handleUpdateRequirementSave(params) {
this.$emit('updateSave', params);
},
}, },
}; };
</script> </script>
<template> <template>
<li class="issue requirement"> <li class="issue requirement">
<div class="issue-box"> <requirement-form
v-if="showUpdateForm"
:requirement="requirement"
:requirement-request-active="updateRequirementRequestActive"
@save="handleUpdateRequirementSave"
@cancel="$emit('updateCancel')"
/>
<div v-else class="issue-box">
<div class="issuable-info-container"> <div class="issuable-info-container">
<span class="issuable-reference text-muted d-none d-sm-block mr-2">{{ reference }}</span> <span class="issuable-reference text-muted d-none d-sm-block mr-2">{{ reference }}</span>
<div class="issuable-main-info"> <div class="issuable-main-info">
...@@ -101,7 +124,13 @@ export default { ...@@ -101,7 +124,13 @@ export default {
<div class="issuable-meta"> <div class="issuable-meta">
<ul v-if="canUpdate || canArchive" class="controls flex-column flex-sm-row"> <ul v-if="canUpdate || canArchive" class="controls flex-column flex-sm-row">
<li v-if="canUpdate" class="requirement-edit d-sm-block"> <li v-if="canUpdate" class="requirement-edit d-sm-block">
<gl-deprecated-button v-gl-tooltip size="sm" class="border-0" :title="__('Edit')"> <gl-deprecated-button
v-gl-tooltip
size="sm"
class="border-0"
:title="__('Edit')"
@click="$emit('editClick', requirement.iid)"
>
<gl-icon name="pencil" /> <gl-icon name="pencil" />
</gl-deprecated-button> </gl-deprecated-button>
</li> </li>
......
...@@ -13,6 +13,7 @@ import RequirementForm from './requirement_form.vue'; ...@@ -13,6 +13,7 @@ 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 createRequirement from '../queries/createRequirement.mutation.graphql';
import updateRequirement from '../queries/updateRequirement.mutation.graphql';
import { FilterState, DEFAULT_PAGE_SIZE } from '../constants'; import { FilterState, DEFAULT_PAGE_SIZE } from '../constants';
...@@ -106,6 +107,7 @@ export default { ...@@ -106,6 +107,7 @@ export default {
data() { data() {
return { return {
showCreateForm: false, showCreateForm: false,
showUpdateFormForRequirement: 0,
createRequirementRequestActive: false, createRequirementRequestActive: false,
currentPage: this.page, currentPage: this.page,
prevPageCursor: this.prev, prevPageCursor: this.prev,
...@@ -181,6 +183,9 @@ export default { ...@@ -181,6 +183,9 @@ export default {
handleNewRequirementClick() { handleNewRequirementClick() {
this.showCreateForm = true; this.showCreateForm = true;
}, },
handleEditRequirementClick(iid) {
this.showUpdateFormForRequirement = iid;
},
handleNewRequirementSave(title) { handleNewRequirementSave(title) {
this.createRequirementRequestActive = true; this.createRequirementRequestActive = true;
return this.$apollo return this.$apollo
...@@ -212,6 +217,37 @@ export default { ...@@ -212,6 +217,37 @@ export default {
handleNewRequirementCancel() { handleNewRequirementCancel() {
this.showCreateForm = false; this.showCreateForm = false;
}, },
handleUpdateRequirementSave({ iid, title }) {
this.createRequirementRequestActive = true;
return this.$apollo
.mutate({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: this.projectPath,
iid,
title,
},
},
})
.then(({ data }) => {
if (!data.updateRequirement.errors.length) {
this.showUpdateFormForRequirement = 0;
} else {
throw new Error(`Error updating a requirement`);
}
})
.catch(e => {
createFlash(__('Something went wrong while updating a requirement.'));
Sentry.captureException(e);
})
.finally(() => {
this.createRequirementRequestActive = false;
});
},
handleUpdateRequirementCancel() {
this.showUpdateFormForRequirement = 0;
},
handlePageChange(page) { handlePageChange(page) {
const { startCursor, endCursor } = this.requirements.pageInfo; const { startCursor, endCursor } = this.requirements.pageInfo;
...@@ -262,6 +298,11 @@ export default { ...@@ -262,6 +298,11 @@ export default {
v-for="requirement in requirements.list" v-for="requirement in requirements.list"
:key="requirement.iid" :key="requirement.iid"
:requirement="requirement" :requirement="requirement"
:show-update-form="showUpdateFormForRequirement === requirement.iid"
:update-requirement-request-active="createRequirementRequestActive"
@updateSave="handleUpdateRequirementSave"
@updateCancel="handleUpdateRequirementCancel"
@editClick="handleEditRequirementClick"
/> />
</ul> </ul>
<gl-pagination <gl-pagination
......
mutation updateRequirement($updateRequirementInput: UpdateRequirementInput!) {
updateRequirement(input: $updateRequirementInput) {
clientMutationId
errors
requirement {
iid
title
state
updatedAt
}
}
}
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import RequirementsRoot from './components/requirements_root.vue'; import RequirementsRoot from './components/requirements_root.vue';
...@@ -16,7 +17,16 @@ export default () => { ...@@ -16,7 +17,16 @@ export default () => {
} }
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(), defaultClient: createDefaultClient(
{},
{
cacheConfig: {
dataIdFromObject: object =>
// eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings
object.__typename === 'Requirement' ? object.iid : defaultDataIdFromObject(object),
},
},
),
}); });
return new Vue({ return new Vue({
......
...@@ -102,6 +102,35 @@ describe 'Requirements list', :js do ...@@ -102,6 +102,35 @@ describe 'Requirements list', :js do
expect(page.find('.issuable-updated-at')).to have_content('updated 2 days ago') expect(page.find('.issuable-updated-at')).to have_content('updated 2 days ago')
end end
end end
it 'shows edit form when edit button is clicked for a requirement' do
page.within('.requirements-list li.requirement', match: :first) do
requirement_title = 'Foobar'
find('li.requirement-edit button[title="Edit"]').click
page.within('.requirement-form') do
find('textarea#requirementTitle').native.send_keys requirement_title
find('button.js-requirement-save').click
wait_for_all_requests
end
expect(page.find('.issue-title-text')).to have_content(requirement_title)
end
end
it 'saves updated title for requirement using edit form' do
page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-edit button[title="Edit"]').click
page.within('.requirement-form') do
expect(page.find('span')).to have_content("REQ-#{requirement1.iid}")
expect(page.find('textarea#requirementTitle')['value']).to have_content("#{requirement1.title}")
expect(page.find('.js-requirement-save')).to have_content('Save changes')
end
end
end
end end
context 'archived tab' do context 'archived tab' do
...@@ -128,7 +157,7 @@ describe 'Requirements list', :js do ...@@ -128,7 +157,7 @@ describe 'Requirements list', :js do
end end
end end
context 'archived tab' do context 'all tab' do
before do before do
find('li > a#state-all').click find('li > a#state-all').click
......
...@@ -49,6 +49,12 @@ describe('RequirementForm', () => { ...@@ -49,6 +49,12 @@ describe('RequirementForm', () => {
expect(wrapperWithRequirement.vm.saveButtonLabel).toBe('Save changes'); expect(wrapperWithRequirement.vm.saveButtonLabel).toBe('Save changes');
}); });
}); });
describe('reference', () => {
it('returns string containing `requirement.iid` prefixed with `REQ-`', () => {
expect(wrapperWithRequirement.vm.reference).toBe(`REQ-${mockRequirementsOpen[0].iid}`);
});
});
}); });
describe('methods', () => { describe('methods', () => {
...@@ -90,11 +96,15 @@ describe('RequirementForm', () => { ...@@ -90,11 +96,15 @@ describe('RequirementForm', () => {
expect(wrapperClasses).toContain('border-bottom'); expect(wrapperClasses).toContain('border-bottom');
}); });
it('renders component container element without classes `p-3 border-bottom` when form is in edit mode', () => { it('renders component container element with classes `d-block d-sm-flex` when form is in edit mode', () => {
const wrapperClasses = wrapperWithRequirement.classes(); const wrapperClasses = wrapperWithRequirement.classes();
expect(wrapperClasses).not.toContain('p-3'); expect(wrapperClasses).toContain('d-block');
expect(wrapperClasses).not.toContain('border-bottom'); expect(wrapperClasses).toContain('d-sm-flex');
});
it('renders element containing requirement reference when form is in edit mode', () => {
expect(wrapperWithRequirement.find('span').text()).toBe(`REQ-${mockRequirementsOpen[0].iid}`);
}); });
it('renders gl-form-group component', () => { it('renders gl-form-group component', () => {
......
...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
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 { requirement1, mockUserPermissions } from '../mock_data'; import { requirement1, mockUserPermissions } from '../mock_data';
...@@ -65,11 +66,34 @@ describe('RequirementItem', () => { ...@@ -65,11 +66,34 @@ describe('RequirementItem', () => {
}); });
}); });
describe('methods', () => {
describe('handleUpdateRequirementSave', () => {
it('emits `updateSave` event on component with params passed as it is', () => {
wrapper.vm.handleUpdateRequirementSave('foo');
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('updateSave')).toBeTruthy();
expect(wrapper.emitted('updateSave')[0]).toEqual(['foo']);
});
});
});
});
describe('template', () => { describe('template', () => {
it('renders component container element containing class `requirement`', () => { it('renders component container element containing class `requirement`', () => {
expect(wrapper.classes()).toContain('requirement'); expect(wrapper.classes()).toContain('requirement');
}); });
it('renders requirement-form component', () => {
wrapper.setProps({
showUpdateForm: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementForm).exists()).toBe(true);
});
});
it('renders element containing requirement reference', () => { it('renders element containing requirement reference', () => {
expect(wrapper.find('.issuable-reference').text()).toBe(`REQ-${requirement1.iid}`); expect(wrapper.find('.issuable-reference').text()).toBe(`REQ-${requirement1.iid}`);
}); });
......
...@@ -10,6 +10,7 @@ import RequirementItem from 'ee/requirements/components/requirement_item.vue'; ...@@ -10,6 +10,7 @@ import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import RequirementForm from 'ee/requirements/components/requirement_form.vue'; import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import createRequirement from 'ee/requirements/queries/createRequirement.mutation.graphql'; import createRequirement from 'ee/requirements/queries/createRequirement.mutation.graphql';
import updateRequirement from 'ee/requirements/queries/updateRequirement.mutation.graphql';
import { import {
FilterState, FilterState,
...@@ -166,6 +167,14 @@ describe('RequirementsRoot', () => { ...@@ -166,6 +167,14 @@ describe('RequirementsRoot', () => {
}); });
}); });
describe('handleEditRequirementClick', () => {
it('sets `showUpdateFormForRequirement` prop to value of passed param', () => {
wrapper.vm.handleEditRequirementClick('10');
expect(wrapper.vm.showUpdateFormForRequirement).toBe('10');
});
});
describe('handleNewRequirementSave', () => { describe('handleNewRequirementSave', () => {
const mockMutationResult = { const mockMutationResult = {
data: { data: {
...@@ -232,6 +241,81 @@ describe('RequirementsRoot', () => { ...@@ -232,6 +241,81 @@ describe('RequirementsRoot', () => {
}); });
}); });
describe('handleUpdateRequirementSave', () => {
const mockMutationResult = {
data: {
createRequirement: {
errors: [],
requirement: {
iid: '1',
title: 'foo',
},
},
},
};
it('sets `createRequirementRequestActive` prop to `true`', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult));
wrapper.vm.handleUpdateRequirementSave('foo');
expect(wrapper.vm.createRequirementRequestActive).toBe(true);
});
it('calls `$apollo.mutate` with updateRequirement mutation and `projectPath`, `iid` & `title` as variables', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult));
wrapper.vm.handleUpdateRequirementSave({
iid: '1',
title: 'foo',
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: 'gitlab-org/gitlab-shell',
iid: '1',
title: 'foo',
},
},
}),
);
});
it('sets `showUpdateFormForRequirement` to `0` and `createRequirementRequestActive` prop to `false` when request is successful', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult));
return wrapper.vm
.handleUpdateRequirementSave({
iid: '1',
title: 'foo',
})
.then(() => {
expect(wrapper.vm.showUpdateFormForRequirement).toBe(0);
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.handleUpdateRequirementSave('foo').then(() => {
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while updating a requirement.',
);
expect(wrapper.vm.createRequirementRequestActive).toBe(false);
});
});
});
describe('handleNewRequirementCancel', () => { describe('handleNewRequirementCancel', () => {
it('sets `showCreateForm` prop to `false`', () => { it('sets `showCreateForm` prop to `false`', () => {
wrapper.setData({ wrapper.setData({
...@@ -244,6 +328,14 @@ describe('RequirementsRoot', () => { ...@@ -244,6 +328,14 @@ describe('RequirementsRoot', () => {
}); });
}); });
describe('handleUpdateRequirementCancel', () => {
it('sets `showUpdateFormForRequirement` prop to `0`', () => {
wrapper.vm.handleUpdateRequirementCancel();
expect(wrapper.vm.showUpdateFormForRequirement).toBe(0);
});
});
describe('handlePageChange', () => { describe('handlePageChange', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn()); jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn());
......
...@@ -18755,6 +18755,9 @@ msgstr "" ...@@ -18755,6 +18755,9 @@ msgstr ""
msgid "Something went wrong while stopping this environment. Please try again." msgid "Something went wrong while stopping this environment. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while updating a requirement."
msgstr ""
msgid "Something went wrong while updating your list settings" msgid "Something went wrong while updating your list settings"
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