Commit a40aac30 authored by Martin Wortschack's avatar Martin Wortschack

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

Add support for archiving & reopening Requirements

Closes #210306

See merge request gitlab-org/gitlab!29132
parents e9d93b27 58d579c1
<script> <script>
import { GlFormGroup, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui'; import { GlFormGroup, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import { MAX_TITLE_LENGTH } from '../constants';
export default { export default {
titleInvalidMessage: sprintf(__('Requirement title cannot have more than %{limit} characters.'), {
limit: MAX_TITLE_LENGTH,
}),
components: { components: {
GlFormGroup, GlFormGroup,
GlFormTextarea, GlFormTextarea,
...@@ -33,8 +38,11 @@ export default { ...@@ -33,8 +38,11 @@ export default {
saveButtonLabel() { saveButtonLabel() {
return this.isCreate ? __('Create requirement') : __('Save changes'); return this.isCreate ? __('Create requirement') : __('Save changes');
}, },
titleInvalid() {
return this.title.length > MAX_TITLE_LENGTH;
},
disableSaveButton() { disableSaveButton() {
return this.title === '' || this.requirementRequestActive; return this.title === '' || this.titleInvalid || this.requirementRequestActive;
}, },
reference() { reference() {
return `REQ-${this.requirement?.iid}`; return `REQ-${this.requirement?.iid}`;
...@@ -62,7 +70,13 @@ export default { ...@@ -62,7 +70,13 @@ export default {
> >
<span v-if="!isCreate" class="text-muted mr-1">{{ reference }}</span> <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 }"> <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 <gl-form-textarea
id="requirementTitle" id="requirementTitle"
v-model.trim="title" v-model.trim="title"
...@@ -72,6 +86,7 @@ export default { ...@@ -72,6 +86,7 @@ export default {
:placeholder="__('Describe the requirement here')" :placeholder="__('Describe the requirement here')"
max-rows="25" max-rows="25"
class="requirement-form-textarea" class="requirement-form-textarea"
:class="{ 'gl-field-error-outline': titleInvalid }"
@keyup.escape.exact="$emit('cancel')" @keyup.escape.exact="$emit('cancel')"
/> />
</gl-form-group> </gl-form-group>
...@@ -85,9 +100,9 @@ export default { ...@@ -85,9 +100,9 @@ export default {
@click="handleSave" @click="handleSave"
>{{ saveButtonLabel }}</gl-deprecated-button >{{ saveButtonLabel }}</gl-deprecated-button
> >
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')"> <gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">{{
{{ __('Cancel') }} __('Cancel')
</gl-deprecated-button> }}</gl-deprecated-button>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
GlAvatar, GlAvatar,
GlDeprecatedButton, GlDeprecatedButton,
GlIcon, GlIcon,
GlLoadingIcon,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
...@@ -14,6 +15,8 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; ...@@ -14,6 +15,8 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
import RequirementForm from './requirement_form.vue'; import RequirementForm from './requirement_form.vue';
import { FilterState } from '../constants';
export default { export default {
components: { components: {
GlPopover, GlPopover,
...@@ -21,6 +24,7 @@ export default { ...@@ -21,6 +24,7 @@ export default {
GlAvatar, GlAvatar,
GlDeprecatedButton, GlDeprecatedButton,
GlIcon, GlIcon,
GlLoadingIcon,
RequirementForm, RequirementForm,
}, },
directives: { directives: {
...@@ -46,6 +50,11 @@ export default { ...@@ -46,6 +50,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
stateChangeRequestActive: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
reference() { reference() {
...@@ -67,6 +76,9 @@ export default { ...@@ -67,6 +76,9 @@ export default {
timeAgo: esc(getTimeago().format(this.requirement.updatedAt)), timeAgo: esc(getTimeago().format(this.requirement.updatedAt)),
}); });
}, },
isArchived() {
return this.requirement?.state === FilterState.archived;
},
author() { author() {
return this.requirement.author; return this.requirement.author;
}, },
...@@ -86,12 +98,24 @@ export default { ...@@ -86,12 +98,24 @@ export default {
handleUpdateRequirementSave(params) { handleUpdateRequirementSave(params) {
this.$emit('updateSave', 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> </script>
<template> <template>
<li class="issue requirement"> <li class="issue requirement" :class="{ 'disabled-content': stateChangeRequestActive }">
<requirement-form <requirement-form
v-if="showUpdateForm" v-if="showUpdateForm"
:requirement="requirement" :requirement="requirement"
...@@ -123,7 +147,7 @@ export default { ...@@ -123,7 +147,7 @@ export default {
</div> </div>
<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 && !isArchived" class="requirement-edit d-sm-block">
<gl-deprecated-button <gl-deprecated-button
v-gl-tooltip v-gl-tooltip
size="sm" size="sm"
...@@ -134,11 +158,27 @@ export default { ...@@ -134,11 +158,27 @@ export default {
<gl-icon name="pencil" /> <gl-icon name="pencil" />
</gl-deprecated-button> </gl-deprecated-button>
</li> </li>
<li v-if="canArchive" class="requirement-archive d-sm-block"> <li v-if="canArchive && !isArchived" class="requirement-archive d-sm-block">
<gl-deprecated-button v-gl-tooltip size="sm" class="border-0" :title="__('Archive')"> <gl-deprecated-button
<gl-icon name="archive" /> 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> </gl-deprecated-button>
</li> </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> </ul>
<div class="float-right issuable-updated-at d-none d-sm-inline-block"> <div class="float-right issuable-updated-at d-none d-sm-inline-block">
<span <span
......
...@@ -86,15 +86,15 @@ export default { ...@@ -86,15 +86,15 @@ export default {
}, },
update(data) { update(data) {
const requirementsRoot = data.project?.requirements; const requirementsRoot = data.project?.requirements;
const count = data.project?.requirementStatesCount; const { opened = 0, archived = 0 } = data.project?.requirementStatesCount;
return { return {
list: requirementsRoot?.nodes || [], list: requirementsRoot?.nodes || [],
pageInfo: requirementsRoot?.pageInfo || {}, pageInfo: requirementsRoot?.pageInfo || {},
count: { count: {
OPENED: count.opened, OPENED: opened,
ARCHIVED: count.archived, ARCHIVED: archived,
ALL: count.opened + count.archived, ALL: opened + archived,
}, },
}; };
}, },
...@@ -105,10 +105,13 @@ export default { ...@@ -105,10 +105,13 @@ export default {
}, },
}, },
data() { data() {
const tabsContainerEl = document.querySelector('.js-requirements-state-filters');
return { return {
showCreateForm: false, showCreateForm: false,
showUpdateFormForRequirement: 0, showUpdateFormForRequirement: 0,
createRequirementRequestActive: false, createRequirementRequestActive: false,
stateChangeRequestActiveFor: 0,
currentPage: this.page, currentPage: this.page,
prevPageCursor: this.prev, prevPageCursor: this.prev,
nextPageCursor: this.next, nextPageCursor: this.next,
...@@ -117,9 +120,23 @@ export default { ...@@ -117,9 +120,23 @@ export default {
count: {}, count: {},
pageInfo: {}, 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: { computed: {
requirementsList() {
return this.filterBy !== FilterState.all
? this.requirements.list.filter(({ state }) => state === this.filterBy)
: this.requirements.list;
},
requirementsListLoading() { requirementsListLoading() {
return this.$apollo.queries.requirements.loading; return this.$apollo.queries.requirements.loading;
}, },
...@@ -140,15 +157,34 @@ export default { ...@@ -140,15 +157,34 @@ export default {
return nextPage > Math.ceil(this.totalRequirements / DEFAULT_PAGE_SIZE) ? null : nextPage; 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() { mounted() {
document if (this.filterBy === FilterState.opened) {
.querySelector('.js-new-requirement') document
.addEventListener('click', this.handleNewRequirementClick); .querySelector('.js-new-requirement')
.addEventListener('click', this.handleNewRequirementClick);
}
}, },
beforeDestroy() { beforeDestroy() {
document if (this.filterBy === FilterState.opened) {
.querySelector('.js-new-requirement') document
.removeEventListener('click', this.handleNewRequirementClick); .querySelector('.js-new-requirement')
.removeEventListener('click', this.handleNewRequirementClick);
}
}, },
methods: { methods: {
/** /**
...@@ -180,6 +216,31 @@ export default { ...@@ -180,6 +216,31 @@ export default {
replace: true, 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() { handleNewRequirementClick() {
this.showCreateForm = true; this.showCreateForm = true;
}, },
...@@ -202,6 +263,7 @@ export default { ...@@ -202,6 +263,7 @@ export default {
if (!data.createRequirement.errors.length) { if (!data.createRequirement.errors.length) {
this.showCreateForm = false; this.showCreateForm = false;
this.$apollo.queries.requirements.refetch(); this.$apollo.queries.requirements.refetch();
this.openedCount += 1;
} else { } else {
throw new Error(`Error creating a requirement`); throw new Error(`Error creating a requirement`);
} }
...@@ -217,19 +279,12 @@ export default { ...@@ -217,19 +279,12 @@ export default {
handleNewRequirementCancel() { handleNewRequirementCancel() {
this.showCreateForm = false; this.showCreateForm = false;
}, },
handleUpdateRequirementSave({ iid, title }) { handleUpdateRequirementSave(params) {
this.createRequirementRequestActive = true; this.createRequirementRequestActive = true;
return this.$apollo return this.updateRequirement({
.mutate({ ...params,
mutation: updateRequirement, errorFlashMessage: __('Something went wrong while updating a requirement.'),
variables: { })
updateRequirementInput: {
projectPath: this.projectPath,
iid,
title,
},
},
})
.then(({ data }) => { .then(({ data }) => {
if (!data.updateRequirement.errors.length) { if (!data.updateRequirement.errors.length) {
this.showUpdateFormForRequirement = 0; this.showUpdateFormForRequirement = 0;
...@@ -237,14 +292,34 @@ export default { ...@@ -237,14 +292,34 @@ export default {
throw new Error(`Error updating a requirement`); throw new Error(`Error updating a requirement`);
} }
}) })
.catch(e => {
createFlash(__('Something went wrong while updating a requirement.'));
Sentry.captureException(e);
})
.finally(() => { .finally(() => {
this.createRequirementRequestActive = false; 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() { handleUpdateRequirementCancel() {
this.showUpdateFormForRequirement = 0; this.showUpdateFormForRequirement = 0;
}, },
...@@ -295,14 +370,17 @@ export default { ...@@ -295,14 +370,17 @@ export default {
class="content-list issuable-list issues-list requirements-list" class="content-list issuable-list issues-list requirements-list"
> >
<requirement-item <requirement-item
v-for="requirement in requirements.list" v-for="requirement in requirementsList"
:key="requirement.iid" :key="requirement.iid"
:requirement="requirement" :requirement="requirement"
:show-update-form="showUpdateFormForRequirement === requirement.iid" :show-update-form="showUpdateFormForRequirement === requirement.iid"
:update-requirement-request-active="createRequirementRequestActive" :update-requirement-request-active="createRequirementRequestActive"
:state-change-request-active="stateChangeRequestActiveFor === requirement.iid"
@updateSave="handleUpdateRequirementSave" @updateSave="handleUpdateRequirementSave"
@updateCancel="handleUpdateRequirementCancel" @updateCancel="handleUpdateRequirementCancel"
@editClick="handleEditRequirementClick" @editClick="handleEditRequirementClick"
@archiveClick="handleRequirementStateChange"
@reopenClick="handleRequirementStateChange"
/> />
</ul> </ul>
<gl-pagination <gl-pagination
......
...@@ -12,3 +12,5 @@ export const FilterStateEmptyMessage = { ...@@ -12,3 +12,5 @@ export const FilterStateEmptyMessage = {
}; };
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
export const MAX_TITLE_LENGTH = 255;
...@@ -23,6 +23,18 @@ ...@@ -23,6 +23,18 @@
} }
.requirements-list-container { .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 { .issuable-info {
// The size here is specific to correctly // The size here is specific to correctly
// align info row perfectly with action buttons & updated date. // align info row perfectly with action buttons & updated date.
......
...@@ -9,12 +9,12 @@ ...@@ -9,12 +9,12 @@
= sprite_icon('requirements') = sprite_icon('requirements')
%span.nav-item-name %span.nav-item-name
= _('Requirements') = _('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 %ul.sidebar-sub-level-items
= nav_link(path: 'requirements#index', html_options: { class: "fly-out-top-item" } ) do = nav_link(path: 'requirements#index', html_options: { class: "fly-out-top-item" } ) do
= link_to project_requirements_path(project) do = link_to project_requirements_path(project) do
%strong.fly-out-top-item-name= _('Requirements') %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 %li.divider.fly-out-top-item
= nav_link(path: 'requirements#index', html_options: { class: 'home' }) do = nav_link(path: 'requirements#index', html_options: { class: 'home' }) do
= link_to project_requirements_path(project), title: 'List' do = link_to project_requirements_path(project), title: 'List' do
......
...@@ -3,28 +3,31 @@ ...@@ -3,28 +3,31 @@
- page_context_word = type.to_s.humanize(capitalize: false) - page_context_word = type.to_s.humanize(capitalize: false)
- @content_class = 'requirements-container' - @content_class = 'requirements-container'
- ignore_page_params = ['next', 'prev', 'page']
- requirements_count = Hash.new(0).merge(@project.requirements.counts_by_state) - requirements_count = Hash.new(0).merge(@project.requirements.counts_by_state)
- is_open_tab = params[:state].nil? || params[:state] == 'opened'
.top-area .top-area
%ul.nav-links.mobile-separator.requirements-state-filters %ul.nav-links.mobile-separator.requirements-state-filters.js-requirements-state-filters
%li{ class: active_when(params[:state].nil? || params[:state] == 'opened') }> %li{ class: active_when(is_open_tab) }>
= 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 = 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') = _('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') }> %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') = _('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') }> %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') = _('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 .nav-controls
%button.btn.btn-success.js-new-requirement.qa-new-requirement-button{ type: 'button' } - if is_open_tab
= _('New requirement') %button.btn.btn-success.js-new-requirement.qa-new-requirement-button{ type: 'button' }
= _('New requirement')
#js-requirements-app{ data: { filter_by: params[:state], #js-requirements-app{ data: { filter_by: params[:state],
page: params[:page], page: params[:page],
......
...@@ -10,6 +10,19 @@ describe 'Requirements list', :js do ...@@ -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(: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) } 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 before do
stub_licensed_features(requirements: true) stub_licensed_features(requirements: true)
project.add_maintainer(user) project.add_maintainer(user)
...@@ -46,13 +59,6 @@ describe 'Requirements list', :js do ...@@ -46,13 +59,6 @@ describe 'Requirements list', :js do
end end
context 'new requirement' do 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 it 'shows requirement create form when "New requirement" button is clicked' do
page.within('.nav-controls') do page.within('.nav-controls') do
find('button.js-new-requirement').click find('button.js-new-requirement').click
...@@ -64,27 +70,43 @@ describe 'Requirements list', :js do ...@@ -64,27 +70,43 @@ describe 'Requirements list', :js do
end end
it 'creates new requirement' do it 'creates new requirement' do
page.within('.nav-controls') do requirement_title = 'Foobar'
find('button.js-new-requirement').click
end
page.within('.requirements-list-container') do create_requirement(requirement_title)
requirement_title = 'Foobar'
find('textarea#requirementTitle').native.send_keys requirement_title
find('button.js-requirement-save').click
wait_for_all_requests
page.within('.requirements-list-container') do
expect(page).to have_selector('li.requirement', count: 4) expect(page).to have_selector('li.requirement', count: 4)
page.within('.requirements-list li.requirement', match: :first) do page.within('.requirements-list li.requirement', match: :first) do
expect(page.find('.issue-title-text')).to have_content(requirement_title) expect(page.find('.issue-title-text')).to have_content(requirement_title)
end end
end 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 end
context 'open tab' do 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 it 'shows list of all open requirements' do
page.within('.requirements-list-container .requirements-list') do page.within('.requirements-list-container .requirements-list') do
expect(page).to have_selector('li.requirement', count: 3) expect(page).to have_selector('li.requirement', count: 3)
...@@ -131,6 +153,20 @@ describe 'Requirements list', :js do ...@@ -131,6 +153,20 @@ describe 'Requirements list', :js do
end end
end 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 end
context 'archived tab' do context 'archived tab' do
...@@ -140,6 +176,12 @@ describe 'Requirements list', :js do ...@@ -140,6 +176,12 @@ describe 'Requirements list', :js do
wait_for_requests wait_for_requests
end 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 it 'shows list of all archived requirements' do
page.within('.requirements-list-container .requirements-list') do page.within('.requirements-list-container .requirements-list') do
expect(page).to have_selector('li.requirement', count: 1) expect(page).to have_selector('li.requirement', count: 1)
...@@ -152,9 +194,24 @@ describe 'Requirements list', :js do ...@@ -152,9 +194,24 @@ describe 'Requirements list', :js do
expect(page.find('.issue-title-text')).to have_content(requirement_archived.title) 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('.issuable-authored')).to have_content('created 1 week ago by')
expect(page.find('.author')).to have_content(user.name) 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') expect(page.find('.issuable-updated-at')).to have_content('updated 2 days ago')
end end
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 end
context 'all tab' do context 'all tab' do
...@@ -164,6 +221,12 @@ describe 'Requirements list', :js do ...@@ -164,6 +221,12 @@ describe 'Requirements list', :js do
wait_for_requests wait_for_requests
end 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 it 'shows list of all requirements' do
page.within('.requirements-list-container .requirements-list') do page.within('.requirements-list-container .requirements-list') do
expect(page).to have_selector('li.requirement', count: 4) expect(page).to have_selector('li.requirement', count: 4)
......
...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlFormGroup, GlFormTextarea } from '@gitlab/ui'; import { GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import RequirementForm from 'ee/requirements/components/requirement_form.vue'; import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import { MAX_TITLE_LENGTH } from 'ee/requirements/constants';
import { mockRequirementsOpen } from '../mock_data'; import { mockRequirementsOpen } from '../mock_data';
...@@ -50,6 +51,25 @@ describe('RequirementForm', () => { ...@@ -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', () => { describe('reference', () => {
it('returns string containing `requirement.iid` prefixed with `REQ-`', () => { it('returns string containing `requirement.iid` prefixed with `REQ-`', () => {
expect(wrapperWithRequirement.vm.reference).toBe(`REQ-${mockRequirementsOpen[0].iid}`); expect(wrapperWithRequirement.vm.reference).toBe(`REQ-${mockRequirementsOpen[0].iid}`);
...@@ -113,6 +133,10 @@ describe('RequirementForm', () => { ...@@ -113,6 +133,10 @@ describe('RequirementForm', () => {
expect(glFormGroup.exists()).toBe(true); expect(glFormGroup.exists()).toBe(true);
expect(glFormGroup.attributes('label')).toBe('New requirement'); expect(glFormGroup.attributes('label')).toBe('New requirement');
expect(glFormGroup.attributes('label-for')).toBe('requirementTitle'); 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', () => { it('renders gl-form-textarea component', () => {
......
import { shallowMount } from '@vue/test-utils'; 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 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 { requirement1, mockUserPermissions } from '../mock_data'; import { requirement1, requirementArchived, mockUserPermissions } from '../mock_data';
const createComponent = (requirement = requirement1) => const createComponent = (requirement = requirement1) =>
shallowMount(RequirementItem, { shallowMount(RequirementItem, {
...@@ -15,13 +15,16 @@ const createComponent = (requirement = requirement1) => ...@@ -15,13 +15,16 @@ const createComponent = (requirement = requirement1) =>
describe('RequirementItem', () => { describe('RequirementItem', () => {
let wrapper; let wrapper;
let wrapperArchived;
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
wrapperArchived = createComponent(requirementArchived);
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapperArchived.destroy();
}); });
describe('computed', () => { describe('computed', () => {
...@@ -59,6 +62,16 @@ describe('RequirementItem', () => { ...@@ -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', () => { describe('author', () => {
it('returns value of `requirement.author`', () => { it('returns value of `requirement.author`', () => {
expect(wrapper.vm.author).toBe(requirement1.author); expect(wrapper.vm.author).toBe(requirement1.author);
...@@ -77,6 +90,38 @@ describe('RequirementItem', () => { ...@@ -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', () => { describe('template', () => {
...@@ -84,6 +129,16 @@ describe('RequirementItem', () => { ...@@ -84,6 +129,16 @@ describe('RequirementItem', () => {
expect(wrapper.classes()).toContain('requirement'); 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', () => { it('renders requirement-form component', () => {
wrapper.setProps({ wrapper.setProps({
showUpdateForm: true, showUpdateForm: true,
...@@ -165,6 +220,29 @@ describe('RequirementItem', () => { ...@@ -165,6 +220,29 @@ describe('RequirementItem', () => {
wrapperNoArchive.destroy(); 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', () => { it('renders element containing requirement updated at', () => {
const updatedAtEl = wrapper.find('.issuable-meta .issuable-updated-at > span'); const updatedAtEl = wrapper.find('.issuable-meta .issuable-updated-at > span');
......
...@@ -16947,6 +16947,9 @@ msgstr "" ...@@ -16947,6 +16947,9 @@ msgstr ""
msgid "Rename/Move" msgid "Rename/Move"
msgstr "" msgstr ""
msgid "Reopen"
msgstr ""
msgid "Reopen epic" msgid "Reopen epic"
msgstr "" msgstr ""
...@@ -17142,6 +17145,9 @@ msgstr "" ...@@ -17142,6 +17145,9 @@ msgstr ""
msgid "Requirement" msgid "Requirement"
msgstr "" msgstr ""
msgid "Requirement title cannot have more than %{limit} characters."
msgstr ""
msgid "Requirements" msgid "Requirements"
msgstr "" msgstr ""
...@@ -18819,6 +18825,9 @@ msgstr "" ...@@ -18819,6 +18825,9 @@ msgstr ""
msgid "Something went wrong while applying the suggestion. Please try again." msgid "Something went wrong while applying the suggestion. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while archiving a requirement."
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 ""
...@@ -18891,6 +18900,9 @@ msgstr "" ...@@ -18891,6 +18900,9 @@ msgstr ""
msgid "Something went wrong while performing the action." msgid "Something went wrong while performing the action."
msgstr "" msgstr ""
msgid "Something went wrong while reopening a requirement."
msgstr ""
msgid "Something went wrong while reopening the %{issuable}. Please try again later" msgid "Something went wrong while reopening the %{issuable}. Please try again later"
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