Commit 30edb799 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '268372-epics-list-using-issuable-list' into 'master'

Epics list using `issuable_list` [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!46769
parents 4ccb852e 8d660da8
---
name: vue_epics_list
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46769
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276189
milestone: '13.9'
type: development
group: group::product planning
default_enabled: false
<script>
import { GlEmptyState } from '@gitlab/ui';
import { __ } from '~/locale';
import { IssuableStates } from '~/issuable_list/constants';
import { FilterStateEmptyMessage } from '../constants';
export default {
components: {
GlEmptyState,
},
inject: ['emptyStatePath'],
props: {
currentState: {
type: String,
required: true,
},
epicsCount: {
type: Object,
required: true,
},
},
computed: {
emptyStateTitle() {
return this.epicsCount[IssuableStates.All]
? FilterStateEmptyMessage[this.currentState]
: __(
'Epics let you manage your portfolio of projects more efficiently and with less effort',
);
},
showDescription() {
return !this.epicsCount[IssuableStates.All];
},
},
};
</script>
<template>
<gl-empty-state :svg-path="emptyStatePath" :title="emptyStateTitle">
<template v-if="showDescription" #description>
{{ __('Track groups of issues that share a theme, across projects and milestones') }}
</template>
</gl-empty-state>
</template>
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import EpicsFilteredSearchMixin from 'ee/roadmap/mixins/filtered_search_mixin';
import { s__, sprintf } from '~/locale';
import createFlash from '~/flash';
import { parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, DEFAULT_PAGE_SIZE } from '~/issuable_list/constants';
import groupEpics from '../queries/group_epics.query.graphql';
import { EpicsSortOptions } from '../constants';
import EpicsListEmptyState from './epics_list_empty_state.vue';
export default {
IssuableListTabs,
EpicsSortOptions,
defaultPageSize: DEFAULT_PAGE_SIZE,
epicSymbol: '&',
components: {
GlButton,
GlIcon,
IssuableList,
EpicsListEmptyState,
},
mixins: [EpicsFilteredSearchMixin],
inject: [
'canCreateEpic',
'canBulkEditEpics',
'page',
'prev',
'next',
'initialState',
'initialSortBy',
'epicsCount',
'epicNewPath',
'groupFullPath',
'groupLabelsPath',
'groupMilestonesPath',
'emptyStatePath',
],
apollo: {
epics: {
query: groupEpics,
variables() {
const queryVariables = {
groupPath: this.groupFullPath,
state: this.currentState,
};
if (this.prevPageCursor) {
queryVariables.prevPageCursor = this.prevPageCursor;
queryVariables.lastPageSize = this.$options.defaultPageSize;
} else if (this.nextPageCursor) {
queryVariables.nextPageCursor = this.nextPageCursor;
queryVariables.firstPageSize = this.$options.defaultPageSize;
} else {
queryVariables.firstPageSize = this.$options.defaultPageSize;
}
if (this.sortedBy) {
queryVariables.sortBy = this.sortedBy;
}
if (Object.keys(this.filterParams).length) {
Object.assign(queryVariables, {
...this.filterParams,
});
}
return queryVariables;
},
update(data) {
const epicsRoot = data.group?.epics;
return {
list: epicsRoot?.nodes || [],
pageInfo: epicsRoot?.pageInfo || {},
};
},
error(error) {
createFlash({
message: s__('Epics|Something went wrong while fetching epics list.'),
captureError: true,
error,
});
},
},
},
props: {
initialFilterParams: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
currentState: this.initialState,
currentPage: this.page,
prevPageCursor: this.prev,
nextPageCursor: this.next,
filterParams: this.initialFilterParams,
sortedBy: this.initialSortBy,
epics: {
list: [],
pageInfo: {},
},
};
},
computed: {
epicsListLoading() {
return this.$apollo.queries.epics.loading;
},
epicsListEmpty() {
return !this.$apollo.queries.epics.loading && !this.epics.list.length;
},
showPaginationControls() {
const { hasPreviousPage, hasNextPage } = this.epics.pageInfo;
// This explicit check is necessary as both the variables
// can also be `false` and we just want to ensure that they're present.
if (hasPreviousPage !== undefined || hasNextPage !== undefined) {
return Boolean(hasPreviousPage || hasNextPage);
}
return !this.epicsListEmpty;
},
previousPage() {
return Math.max(this.currentPage - 1, 0);
},
nextPage() {
const nextPage = this.currentPage + 1;
return nextPage >
Math.ceil(this.epicsCount[this.currentState] / this.$options.defaultPageSize)
? null
: nextPage;
},
},
methods: {
epicTimeframe({ startDate, dueDate }) {
const start = startDate ? parsePikadayDate(startDate) : null;
const due = dueDate ? parsePikadayDate(dueDate) : null;
if (startDate && dueDate) {
const startDateInWords = dateInWords(
start,
true,
start.getFullYear() === due.getFullYear(),
);
const dueDateInWords = dateInWords(due, true);
return sprintf(s__('Epics|%{startDate} – %{dueDate}'), {
startDate: startDateInWords,
dueDate: dueDateInWords,
});
} else if (startDate && !dueDate) {
return sprintf(s__('Epics|%{startDate} – No due date'), {
startDate: dateInWords(start, true, false),
});
} else if (!startDate && dueDate) {
return sprintf(s__('Epics|No start date – %{dueDate}'), {
dueDate: dateInWords(due, true, false),
});
}
return '';
},
fetchEpicsBy(propsName, propValue) {
if (propsName === 'currentPage') {
const { startCursor, endCursor } = this.epics.pageInfo;
if (propValue > this.currentPage) {
this.prevPageCursor = '';
this.nextPageCursor = endCursor;
} else {
this.prevPageCursor = startCursor;
this.nextPageCursor = '';
}
} else if (propsName === 'currentState') {
this.currentPage = 1;
this.prevPageCursor = '';
this.nextPageCursor = '';
}
this[propsName] = propValue;
},
handleFilterEpics(filters) {
this.filterParams = this.getFilterParams(filters);
},
},
};
</script>
<template>
<issuable-list
:namespace="groupFullPath"
:tabs="$options.IssuableListTabs"
:current-tab="currentState"
:tab-counts="epicsCount"
:search-input-placeholder="__('Search or filter results...')"
:search-tokens="getFilteredSearchTokens()"
:sort-options="$options.EpicsSortOptions"
:initial-filter-value="getFilteredSearchValue()"
:initial-sort-by="sortedBy"
:issuables="epics.list"
:issuables-loading="epicsListLoading"
:show-pagination-controls="showPaginationControls"
:show-discussions="true"
:default-page-size="$options.defaultPageSize"
:current-page="currentPage"
:previous-page="previousPage"
:next-page="nextPage"
:url-params="urlParams"
:issuable-symbol="$options.epicSymbol"
recent-searches-storage-key="epics"
@click-tab="fetchEpicsBy('currentState', $event)"
@page-change="fetchEpicsBy('currentPage', $event)"
@sort="fetchEpicsBy('sortedBy', $event)"
@filter="handleFilterEpics"
>
<template v-if="canCreateEpic || canBulkEditEpics" #nav-actions>
<gl-button v-if="canCreateEpic" category="primary" variant="success" :href="epicNewPath">{{
__('New epic')
}}</gl-button>
</template>
<template #timeframe="{ issuable }">
<gl-icon name="calendar" />
{{ epicTimeframe(issuable) }}
</template>
<template #empty-state>
<epics-list-empty-state :current-state="currentState" :epics-count="epicsCount" />
</template>
</issuable-list>
</template>
import { __ } from '~/locale';
import { AvailableSortOptions } from '~/issuable_list/constants';
export const EpicsSortOptions = [
{
id: AvailableSortOptions.length + 10,
title: __('Start date'),
sortDirection: {
descending: 'start_date_desc',
ascending: 'start_date_asc',
},
},
{
id: AvailableSortOptions.length + 20,
title: __('Due date'),
sortDirection: {
descending: 'end_date_desc',
ascending: 'end_date_asc',
},
},
];
export const FilterStateEmptyMessage = {
opened: __('There are no open epics'),
closed: __('There are no closed epics'),
};
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import {
urlParamsToObject,
parseBoolean,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import { IssuableStates } from '~/issuable_list/constants';
import EpicsListApp from './components/epics_list_root.vue';
Vue.use(VueApollo);
export default function initEpicsList({ mountPointSelector }) {
const mountPointEl = document.querySelector(mountPointSelector);
if (!mountPointEl) {
return null;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const {
page = 1,
prev = '',
next = '',
initialState = IssuableStates.Opened,
initialSortBy = 'start_date_desc',
canCreateEpic,
canBulkEditEpics,
epicsCountOpened,
epicsCountClosed,
epicsCountAll,
epicNewPath,
groupFullPath,
groupLabelsPath,
groupMilestonesPath,
emptyStatePath,
} = mountPointEl.dataset;
const rawFilterParams = urlParamsToObject(window.location.search);
const initialFilterParams = {
...convertObjectPropsToCamelCase(rawFilterParams, {
dropKeys: ['scope', 'utf8', 'state', 'sort'], // These keys are unsupported/unnecessary
}),
// We shall put parsed value of `confidential` only
// when it is defined.
...(rawFilterParams.confidential && {
confidential: parseBoolean(rawFilterParams.confidential),
}),
};
return new Vue({
el: mountPointEl,
apolloProvider,
provide: {
initialState,
initialSortBy,
prev,
next,
page: parseInt(page, 10),
canCreateEpic: parseBoolean(canCreateEpic),
canBulkEditEpics: parseBoolean(canBulkEditEpics),
epicsCount: {
[IssuableStates.Opened]: parseInt(epicsCountOpened, 10),
[IssuableStates.Closed]: parseInt(epicsCountClosed, 10),
[IssuableStates.All]: parseInt(epicsCountAll, 10),
},
epicNewPath,
groupFullPath,
groupLabelsPath,
groupMilestonesPath,
emptyStatePath,
},
render: (createElement) =>
createElement(EpicsListApp, {
props: {
initialFilterParams,
},
}),
});
}
#import "~/graphql_shared/fragments/author.fragment.graphql"
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query groupEpics(
$groupPath: ID!
$state: EpicState
$authorUsername: String
$labelName: [String!]
$milestoneTitle: String = ""
$confidential: Boolean
$sortBy: EpicSort
$firstPageSize: Int
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
) {
group(fullPath: $groupPath) {
epics(
state: $state
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
confidential: $confidential
sort: $sortBy
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
before: $prevPageCursor
) {
nodes {
id
iid
title
createdAt
updatedAt
startDate
dueDate
webUrl
userDiscussionsCount
confidential
author {
...Author
}
labels {
nodes {
...Label
}
}
}
pageInfo {
...PageInfo
}
}
}
}
import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics'; import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics';
import initEpicCreateApp from 'ee/epic/epic_bundle'; import initEpicCreateApp from 'ee/epic/epic_bundle';
import initEpicsList from 'ee/epics_list/epics_list_bundle';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
const EPIC_BULK_UPDATE_PREFIX = 'epic_'; const EPIC_BULK_UPDATE_PREFIX = 'epic_';
initFilteredSearch({ if (gon.features.vueEpicsList) {
initEpicsList({
mountPointSelector: '#js-epics-list',
});
} else {
initFilteredSearch({
page: 'epics', page: 'epics',
isGroup: true, isGroup: true,
isGroupDecendent: true, isGroupDecendent: true,
useDefaultState: true, useDefaultState: true,
filteredSearchTokenKeys: FilteredSearchTokenKeysEpics, filteredSearchTokenKeys: FilteredSearchTokenKeysEpics,
stateFiltersSelector: '.epics-state-filters', stateFiltersSelector: '.epics-state-filters',
}); });
initEpicCreateApp(true); initEpicCreateApp(true);
issuableInitBulkUpdateSidebar.init(EPIC_BULK_UPDATE_PREFIX); issuableInitBulkUpdateSidebar.init(EPIC_BULK_UPDATE_PREFIX);
}
...@@ -18,6 +18,10 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -18,6 +18,10 @@ class Groups::EpicsController < Groups::ApplicationController
before_action :verify_group_bulk_edit_enabled!, only: [:bulk_update] before_action :verify_group_bulk_edit_enabled!, only: [:bulk_update]
after_action :log_epic_show, only: :show after_action :log_epic_show, only: :show
before_action do
push_frontend_feature_flag(:vue_epics_list, @group, type: :development, default_enabled: :yaml)
end
feature_category :epics feature_category :epics
def new def new
......
...@@ -2,7 +2,24 @@ ...@@ -2,7 +2,24 @@
- page_title _("Epics") - page_title _("Epics")
.top-area - if Feature.enabled?(:vue_epics_list, @group)
#js-epics-list{ data: { can_create_epic: can?(current_user, :create_epic, @group).to_s,
can_bulk_edit_epics: @can_bulk_update.to_s,
page: params[:page],
prev: params[:prev],
next: params[:next],
initial_state: params[:state],
initial_sort_by: params[:sort],
epics_count: { opened: issuables_count_for_state(:epic, :opened),
closed: issuables_count_for_state(:epic, :closed),
all: issuables_count_for_state(:epic, :all) },
epic_new_path: new_group_epic_url(@group),
group_full_path: @group.full_path,
group_labels_path: group_labels_path(@group, format: :json),
group_milestones_path: group_milestones_path(@group, format: :json),
empty_state_path: image_path('illustrations/epics/list.svg') } }
- else
.top-area
= render 'shared/issuable/epic_nav', type: :epics = render 'shared/issuable/epic_nav', type: :epics
.nav-controls .nav-controls
- if @can_bulk_update - if @can_bulk_update
...@@ -10,12 +27,12 @@ ...@@ -10,12 +27,12 @@
- if can?(current_user, :create_epic, @group) - if can?(current_user, :create_epic, @group)
= link_to _('New epic'), new_group_epic_path(@group), class: 'btn btn-success gl-button', data: { qa_selector: 'new_epic_button' } = link_to _('New epic'), new_group_epic_path(@group), class: 'btn btn-success gl-button', data: { qa_selector: 'new_epic_button' }
= render 'shared/epic/search_bar', type: :epics = render 'shared/epic/search_bar', type: :epics
- if @can_bulk_update - if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :epics = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :epics
- if @epics.to_a.any? - if @epics.to_a.any?
= render 'shared/epics' = render 'shared/epics'
- else - else
= render 'shared/empty_states/epics' = render 'shared/empty_states/epics'
...@@ -10,6 +10,7 @@ RSpec.describe 'epics list', :js do ...@@ -10,6 +10,7 @@ RSpec.describe 'epics list', :js do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
stub_feature_flags(unfiltered_epic_aggregates: false) stub_feature_flags(unfiltered_epic_aggregates: false)
stub_feature_flags(async_filtering: false) stub_feature_flags(async_filtering: false)
stub_feature_flags(vue_epics_list: false)
sign_in(user) sign_in(user)
end end
...@@ -212,4 +213,45 @@ RSpec.describe 'epics list', :js do ...@@ -212,4 +213,45 @@ RSpec.describe 'epics list', :js do
end end
end end
end end
context 'vue epics list' do
let!(:epic1) { create(:epic, group: group, start_date: '2020-12-15', end_date: '2021-1-15') }
let!(:epic2) { create(:epic, group: group, start_date: '2020-12-15') }
let!(:epic3) { create(:epic, group: group, end_date: '2021-1-15') }
before do
stub_feature_flags(vue_epics_list: true)
group.add_developer(user)
visit group_epics_path(group)
wait_for_requests
end
it 'renders epics list' do
page.within('.issuable-list-container') do
expect(page).to have_selector('.gl-tabs')
expect(page).to have_link('New epic')
expect(page).to have_selector('.vue-filtered-search-bar-container')
expect(page.find('.issuable-list')).to have_selector('li.issue', count: 3)
end
end
it 'renders epics item with metadata' do
page.within('.issuable-list-container .issuable-list') do
expect(page.all('.issuable-info-container')[0].find('.issue-title')).to have_content(epic2.title)
expect(page.all('.issuable-info-container')[0].find('.issuable-reference')).to have_content("&#{epic2.iid}")
expect(page.all('.issuable-info-container')[0].find('.issuable-authored')).to have_content('created')
expect(page.all('.issuable-info-container')[0].find('.issuable-authored')).to have_content("by #{epic2.author.name}")
end
end
it 'renders epic item timeframe' do
page.within('.issuable-list-container .issuable-list') do
expect(page.all('.issuable-info-container')[0].find('.issuable-info')).to have_content('Dec 15, 2020 – No due date')
expect(page.all('.issuable-info-container')[1].find('.issuable-info')).to have_content('Dec 15, 2020 – Jan 15, 2021')
expect(page.all('.issuable-info-container')[2].find('.issuable-info')).to have_content('No start date – Jan 15, 2021')
end
end
end
end end
...@@ -16,6 +16,7 @@ RSpec.describe 'epics list', :js do ...@@ -16,6 +16,7 @@ RSpec.describe 'epics list', :js do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
stub_feature_flags(vue_epics_list: false)
sign_in(user) sign_in(user)
......
import { GlEmptyState } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import EpicsListEmptyState from 'ee/epics_list/components/epics_list_empty_state.vue';
const createComponent = (props = {}) =>
mount(EpicsListEmptyState, {
provide: {
emptyStatePath: '/assets/illustrations/empty-state/epics.svg',
},
propsData: {
currentState: 'opened',
epicsCount: {
opened: 0,
closed: 0,
all: 0,
},
...props,
},
});
describe('EpicsListEmptyState', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders gl-empty-state component', () => {
const emptyStateEl = wrapper.find(GlEmptyState);
expect(emptyStateEl.exists()).toBe(true);
expect(emptyStateEl.props('svgPath')).toBe('/assets/illustrations/empty-state/epics.svg');
});
it('returns string "There are no open epics" when value of `currentState` prop is "opened" and group has some epics', async () => {
wrapper.setProps({
epicsCount: {
opened: 0,
closed: 2,
all: 2,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.find('h1').text()).toBe('There are no open epics');
});
it('returns string "There are no archived epics" when value of `currenState` prop is "closed" and group has some epics', async () => {
wrapper.setProps({
currentState: 'closed',
epicsCount: {
opened: 2,
closed: 0,
all: 2,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.find('h1').text()).toBe('There are no closed epics');
});
it('returns a generic string when group has no epics', () => {
expect(wrapper.find('h1').text()).toBe(
'Epics let you manage your portfolio of projects more efficiently and with less effort',
);
});
it('renders empty state description with default description when all epics count is not zero', async () => {
wrapper.setProps({
epicsCount: {
opened: 0,
closed: 0,
all: 0,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.find('p').exists()).toBe(true);
expect(wrapper.find('p').text()).toContain(
'Track groups of issues that share a theme, across projects and milestones',
);
});
it('does not render empty state description when all epics count is zero', async () => {
wrapper.setProps({
epicsCount: {
opened: 1,
closed: 0,
all: 1,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.find('p').exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { pick } from 'lodash';
import { stubComponent } from 'helpers/stub_component';
import EpicsListRoot from 'ee/epics_list/components/epics_list_root.vue';
import { EpicsSortOptions } from 'ee/epics_list/constants';
import { mockFormattedEpic } from 'ee_jest/roadmap/mock_data';
import { mockAuthor, mockLabels } from 'jest/issuable_list/mock_data';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs } from '~/issuable_list/constants';
jest.mock('~/issuable_list/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
IssuableListTabs: jest.requireActual('~/issuable_list/constants').IssuableListTabs,
AvailableSortOptions: jest.requireActual('~/issuable_list/constants').AvailableSortOptions,
}));
const mockRawEpic = {
...pick(mockFormattedEpic, [
'title',
'createdAt',
'updatedAt',
'webUrl',
'userDiscussionsCount',
'confidential',
]),
author: mockAuthor,
labels: {
nodes: mockLabels,
},
startDate: '2021-04-01',
dueDate: '2021-06-30',
};
const mockEpics = new Array(5)
.fill()
.map((_, i) => ({ ...mockRawEpic, id: i + 1, iid: (i + 1) * 10 }));
const mockProvide = {
canCreateEpic: true,
canBulkEditEpics: true,
page: 1,
prev: '',
next: '',
initialState: 'opened',
initialSortBy: 'created_desc',
epicsCount: {
opened: 5,
closed: 0,
all: 5,
},
epicNewPath: '/groups/gitlab-org/-/epics/new',
groupFullPath: 'gitlab-org',
groupLabelsPath: '/gitlab-org/-/labels.json',
groupMilestonesPath: '/gitlab-org/-/milestone.json',
emptyStatePath: '/assets/illustrations/empty-state/epics.svg',
};
const mockPageInfo = {
startCursor: 'eyJpZCI6IjI1IiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzI6MTQgVVRDIn0',
endCursor: 'eyJpZCI6IjIxIiwiY3JlYXRlZF9hdCI6IjIwMjAtMDMtMzEgMTM6MzE6MTUgVVRDIn0',
};
const createComponent = ({
provide = mockProvide,
initialFilterParams = {},
epicsLoading = false,
epicsList = mockEpics,
} = {}) =>
shallowMount(EpicsListRoot, {
propsData: {
initialFilterParams,
},
provide,
mocks: {
$apollo: {
queries: {
epics: {
loading: epicsLoading,
list: epicsList,
pageInfo: mockPageInfo,
},
},
},
},
stubs: {
IssuableList: stubComponent(IssuableList),
},
});
describe('EpicsListRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('epicTimeframe', () => {
it.each`
startDate | dueDate | returnValue
${'2021-1-1'} | ${'2021-2-28'} | ${'Jan 1 – Feb 28, 2021'}
${'2021-1-1'} | ${'2022-2-28'} | ${'Jan 1, 2021 – Feb 28, 2022'}
${'2021-1-1'} | ${null} | ${'Jan 1, 2021 – No due date'}
${null} | ${'2021-2-28'} | ${'No start date – Feb 28, 2021'}
`(
'returns string "$returnValue" when startDate is $startDate and dueDate is $dueDate',
({ startDate, dueDate, returnValue }) => {
expect(
wrapper.vm.epicTimeframe({
startDate,
dueDate,
}),
).toBe(returnValue);
},
);
});
describe('fetchEpicsBy', () => {
it('updates prevPageCursor and nextPageCursor values when provided propsName param is "currentPage"', async () => {
wrapper.setData({
epics: {
pageInfo: mockPageInfo,
},
});
wrapper.vm.fetchEpicsBy('currentPage', 2);
await wrapper.vm.$nextTick();
expect(wrapper.vm.prevPageCursor).toBe('');
expect(wrapper.vm.nextPageCursor).toBe(mockPageInfo.endCursor);
expect(wrapper.vm.currentPage).toBe(2);
});
});
});
describe('template', () => {
const getIssuableList = () => wrapper.find(IssuableList);
it('renders issuable-list component', async () => {
wrapper.setData({
filterParams: {
search: 'foo',
},
});
await wrapper.vm.$nextTick();
expect(getIssuableList().exists()).toBe(true);
expect(getIssuableList().props()).toMatchObject({
namespace: mockProvide.groupFullPath,
tabs: IssuableListTabs,
currentTab: 'opened',
tabCounts: mockProvide.epicsCount,
searchInputPlaceholder: 'Search or filter results...',
sortOptions: EpicsSortOptions,
initialFilterValue: ['foo'],
initialSortBy: 'created_desc',
urlParams: wrapper.vm.urlParams,
issuableSymbol: '&',
recentSearchesStorageKey: 'epics',
});
});
it.each`
hasPreviousPage | hasNextPage | returnValue
${true} | ${undefined} | ${true}
${undefined} | ${true} | ${true}
${false} | ${undefined} | ${false}
${undefined} | ${false} | ${false}
${false} | ${false} | ${false}
${true} | ${true} | ${true}
`(
'sets showPaginationControls prop value as $returnValue when hasPreviousPage is $hasPreviousPage and hasNextPage is $hasNextPage within `epics.pageInfo`',
async ({ hasPreviousPage, hasNextPage, returnValue }) => {
wrapper.setData({
epics: {
pageInfo: {
hasPreviousPage,
hasNextPage,
},
},
});
await wrapper.vm.$nextTick();
expect(getIssuableList().props('showPaginationControls')).toBe(returnValue);
},
);
it('sets previousPage prop value a number representing previous page based on currentPage value', async () => {
wrapper.setData({
currentPage: 3,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.previousPage).toBe(2);
});
it('sets nextPage prop value a number representing next page based on currentPage value', async () => {
wrapper.setData({
currentPage: 1,
epicsCount: {
opened: 5,
closed: 0,
all: 5,
},
});
await wrapper.vm.$nextTick();
expect(getIssuableList().props('nextPage')).toBe(2);
});
it('sets nextPage prop value as `null` when currentPage is already last page', async () => {
wrapper.setData({
currentPage: 3,
epicsCount: {
opened: 5,
closed: 0,
all: 5,
},
});
await wrapper.vm.$nextTick();
expect(getIssuableList().props('nextPage')).toBeNull();
});
});
});
...@@ -179,6 +179,10 @@ export const mockRawEpic = { ...@@ -179,6 +179,10 @@ export const mockRawEpic = {
start_date: '2017-6-26', start_date: '2017-6-26',
end_date: '2018-03-10', end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/2', web_url: '/groups/gitlab-org/marketing/-/epics/2',
created_at: '2021-01-22T13:51:19Z',
updated_at: '2021-01-27T05:40:19Z',
user_discussions_count: 2,
confidential: false,
descendantCounts: { descendantCounts: {
openedEpics: 3, openedEpics: 3,
closedEpics: 2, closedEpics: 2,
...@@ -251,6 +255,10 @@ export const mockFormattedEpic = { ...@@ -251,6 +255,10 @@ export const mockFormattedEpic = {
endDateOutOfRange: false, endDateOutOfRange: false,
webUrl: '/groups/gitlab-org/marketing/-/epics/2', webUrl: '/groups/gitlab-org/marketing/-/epics/2',
newEpic: undefined, newEpic: undefined,
createdAt: '2021-01-22T13:51:19Z',
updatedAt: '2021-01-27T05:40:19Z',
userDiscussionsCount: 2,
confidential: false,
descendantWeightSum: { descendantWeightSum: {
closedIssues: 3, closedIssues: 3,
openedIssues: 2, openedIssues: 2,
......
...@@ -11544,6 +11544,12 @@ msgstr "" ...@@ -11544,6 +11544,12 @@ msgstr ""
msgid "Epics, Issues, and Merge Requests" msgid "Epics, Issues, and Merge Requests"
msgstr "" msgstr ""
msgid "Epics|%{startDate} – %{dueDate}"
msgstr ""
msgid "Epics|%{startDate} – No due date"
msgstr ""
msgid "Epics|Add a new epic" msgid "Epics|Add a new epic"
msgstr "" msgstr ""
...@@ -11571,6 +11577,9 @@ msgstr "" ...@@ -11571,6 +11577,9 @@ msgstr ""
msgid "Epics|Leave empty to inherit from milestone dates" msgid "Epics|Leave empty to inherit from milestone dates"
msgstr "" msgstr ""
msgid "Epics|No start date – %{dueDate}"
msgstr ""
msgid "Epics|Remove epic" msgid "Epics|Remove epic"
msgstr "" msgstr ""
...@@ -11598,6 +11607,9 @@ msgstr "" ...@@ -11598,6 +11607,9 @@ msgstr ""
msgid "Epics|Something went wrong while fetching child epics." msgid "Epics|Something went wrong while fetching child epics."
msgstr "" msgstr ""
msgid "Epics|Something went wrong while fetching epics list."
msgstr ""
msgid "Epics|Something went wrong while fetching group epics." msgid "Epics|Something went wrong while fetching group epics."
msgstr "" msgstr ""
...@@ -29413,6 +29425,9 @@ msgstr "" ...@@ -29413,6 +29425,9 @@ msgstr ""
msgid "There are no charts configured for this page" msgid "There are no charts configured for this page"
msgstr "" msgstr ""
msgid "There are no closed epics"
msgstr ""
msgid "There are no closed issues" msgid "There are no closed issues"
msgstr "" msgstr ""
...@@ -29437,6 +29452,9 @@ msgstr "" ...@@ -29437,6 +29452,9 @@ msgstr ""
msgid "There are no matching files" msgid "There are no matching files"
msgstr "" msgstr ""
msgid "There are no open epics"
msgstr ""
msgid "There are no open issues" msgid "There are no open issues"
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