Commit 2946e918 authored by Kushal Pandya's avatar Kushal Pandya

Move tabs within Requirements Vue app

- Moves Requirements tabs into Vue app.
- Makes Requirements count more reliable by refetching it from backend
on mutation.
parent 7da7c18c
...@@ -4,14 +4,16 @@ import { GlPagination } from '@gitlab/ui'; ...@@ -4,14 +4,16 @@ import { GlPagination } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import RequirementsTabs from './requirements_tabs.vue';
import RequirementsLoading from './requirements_loading.vue'; 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 RequirementForm from './requirement_form.vue'; import RequirementForm from './requirement_form.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql'; import projectRequirements from '../queries/projectRequirements.query.graphql';
import projectRequirementsCount from '../queries/projectRequirementsCount.query.graphql';
import createRequirement from '../queries/createRequirement.mutation.graphql'; import createRequirement from '../queries/createRequirement.mutation.graphql';
import updateRequirement from '../queries/updateRequirement.mutation.graphql'; import updateRequirement from '../queries/updateRequirement.mutation.graphql';
...@@ -20,6 +22,7 @@ import { FilterState, DEFAULT_PAGE_SIZE } from '../constants'; ...@@ -20,6 +22,7 @@ import { FilterState, DEFAULT_PAGE_SIZE } from '../constants';
export default { export default {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
components: { components: {
RequirementsTabs,
GlPagination, GlPagination,
RequirementsLoading, RequirementsLoading,
RequirementsEmptyState, RequirementsEmptyState,
...@@ -31,11 +34,11 @@ export default { ...@@ -31,11 +34,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
filterBy: { initialFilterBy: {
type: String, type: String,
required: true, required: true,
}, },
requirementsCount: { initialRequirementsCount: {
type: Object, type: Object,
required: true, required: true,
validator: value => validator: value =>
...@@ -87,6 +90,8 @@ export default { ...@@ -87,6 +90,8 @@ export default {
queryVariables.firstPageSize = DEFAULT_PAGE_SIZE; queryVariables.firstPageSize = DEFAULT_PAGE_SIZE;
} }
// Include `state` only if `filterBy` is not `ALL`.
// as Grqph query only supports `OPEN` and `ARCHIVED`.
if (this.filterBy !== FilterState.all) { if (this.filterBy !== FilterState.all) {
queryVariables.state = this.filterBy; queryVariables.state = this.filterBy;
} }
...@@ -95,16 +100,10 @@ export default { ...@@ -95,16 +100,10 @@ export default {
}, },
update(data) { update(data) {
const requirementsRoot = data.project?.requirements; const requirementsRoot = data.project?.requirements;
const { opened = 0, archived = 0 } = data.project?.requirementStatesCount;
return { return {
list: requirementsRoot?.nodes || [], list: requirementsRoot?.nodes || [],
pageInfo: requirementsRoot?.pageInfo || {}, pageInfo: requirementsRoot?.pageInfo || {},
count: {
OPENED: opened,
ARCHIVED: archived,
ALL: opened + archived,
},
}; };
}, },
error: e => { error: e => {
...@@ -112,12 +111,31 @@ export default { ...@@ -112,12 +111,31 @@ export default {
Sentry.captureException(e); Sentry.captureException(e);
}, },
}, },
requirementsCount: {
query: projectRequirementsCount,
variables() {
return {
projectPath: this.projectPath,
};
},
update({ project = {} }) {
const { opened = 0, archived = 0 } = project.requirementStatesCount;
return {
OPENED: opened,
ARCHIVED: archived,
ALL: opened + archived,
};
},
error: e => {
createFlash(__('Something went wrong while fetching requirements count.'));
Sentry.captureException(e);
},
},
}, },
data() { data() {
const tabsContainerEl = document.querySelector('.js-requirements-state-filters');
return { return {
newRequirementEl: null, filterBy: this.initialFilterBy,
showCreateForm: false, showCreateForm: false,
showUpdateFormForRequirement: 0, showUpdateFormForRequirement: 0,
createRequirementRequestActive: false, createRequirementRequestActive: false,
...@@ -127,15 +145,12 @@ export default { ...@@ -127,15 +145,12 @@ export default {
nextPageCursor: this.next, nextPageCursor: this.next,
requirements: { requirements: {
list: [], list: [],
count: {},
pageInfo: {}, pageInfo: {},
}, },
openedCount: this.requirementsCount[FilterState.opened], requirementsCount: {
archivedCount: this.requirementsCount[FilterState.archived], OPENED: this.initialRequirementsCount[FilterState.opened],
countEls: { ARCHIVED: this.initialRequirementsCount[FilterState.archived],
opened: tabsContainerEl.querySelector('.js-opened-count'), ALL: this.initialRequirementsCount[FilterState.all],
archived: tabsContainerEl.querySelector('.js-archived-count'),
all: tabsContainerEl.querySelector('.js-all-count'),
}, },
}; };
}, },
...@@ -149,31 +164,17 @@ export default { ...@@ -149,31 +164,17 @@ export default {
return this.$apollo.queries.requirements.loading; return this.$apollo.queries.requirements.loading;
}, },
requirementsListEmpty() { requirementsListEmpty() {
return !this.$apollo.queries.requirements.loading && !this.requirements.list.length; return (
!this.$apollo.queries.requirements.loading &&
!this.requirements.list.length &&
this.requirementsCount[this.filterBy] === 0
);
}, },
/**
* We want to ensure that count `0` is prioritized
* over `this.requirements.count` (GraphQL) or `this.requirementsCount` (HAML prop)
* as both of them are invalid once user does archive/reopen actions.
* this is a technical debt that we want to clean up once mutations support
* `requirementStatesCount` connection.
*/
totalRequirementsForCurrentTab() { totalRequirementsForCurrentTab() {
if (this.filterBy === FilterState.opened) { return this.requirementsCount[this.filterBy];
return this.openedCount === 0
? 0
: this.requirements.count.OPENED || this.requirementsCount.OPENED;
} else if (this.filterBy === FilterState.archived) {
return this.archivedCount === 0
? 0
: this.requirements.count.ARCHIVED || this.requirementsCount.ARCHIVED;
}
return this.requirements.count[this.filterBy] || this.requirementsCount[this.filterBy];
}, },
showEmptyState() { showEmptyState() {
return ( return this.requirementsListEmpty && !this.showCreateForm;
(this.requirementsListEmpty && !this.showCreateForm) || !this.totalRequirementsForCurrentTab
);
}, },
showPaginationControls() { showPaginationControls() {
return this.totalRequirementsForCurrentTab > DEFAULT_PAGE_SIZE && !this.requirementsListEmpty; return this.totalRequirementsForCurrentTab > DEFAULT_PAGE_SIZE && !this.requirementsListEmpty;
...@@ -188,36 +189,6 @@ export default { ...@@ -188,36 +189,6 @@ export default {
: nextPage; : nextPage;
}, },
}, },
watch: {
showCreateForm(value) {
this.enableOrDisableNewRequirement({
disable: value,
});
},
requirements() {
const totalCount = this.requirements.count.ALL;
this.countEls.all.innerText = totalCount;
},
openedCount(value) {
this.countEls.opened.innerText = value;
},
archivedCount(value) {
this.countEls.archived.innerText = value;
},
},
mounted() {
if (this.filterBy === FilterState.opened) {
this.newRequirementEl = document.querySelector('.js-new-requirement');
this.newRequirementEl.addEventListener('click', this.handleNewRequirementClick);
}
},
beforeDestroy() {
if (this.filterBy === FilterState.opened) {
this.newRequirementEl.removeEventListener('click', this.handleNewRequirementClick);
}
},
methods: { methods: {
/** /**
* Update browser URL with updated query-param values * Update browser URL with updated query-param values
...@@ -273,21 +244,21 @@ export default { ...@@ -273,21 +244,21 @@ export default {
Sentry.captureException(e); Sentry.captureException(e);
}); });
}, },
/** handleTabClick({ filterBy }) {
* This method is only needed until we move Requirements page this.filterBy = filterBy;
* tabs and button into this Vue app instead of rendering it this.prevPageCursor = '';
* using HAML. this.nextPageCursor = '';
*/
enableOrDisableNewRequirement({ disable = true }) { // Update browser URL
if (this.newRequirementEl) { updateHistory({
if (disable) { url: setUrlParams({ state: filterBy.toLowerCase() }, window.location.href, true),
this.newRequirementEl.setAttribute('disabled', 'disabled'); title: document.title,
this.newRequirementEl.classList.add('disabled'); replace: true,
} else { });
this.newRequirementEl.removeAttribute('disabled');
this.newRequirementEl.classList.remove('disabled'); // Wait for changes to propagate in component
} // and then fetch again.
} this.$nextTick(() => this.$apollo.queries.requirements.refetch());
}, },
handleNewRequirementClick() { handleNewRequirementClick() {
this.showCreateForm = true; this.showCreateForm = true;
...@@ -296,7 +267,6 @@ export default { ...@@ -296,7 +267,6 @@ export default {
this.showUpdateFormForRequirement = iid; this.showUpdateFormForRequirement = iid;
}, },
handleNewRequirementSave(title) { handleNewRequirementSave(title) {
const reloadPage = this.totalRequirementsForCurrentTab === 0;
this.createRequirementRequestActive = true; this.createRequirementRequestActive = true;
return this.$apollo return this.$apollo
.mutate({ .mutate({
...@@ -310,18 +280,14 @@ export default { ...@@ -310,18 +280,14 @@ export default {
}) })
.then(({ data }) => { .then(({ data }) => {
if (!data.createRequirement.errors.length) { if (!data.createRequirement.errors.length) {
if (reloadPage) { this.$apollo.queries.requirementsCount.refetch();
visitUrl(this.requirementsWebUrl); this.$apollo.queries.requirements.refetch();
} else { this.$toast.show(
this.showCreateForm = false; sprintf(__('Requirement %{reference} has been added'), {
this.$apollo.queries.requirements.refetch(); reference: `REQ-${data.createRequirement.requirement.iid}`,
this.openedCount += 1; }),
this.$toast.show( );
sprintf(__('Requirement %{reference} has been added'), { this.showCreateForm = false;
reference: `REQ-${data.createRequirement.requirement.iid}`,
}),
);
}
} else { } else {
throw new Error(`Error creating a requirement`); throw new Error(`Error creating a requirement`);
} }
...@@ -369,17 +335,14 @@ export default { ...@@ -369,17 +335,14 @@ export default {
: __('Something went wrong while archiving a requirement.'), : __('Something went wrong while archiving a requirement.'),
}).then(({ data }) => { }).then(({ data }) => {
if (!data.updateRequirement.errors.length) { if (!data.updateRequirement.errors.length) {
this.$apollo.queries.requirementsCount.refetch();
this.stateChangeRequestActiveFor = 0; this.stateChangeRequestActiveFor = 0;
let toastMessage; let toastMessage;
if (params.state === FilterState.opened) { if (params.state === FilterState.opened) {
this.openedCount += 1;
this.archivedCount -= 1;
toastMessage = sprintf(__('Requirement %{reference} has been reopened'), { toastMessage = sprintf(__('Requirement %{reference} has been reopened'), {
reference: `REQ-${data.updateRequirement.requirement.iid}`, reference: `REQ-${data.updateRequirement.requirement.iid}`,
}); });
} else { } else {
this.openedCount -= 1;
this.archivedCount += 1;
toastMessage = sprintf(__('Requirement %{reference} has been archived'), { toastMessage = sprintf(__('Requirement %{reference} has been archived'), {
reference: `REQ-${data.updateRequirement.requirement.iid}`, reference: `REQ-${data.updateRequirement.requirement.iid}`,
}); });
...@@ -418,6 +381,14 @@ export default { ...@@ -418,6 +381,14 @@ export default {
<template> <template>
<div class="requirements-list-container"> <div class="requirements-list-container">
<requirements-tabs
:filter-by="filterBy"
:requirements-count="requirementsCount"
:show-create-form="showCreateForm"
:can-create-requirement="canCreateRequirement"
@clickTab="handleTabClick"
@clickNewRequirement="handleNewRequirementClick"
/>
<requirement-form <requirement-form
v-if="showCreateForm" v-if="showCreateForm"
:requirement-request-active="createRequirementRequestActive" :requirement-request-active="createRequirementRequestActive"
......
<script>
import { GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { FilterState } from '../constants';
export default {
FilterState,
components: {
GlLink,
GlBadge,
GlButton,
},
props: {
filterBy: {
type: String,
required: true,
},
requirementsCount: {
type: Object,
required: true,
},
showCreateForm: {
type: Boolean,
required: true,
},
canCreateRequirement: {
type: Boolean,
required: false,
},
},
computed: {
isOpenTab() {
return this.filterBy === FilterState.opened;
},
isArchivedTab() {
return this.filterBy === FilterState.archived;
},
isAllTab() {
return this.filterBy === FilterState.all;
},
},
};
</script>
<template>
<div class="top-area">
<ul class="nav-links mobile-separator requirements-state-filters js-requirements-state-filters">
<li :class="{ active: isOpenTab }">
<gl-link
id="state-opened"
data-state="opened"
:title="__('Filter by requirements that are currently opened.')"
@click="$emit('clickTab', { filterBy: $options.FilterState.opened })"
>
{{ __('Open') }}
<gl-badge class="badge-pill">{{ requirementsCount.OPENED }}</gl-badge>
</gl-link>
</li>
<li :class="{ active: isArchivedTab }">
<gl-link
id="state-archived"
data-state="archived"
:title="__('Filter by requirements that are currently archived.')"
@click="$emit('clickTab', { filterBy: $options.FilterState.archived })"
>
{{ __('Archived') }}
<gl-badge class="badge-pill">{{ requirementsCount.ARCHIVED }}</gl-badge>
</gl-link>
</li>
<li :class="{ active: isAllTab }">
<gl-link
id="state-all"
data-state="all"
:title="__('Show all requirements.')"
@click="$emit('clickTab', { filterBy: $options.FilterState.all })"
>
{{ __('All') }}
<gl-badge class="badge-pill">{{ requirementsCount.ALL }}</gl-badge>
</gl-link>
</li>
</ul>
<div v-if="isOpenTab && canCreateRequirement" class="nav-controls">
<gl-button
category="primary"
variant="success"
class="js-new-requirement qa-new-requirement-button"
:disabled="showCreateForm"
@click="$emit('clickNewRequirement')"
>{{ __('New requirement') }}</gl-button
>
</div>
</div>
</template>
...@@ -7,10 +7,6 @@ query projectRequirements( ...@@ -7,10 +7,6 @@ query projectRequirements(
$nextPageCursor: String = "" $nextPageCursor: String = ""
) { ) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
requirementStatesCount {
opened
archived
}
requirements( requirements(
first: $firstPageSize first: $firstPageSize
last: $lastPageSize last: $lastPageSize
......
query projectRequirements($projectPath: ID!) {
project(fullPath: $projectPath) {
requirementStatesCount {
opened
archived
}
}
}
...@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; ...@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
import { GlToast } from '@gitlab/ui'; import { GlToast } from '@gitlab/ui';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import RequirementsRoot from './components/requirements_root.vue'; import RequirementsRoot from './components/requirements_root.vue';
...@@ -58,8 +59,8 @@ export default () => { ...@@ -58,8 +59,8 @@ export default () => {
const ALL = parseInt(all, 10); const ALL = parseInt(all, 10);
return { return {
filterBy: stateFilterBy, initialFilterBy: stateFilterBy,
requirementsCount: { initialRequirementsCount: {
OPENED, OPENED,
ARCHIVED, ARCHIVED,
ALL, ALL,
...@@ -77,13 +78,13 @@ export default () => { ...@@ -77,13 +78,13 @@ export default () => {
return createElement('requirements-root', { return createElement('requirements-root', {
props: { props: {
projectPath: this.projectPath, projectPath: this.projectPath,
filterBy: this.filterBy, initialFilterBy: this.initialFilterBy,
requirementsCount: this.requirementsCount, initialRequirementsCount: this.initialRequirementsCount,
page: parseInt(this.page, 10) || 1, page: parseInt(this.page, 10) || 1,
prev: this.prev, prev: this.prev,
next: this.next, next: this.next,
emptyStatePath: this.emptyStatePath, emptyStatePath: this.emptyStatePath,
canCreateRequirement: this.canCreateRequirement, canCreateRequirement: parseBoolean(this.canCreateRequirement),
requirementsWebUrl: this.requirementsWebUrl, requirementsWebUrl: this.requirementsWebUrl,
}, },
}); });
......
- page_title _('Requirements') - page_title _('Requirements')
- type = :requirements
- page_context_word = type.to_s.humanize(capitalize: false)
- @content_class = 'requirements-container' - @content_class = 'requirements-container'
-# We'd prefer to have following declarations be part of -# We'd prefer to have following declarations be part of
-# helpers in some way but given that they're very frontend-centeric, -# helpers in some way but given that they're very frontend-centeric,
-# keeping them in HAML view makes more sense. -# keeping them in HAML view makes more sense.
- page_size = 20 - page_size = 20
- 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)
- total_requirements = requirements_count['opened'] + requirements_count['archived'] - total_requirements = requirements_count['opened'] + requirements_count['archived']
- is_open_tab = params[:state].nil? || params[:state] == 'opened' - is_open_tab = params[:state].nil? || params[:state] == 'opened'
...@@ -19,28 +16,6 @@ ...@@ -19,28 +16,6 @@
- else - else
- current_tab_count = total_requirements > page_size ? page_size : total_requirements - current_tab_count = total_requirements > page_size ? page_size : total_requirements
.top-area
%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.js-opened-count= requirements_count['opened']
%li{ class: active_when(params[:state] == 'archived') }>
= 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.js-archived-count= requirements_count['archived']
%li{ class: active_when(params[:state] == 'all') }>
= 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.js-all-count= total_requirements
.nav-controls
- if is_open_tab && can?(current_user, :create_requirement, @project)
%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],
prev: params[:prev], prev: params[:prev],
...@@ -50,7 +25,7 @@ ...@@ -50,7 +25,7 @@
archived: requirements_count['archived'], archived: requirements_count['archived'],
all: total_requirements, all: total_requirements,
requirements_web_url: project_requirements_management_requirements_path(@project), requirements_web_url: project_requirements_management_requirements_path(@project),
can_create_requirement: can?(current_user, :create_requirement, @project), can_create_requirement: "#{can?(current_user, :create_requirement, @project)}",
empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } } empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } }
- if current_tab_count == 0 - if current_tab_count == 0
-# Show regular spinner only when there will be no -# Show regular spinner only when there will be no
......
...@@ -184,9 +184,7 @@ describe 'Requirements list', :js do ...@@ -184,9 +184,7 @@ describe 'Requirements list', :js do
end end
it 'does not show button "New requirement"' do it 'does not show button "New requirement"' do
page.within('.nav-controls') do expect(page).not_to have_selector('.nav-controls button.js-new-requirement')
expect(page).not_to have_selector('button.js-new-requirement')
end
end end
it 'shows list of all archived requirements' do it 'shows list of all archived requirements' do
...@@ -229,9 +227,7 @@ describe 'Requirements list', :js do ...@@ -229,9 +227,7 @@ describe 'Requirements list', :js do
end end
it 'does not show button "New requirement"' do it 'does not show button "New requirement"' do
page.within('.nav-controls') do expect(page).not_to have_selector('.nav-controls button.js-new-requirement')
expect(page).not_to have_selector('button.js-new-requirement')
end
end end
it 'shows list of all requirements' do it 'shows list of all requirements' do
...@@ -251,9 +247,7 @@ describe 'Requirements list', :js do ...@@ -251,9 +247,7 @@ describe 'Requirements list', :js do
end end
it 'open tab does not show button "New requirement"' do it 'open tab does not show button "New requirement"' do
page.within('.nav-controls') do expect(page).not_to have_selector('.nav-controls button.js-new-requirement')
expect(page).not_to have_selector('button.js-new-requirement')
end
end end
end end
end end
...@@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue'; import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
import RequirementsTabs from 'ee/requirements/components/requirements_tabs.vue';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.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';
...@@ -31,10 +31,6 @@ jest.mock('ee/requirements/constants', () => ({ ...@@ -31,10 +31,6 @@ jest.mock('ee/requirements/constants', () => ({
})); }));
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
visitUrl: jest.fn(),
}));
const $toast = { const $toast = {
show: jest.fn(), show: jest.fn(),
...@@ -42,8 +38,8 @@ const $toast = { ...@@ -42,8 +38,8 @@ const $toast = {
const createComponent = ({ const createComponent = ({
projectPath = 'gitlab-org/gitlab-shell', projectPath = 'gitlab-org/gitlab-shell',
filterBy = FilterState.opened, initialFilterBy = FilterState.opened,
requirementsCount = mockRequirementsCount, initialRequirementsCount = mockRequirementsCount,
showCreateRequirement = false, showCreateRequirement = false,
emptyStatePath = '/assets/illustrations/empty-state/requirements.svg', emptyStatePath = '/assets/illustrations/empty-state/requirements.svg',
loading = false, loading = false,
...@@ -53,8 +49,8 @@ const createComponent = ({ ...@@ -53,8 +49,8 @@ const createComponent = ({
shallowMount(RequirementsRoot, { shallowMount(RequirementsRoot, {
propsData: { propsData: {
projectPath, projectPath,
filterBy, initialFilterBy,
requirementsCount, initialRequirementsCount,
showCreateRequirement, showCreateRequirement,
emptyStatePath, emptyStatePath,
canCreateRequirement, canCreateRequirement,
...@@ -67,7 +63,10 @@ const createComponent = ({ ...@@ -67,7 +63,10 @@ const createComponent = ({
loading, loading,
list: [], list: [],
pageInfo: {}, pageInfo: {},
count: {}, refetch: jest.fn(),
},
requirementsCount: {
...initialRequirementsCount,
refetch: jest.fn(), refetch: jest.fn(),
}, },
}, },
...@@ -81,16 +80,6 @@ describe('RequirementsRoot', () => { ...@@ -81,16 +80,6 @@ describe('RequirementsRoot', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
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(); wrapper = createComponent();
}); });
...@@ -99,21 +88,64 @@ describe('RequirementsRoot', () => { ...@@ -99,21 +88,64 @@ describe('RequirementsRoot', () => {
}); });
describe('computed', () => { describe('computed', () => {
describe('totalRequirementsForCurrentTab', () => { describe('requirementsListEmpty', () => {
it('returns number representing total requirements for current tab', () => { it('returns `false` when `$apollo.queries.requirements.loading` is true', () => {
expect(wrapper.vm.totalRequirementsForCurrentTab).toBe(mockRequirementsCount.OPENED); const wrapperLoading = createComponent({ loading: true });
expect(wrapperLoading.vm.requirementsListEmpty).toBe(false);
wrapperLoading.destroy();
});
it('returns `false` when `requirements.list` is empty', () => {
wrapper.setData({
requirements: {
list: [],
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.requirementsListEmpty).toBe(false);
});
}); });
it('returns 0 when `openedCount` is 0 and filterBy represents opened tab', () => { it('returns `true` when `requirementsCount` for current filterBy value is 0', () => {
wrapper.setProps({ wrapper.setData({
filterBy: FilterState.opened, filterBy: FilterState.opened,
requirementsCount: {
OPENED: 0,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.requirementsListEmpty).toBe(true);
}); });
});
});
describe('totalRequirementsForCurrentTab', () => {
it('returns number representing total requirements for current tab', () => {
wrapper.setData({ wrapper.setData({
openedCount: 0, filterBy: FilterState.opened,
requirementsCount: {
OPENED: mockRequirementsCount.OPENED,
},
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.totalRequirementsForCurrentTab).toBe(0); expect(wrapper.vm.totalRequirementsForCurrentTab).toBe(mockRequirementsCount.OPENED);
});
});
});
describe('showEmptyState', () => {
it('returns `false` when `showCreateForm` is true', () => {
wrapper.setData({
showCreateForm: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.showEmptyState).toBe(false);
}); });
}); });
}); });
...@@ -123,9 +155,9 @@ describe('RequirementsRoot', () => { ...@@ -123,9 +155,9 @@ describe('RequirementsRoot', () => {
wrapper.setData({ wrapper.setData({
requirements: { requirements: {
list: mockRequirementsOpen, list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo, pageInfo: mockPageInfo,
}, },
requirementsCount: mockRequirementsCount,
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
...@@ -137,12 +169,12 @@ describe('RequirementsRoot', () => { ...@@ -137,12 +169,12 @@ describe('RequirementsRoot', () => {
wrapper.setData({ wrapper.setData({
requirements: { requirements: {
list: [mockRequirementsOpen[0]], list: [mockRequirementsOpen[0]],
count: {
...mockRequirementsCount,
OPENED: 1,
},
pageInfo: mockPageInfo, pageInfo: mockPageInfo,
}, },
requirementsCount: {
...mockRequirementsCount,
OPENED: 1,
},
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
...@@ -294,34 +326,6 @@ describe('RequirementsRoot', () => { ...@@ -294,34 +326,6 @@ describe('RequirementsRoot', () => {
}); });
}); });
describe('enableOrDisableNewRequirement', () => {
it('disables new requirement button when called with param `{ disable: true }`', () => {
wrapper.vm.enableOrDisableNewRequirement({
disable: true,
});
return wrapper.vm.$nextTick(() => {
const newReqButton = document.querySelector('.js-new-requirement');
expect(newReqButton.getAttribute('disabled')).toBe('disabled');
expect(newReqButton.classList.contains('disabled')).toBe(true);
});
});
it('enables new requirement button when called with param `{ disable: false }`', () => {
wrapper.vm.enableOrDisableNewRequirement({
disable: false,
});
return wrapper.vm.$nextTick(() => {
const newReqButton = document.querySelector('.js-new-requirement');
expect(newReqButton.getAttribute('disabled')).toBeNull();
expect(newReqButton.classList.contains('disabled')).toBe(false);
});
});
});
describe('handleNewRequirementClick', () => { describe('handleNewRequirementClick', () => {
it('sets `showCreateForm` prop to `true`', () => { it('sets `showCreateForm` prop to `true`', () => {
wrapper.vm.handleNewRequirementClick(); wrapper.vm.handleNewRequirementClick();
...@@ -380,33 +384,21 @@ describe('RequirementsRoot', () => { ...@@ -380,33 +384,21 @@ describe('RequirementsRoot', () => {
); );
}); });
it('calls `visitUrl` when project has no requirements and request is successful', () => { it('sets `showCreateForm` and `createRequirementRequestActive` props to `false` and refetches requirements count and list when request is successful', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockMutationResult);
wrapper.setProps({
requirementsCount: {
OPENED: 0,
ARCHIVED: 0,
ALL: 0,
},
});
return wrapper.vm.handleNewRequirementSave('foo').then(() => {
expect(visitUrl).toHaveBeenCalledWith('/gitlab-org/gitlab-shell/-/requirements');
});
});
it('sets `showCreateForm` and `createRequirementRequestActive` props to `false` and calls `$apollo.queries.requirements.refetch()` when request is successful', () => {
jest jest
.spyOn(wrapper.vm.$apollo, 'mutate') .spyOn(wrapper.vm.$apollo, 'mutate')
.mockReturnValue(Promise.resolve(mockMutationResult)); .mockReturnValue(Promise.resolve(mockMutationResult));
jest
.spyOn(wrapper.vm.$apollo.queries.requirementsCount, 'refetch')
.mockImplementation(jest.fn());
jest jest
.spyOn(wrapper.vm.$apollo.queries.requirements, 'refetch') .spyOn(wrapper.vm.$apollo.queries.requirements, 'refetch')
.mockImplementation(jest.fn()); .mockImplementation(jest.fn());
return wrapper.vm.handleNewRequirementSave('foo').then(() => { return wrapper.vm.handleNewRequirementSave('foo').then(() => {
expect(wrapper.vm.showCreateForm).toBe(false); expect(wrapper.vm.$apollo.queries.requirementsCount.refetch).toHaveBeenCalled();
expect(wrapper.vm.$apollo.queries.requirements.refetch).toHaveBeenCalled(); expect(wrapper.vm.$apollo.queries.requirements.refetch).toHaveBeenCalled();
expect(wrapper.vm.showCreateForm).toBe(false);
expect(wrapper.vm.createRequirementRequestActive).toBe(false); expect(wrapper.vm.createRequirementRequestActive).toBe(false);
}); });
}); });
...@@ -571,11 +563,10 @@ describe('RequirementsRoot', () => { ...@@ -571,11 +563,10 @@ describe('RequirementsRoot', () => {
}); });
}); });
it('increments `openedCount` by 1 and decrements `archivedCount` by 1 when `params.state` is "OPENED"', () => { it('refetches requirementsCount query when request is successful', () => {
wrapper.setData({ jest
openedCount: 1, .spyOn(wrapper.vm.$apollo.queries.requirementsCount, 'refetch')
archivedCount: 1, .mockImplementation(jest.fn());
});
return wrapper.vm return wrapper.vm
.handleRequirementStateChange({ .handleRequirementStateChange({
...@@ -583,8 +574,7 @@ describe('RequirementsRoot', () => { ...@@ -583,8 +574,7 @@ describe('RequirementsRoot', () => {
state: FilterState.opened, state: FilterState.opened,
}) })
.then(() => { .then(() => {
expect(wrapper.vm.openedCount).toBe(2); expect(wrapper.vm.$apollo.queries.requirementsCount.refetch).toHaveBeenCalled();
expect(wrapper.vm.archivedCount).toBe(0);
}); });
}); });
...@@ -601,23 +591,6 @@ describe('RequirementsRoot', () => { ...@@ -601,23 +591,6 @@ describe('RequirementsRoot', () => {
}); });
}); });
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);
});
});
it('calls `$toast.show` with string "Requirement has been archived" when `params.state` is "ARCHIVED" and request is successful', () => { it('calls `$toast.show` with string "Requirement has been archived" when `params.state` is "ARCHIVED" and request is successful', () => {
return wrapper.vm return wrapper.vm
.handleRequirementStateChange({ .handleRequirementStateChange({
...@@ -647,9 +620,9 @@ describe('RequirementsRoot', () => { ...@@ -647,9 +620,9 @@ describe('RequirementsRoot', () => {
wrapper.setData({ wrapper.setData({
requirements: { requirements: {
list: mockRequirementsOpen, list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo, pageInfo: mockPageInfo,
}, },
requirementsCount: mockRequirementsCount,
}); });
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
...@@ -688,8 +661,23 @@ describe('RequirementsRoot', () => { ...@@ -688,8 +661,23 @@ describe('RequirementsRoot', () => {
expect(wrapper.classes()).toContain('requirements-list-container'); expect(wrapper.classes()).toContain('requirements-list-container');
}); });
it('renders requirements-tabs component', () => {
expect(wrapper.find(RequirementsTabs).exists()).toBe(true);
});
it('renders empty state when query results are empty', () => { it('renders empty state when query results are empty', () => {
expect(wrapper.find(RequirementsEmptyState).exists()).toBe(true); wrapper.setData({
requirements: {
list: [],
},
requirementsCount: {
OPENED: 0,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementsEmptyState).exists()).toBe(true);
});
}); });
it('renders requirements-loading component when query results are still being loaded', () => { it('renders requirements-loading component when query results are still being loaded', () => {
...@@ -724,9 +712,9 @@ describe('RequirementsRoot', () => { ...@@ -724,9 +712,9 @@ describe('RequirementsRoot', () => {
wrapper.setData({ wrapper.setData({
requirements: { requirements: {
list: mockRequirementsOpen, list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo, pageInfo: mockPageInfo,
}, },
requirementsCount: mockRequirementsCount,
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
...@@ -741,9 +729,9 @@ describe('RequirementsRoot', () => { ...@@ -741,9 +729,9 @@ describe('RequirementsRoot', () => {
wrapper.setData({ wrapper.setData({
requirements: { requirements: {
list: mockRequirementsOpen, list: mockRequirementsOpen,
count: mockRequirementsCount,
pageInfo: mockPageInfo, pageInfo: mockPageInfo,
}, },
requirementsCount: mockRequirementsCount,
}); });
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
......
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlBadge, GlButton } from '@gitlab/ui';
import RequirementsTabs from 'ee/requirements/components/requirements_tabs.vue';
import { FilterState } from 'ee/requirements/constants';
import { mockRequirementsCount } from '../mock_data';
const createComponent = ({
filterBy = FilterState.opened,
requirementsCount = mockRequirementsCount,
showCreateForm = false,
canCreateRequirement = true,
} = {}) =>
shallowMount(RequirementsTabs, {
propsData: {
filterBy,
requirementsCount,
showCreateForm,
canCreateRequirement,
},
});
describe('RequirementsTabs', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders "Open" tab', () => {
const tabEl = wrapper.findAll(GlLink).at(0);
expect(tabEl.attributes('id')).toBe('state-opened');
expect(tabEl.attributes('data-state')).toBe('opened');
expect(tabEl.attributes('title')).toBe('Filter by requirements that are currently opened.');
expect(tabEl.text()).toContain('Open');
expect(tabEl.find(GlBadge).text()).toBe(`${mockRequirementsCount.OPENED}`);
});
it('renders "Archived" tab', () => {
const tabEl = wrapper.findAll(GlLink).at(1);
expect(tabEl.attributes('id')).toBe('state-archived');
expect(tabEl.attributes('data-state')).toBe('archived');
expect(tabEl.attributes('title')).toBe('Filter by requirements that are currently archived.');
expect(tabEl.text()).toContain('Archived');
expect(tabEl.find(GlBadge).text()).toBe(`${mockRequirementsCount.ARCHIVED}`);
});
it('renders "All" tab', () => {
const tabEl = wrapper.findAll(GlLink).at(2);
expect(tabEl.attributes('id')).toBe('state-all');
expect(tabEl.attributes('data-state')).toBe('all');
expect(tabEl.attributes('title')).toBe('Show all requirements.');
expect(tabEl.text()).toContain('All');
expect(tabEl.find(GlBadge).text()).toBe(`${mockRequirementsCount.ALL}`);
});
it('renders class `active` on currently selected tab', () => {
const tabEl = wrapper.findAll('li').at(0);
expect(tabEl.classes()).toContain('active');
});
it('renders "New requirement" button when current tab is "Open" tab', () => {
wrapper.setProps({
filterBy: FilterState.opened,
});
return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.find(GlButton);
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.text()).toBe('New requirement');
});
});
it('does not render "New requirement" button when current tab is not "Open" tab', () => {
wrapper.setProps({
filterBy: FilterState.closed,
});
return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.find(GlButton);
expect(buttonEl.exists()).toBe(false);
});
});
it('does not render "New requirement" button when `canCreateRequirement` prop is false', () => {
wrapper.setProps({
filterBy: FilterState.opened,
canCreateRequirement: false,
});
return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.find(GlButton);
expect(buttonEl.exists()).toBe(false);
});
});
it('disables "New requirement" button when `showCreateForm` is true', () => {
wrapper.setProps({
showCreateForm: true,
});
return wrapper.vm.$nextTick(() => {
const buttonEl = wrapper.find(GlButton);
expect(buttonEl.props('disabled')).toBe(true);
});
});
});
});
...@@ -9308,9 +9308,6 @@ msgstr "" ...@@ -9308,9 +9308,6 @@ msgstr ""
msgid "Filter" msgid "Filter"
msgstr "" msgstr ""
msgid "Filter by %{issuable_type} that are currently archived."
msgstr ""
msgid "Filter by %{issuable_type} that are currently closed." msgid "Filter by %{issuable_type} that are currently closed."
msgstr "" msgstr ""
...@@ -9326,6 +9323,12 @@ msgstr "" ...@@ -9326,6 +9323,12 @@ msgstr ""
msgid "Filter by name" msgid "Filter by name"
msgstr "" msgstr ""
msgid "Filter by requirements that are currently archived."
msgstr ""
msgid "Filter by requirements that are currently opened."
msgstr ""
msgid "Filter by status" msgid "Filter by status"
msgstr "" msgstr ""
...@@ -19152,15 +19155,15 @@ msgstr "" ...@@ -19152,15 +19155,15 @@ 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 ""
msgid "Show all members" msgid "Show all members"
msgstr "" msgstr ""
msgid "Show all requirements."
msgstr ""
msgid "Show archived projects" msgid "Show archived projects"
msgstr "" msgstr ""
...@@ -19535,6 +19538,9 @@ msgstr "" ...@@ -19535,6 +19538,9 @@ msgstr ""
msgid "Something went wrong while fetching related merge requests." msgid "Something went wrong while fetching related merge requests."
msgstr "" msgstr ""
msgid "Something went wrong while fetching requirements count."
msgstr ""
msgid "Something went wrong while fetching requirements list." msgid "Something went wrong while fetching requirements list."
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