Commit 316c00f9 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch 'psi-cadence-list-iterations' into 'master'

Show iterations for cadence

See merge request gitlab-org/gitlab!61546
parents 43438179 63a83b9b
<script>
export default {
props: {
title: {
type: String,
required: true,
},
},
};
</script>
<template>
<li>
{{ title }}
</li>
</template>
<script>
import {
GlAlert,
GlButton,
GlCollapse,
GlIcon,
GlInfiniteScroll,
GlSkeletonLoader,
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import query from '../queries/iterations_in_cadence.query.graphql';
const pageSize = 20;
const i18n = Object.freeze({
noResults: s__('Iterations|No iterations in cadence.'),
error: __('Error loading iterations'),
});
export default {
i18n,
components: {
GlAlert,
GlButton,
GlCollapse,
GlIcon,
GlInfiniteScroll,
GlSkeletonLoader,
},
apollo: {
group: {
skip() {
return !this.expanded;
},
query,
variables() {
return this.queryVariables;
},
error() {
this.error = i18n.error;
},
},
},
inject: ['groupPath'],
props: {
title: {
type: String,
required: true,
},
durationInWeeks: {
type: Number,
required: false,
default: null,
},
cadenceId: {
type: String,
required: true,
},
iterationState: {
type: String,
required: true,
},
},
data() {
return {
i18n,
expanded: false,
// query response
group: {
iterations: {
nodes: [],
pageInfo: {
hasNextPage: true,
},
},
},
afterCursor: null,
showMoreEnabled: true,
error: '',
};
},
computed: {
queryVariables() {
return {
fullPath: this.groupPath,
iterationCadenceId: this.cadenceId,
firstPageSize: pageSize,
state: this.iterationState,
};
},
pageInfo() {
return this.group.iterations?.pageInfo || {};
},
hasNextPage() {
return this.pageInfo.hasNextPage;
},
iterations() {
return this.group?.iterations?.nodes || [];
},
loading() {
return this.$apollo.queries.group.loading;
},
editCadence() {
return {
name: 'edit',
params: {
cadenceId: getIdFromGraphQLId(this.cadenceId),
},
};
},
},
methods: {
fetchMore() {
if (this.iterations.length === 0 || !this.hasNextPage || this.loading) {
return;
}
// Fetch more data and transform the original result
this.$apollo.queries.group.fetchMore({
variables: {
...this.queryVariables,
afterCursor: this.pageInfo.endCursor,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newIterations = fetchMoreResult.group?.iterations.nodes || [];
return {
group: {
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Group',
iterations: {
__typename: 'IterationConnection',
// Merging the list
nodes: [...previousResult.group.iterations.nodes, ...newIterations],
pageInfo: fetchMoreResult.group?.iterations.pageInfo || {},
},
},
};
},
});
},
path(iterationId) {
return {
name: 'iteration',
params: {
cadenceId: getIdFromGraphQLId(this.cadenceId),
iterationId: getIdFromGraphQLId(iterationId),
},
};
},
},
};
</script>
<template>
<li class="gl-py-0!">
<div class="gl-display-flex gl-align-items-center">
<gl-button
variant="link"
class="gl-font-weight-bold gl-text-body! gl-py-5! gl-px-3! gl-mr-auto"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<gl-icon
name="chevron-right"
class="gl-transition-medium"
:class="{ 'gl-rotate-90': expanded }"
/>
{{ title }}
</gl-button>
<span v-if="durationInWeeks" class="gl-mr-5">
<gl-icon name="clock" class="gl-mr-3" />
{{ n__('Every week', 'Every %d weeks', durationInWeeks) }}</span
>
</div>
<gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="error = ''">
{{ error }}
</gl-alert>
<gl-collapse :visible="expanded">
<div v-if="loading && iterations.length === 0" class="gl-p-5">
<gl-skeleton-loader :lines="2" />
</div>
<gl-infinite-scroll
v-else-if="iterations.length || loading"
:fetched-items="iterations.length"
:max-list-height="250"
@bottomReached="fetchMore"
>
<template #items>
<ol class="gl-pl-0">
<li
v-for="iteration in iterations"
:key="iteration.id"
class="gl-bg-gray-10 gl-p-5 gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-list-style-position-inside"
>
<router-link :to="path(iteration.id)">
{{ iteration.title }}
</router-link>
</li>
</ol>
<div v-if="loading" class="gl-p-5">
<gl-skeleton-loader :lines="2" />
</div>
</template>
</gl-infinite-scroll>
<p v-else-if="!loading" class="gl-px-5">
{{ i18n.noResults }}
</p>
</gl-collapse>
</li>
</template>
......@@ -2,14 +2,14 @@
import { GlAlert, GlButton, GlLoadingIcon, GlKeysetPagination, GlTab, GlTabs } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import query from '../queries/iteration_cadences_list.query.graphql';
import IterationCadence from './iteration_cadence.vue';
import IterationCadenceListItem from './iteration_cadence_list_item.vue';
const pageSize = 20;
export default {
tabTitles: [__('Open'), __('Done'), __('All')],
components: {
IterationCadence,
IterationCadenceListItem,
GlAlert,
GlButton,
GlLoadingIcon,
......@@ -49,10 +49,6 @@ export default {
fullPath: this.groupPath,
};
if (this.active !== undefined) {
vars.active = this.active;
}
if (this.pagination.beforeCursor) {
vars.beforeCursor = this.pagination.beforeCursor;
vars.lastPageSize = pageSize;
......@@ -72,15 +68,15 @@ export default {
loading() {
return this.$apollo.queries.group.loading;
},
active() {
state() {
switch (this.tabIndex) {
default:
case 0:
return true;
return 'opened';
case 1:
return false;
return 'closed';
case 2:
return undefined;
return 'all';
}
},
},
......@@ -115,7 +111,14 @@ export default {
</gl-alert>
<template v-else>
<ul v-if="cadences.length" class="content-list">
<iteration-cadence v-for="cadence in cadences" :key="cadence.id" :title="cadence.title" />
<iteration-cadence-list-item
v-for="cadence in cadences"
:key="cadence.id"
:cadence-id="cadence.id"
:duration-in-weeks="cadence.durationInWeeks"
:title="cadence.title"
:iteration-state="state"
/>
</ul>
<p v-else class="nothing-here-block">
{{ s__('Iterations|No iteration cadences to show.') }}
......
......@@ -64,11 +64,8 @@ export default {
},
},
mixins: [glFeatureFlagsMixin()],
inject: ['fullPath'],
props: {
fullPath: {
type: String,
required: true,
},
hasScopedLabelsFeature: {
type: Boolean,
required: false,
......
......@@ -73,10 +73,12 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
return new Vue({
el,
apolloProvider,
provide: {
fullPath,
},
render(createElement) {
return createElement(IterationReport, {
props: {
fullPath,
hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
iterationId,
labelsFetchPath,
......
......@@ -2,7 +2,6 @@
query IterationCadences(
$fullPath: ID!
$active: Boolean
$beforeCursor: String = ""
$afterCursor: String = ""
$firstPageSize: Int
......@@ -10,7 +9,6 @@ query IterationCadences(
) {
group(fullPath: $fullPath) {
iterationCadences(
active: $active
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./iteration_list_item.fragment.graphql"
query Iterations(
$fullPath: ID!
$iterationCadenceId: ID!
$state: IterationState!
$beforeCursor: String
$afterCursor: String
$firstPageSize: Int
$lastPageSize: Int
) {
group(fullPath: $fullPath) {
iterations(
iterationCadenceIds: [$iterationCadenceId]
state: $state
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
nodes {
...IterationListItem
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import IterationCadenceForm from './components/iteration_cadence_form.vue';
import IterationCadenceList from './components/iteration_cadences_list.vue';
import IterationReport from './components/iteration_report.vue';
Vue.use(VueRouter);
......@@ -16,6 +17,11 @@ const routes = [
path: '/',
component: IterationCadenceList,
},
{
name: 'iteration',
path: '/:cadenceId/iterations/:iterationId',
component: IterationReport,
},
];
export default function createRouter(base) {
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User views iteration cadences', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:cadence) { create(:iterations_cadence, group: group) }
let_it_be(:other_cadence) { create(:iterations_cadence, group: group) }
let_it_be(:iteration_in_cadence) { create(:iteration, group: group, iterations_cadence: cadence) }
let_it_be(:closed_iteration_in_cadence) { create(:closed_iteration, group: group, iterations_cadence: cadence) }
let_it_be(:iteration_in_other_cadence) { create(:iteration, group: group, iterations_cadence: other_cadence) }
before do
stub_licensed_features(iterations: true)
visit group_iteration_cadences_path(group)
end
it 'shows iteration cadences with iterations when expanded', :aggregate_failures do
expect(page).to have_title('Iteration cadences')
expect(page).to have_content(cadence.title)
expect(page).to have_content(other_cadence.title)
expect(page).not_to have_content(iteration_in_cadence.title)
expect(page).not_to have_content(iteration_in_other_cadence.title)
click_button cadence.title
expect(page).to have_content(iteration_in_cadence.title)
expect(page).not_to have_content(iteration_in_other_cadence.title)
expect(page).not_to have_content(closed_iteration_in_cadence.title)
end
it 'only shows completed iterations on Done tab', :aggregate_failures do
click_link 'Done'
click_button cadence.title
expect(page).not_to have_content(iteration_in_cadence.title)
expect(page).to have_content(closed_iteration_in_cadence.title)
end
end
import { GlInfiniteScroll, GlSkeletonLoader } from '@gitlab/ui';
import { createLocalVue, RouterLinkStub } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import IterationCadenceListItem from 'ee/iterations/components/iteration_cadence_list_item.vue';
import iterationsInCadenceQuery from 'ee/iterations/queries/iterations_in_cadence.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended as mount } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
const push = jest.fn();
const $router = {
push,
};
const localVue = createLocalVue();
function createMockApolloProvider(requestHandlers) {
localVue.use(VueApollo);
return createMockApollo(requestHandlers);
}
describe('Iteration cadence list item', () => {
let wrapper;
let apolloProvider;
const groupPath = 'gitlab-org';
const iterations = [
{
dueDate: '2021-08-14',
id: 'gid://gitlab/Iteration/41',
scopedPath: '/groups/group1/-/iterations/41',
startDate: '2021-08-13',
state: 'upcoming',
title: 'My title 44',
webPath: '/groups/group1/-/iterations/41',
__typename: 'Iteration',
},
];
const cadence = {
id: 'gid://gitlab/Iterations::Cadence/561',
title: 'Weekly cadence',
durationInWeeks: 3,
};
const startCursor = 'MQ';
const endCursor = 'MjA';
const querySuccessResponse = {
data: {
group: {
iterations: {
nodes: iterations,
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor,
endCursor,
},
},
},
},
};
const queryEmptyResponse = {
data: {
group: {
iterations: {
nodes: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
},
},
},
};
function createComponent({
props = {},
canCreateCadence,
resolverMock = jest.fn().mockResolvedValue(querySuccessResponse),
} = {}) {
apolloProvider = createMockApolloProvider([[iterationsInCadenceQuery, resolverMock]]);
wrapper = mount(IterationCadenceListItem, {
localVue,
apolloProvider,
mocks: {
$router,
},
stubs: {
RouterLink: RouterLinkStub,
},
provide: {
groupPath,
canCreateCadence,
},
propsData: {
title: cadence.title,
cadenceId: cadence.id,
iterationState: 'open',
...props,
},
});
return nextTick();
}
const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const expand = () => wrapper.findByRole('button', { text: cadence.title }).trigger('click');
afterEach(() => {
wrapper.destroy();
apolloProvider = null;
});
it('does not query iterations when component mounted', async () => {
const resolverMock = jest.fn();
await createComponent({
resolverMock,
});
expect(resolverMock).not.toHaveBeenCalled();
});
it('shows empty text when no results', async () => {
await createComponent({
resolverMock: jest.fn().mockResolvedValue(queryEmptyResponse),
});
expand();
await waitForPromises();
expect(findLoader().exists()).toBe(false);
expect(wrapper.text()).toContain(IterationCadenceListItem.i18n.noResults);
});
it('shows iterations after loading', async () => {
await createComponent();
expand();
await waitForPromises();
iterations.forEach(({ title }) => {
expect(wrapper.text()).toContain(title);
});
});
it('shows alert on query error', async () => {
await createComponent({
resolverMock: jest.fn().mockRejectedValue(queryEmptyResponse),
});
await expand();
await waitForPromises();
expect(findLoader().exists()).toBe(false);
expect(wrapper.text()).toContain(IterationCadenceListItem.i18n.error);
});
it('calls fetchMore after scrolling down', async () => {
await createComponent();
jest.spyOn(wrapper.vm.$apollo.queries.group, 'fetchMore').mockResolvedValue({});
expand();
await waitForPromises();
wrapper.findComponent(GlInfiniteScroll).vm.$emit('bottomReached');
expect(wrapper.vm.$apollo.queries.group.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({
afterCursor: endCursor,
}),
}),
);
});
});
......@@ -45,6 +45,9 @@ describe('Iterations report', () => {
localVue,
apolloProvider: mockApollo,
propsData: props,
provide: {
fullPath: props.fullPath,
},
stubs: {
GlLoadingIcon,
GlTab,
......@@ -114,6 +117,9 @@ describe('Iterations report', () => {
queries: { iteration: { loading } },
},
},
provide: {
fullPath: props.fullPath,
},
stubs: {
GlLoadingIcon,
GlTab,
......
......@@ -13157,7 +13157,9 @@ msgid "Every two weeks"
msgstr ""
msgid "Every week"
msgstr ""
msgid_plural "Every %d weeks"
msgstr[0] ""
msgstr[1] ""
msgid "Every week (%{weekday} at %{time})"
msgstr ""
......@@ -18533,6 +18535,9 @@ msgstr ""
msgid "Iterations|No iteration cadences to show."
msgstr ""
msgid "Iterations|No iterations in cadence."
msgstr ""
msgid "Iterations|Number of future iterations you would like to have scheduled"
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