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 {
disableSaveButton() {
return this.title === '' || this.requirementRequestActive;
},
reference() {
return `REQ-${this.requirement?.iid}`;
},
},
methods: {
handleSave() {
......@@ -53,7 +56,12 @@ export default {
</script>
<template>
<div class="requirement-form" :class="{ 'p-3 border-bottom': isCreate }">
<div
class="requirement-form"
:class="{ 'p-3 border-bottom': isCreate, 'd-block d-sm-flex': !isCreate }"
>
<span v-if="!isCreate" class="text-muted mr-1">{{ reference }}</span>
<div class="requirement-form-container" :class="{ 'flex-grow-1 ml-sm-1 mt-1': !isCreate }">
<gl-form-group :label="fieldLabel" label-for="requirementTitle">
<gl-form-textarea
id="requirementTitle"
......@@ -77,9 +85,10 @@ export default {
@click="handleSave"
>{{ saveButtonLabel }}</gl-deprecated-button
>
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">{{
__('Cancel')
}}</gl-deprecated-button>
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">
{{ __('Cancel') }}
</gl-deprecated-button>
</div>
</div>
</div>
</template>
......@@ -12,6 +12,8 @@ import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import RequirementForm from './requirement_form.vue';
export default {
components: {
GlPopover,
......@@ -19,6 +21,7 @@ export default {
GlAvatar,
GlDeprecatedButton,
GlIcon,
RequirementForm,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -33,6 +36,16 @@ export default {
prop => value[prop],
),
},
showUpdateForm: {
type: Boolean,
required: false,
default: false,
},
updateRequirementRequestActive: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
reference() {
......@@ -70,13 +83,23 @@ export default {
}
return '';
},
handleUpdateRequirementSave(params) {
this.$emit('updateSave', params);
},
},
};
</script>
<template>
<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">
<span class="issuable-reference text-muted d-none d-sm-block mr-2">{{ reference }}</span>
<div class="issuable-main-info">
......@@ -101,7 +124,13 @@ export default {
<div class="issuable-meta">
<ul v-if="canUpdate || canArchive" class="controls flex-column flex-sm-row">
<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-deprecated-button>
</li>
......
......@@ -13,6 +13,7 @@ import RequirementForm from './requirement_form.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql';
import createRequirement from '../queries/createRequirement.mutation.graphql';
import updateRequirement from '../queries/updateRequirement.mutation.graphql';
import { FilterState, DEFAULT_PAGE_SIZE } from '../constants';
......@@ -106,6 +107,7 @@ export default {
data() {
return {
showCreateForm: false,
showUpdateFormForRequirement: 0,
createRequirementRequestActive: false,
currentPage: this.page,
prevPageCursor: this.prev,
......@@ -181,6 +183,9 @@ export default {
handleNewRequirementClick() {
this.showCreateForm = true;
},
handleEditRequirementClick(iid) {
this.showUpdateFormForRequirement = iid;
},
handleNewRequirementSave(title) {
this.createRequirementRequestActive = true;
return this.$apollo
......@@ -212,6 +217,37 @@ export default {
handleNewRequirementCancel() {
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) {
const { startCursor, endCursor } = this.requirements.pageInfo;
......@@ -262,6 +298,11 @@ export default {
v-for="requirement in requirements.list"
:key="requirement.iid"
:requirement="requirement"
:show-update-form="showUpdateFormForRequirement === requirement.iid"
:update-requirement-request-active="createRequirementRequestActive"
@updateSave="handleUpdateRequirementSave"
@updateCancel="handleUpdateRequirementCancel"
@editClick="handleEditRequirementClick"
/>
</ul>
<gl-pagination
......
mutation updateRequirement($updateRequirementInput: UpdateRequirementInput!) {
updateRequirement(input: $updateRequirementInput) {
clientMutationId
errors
requirement {
iid
title
state
updatedAt
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import RequirementsRoot from './components/requirements_root.vue';
......@@ -16,7 +17,16 @@ export default () => {
}
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({
......
......@@ -102,6 +102,35 @@ describe 'Requirements list', :js do
expect(page.find('.issuable-updated-at')).to have_content('updated 2 days ago')
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
context 'archived tab' do
......@@ -128,7 +157,7 @@ describe 'Requirements list', :js do
end
end
context 'archived tab' do
context 'all tab' do
before do
find('li > a#state-all').click
......
......@@ -49,6 +49,12 @@ describe('RequirementForm', () => {
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', () => {
......@@ -90,11 +96,15 @@ describe('RequirementForm', () => {
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();
expect(wrapperClasses).not.toContain('p-3');
expect(wrapperClasses).not.toContain('border-bottom');
expect(wrapperClasses).toContain('d-block');
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', () => {
......
......@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import { requirement1, mockUserPermissions } from '../mock_data';
......@@ -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', () => {
it('renders component container element containing class `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', () => {
expect(wrapper.find('.issuable-reference').text()).toBe(`REQ-${requirement1.iid}`);
});
......
......@@ -10,6 +10,7 @@ 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 updateRequirement from 'ee/requirements/queries/updateRequirement.mutation.graphql';
import {
FilterState,
......@@ -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', () => {
const mockMutationResult = {
data: {
......@@ -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', () => {
it('sets `showCreateForm` prop to `false`', () => {
wrapper.setData({
......@@ -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', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn());
......
......@@ -18755,6 +18755,9 @@ msgstr ""
msgid "Something went wrong while stopping this environment. Please try again."
msgstr ""
msgid "Something went wrong while updating a requirement."
msgstr ""
msgid "Something went wrong while updating your list settings"
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