Commit 58d579c1 authored by Kushal Pandya's avatar Kushal Pandya

Add support for archive & reopening Requirements

Adds support for archiving open Requirements and
reopening archived requirements, also dynamically
updates counts on tabs and nav sidebar.
parent 6cf6d3ec
<script>
import { GlFormGroup, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import { MAX_TITLE_LENGTH } from '../constants';
export default {
titleInvalidMessage: sprintf(__('Requirement title cannot have more than %{limit} characters.'), {
limit: MAX_TITLE_LENGTH,
}),
components: {
GlFormGroup,
GlFormTextarea,
......@@ -33,8 +38,11 @@ export default {
saveButtonLabel() {
return this.isCreate ? __('Create requirement') : __('Save changes');
},
titleInvalid() {
return this.title.length > MAX_TITLE_LENGTH;
},
disableSaveButton() {
return this.title === '' || this.requirementRequestActive;
return this.title === '' || this.titleInvalid || this.requirementRequestActive;
},
reference() {
return `REQ-${this.requirement?.iid}`;
......@@ -62,7 +70,13 @@ export default {
>
<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-group
:label="fieldLabel"
:invalid-feedback="$options.titleInvalidMessage"
:state="!titleInvalid"
class="gl-show-field-errors"
label-for="requirementTitle"
>
<gl-form-textarea
id="requirementTitle"
v-model.trim="title"
......@@ -72,6 +86,7 @@ export default {
:placeholder="__('Describe the requirement here')"
max-rows="25"
class="requirement-form-textarea"
:class="{ 'gl-field-error-outline': titleInvalid }"
@keyup.escape.exact="$emit('cancel')"
/>
</gl-form-group>
......@@ -85,9 +100,9 @@ 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>
......
......@@ -6,6 +6,7 @@ import {
GlAvatar,
GlDeprecatedButton,
GlIcon,
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
......@@ -14,6 +15,8 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
import RequirementForm from './requirement_form.vue';
import { FilterState } from '../constants';
export default {
components: {
GlPopover,
......@@ -21,6 +24,7 @@ export default {
GlAvatar,
GlDeprecatedButton,
GlIcon,
GlLoadingIcon,
RequirementForm,
},
directives: {
......@@ -46,6 +50,11 @@ export default {
required: false,
default: false,
},
stateChangeRequestActive: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
reference() {
......@@ -67,6 +76,9 @@ export default {
timeAgo: esc(getTimeago().format(this.requirement.updatedAt)),
});
},
isArchived() {
return this.requirement?.state === FilterState.archived;
},
author() {
return this.requirement.author;
},
......@@ -86,12 +98,24 @@ export default {
handleUpdateRequirementSave(params) {
this.$emit('updateSave', params);
},
handleArchiveClick() {
this.$emit('archiveClick', {
iid: this.requirement.iid,
state: FilterState.archived,
});
},
handleReopenClick() {
this.$emit('reopenClick', {
iid: this.requirement.iid,
state: FilterState.opened,
});
},
},
};
</script>
<template>
<li class="issue requirement">
<li class="issue requirement" :class="{ 'disabled-content': stateChangeRequestActive }">
<requirement-form
v-if="showUpdateForm"
:requirement="requirement"
......@@ -123,7 +147,7 @@ export default {
</div>
<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">
<li v-if="canUpdate && !isArchived" class="requirement-edit d-sm-block">
<gl-deprecated-button
v-gl-tooltip
size="sm"
......@@ -134,11 +158,27 @@ export default {
<gl-icon name="pencil" />
</gl-deprecated-button>
</li>
<li v-if="canArchive" class="requirement-archive d-sm-block">
<gl-deprecated-button v-gl-tooltip size="sm" class="border-0" :title="__('Archive')">
<gl-icon name="archive" />
<li v-if="canArchive && !isArchived" class="requirement-archive d-sm-block">
<gl-deprecated-button
v-gl-tooltip
size="sm"
class="border-0"
:title="__('Archive')"
@click="handleArchiveClick"
>
<gl-icon v-if="!stateChangeRequestActive" name="archive" />
<gl-loading-icon v-else />
</gl-deprecated-button>
</li>
<li v-if="isArchived" class="requirement-reopen d-sm-block">
<gl-deprecated-button
size="xs"
class="p-2"
:loading="stateChangeRequestActive"
@click="handleReopenClick"
>{{ __('Reopen') }}</gl-deprecated-button
>
</li>
</ul>
<div class="float-right issuable-updated-at d-none d-sm-inline-block">
<span
......
......@@ -86,15 +86,15 @@ export default {
},
update(data) {
const requirementsRoot = data.project?.requirements;
const count = data.project?.requirementStatesCount;
const { opened = 0, archived = 0 } = data.project?.requirementStatesCount;
return {
list: requirementsRoot?.nodes || [],
pageInfo: requirementsRoot?.pageInfo || {},
count: {
OPENED: count.opened,
ARCHIVED: count.archived,
ALL: count.opened + count.archived,
OPENED: opened,
ARCHIVED: archived,
ALL: opened + archived,
},
};
},
......@@ -105,10 +105,13 @@ export default {
},
},
data() {
const tabsContainerEl = document.querySelector('.js-requirements-state-filters');
return {
showCreateForm: false,
showUpdateFormForRequirement: 0,
createRequirementRequestActive: false,
stateChangeRequestActiveFor: 0,
currentPage: this.page,
prevPageCursor: this.prev,
nextPageCursor: this.next,
......@@ -117,9 +120,23 @@ export default {
count: {},
pageInfo: {},
},
openedCount: this.requirementsCount[FilterState.opened],
archivedCount: this.requirementsCount[FilterState.archived],
countEls: {
opened: tabsContainerEl.querySelector('.js-opened-count'),
archived: tabsContainerEl.querySelector('.js-archived-count'),
all: tabsContainerEl.querySelector('.js-all-count'),
nav: document.querySelector('.js-nav-requirements-count'),
navFlyOut: document.querySelector('.js-nav-requirements-count-fly-out'),
},
};
},
computed: {
requirementsList() {
return this.filterBy !== FilterState.all
? this.requirements.list.filter(({ state }) => state === this.filterBy)
: this.requirements.list;
},
requirementsListLoading() {
return this.$apollo.queries.requirements.loading;
},
......@@ -140,15 +157,34 @@ export default {
return nextPage > Math.ceil(this.totalRequirements / DEFAULT_PAGE_SIZE) ? null : nextPage;
},
},
watch: {
requirements() {
const totalCount = this.requirements.count.ALL;
this.countEls.all.innerText = totalCount;
this.countEls.nav.innerText = totalCount;
this.countEls.navFlyOut.innerText = totalCount;
},
openedCount(value) {
this.countEls.opened.innerText = value;
},
archivedCount(value) {
this.countEls.archived.innerText = value;
},
},
mounted() {
document
.querySelector('.js-new-requirement')
.addEventListener('click', this.handleNewRequirementClick);
if (this.filterBy === FilterState.opened) {
document
.querySelector('.js-new-requirement')
.addEventListener('click', this.handleNewRequirementClick);
}
},
beforeDestroy() {
document
.querySelector('.js-new-requirement')
.removeEventListener('click', this.handleNewRequirementClick);
if (this.filterBy === FilterState.opened) {
document
.querySelector('.js-new-requirement')
.removeEventListener('click', this.handleNewRequirementClick);
}
},
methods: {
/**
......@@ -180,6 +216,31 @@ export default {
replace: true,
});
},
updateRequirement({ iid, title, state, errorFlashMessage }) {
const updateRequirementInput = {
projectPath: this.projectPath,
iid,
};
if (title) {
updateRequirementInput.title = title;
}
if (state) {
updateRequirementInput.state = state;
}
return this.$apollo
.mutate({
mutation: updateRequirement,
variables: {
updateRequirementInput,
},
})
.catch(e => {
createFlash(errorFlashMessage);
Sentry.captureException(e);
});
},
handleNewRequirementClick() {
this.showCreateForm = true;
},
......@@ -202,6 +263,7 @@ export default {
if (!data.createRequirement.errors.length) {
this.showCreateForm = false;
this.$apollo.queries.requirements.refetch();
this.openedCount += 1;
} else {
throw new Error(`Error creating a requirement`);
}
......@@ -217,19 +279,12 @@ export default {
handleNewRequirementCancel() {
this.showCreateForm = false;
},
handleUpdateRequirementSave({ iid, title }) {
handleUpdateRequirementSave(params) {
this.createRequirementRequestActive = true;
return this.$apollo
.mutate({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: this.projectPath,
iid,
title,
},
},
})
return this.updateRequirement({
...params,
errorFlashMessage: __('Something went wrong while updating a requirement.'),
})
.then(({ data }) => {
if (!data.updateRequirement.errors.length) {
this.showUpdateFormForRequirement = 0;
......@@ -237,14 +292,34 @@ export default {
throw new Error(`Error updating a requirement`);
}
})
.catch(e => {
createFlash(__('Something went wrong while updating a requirement.'));
Sentry.captureException(e);
})
.finally(() => {
this.createRequirementRequestActive = false;
});
},
handleRequirementStateChange(params) {
this.stateChangeRequestActiveFor = params.iid;
return this.updateRequirement({
...params,
errorFlashMessage:
params.state === FilterState.opened
? __('Something went wrong while reopening a requirement.')
: __('Something went wrong while archiving a requirement.'),
}).then(({ data }) => {
if (!data.updateRequirement.errors.length) {
this.stateChangeRequestActiveFor = 0;
} else {
throw new Error(`Error archiving a requirement`);
}
if (params.state === FilterState.opened) {
this.openedCount += 1;
this.archivedCount -= 1;
} else {
this.openedCount -= 1;
this.archivedCount += 1;
}
});
},
handleUpdateRequirementCancel() {
this.showUpdateFormForRequirement = 0;
},
......@@ -295,14 +370,17 @@ export default {
class="content-list issuable-list issues-list requirements-list"
>
<requirement-item
v-for="requirement in requirements.list"
v-for="requirement in requirementsList"
:key="requirement.iid"
:requirement="requirement"
:show-update-form="showUpdateFormForRequirement === requirement.iid"
:update-requirement-request-active="createRequirementRequestActive"
:state-change-request-active="stateChangeRequestActiveFor === requirement.iid"
@updateSave="handleUpdateRequirementSave"
@updateCancel="handleUpdateRequirementCancel"
@editClick="handleEditRequirementClick"
@archiveClick="handleRequirementStateChange"
@reopenClick="handleRequirementStateChange"
/>
</ul>
<gl-pagination
......
......@@ -12,3 +12,5 @@ export const FilterStateEmptyMessage = {
};
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_TITLE_LENGTH = 255;
......@@ -23,6 +23,18 @@
}
.requirements-list-container {
.requirements-list {
li .issuable-main-info {
// These rules prevent adjecant REQ ID from wrapping
// when requirement title is too long.
flex-basis: inherit;
// Value `100` ensures that requirement title
// takes up maximum available horizontal space
// while still preventing REQ ID from wrapping.
flex-grow: 100;
}
}
.issuable-info {
// The size here is specific to correctly
// align info row perfectly with action buttons & updated date.
......
......@@ -9,12 +9,12 @@
= sprite_icon('requirements')
%span.nav-item-name
= _('Requirements')
%span.badge.badge-pill.count= number_with_delimiter(total_count)
%span.badge.badge-pill.count.js-nav-requirements-count= number_with_delimiter(total_count)
%ul.sidebar-sub-level-items
= nav_link(path: 'requirements#index', html_options: { class: "fly-out-top-item" } ) do
= link_to project_requirements_path(project) do
%strong.fly-out-top-item-name= _('Requirements')
%span.badge.badge-pill.count.requirements_counter.fly-out-badge= number_with_delimiter(total_count)
%span.badge.badge-pill.count.requirements_counter.fly-out-badge.js-nav-requirements-count-fly-out= number_with_delimiter(total_count)
%li.divider.fly-out-top-item
= nav_link(path: 'requirements#index', html_options: { class: 'home' }) do
= link_to project_requirements_path(project), title: 'List' do
......
......@@ -3,28 +3,31 @@
- page_context_word = type.to_s.humanize(capitalize: false)
- @content_class = 'requirements-container'
- ignore_page_params = ['next', 'prev', 'page']
- requirements_count = Hash.new(0).merge(@project.requirements.counts_by_state)
- is_open_tab = params[:state].nil? || params[:state] == 'opened'
.top-area
%ul.nav-links.mobile-separator.requirements-state-filters
%li{ class: active_when(params[:state].nil? || params[:state] == 'opened') }>
= link_to page_filter_path(state: 'opened'), id: 'state-opened', title: (_("Filter by %{issuable_type} that are currently opened.") % { issuable_type: page_context_word }), data: { state: 'opened' } do
%ul.nav-links.mobile-separator.requirements-state-filters.js-requirements-state-filters
%li{ class: active_when(is_open_tab) }>
= link_to page_filter_path(state: 'opened', without: ignore_page_params), id: 'state-opened', title: (_("Filter by %{issuable_type} that are currently opened.") % { issuable_type: page_context_word }), data: { state: 'opened' } do
= _('Open')
%span.badge.badge-pill= requirements_count['opened']
%span.badge.badge-pill.js-opened-count= requirements_count['opened']
%li{ class: active_when(params[:state] == 'archived') }>
= link_to page_filter_path(state: 'archived'), id: 'state-archived', title: (_("Filter by %{issuable_type} that are currently archived.") % { issuable_type: page_context_word }), data: { state: 'archived' } do
= link_to page_filter_path(state: 'archived', without: ignore_page_params), id: 'state-archived', title: (_("Filter by %{issuable_type} that are currently archived.") % { issuable_type: page_context_word }), data: { state: 'archived' } do
= _('Archived')
%span.badge.badge-pill= requirements_count['archived']
%span.badge.badge-pill.js-archived-count= requirements_count['archived']
%li{ class: active_when(params[:state] == 'all') }>
= link_to page_filter_path(state: 'all'), id: 'state-all', title: (_("Show all %{issuable_type}.") % { issuable_type: page_context_word }), data: { state: 'all' } do
= link_to page_filter_path(state: 'all', without: ignore_page_params), id: 'state-all', title: (_("Show all %{issuable_type}.") % { issuable_type: page_context_word }), data: { state: 'all' } do
= _('All')
%span.badge.badge-pill= requirements_count['opened'] + requirements_count['archived']
%span.badge.badge-pill.js-all-count= requirements_count['opened'] + requirements_count['archived']
.nav-controls
%button.btn.btn-success.js-new-requirement.qa-new-requirement-button{ type: 'button' }
= _('New requirement')
- if is_open_tab
%button.btn.btn-success.js-new-requirement.qa-new-requirement-button{ type: 'button' }
= _('New requirement')
#js-requirements-app{ data: { filter_by: params[:state],
page: params[:page],
......
......@@ -10,6 +10,19 @@ describe 'Requirements list', :js do
let_it_be(:requirement3) { create(:requirement, project: project, title: 'Some requirement-3', author: user, created_at: 7.days.ago, updated_at: 2.days.ago) }
let_it_be(:requirement_archived) { create(:requirement, project: project, title: 'Some requirement-3', state: :archived, author: user, created_at: 8.days.ago, updated_at: 2.days.ago) }
def create_requirement(title)
page.within('.nav-controls') do
find('button.js-new-requirement').click
end
page.within('.requirements-list-container') do
find('textarea#requirementTitle').native.send_keys title
find('button.js-requirement-save').click
wait_for_all_requests
end
end
before do
stub_licensed_features(requirements: true)
project.add_maintainer(user)
......@@ -46,13 +59,6 @@ describe 'Requirements list', :js do
end
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
......@@ -64,27 +70,43 @@ describe 'Requirements list', :js do
end
it 'creates new requirement' do
page.within('.nav-controls') do
find('button.js-new-requirement').click
end
requirement_title = 'Foobar'
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
create_requirement(requirement_title)
page.within('.requirements-list-container') do
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
it 'updates requirements count in nav sidebar and opened and all tab badges' do
expect(page.find('.js-nav-requirements-count')).to have_content('4')
page.within('.requirements-state-filters') do
expect(find('li > a#state-opened .badge')).to have_content('3')
expect(find('li > a#state-all .badge')).to have_content('4')
end
create_requirement('Foobar')
expect(page.find('.js-nav-requirements-count')).to have_content('5')
page.within('.requirements-state-filters') do
expect(find('li > a#state-opened .badge')).to have_content('4')
expect(find('li > a#state-all .badge')).to have_content('5')
end
end
end
context 'open tab' 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 list of all open requirements' do
page.within('.requirements-list-container .requirements-list') do
expect(page).to have_selector('li.requirement', count: 3)
......@@ -131,6 +153,20 @@ describe 'Requirements list', :js do
end
end
end
it 'archives a requirement' do
page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-archive button[title="Archive"]').click
wait_for_requests
end
expect(page.find('.requirements-list-container')).to have_selector('li.requirement', count: 2)
page.within('.requirements-state-filters') do
expect(find('li > a#state-opened .badge')).to have_content('2')
expect(find('li > a#state-archived .badge')).to have_content('2')
end
end
end
context 'archived tab' do
......@@ -140,6 +176,12 @@ describe 'Requirements list', :js do
wait_for_requests
end
it 'does not show button "New requirement"' do
page.within('.nav-controls') do
expect(page).not_to have_selector('button.js-new-requirement')
end
end
it 'shows list of all archived requirements' do
page.within('.requirements-list-container .requirements-list') do
expect(page).to have_selector('li.requirement', count: 1)
......@@ -152,9 +194,24 @@ describe 'Requirements list', :js do
expect(page.find('.issue-title-text')).to have_content(requirement_archived.title)
expect(page.find('.issuable-authored')).to have_content('created 1 week ago by')
expect(page.find('.author')).to have_content(user.name)
expect(page.find('.controls')).to have_selector('li.requirement-reopen button', text: 'Reopen')
expect(page.find('.issuable-updated-at')).to have_content('updated 2 days ago')
end
end
it 'reopens a requirement' do
page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-reopen button').click
wait_for_requests
end
expect(page.find('.requirements-list-container')).to have_selector('li.requirement', count: 0)
page.within('.requirements-state-filters') do
expect(find('li > a#state-opened .badge')).to have_content('4')
expect(find('li > a#state-archived .badge')).to have_content('0')
end
end
end
context 'all tab' do
......@@ -164,6 +221,12 @@ describe 'Requirements list', :js do
wait_for_requests
end
it 'does not show button "New requirement"' do
page.within('.nav-controls') do
expect(page).not_to have_selector('button.js-new-requirement')
end
end
it 'shows list of all requirements' do
page.within('.requirements-list-container .requirements-list') do
expect(page).to have_selector('li.requirement', count: 4)
......
......@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import { MAX_TITLE_LENGTH } from 'ee/requirements/constants';
import { mockRequirementsOpen } from '../mock_data';
......@@ -50,6 +51,25 @@ describe('RequirementForm', () => {
});
});
describe('titleInvalid', () => {
it('returns `false` when `title` length is less than max title limit', () => {
expect(wrapper.vm.titleInvalid).toBe(false);
});
it('returns `true` when `title` length is more than max title limit', () => {
wrapper.setData({
title: Array(MAX_TITLE_LENGTH + 1)
.fill()
.map(() => 'a')
.join(''),
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.titleInvalid).toBe(true);
});
});
});
describe('reference', () => {
it('returns string containing `requirement.iid` prefixed with `REQ-`', () => {
expect(wrapperWithRequirement.vm.reference).toBe(`REQ-${mockRequirementsOpen[0].iid}`);
......@@ -113,6 +133,10 @@ describe('RequirementForm', () => {
expect(glFormGroup.exists()).toBe(true);
expect(glFormGroup.attributes('label')).toBe('New requirement');
expect(glFormGroup.attributes('label-for')).toBe('requirementTitle');
expect(glFormGroup.attributes('invalid-feedback')).toBe(
`Requirement title cannot have more than ${MAX_TITLE_LENGTH} characters.`,
);
expect(glFormGroup.attributes('state')).toBe('true');
});
it('renders gl-form-textarea component', () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { GlLink, GlDeprecatedButton, GlIcon, GlLoadingIcon } 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';
import { requirement1, requirementArchived, mockUserPermissions } from '../mock_data';
const createComponent = (requirement = requirement1) =>
shallowMount(RequirementItem, {
......@@ -15,13 +15,16 @@ const createComponent = (requirement = requirement1) =>
describe('RequirementItem', () => {
let wrapper;
let wrapperArchived;
beforeEach(() => {
wrapper = createComponent();
wrapperArchived = createComponent(requirementArchived);
});
afterEach(() => {
wrapper.destroy();
wrapperArchived.destroy();
});
describe('computed', () => {
......@@ -59,6 +62,16 @@ describe('RequirementItem', () => {
});
});
describe('isArchived', () => {
it('returns `true` when current requirement is archived', () => {
expect(wrapperArchived.vm.isArchived).toBe(true);
});
it('returns `false` when current requirement is archived', () => {
expect(wrapper.vm.isArchived).toBe(false);
});
});
describe('author', () => {
it('returns value of `requirement.author`', () => {
expect(wrapper.vm.author).toBe(requirement1.author);
......@@ -77,6 +90,38 @@ describe('RequirementItem', () => {
});
});
});
describe('handleArchiveClick', () => {
it('emits `archiveClick` event on component with object containing `requirement.iid` & `state` as "ARCHIVED" as param', () => {
wrapper.vm.handleArchiveClick();
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('archiveClick')).toBeTruthy();
expect(wrapper.emitted('archiveClick')[0]).toEqual([
{
iid: requirement1.iid,
state: 'ARCHIVED',
},
]);
});
});
});
describe('handleReopenClick', () => {
it('emits `reopenClick` event on component with object containing `requirement.iid` & `state` as "OPENED" as param', () => {
wrapperArchived.vm.handleReopenClick();
return wrapperArchived.vm.$nextTick(() => {
expect(wrapperArchived.emitted('reopenClick')).toBeTruthy();
expect(wrapperArchived.emitted('reopenClick')[0]).toEqual([
{
iid: requirementArchived.iid,
state: 'OPENED',
},
]);
});
});
});
});
describe('template', () => {
......@@ -84,6 +129,16 @@ describe('RequirementItem', () => {
expect(wrapper.classes()).toContain('requirement');
});
it('renders component container element with class `disabled-content` when `stateChangeRequestActive` prop is true', () => {
wrapper.setProps({
stateChangeRequestActive: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.classes()).toContain('disabled-content');
});
});
it('renders requirement-form component', () => {
wrapper.setProps({
showUpdateForm: true,
......@@ -165,6 +220,29 @@ describe('RequirementItem', () => {
wrapperNoArchive.destroy();
});
it('renders loading icon within archive button when `stateChangeRequestActive` prop is true', () => {
wrapper.setProps({
stateChangeRequestActive: true,
});
return wrapper.vm.$nextTick(() => {
expect(
wrapper
.find('.requirement-archive')
.find(GlLoadingIcon)
.exists(),
).toBe(true);
});
});
it('renders `Reopen` button when current requirement is archived', () => {
const reopenButton = wrapperArchived.find('.requirement-reopen').find(GlDeprecatedButton);
expect(reopenButton.exists()).toBe(true);
expect(reopenButton.props('loading')).toBe(false);
expect(reopenButton.text()).toBe('Reopen');
});
it('renders element containing requirement updated at', () => {
const updatedAtEl = wrapper.find('.issuable-meta .issuable-updated-at > span');
......
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import createFlash from '~/flash';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
......@@ -21,6 +22,11 @@ import {
jest.mock('ee/requirements/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
FilterState: {
opened: 'OPENED',
archived: 'ARCHIVED',
all: 'ALL',
},
}));
jest.mock('~/flash');
......@@ -61,7 +67,16 @@ describe('RequirementsRoot', () => {
let wrapper;
beforeEach(() => {
setFixtures('<button class="js-new-requirement">New requirement</button>');
setFixtures(`
<div class="js-nav-requirements-count"></div>
<div class="js-nav-requirements-count-fly-out"></div>
<div class="js-requirements-state-filters">
<span class="js-opened-count"></span>
<span class="js-archived-count"></span>
<span class="js-all-count"></span>
</div>
<button class="js-new-requirement">New requirement</button>
`);
wrapper = createComponent();
});
......@@ -139,6 +154,18 @@ describe('RequirementsRoot', () => {
});
describe('methods', () => {
const mockUpdateMutationResult = {
data: {
updateRequirement: {
errors: [],
requirement: {
iid: '1',
title: 'foo',
},
},
},
};
describe('updateUrl', () => {
it('updates window URL with query params `page` and `prev`', () => {
wrapper.vm.updateUrl({
......@@ -159,6 +186,87 @@ describe('RequirementsRoot', () => {
});
});
describe('updateRequirement', () => {
it('calls `$apollo.mutate` with `updateRequirement` mutation and variables containing `projectPath` & `iid`', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult);
wrapper.vm.updateRequirement({
iid: '1',
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: 'gitlab-org/gitlab-shell',
iid: '1',
},
},
}),
);
});
it('calls `$apollo.mutate` with variables containing `title` when it is included in object param', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult);
wrapper.vm.updateRequirement({
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('calls `$apollo.mutate` with variables containing `state` when it is included in object param', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult);
wrapper.vm.updateRequirement({
iid: '1',
state: FilterState.opened,
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: 'gitlab-org/gitlab-shell',
iid: '1',
state: FilterState.opened,
},
},
}),
);
});
it('calls `createFlash` with provided `errorFlashMessage` param and `Sentry.captureException` when request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(new Error());
jest.spyOn(Sentry, 'captureException').mockImplementation();
return wrapper.vm
.updateRequirement({
iid: '1',
errorFlashMessage: 'Something went wrong',
})
.then(() => {
expect(createFlash).toHaveBeenCalledWith('Something went wrong');
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Object));
});
});
});
describe('handleNewRequirementClick', () => {
it('sets `showCreateForm` prop to `true`', () => {
wrapper.vm.handleNewRequirementClick();
......@@ -242,56 +350,35 @@ 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));
jest.spyOn(wrapper.vm, 'updateRequirement').mockResolvedValue(mockUpdateMutationResult);
wrapper.vm.handleUpdateRequirementSave('foo');
wrapper.vm.handleUpdateRequirementSave({
title: '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));
it('calls `updateRequirement` with object containing `iid`, `title` & `errorFlashMessage` props', () => {
jest.spyOn(wrapper.vm, 'updateRequirement').mockResolvedValue(mockUpdateMutationResult);
wrapper.vm.handleUpdateRequirementSave({
iid: '1',
title: 'foo',
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
expect(wrapper.vm.updateRequirement).toHaveBeenCalledWith(
expect.objectContaining({
mutation: updateRequirement,
variables: {
updateRequirementInput: {
projectPath: 'gitlab-org/gitlab-shell',
iid: '1',
title: 'foo',
},
},
iid: '1',
title: 'foo',
errorFlashMessage: 'Something went wrong while updating a requirement.',
}),
);
});
it('sets `showUpdateFormForRequirement` to `0` and `createRequirementRequestActive` prop to `false` when request is successful', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult));
jest.spyOn(wrapper.vm, 'updateRequirement').mockResolvedValue(mockUpdateMutationResult);
return wrapper.vm
.handleUpdateRequirementSave({
......@@ -304,15 +391,16 @@ describe('RequirementsRoot', () => {
});
});
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()));
it('sets `createRequirementRequestActive` prop to `false` when request fails', () => {
jest.spyOn(wrapper.vm, 'updateRequirement').mockRejectedValue(new Error());
return wrapper.vm.handleUpdateRequirementSave('foo').then(() => {
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while updating a requirement.',
);
expect(wrapper.vm.createRequirementRequestActive).toBe(false);
});
return wrapper.vm
.handleUpdateRequirementSave({
title: 'foo',
})
.catch(() => {
expect(wrapper.vm.createRequirementRequestActive).toBe(false);
});
});
});
......@@ -328,6 +416,99 @@ describe('RequirementsRoot', () => {
});
});
describe('handleRequirementStateChange', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateRequirement').mockResolvedValue(mockUpdateMutationResult);
});
it('sets `stateChangeRequestActiveFor` value to `iid` provided within object param', () => {
wrapper.vm.handleRequirementStateChange({
iid: '1',
});
expect(wrapper.vm.stateChangeRequestActiveFor).toBe('1');
});
it('calls `updateRequirement` with object containing params and errorFlashMessage when `params.state` is "OPENED"', () => {
return wrapper.vm
.handleRequirementStateChange({
iid: '1',
state: FilterState.opened,
})
.then(() => {
expect(wrapper.vm.updateRequirement).toHaveBeenCalledWith(
expect.objectContaining({
iid: '1',
state: FilterState.opened,
errorFlashMessage: 'Something went wrong while reopening a requirement.',
}),
);
});
});
it('calls `updateRequirement` with object containing params and errorFlashMessage when `params.state` is "ARCHIVED"', () => {
return wrapper.vm
.handleRequirementStateChange({
iid: '1',
state: FilterState.archived,
})
.then(() => {
expect(wrapper.vm.updateRequirement).toHaveBeenCalledWith(
expect.objectContaining({
iid: '1',
state: FilterState.archived,
errorFlashMessage: 'Something went wrong while archiving a requirement.',
}),
);
});
});
it('sets `stateChangeRequestActiveFor` to 0', () => {
return wrapper.vm
.handleRequirementStateChange({
iid: '1',
state: FilterState.opened,
})
.then(() => {
expect(wrapper.vm.stateChangeRequestActiveFor).toBe(0);
});
});
it('increments `openedCount` by 1 and decrements `archivedCount` by 1 when `params.state` is "OPENED"', () => {
wrapper.setData({
openedCount: 1,
archivedCount: 1,
});
return wrapper.vm
.handleRequirementStateChange({
iid: '1',
state: FilterState.opened,
})
.then(() => {
expect(wrapper.vm.openedCount).toBe(2);
expect(wrapper.vm.archivedCount).toBe(0);
});
});
it('decrements `openedCount` by 1 and increments `archivedCount` by 1 when `params.state` is "ARCHIVED"', () => {
wrapper.setData({
openedCount: 1,
archivedCount: 1,
});
return wrapper.vm
.handleRequirementStateChange({
iid: '1',
state: FilterState.archived,
})
.then(() => {
expect(wrapper.vm.openedCount).toBe(0);
expect(wrapper.vm.archivedCount).toBe(2);
});
});
});
describe('handleUpdateRequirementCancel', () => {
it('sets `showUpdateFormForRequirement` prop to `0`', () => {
wrapper.vm.handleUpdateRequirementCancel();
......
......@@ -16945,6 +16945,9 @@ msgstr ""
msgid "Rename/Move"
msgstr ""
msgid "Reopen"
msgstr ""
msgid "Reopen epic"
msgstr ""
......@@ -17140,6 +17143,9 @@ msgstr ""
msgid "Requirement"
msgstr ""
msgid "Requirement title cannot have more than %{limit} characters."
msgstr ""
msgid "Requirements"
msgstr ""
......@@ -18808,6 +18814,9 @@ msgstr ""
msgid "Something went wrong while applying the suggestion. Please try again."
msgstr ""
msgid "Something went wrong while archiving a requirement."
msgstr ""
msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr ""
......@@ -18880,6 +18889,9 @@ msgstr ""
msgid "Something went wrong while performing the action."
msgstr ""
msgid "Something went wrong while reopening a requirement."
msgstr ""
msgid "Something went wrong while reopening the %{issuable}. Please try again later"
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