Commit 50afa65e authored by Kushal Pandya's avatar Kushal Pandya

Add pagination support in Requirements list

Adds pagination support in Requirements list
based on requirements count and default page size
parent b849cd74
...@@ -51,6 +51,19 @@ export default { ...@@ -51,6 +51,19 @@ export default {
return this.requirement.author; return this.requirement.author;
}, },
}, },
methods: {
/**
* This is needed as an independent method since
* when user changes current page, `$refs.authorLink`
* will be null until next page results are loaded & rendered.
*/
getAuthorPopoverTarget() {
if (this.$refs.authorLink) {
return this.$refs.authorLink.$el;
}
return '';
},
},
}; };
</script> </script>
...@@ -101,7 +114,7 @@ export default { ...@@ -101,7 +114,7 @@ export default {
</div> </div>
</div> </div>
</div> </div>
<gl-popover :target="() => $refs.authorLink.$el" triggers="hover focus" placement="top"> <gl-popover :target="getAuthorPopoverTarget()" triggers="hover focus" placement="top">
<div class="user-popover p-0 d-flex"> <div class="user-popover p-0 d-flex">
<div class="p-1 flex-shrink-1"> <div class="p-1 flex-shrink-1">
<gl-avatar :entity-name="author.name" :alt="author.name" :src="author.avatarUrl" /> <gl-avatar :entity-name="author.name" :alt="author.name" :src="author.avatarUrl" />
......
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { DEFAULT_PAGE_SIZE } from '../constants';
export default {
components: {
GlSkeletonLoading,
},
props: {
filterBy: {
type: String,
required: true,
},
currentTabCount: {
type: Number,
required: true,
},
currentPage: {
type: Number,
required: true,
},
},
computed: {
lastPage() {
return Math.ceil(this.currentTabCount / DEFAULT_PAGE_SIZE);
},
loaderCount() {
if (this.currentTabCount > DEFAULT_PAGE_SIZE && this.currentPage !== this.lastPage) {
return DEFAULT_PAGE_SIZE;
}
return this.currentTabCount % DEFAULT_PAGE_SIZE || DEFAULT_PAGE_SIZE;
},
},
};
</script>
<template>
<ul class="content-list issuable-list issues-list requirements-list-loading">
<li v-for="(i, index) in Array(loaderCount).fill()" :key="index" class="issue requirement">
<gl-skeleton-loading :lines="2" class="pt-2" />
</li>
</ul>
</template>
<script> <script>
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import RequirementsLoading from './requirements_loading.vue';
import RequirementsEmptyState from './requirements_empty_state.vue'; import RequirementsEmptyState from './requirements_empty_state.vue';
import RequirementItem from './requirement_item.vue'; import RequirementItem from './requirement_item.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql'; import projectRequirements from '../queries/projectRequirements.query.graphql';
import { FilterState } from '../constants'; import { FilterState, DEFAULT_PAGE_SIZE } from '../constants';
export default { export default {
DEFAULT_PAGE_SIZE,
components: { components: {
GlLoadingIcon, GlPagination,
RequirementsLoading,
RequirementsEmptyState, RequirementsEmptyState,
RequirementItem, RequirementItem,
}, },
...@@ -25,6 +30,26 @@ export default { ...@@ -25,6 +30,26 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
requirementsCount: {
type: Object,
required: true,
validator: value => ['OPENED', 'ARCHIVED', 'ALL'].every(prop => value[prop]),
},
page: {
type: Number,
required: false,
default: 1,
},
prev: {
type: String,
required: false,
default: '',
},
next: {
type: String,
required: false,
default: '',
},
showCreateRequirement: { showCreateRequirement: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -42,13 +67,36 @@ export default { ...@@ -42,13 +67,36 @@ export default {
projectPath: this.projectPath, projectPath: this.projectPath,
}; };
if (this.prevPageCursor) {
queryVariables.prevPageCursor = this.prevPageCursor;
queryVariables.lastPageSize = DEFAULT_PAGE_SIZE;
} else if (this.nextPageCursor) {
queryVariables.nextPageCursor = this.nextPageCursor;
queryVariables.firstPageSize = DEFAULT_PAGE_SIZE;
} else {
queryVariables.firstPageSize = DEFAULT_PAGE_SIZE;
}
if (this.filterBy !== FilterState.all) { if (this.filterBy !== FilterState.all) {
queryVariables.state = this.filterBy; queryVariables.state = this.filterBy;
} }
return queryVariables; return queryVariables;
}, },
update: data => data.project?.requirements?.nodes || [], update(data) {
const requirementsRoot = data.project?.requirements;
const count = data.project?.requirementStatesCount;
return {
list: requirementsRoot?.nodes || [],
pageInfo: requirementsRoot?.pageInfo || {},
count: {
OPENED: count.opened,
ARCHIVED: count.archived,
ALL: count.opened + count.archived,
},
};
},
error: e => { error: e => {
createFlash(__('Something went wrong while fetching requirements list.')); createFlash(__('Something went wrong while fetching requirements list.'));
Sentry.captureException(e); Sentry.captureException(e);
...@@ -57,7 +105,14 @@ export default { ...@@ -57,7 +105,14 @@ export default {
}, },
data() { data() {
return { return {
requirements: [], currentPage: this.page,
prevPageCursor: this.prev,
nextPageCursor: this.next,
requirements: {
list: [],
count: {},
pageInfo: {},
},
}; };
}, },
computed: { computed: {
...@@ -65,7 +120,70 @@ export default { ...@@ -65,7 +120,70 @@ export default {
return this.$apollo.queries.requirements.loading; return this.$apollo.queries.requirements.loading;
}, },
requirementsListEmpty() { requirementsListEmpty() {
return !this.$apollo.queries.requirements.loading && !this.requirements.length; return !this.$apollo.queries.requirements.loading && !this.requirements.list.length;
},
totalRequirements() {
return this.requirements.count[this.filterBy] || this.requirementsCount[this.filterBy];
},
showPaginationControls() {
return this.totalRequirements > DEFAULT_PAGE_SIZE && !this.requirementsListEmpty;
},
prevPage() {
return Math.max(this.currentPage - 1, 0);
},
nextPage() {
const nextPage = this.currentPage + 1;
return nextPage > Math.ceil(this.totalRequirements / DEFAULT_PAGE_SIZE) ? null : nextPage;
},
},
methods: {
/**
* Update browser URL with updated query-param values
* based on current page details.
*/
updateUrl({ page, prev, next }) {
const { href, search } = window.location;
const queryParams = urlParamsToObject(search);
queryParams.page = page || 1;
// Only keep params that have any values.
if (prev) {
queryParams.prev = prev;
} else {
delete queryParams.prev;
}
if (next) {
queryParams.next = next;
} else {
delete queryParams.next;
}
// We want to replace the history state so that back button
// correctly reloads the page with previous URL.
updateHistory({
url: setUrlParams(queryParams, href, true),
title: document.title,
replace: true,
});
},
handlePageChange(page) {
const { startCursor, endCursor } = this.requirements.pageInfo;
if (page > this.currentPage) {
this.prevPageCursor = '';
this.nextPageCursor = endCursor;
} else {
this.prevPageCursor = startCursor;
this.nextPageCursor = '';
}
this.currentPage = page;
this.updateUrl({
page,
prev: this.prevPageCursor,
next: this.nextPageCursor,
});
}, },
}, },
}; };
...@@ -78,13 +196,31 @@ export default { ...@@ -78,13 +196,31 @@ export default {
:filter-by="filterBy" :filter-by="filterBy"
:empty-state-path="emptyStatePath" :empty-state-path="emptyStatePath"
/> />
<gl-loading-icon v-if="requirementsListLoading" class="mt-3" size="md" /> <requirements-loading
<ul v-else class="content-list issuable-list issues-list requirements-list"> v-show="requirementsListLoading"
:filter-by="filterBy"
:current-tab-count="totalRequirements"
:current-page="currentPage"
/>
<ul
v-if="!requirementsListLoading && !requirementsListEmpty"
class="content-list issuable-list issues-list requirements-list"
>
<requirement-item <requirement-item
v-for="requirement in requirements" v-for="requirement in requirements.list"
:key="requirement.iid" :key="requirement.iid"
:requirement="requirement" :requirement="requirement"
/> />
</ul> </ul>
<gl-pagination
v-if="showPaginationControls"
:value="currentPage"
:per-page="$options.DEFAULT_PAGE_SIZE"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination prepend-top-default"
@input="handlePageChange"
/>
</div> </div>
</template> </template>
...@@ -10,3 +10,5 @@ export const FilterStateEmptyMessage = { ...@@ -10,3 +10,5 @@ export const FilterStateEmptyMessage = {
OPENED: __('There are no open requirements'), OPENED: __('There are no open requirements'),
ARCHIVED: __('There are no archived requirements'), ARCHIVED: __('There are no archived requirements'),
}; };
export const DEFAULT_PAGE_SIZE = 20;
query projectRequirements($projectPath: ID!, $state: RequirementState) { query projectRequirements(
$projectPath: ID!
$state: RequirementState
$firstPageSize: Int
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
requirements(sort: created_desc, state: $state) { requirementStatesCount {
opened
archived
}
requirements(
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
before: $prevPageCursor
sort: created_desc
state: $state
) {
nodes { nodes {
iid iid
title title
...@@ -18,6 +36,10 @@ query projectRequirements($projectPath: ID!, $state: RequirementState) { ...@@ -18,6 +36,10 @@ query projectRequirements($projectPath: ID!, $state: RequirementState) {
webUrl webUrl
} }
} }
pageInfo {
startCursor
endCursor
}
} }
} }
} }
...@@ -27,12 +27,32 @@ export default () => { ...@@ -27,12 +27,32 @@ export default () => {
RequirementsRoot, RequirementsRoot,
}, },
data() { data() {
const { filterBy, projectPath, emptyStatePath } = el.dataset; const {
filterBy,
page,
next,
prev,
projectPath,
emptyStatePath,
opened,
archived,
} = el.dataset;
const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened; const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened;
const OPENED = parseInt(opened, 10);
const ARCHIVED = parseInt(archived, 10);
return { return {
showCreateRequirement: false, showCreateRequirement: false,
filterBy: stateFilterBy, filterBy: stateFilterBy,
requirementsCount: {
OPENED,
ARCHIVED,
ALL: OPENED + ARCHIVED,
},
page,
prev,
next,
emptyStatePath, emptyStatePath,
projectPath, projectPath,
}; };
...@@ -53,6 +73,10 @@ export default () => { ...@@ -53,6 +73,10 @@ export default () => {
props: { props: {
projectPath: this.projectPath, projectPath: this.projectPath,
filterBy: this.filterBy, filterBy: this.filterBy,
requirementsCount: this.requirementsCount,
page: parseInt(this.page, 10) || 1,
prev: this.prev,
next: this.next,
showCreateRequirement: this.showCreateRequirement, showCreateRequirement: this.showCreateRequirement,
emptyStatePath: this.emptyStatePath, emptyStatePath: this.emptyStatePath,
}, },
......
...@@ -26,4 +26,21 @@ ...@@ -26,4 +26,21 @@
padding: $gl-padding-4 $gl-vert-padding; padding: $gl-padding-4 $gl-vert-padding;
} }
} }
.requirements-list-loading {
.animation-container {
// This absolute height ensures that
// animation container takes up average height
// similar to a rendered requirement item.
height: 51px;
.skeleton-line-1 {
width: 70%;
}
.skeleton-line-2 {
width: 60%;
}
}
}
} }
- return unless Feature.enabled?(:requirements_management, project) - return unless Feature.enabled?(:requirements_management, project)
- requirements_count = Hash.new(0).merge(project.requirements.counts_by_state)
- total_count = requirements_count['opened'] + requirements_count['archived']
= nav_link(path: 'requirements#index') do = nav_link(path: 'requirements#index') do
= link_to project_requirements_path(project), class: 'qa-project-requirements-link' do = link_to project_requirements_path(project), class: 'qa-project-requirements-link' do
.nav-icon-container .nav-icon-container
= 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)
%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 %span.badge.badge-pill.count.requirements_counter.fly-out-badge= 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,24 +3,36 @@ ...@@ -3,24 +3,36 @@
- 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'
- requirements_count = Hash.new(0).merge(@project.requirements.counts_by_state)
.top-area .top-area
%ul.nav-links.mobile-separator.requirements-state-filters %ul.nav-links.mobile-separator.requirements-state-filters
%li{ class: active_when(params[:state].nil? || params[:state] == 'opened') }> %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 = 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
= _('Open') = _('Open')
%span.badge.badge-pill= 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'), 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']
= render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: _('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
= _('All')
%span.badge.badge-pill= requirements_count['opened'] + requirements_count['archived']
.nav-controls .nav-controls
%button.btn.btn-success.js-new-requirement.qa-new-requirement-button{ type: 'button' } %button.btn.btn-success.js-new-requirement.qa-new-requirement-button{ type: 'button' }
= _('New requirement') = _('New requirement')
#js-requirements-app{ data: { filter_by: params[:state], #js-requirements-app{ data: { filter_by: params[:state],
page: params[:page],
prev: params[:prev],
next: params[:next],
project_path: @project.full_path, project_path: @project.full_path,
opened: requirements_count['opened'],
archived: requirements_count['archived'],
empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } } empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } }
.gl-spinner-container.mt-3 .gl-spinner-container.mt-3
%span.align-text-bottom.gl-spinner.gl-spinner-orange.gl-spinner-md{ aria: { label: _('Loading'), hidden: 'true' } } %span.align-text-bottom.gl-spinner.gl-spinner-orange.gl-spinner-md{ aria: { label: _('Loading'), hidden: 'true' } }
...@@ -8,6 +8,7 @@ describe 'Requirements list', :js do ...@@ -8,6 +8,7 @@ describe 'Requirements list', :js do
let_it_be(:requirement1) { create(:requirement, project: project, title: 'Some requirement-1', author: user, created_at: 5.days.ago, updated_at: 2.days.ago) } let_it_be(:requirement1) { create(:requirement, project: project, title: 'Some requirement-1', author: user, created_at: 5.days.ago, updated_at: 2.days.ago) }
let_it_be(:requirement2) { create(:requirement, project: project, title: 'Some requirement-2', author: user, created_at: 6.days.ago, updated_at: 2.days.ago) } let_it_be(:requirement2) { create(:requirement, project: project, title: 'Some requirement-2', author: user, created_at: 6.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(: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) }
before do before do
stub_licensed_features(requirements: true) stub_licensed_features(requirements: true)
...@@ -25,18 +26,22 @@ describe 'Requirements list', :js do ...@@ -25,18 +26,22 @@ describe 'Requirements list', :js do
it 'shows the requirements in the navigation sidebar' do it 'shows the requirements in the navigation sidebar' do
expect(first('.nav-sidebar .active a .nav-item-name')).to have_content('Requirements') expect(first('.nav-sidebar .active a .nav-item-name')).to have_content('Requirements')
expect(first('.nav-sidebar .active a .count')).to have_content('4')
end end
it 'shows requirements tabs for each status type' do it 'shows requirements tabs for each status type' do
page.within('.requirements-state-filters') do page.within('.requirements-state-filters') do
expect(page).to have_selector('li > a#state-opened') expect(page).to have_selector('li > a#state-opened')
expect(find('li > a#state-opened')[:title]).to eq('Filter by requirements that are currently opened.') expect(find('li > a#state-opened')[:title]).to eq('Filter by requirements that are currently opened.')
expect(find('li > a#state-opened .badge')).to have_content('3')
expect(page).to have_selector('li > a#state-archived') expect(page).to have_selector('li > a#state-archived')
expect(find('li > a#state-archived')[:title]).to eq('Filter by requirements that are currently archived.') expect(find('li > a#state-archived')[:title]).to eq('Filter by requirements that are currently archived.')
expect(find('li > a#state-archived .badge')).to have_content('1')
expect(page).to have_selector('li > a#state-all') expect(page).to have_selector('li > a#state-all')
expect(find('li > a#state-all')[:title]).to eq('Show all requirements.') expect(find('li > a#state-all')[:title]).to eq('Show all requirements.')
expect(find('li > a#state-all .badge')).to have_content('4')
end end
end end
...@@ -47,7 +52,8 @@ describe 'Requirements list', :js do ...@@ -47,7 +52,8 @@ describe 'Requirements list', :js do
end end
end end
it 'shows list of all available requirements' do context 'open tab' 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)
end end
...@@ -65,4 +71,43 @@ describe 'Requirements list', :js do ...@@ -65,4 +71,43 @@ describe 'Requirements list', :js do
end end
end end
end end
context 'archived tab' do
before do
find('li > a#state-archived').click
wait_for_requests
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)
end
end
it 'shows title, metadata and actions within each requirement item' do
page.within('.requirements-list li.requirement', match: :first) do
expect(page.find('.issuable-reference')).to have_content("REQ-#{requirement_archived.iid}")
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('.issuable-updated-at')).to have_content('updated 2 days ago')
end
end
end
context 'archived tab' do
before do
find('li > a#state-all').click
wait_for_requests
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)
end
end
end
end
end end
import { shallowMount } from '@vue/test-utils';
import { GlSkeletonLoading } from '@gitlab/ui';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
import { FilterState, mockRequirementsCount } from '../mock_data';
jest.mock('ee/requirements/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
}));
const createComponent = ({
filterBy = FilterState.opened,
currentTabCount = mockRequirementsCount.OPENED,
currentPage = 1,
} = {}) =>
shallowMount(RequirementsLoading, {
propsData: {
filterBy,
currentTabCount,
currentPage,
},
});
describe('RequirementsLoading', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('lastPage', () => {
it('returns number representing last page of the list', () => {
expect(wrapper.vm.lastPage).toBe(2);
});
});
describe('loaderCount', () => {
it('returns value of DEFAULT_PAGE_SIZE when current page is not the last page total requirements are more than DEFAULT_PAGE_SIZE', () => {
expect(wrapper.vm.loaderCount).toBe(2);
});
it('returns value of remainder requirements for last page when current page is the last page total requirements are more than DEFAULT_PAGE_SIZE', () => {
wrapper.setProps({
currentPage: 2,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.loaderCount).toBe(1);
});
});
it('returns value DEFAULT_PAGE_SIZE when current page is the last page total requirements are less than DEFAULT_PAGE_SIZE', () => {
wrapper.setProps({
currentPage: 1,
currentTabCount: 1,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.loaderCount).toBe(1);
});
});
});
});
describe('template', () => {
it('renders gl-skeleton-loading component based on loaderCount', () => {
const loaders = wrapper.find('.requirements-list-loading').findAll(GlSkeletonLoading);
expect(loaders.length).toBe(2);
expect(loaders.at(0).props('lines')).toBe(2);
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue'; import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue'; import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue';
import RequirementItem from 'ee/requirements/components/requirement_item.vue'; import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import { FilterState } from 'ee/requirements/constants';
import { mockRequirements } from '../mock_data'; import {
FilterState,
mockRequirementsOpen,
mockRequirementsCount,
mockPageInfo,
} from '../mock_data';
jest.mock('ee/requirements/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
}));
const createComponent = ({ const createComponent = ({
projectPath = 'gitlab-org/gitlab-shell', projectPath = 'gitlab-org/gitlab-shell',
filterBy = FilterState.opened, filterBy = FilterState.opened,
requirementsCount = mockRequirementsCount,
showCreateRequirement = false, showCreateRequirement = false,
emptyStatePath = '/assets/illustrations/empty-state/requirements.svg', emptyStatePath = '/assets/illustrations/empty-state/requirements.svg',
loading = false, loading = false,
...@@ -19,6 +29,7 @@ const createComponent = ({ ...@@ -19,6 +29,7 @@ const createComponent = ({
propsData: { propsData: {
projectPath, projectPath,
filterBy, filterBy,
requirementsCount,
showCreateRequirement, showCreateRequirement,
emptyStatePath, emptyStatePath,
}, },
...@@ -27,6 +38,9 @@ const createComponent = ({ ...@@ -27,6 +38,9 @@ const createComponent = ({
queries: { queries: {
requirements: { requirements: {
loading, loading,
list: [],
pageInfo: {},
count: {},
}, },
}, },
}, },
...@@ -44,33 +58,189 @@ describe('RequirementsRoot', () => { ...@@ -44,33 +58,189 @@ describe('RequirementsRoot', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('computed', () => {
describe('totalRequirements', () => {
it('returns number representing total requirements for current tab', () => {
expect(wrapper.vm.totalRequirements).toBe(mockRequirementsCount.OPENED);
});
});
describe('showPaginationControls', () => {
it('returns `true` when totalRequirements is more than default page size', () => {
wrapper.setData({
requirements: {
list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.showPaginationControls).toBe(true);
});
});
it('returns `false` when totalRequirements is less than default page size', () => {
wrapper.setData({
requirements: {
list: [mockRequirementsOpen[0]],
count: {
...mockRequirementsCount,
OPENED: 1,
},
pageInfo: mockPageInfo,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.showPaginationControls).toBe(false);
});
});
});
describe('prevPage', () => {
it('returns number representing previous page based on currentPage value', () => {
wrapper.setData({
currentPage: 3,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.prevPage).toBe(2);
});
});
});
describe('nextPage', () => {
it('returns number representing next page based on currentPage value', () => {
expect(wrapper.vm.nextPage).toBe(2);
});
it('returns `null` when currentPage is already last page', () => {
wrapper.setData({
currentPage: 2,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.nextPage).toBeNull();
});
});
});
});
describe('methods', () => {
describe('updateUrl', () => {
it('updates window URL with query params `page` and `prev`', () => {
wrapper.vm.updateUrl({
page: 2,
prev: mockPageInfo.startCursor,
});
expect(global.window.location.href).toContain(`?page=2&prev=${mockPageInfo.startCursor}`);
});
it('updates window URL with query params `page` and `next`', () => {
wrapper.vm.updateUrl({
page: 1,
next: mockPageInfo.endCursor,
});
expect(global.window.location.href).toContain(`?page=1&next=${mockPageInfo.endCursor}`);
});
});
describe('handlePageChange', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateUrl').mockImplementation(jest.fn());
wrapper.setData({
requirements: {
list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo,
},
});
return wrapper.vm.$nextTick();
});
it('calls `updateUrl` with `page` and `next` params when value of page is `2`', () => {
wrapper.vm.handlePageChange(2);
expect(wrapper.vm.updateUrl).toHaveBeenCalledWith({
page: 2,
prev: '',
next: mockPageInfo.endCursor,
});
});
it('calls `updateUrl` with `page` and `next` params when value of page is `1`', () => {
wrapper.setData({
currentPage: 2,
});
return wrapper.vm.$nextTick(() => {
wrapper.vm.handlePageChange(1);
expect(wrapper.vm.updateUrl).toHaveBeenCalledWith({
page: 1,
prev: mockPageInfo.startCursor,
next: '',
});
});
});
});
});
describe('template', () => { describe('template', () => {
it('renders component container element with class `requirements-list-container`', () => { it('renders component container element with class `requirements-list-container`', () => {
expect(wrapper.classes()).toContain('requirements-list-container'); expect(wrapper.classes()).toContain('requirements-list-container');
}); });
it('renders empty state query results are empty', () => { it('renders empty state when query results are empty', () => {
expect(wrapper.find(RequirementsEmptyState).exists()).toBe(true); expect(wrapper.find(RequirementsEmptyState).exists()).toBe(true);
}); });
it('renders loading icon when query results are still being loaded', () => { it('renders requirements-loading component when query results are still being loaded', () => {
const wrapperLoading = createComponent({ loading: true }); const wrapperLoading = createComponent({ loading: true });
expect(wrapperLoading.find(GlLoadingIcon).exists()).toBe(true); expect(wrapperLoading.find(RequirementsLoading).isVisible()).toBe(true);
wrapperLoading.destroy(); wrapperLoading.destroy();
}); });
it('renders requirement items for all the requirements', () => { it('renders requirement items for all the requirements', () => {
wrapper.setData({ wrapper.setData({
requirements: mockRequirements, requirements: {
list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo,
},
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const itemsContainer = wrapper.find('ul.requirements-list'); const itemsContainer = wrapper.find('ul.requirements-list');
expect(itemsContainer.exists()).toBe(true); expect(itemsContainer.exists()).toBe(true);
expect(itemsContainer.findAll(RequirementItem).length).toBe(mockRequirements.length); expect(itemsContainer.findAll(RequirementItem)).toHaveLength(mockRequirementsOpen.length);
});
});
it('renders pagination controls', () => {
wrapper.setData({
requirements: {
list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo,
},
});
return wrapper.vm.$nextTick(() => {
const pagination = wrapper.find(GlPagination);
expect(pagination.exists()).toBe(true);
expect(pagination.props('value')).toBe(1);
expect(pagination.props('perPage')).toBe(2); // We're mocking this page size
expect(pagination.props('align')).toBe('center');
}); });
}); });
}); });
......
...@@ -40,4 +40,35 @@ export const requirement3 = { ...@@ -40,4 +40,35 @@ export const requirement3 = {
author: mockAuthor, author: mockAuthor,
}; };
export const mockRequirements = [requirement1, requirement2, requirement3]; export const requirementArchived = {
iid: '23',
title: 'Cuius quidem, quoniam Stoicus fuit',
createdAt: '2020-03-31T13:31:40Z',
updatedAt: '2020-03-31T13:31:40Z',
state: 'ARCHIVED',
userPermissions: mockUserPermissions,
author: mockAuthor,
};
export const mockRequirementsOpen = [requirement1, requirement2, requirement3];
export const mockRequirementsArchived = [requirementArchived];
export const mockRequirementsAll = [...mockRequirementsOpen, ...mockRequirementsArchived];
export const mockRequirementsCount = {
OPENED: 3,
ARCHIVED: 1,
ALL: 4,
};
export const FilterState = {
opened: 'OPENED',
archived: 'ARCHIVED',
all: 'ALL',
};
export const mockPageInfo = {
startCursor: 'eyJpZCI6IjI1IiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzI6MTQgVVRDIn0',
endCursor: 'eyJpZCI6IjIxIiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzE6MTUgVVRDIn0',
};
...@@ -18342,6 +18342,9 @@ msgstr "" ...@@ -18342,6 +18342,9 @@ msgstr ""
msgid "Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account." msgid "Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account."
msgstr "" msgstr ""
msgid "Show all %{issuable_type}."
msgstr ""
msgid "Show all activity" msgid "Show all activity"
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