Commit e4b819ca authored by Phil Hughes's avatar Phil Hughes

Merge branch '214713-display-iterations-list' into 'master'

Display iterations list

Closes #214713

See merge request gitlab-org/gitlab!31335
parents 9a903bc1 0b8e350c
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
%span.badge.badge-pill.count= number_with_delimiter(issues_count) %span.badge.badge-pill.count= number_with_delimiter(issues_count)
%ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} } %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} }
= nav_link(path: ['groups#issues', 'labels#index', 'milestones#index'], html_options: { class: "fly-out-top-item" } ) do = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index', 'iterations#index'], html_options: { class: "fly-out-top-item" } ) do
= link_to issues_group_path(@group) do = link_to issues_group_path(@group) do
%strong.fly-out-top-item-name %strong.fly-out-top-item-name
= _('Issues') = _('Issues')
...@@ -85,6 +85,8 @@ ...@@ -85,6 +85,8 @@
%span %span
= _('Milestones') = _('Milestones')
= render_if_exists 'layouts/nav/sidebar/iterations_link'
- if group_sidebar_link?(:merge_requests) - if group_sidebar_link?(:merge_requests)
= nav_link(path: 'groups#merge_requests') do = nav_link(path: 'groups#merge_requests') do
= link_to merge_requests_group_path(@group) do = link_to merge_requests_group_path(@group) do
......
---
type: reference
stage: Plan
group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Iterations **(STARTER)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/214713) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.1.
> - It's deployed behind a feature flag, disabled by default.
> - It's disabled on GitLab.com.
> - It's able to be enabled or disabled per-group
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-iterations-core-only). **(CORE ONLY)**
Iterations are a way to track issues over a period of time. This allows teams
to track velocity and volatility metrics. Iterations can be used with [milestones](../../project/milestones/index.md)
for tracking over different time periods.
For example, you can use:
- Milestones for Program Increments, which span 8-12 weeks.
- Iterations for Sprints, which span 2 weeks.
In GitLab, iterations are similar to milestones, with a few differences:
- Iterations are only available to groups.
- A group can only have one active iteration at a time.
- Iterations require both a start and an end date.
- Iteration date ranges cannot overlap.
## View the iterations list
To view the iterations list, in a group, go to **{issues}** **Issues > Iterations**.
From there you can create a new iteration or click an iteration to get a more detailed view.
## Create an iteration
NOTE: **Note:**
A permission level of [Developer or higher](../../permissions.md) is required to create iterations.
To create an iteration:
1. In a group, go to **{issues}** **Issues > Iterations**.
1. Click **New iteration**.
1. Enter the title, a description (optional), a start date, and a due date.
1. Click **Create iteration**. The iteration details page opens.
### Enable Iterations **(CORE ONLY)**
GitLab Iterations feature is under development and not ready for production use.
It is deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can enable it for your instance. `:group_iterations` can be enabled or disabled per-group.
To enable it:
```ruby
# Instance-wide
Feature.enable(:group_iterations)
# or by group
Feature.enable(:group_iterations, Group.find(<group id>))
```
To disable it:
```ruby
# Instance-wide
Feature.disable(:group_iterations)
# or by group
Feature.disable(:group_iterations, Group.find(<group id>))
```
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
one might have when setting this up, or when something is changed, or on upgrading, it's
important to describe those, too. Think of things that may go wrong and include them here.
This is important to minimize requests for support, and to avoid doc comments with
questions that you know someone might ask.
Each scenario can be a third-level heading, e.g. `### Getting error message X`.
If you have none to add when creating a doc, leave this section in place
but commented out to help encourage others to add to it in the future. -->
<script>
import { GlButton, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import IterationsList from './iterations_list.vue';
import GroupIterationQuery from '../queries/group_iterations.query.graphql';
export default {
components: {
IterationsList,
GlButton,
GlLoadingIcon,
GlTab,
GlTabs,
},
props: {
groupPath: {
type: String,
required: true,
},
canAdmin: {
type: Boolean,
required: false,
default: false,
},
newIterationPath: {
type: String,
required: false,
default: '',
},
},
apollo: {
iterations: {
query: GroupIterationQuery,
update: data => data.group.iterations.nodes,
variables() {
return {
fullPath: this.groupPath,
state: this.state,
};
},
},
},
data() {
return {
iterations: [],
tabIndex: 0,
};
},
computed: {
loading() {
return this.$apollo.queries.iterations.loading;
},
state() {
switch (this.tabIndex) {
default:
case 0:
return 'opened';
case 1:
return 'closed';
case 2:
return 'all';
}
},
},
};
</script>
<template>
<gl-tabs v-model="tabIndex">
<gl-tab v-for="tab in [__('Open'), __('Closed'), __('All')]" :key="tab">
<template #title>
{{ tab }}
</template>
<div v-if="loading" class="gl-my-5">
<gl-loading-icon size="lg" />
</div>
<iterations-list v-else :iterations="iterations" />
</gl-tab>
<template v-if="canAdmin" #tabs-end>
<li class="gl-ml-auto gl-display-flex gl-align-items-center">
<gl-button variant="success" :href="newIterationPath">{{ __('New iteration') }}</gl-button>
</li>
</template>
</gl-tabs>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import dateFormat from 'dateformat';
export default {
components: {
GlLink,
},
filters: {
date: value => {
const date = new Date(value);
return dateFormat(date, 'mmm d, yyyy', true);
},
},
props: {
iterations: {
type: Array,
required: false,
default: () => [],
},
},
};
</script>
<template>
<div class="milestones mt-0">
<ul v-if="iterations.length > 0" class="content-list">
<li v-for="iteration in iterations" :key="iteration.id" class="milestone">
<div class="gl-mb-3">
<gl-link :href="iteration.webPath"
><strong>{{ iteration.title }}</strong></gl-link
>
</div>
<div class="text-secondary gl-mb-3">
{{ iteration.startDate | date }}{{ iteration.dueDate | date }}
</div>
</li>
</ul>
<div v-else class="nothing-here-block">
{{ __('No iterations to show') }}
</div>
</div>
</template>
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import Iterations from './components/iterations.vue';
import IterationForm from './components/iteration_form.vue'; import IterationForm from './components/iteration_form.vue';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -9,13 +11,27 @@ const apolloProvider = new VueApollo({ ...@@ -9,13 +11,27 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(), defaultClient: createDefaultClient(),
}); });
export function initIterationsList() {
const el = document.querySelector('.js-iterations-list');
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(Iterations, {
props: {
groupPath: el.dataset.groupFullPath,
canAdmin: parseBoolean(el.dataset.canAdmin),
newIterationPath: el.dataset.newIterationPath,
},
});
},
});
}
export function initIterationForm() { export function initIterationForm() {
const el = document.querySelector('.js-iteration-new'); const el = document.querySelector('.js-iteration-new');
if (!el) {
return null;
}
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
......
query GroupIterations($fullPath: ID!, $state: IterationState) { query GroupIterations($fullPath: ID!, $state: IterationState!) {
group(fullPath: $fullPath) { group(fullPath: $fullPath) {
sprints(state: $state, first: 20) { iterations(state: $state, first: 20) {
nodes { nodes {
title title
state state
......
import { initIterationsList } from 'ee/iterations';
document.addEventListener('DOMContentLoaded', () => {
initIterationsList();
});
...@@ -146,6 +146,10 @@ module EE ...@@ -146,6 +146,10 @@ module EE
links << :productivity_analytics links << :productivity_analytics
end end
if ::Feature.enabled?(:group_iterations, @group) && @group.feature_available?(:iterations) && can?(current_user, :read_iteration, @group)
links << :iterations
end
links links
end end
end end
......
...@@ -23,6 +23,11 @@ module EE ...@@ -23,6 +23,11 @@ module EE
override :group_issues_sub_menu_items override :group_issues_sub_menu_items
def group_issues_sub_menu_items def group_issues_sub_menu_items
controllers = %w(issues_analytics#show) controllers = %w(issues_analytics#show)
if @group&.feature_available?(:iterations)
controllers = %w(iterations#index)
end
super.concat(controllers) super.concat(controllers)
end end
end end
......
- page_title _("Iterations") - page_title _("Iterations")
.js-iterations-list{ data: { group_full_path: @group.full_path, can_admin: can?(current_user, :admin_iteration, @group) } } - if Feature.enabled?(:group_iterations, @group)
.js-iterations-list{ data: { group_full_path: @group.full_path, can_admin: can?(current_user, :create_iteration, @group).to_s, new_iteration_path: new_group_iteration_path(@group) } }
- if group_sidebar_link?(:iterations)
= nav_link(path: 'iterations#index') do
= link_to group_iterations_path(@group) do
%span
= _('Iterations')
# frozen_string_literal: true
require 'spec_helper'
describe 'Iterations list', :js do
let(:now) { Time.now }
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let!(:started_iteration) { create(:iteration, :skip_future_date_validation, group: group, start_date: now - 1.day, due_date: now) }
let!(:upcoming_iteration) { create(:iteration, group: group, start_date: now + 1.day, due_date: now + 2.days) }
let!(:closed_iteration) { create(:closed_iteration, :skip_future_date_validation, group: group, start_date: now - 3.days, due_date: now - 2.days) }
context 'as guest' do
before do
visit group_iterations_path(group)
end
it 'hides New iteration button' do
expect(page).not_to have_link('New iteration', href: new_group_iteration_path(group))
end
it 'shows iterations on each tab' do
expect(page).to have_link(started_iteration.title)
expect(page).to have_link(upcoming_iteration.title)
expect(page).not_to have_link(closed_iteration.title)
click_link('Closed')
expect(page).to have_link(closed_iteration.title)
expect(page).not_to have_link(started_iteration.title)
expect(page).not_to have_link(upcoming_iteration.title)
click_link('All')
expect(page).to have_link(started_iteration.title)
expect(page).to have_link(upcoming_iteration.title)
expect(page).to have_link(closed_iteration.title)
end
end
context 'as user' do
before do
stub_licensed_features(iterations: true)
stub_feature_flags(group_iterations: true)
group.add_developer(user)
sign_in(user)
visit group_iterations_path(group)
end
it 'shows "New iteration" button' do
expect(page).to have_link('New iteration', href: new_group_iteration_path(group))
end
end
end
...@@ -14,6 +14,7 @@ RSpec.describe 'Group navbar' do ...@@ -14,6 +14,7 @@ RSpec.describe 'Group navbar' do
before do before do
group.add_maintainer(user) group.add_maintainer(user)
stub_feature_flags(group_push_rules: false) stub_feature_flags(group_push_rules: false)
stub_feature_flags(group_iterations: false)
sign_in(user) sign_in(user)
end end
...@@ -199,4 +200,21 @@ RSpec.describe 'Group navbar' do ...@@ -199,4 +200,21 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar' it_behaves_like 'verified navigation bar'
end end
context 'when iterations are available' do
before do
stub_licensed_features(iterations: true)
stub_feature_flags(group_iterations: true)
insert_after_sub_nav_item(
_('Milestones'),
within: _('Issues'),
new_sub_nav_item_name: _('Iterations')
)
visit group_path(group)
end
it_behaves_like 'verified navigation bar'
end
end end
import IterationsList from 'ee/iterations/components/iterations_list.vue';
import { shallowMount } from '@vue/test-utils';
describe('Iterations list', () => {
let wrapper;
const mountComponent = (propsData = { iterations: [] }) => {
wrapper = shallowMount(IterationsList, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows empty state', () => {
mountComponent();
expect(wrapper.html()).toHaveText('No iterations to show');
});
it('shows iteration', () => {
const iteration = {
id: '123',
title: 'Iteration #1',
startDate: '2020-05-27',
dueDate: '2020-05-28',
};
mountComponent({
iterations: [iteration],
});
expect(wrapper.html()).not.toHaveText('No iterations to show');
expect(wrapper.html()).toHaveText(iteration.title);
});
});
import Iterations from 'ee/iterations/components/iterations.vue';
import IterationsList from 'ee/iterations/components/iterations_list.vue';
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
describe('Iterations tabs', () => {
let wrapper;
const defaultProps = {
groupPath: 'gitlab-org',
};
const mountComponent = ({ props = defaultProps, loading = false } = {}) => {
wrapper = shallowMount(Iterations, {
propsData: props,
mocks: {
$apollo: {
queries: { iterations: { loading } },
},
},
stubs: {
GlLoadingIcon,
GlTab,
GlTabs,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('hides list while loading', () => {
mountComponent({
loading: true,
});
expect(wrapper.find(GlLoadingIcon).exists()).toBeTruthy();
expect(wrapper.find(IterationsList).exists()).toBeFalsy();
});
it('shows iterations list when not loading', () => {
mountComponent({
loading: false,
});
expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy();
expect(wrapper.find(IterationsList).exists()).toBeTruthy();
});
it('sets computed state from tabIndex', () => {
mountComponent();
expect(wrapper.vm.state).toEqual('opened');
wrapper.vm.tabIndex = 1;
expect(wrapper.vm.state).toEqual('closed');
wrapper.vm.tabIndex = 2;
expect(wrapper.vm.state).toEqual('all');
});
});
...@@ -199,4 +199,56 @@ RSpec.describe 'layouts/nav/sidebar/_group' do ...@@ -199,4 +199,56 @@ RSpec.describe 'layouts/nav/sidebar/_group' do
end end
end end
end end
describe 'iterations link' do
let_it_be(:current_user) { create(:user) }
before do
group.add_guest(current_user)
allow(view).to receive(:current_user).and_return(current_user)
end
context 'with iterations licensed feature available' do
before do
stub_licensed_features(iterations: true)
end
context 'with group iterations feature flag enabled' do
before do
stub_feature_flags(group_iterations: true)
end
it 'is visible' do
render
expect(rendered).to have_text 'Iterations'
end
end
context 'with iterations feature flag disabled' do
before do
stub_feature_flags(group_iterations: false)
end
it 'is not visible' do
render
expect(rendered).not_to have_text 'Iterations'
end
end
end
context 'with iterations licensed feature disabled' do
before do
stub_licensed_features(iterations: false)
end
it 'is not visible' do
render
expect(rendered).not_to have_text 'Iterations'
end
end
end
end end
...@@ -14642,6 +14642,9 @@ msgstr "" ...@@ -14642,6 +14642,9 @@ msgstr ""
msgid "New issue title" msgid "New issue title"
msgstr "" msgstr ""
msgid "New iteration"
msgstr ""
msgid "New iteration created" msgid "New iteration created"
msgstr "" msgstr ""
...@@ -14819,6 +14822,9 @@ msgstr "" ...@@ -14819,6 +14822,9 @@ msgstr ""
msgid "No grouping" msgid "No grouping"
msgstr "" msgstr ""
msgid "No iterations to show"
msgstr ""
msgid "No job log" msgid "No job log"
msgstr "" msgstr ""
......
...@@ -46,6 +46,7 @@ describe 'Group navbar' do ...@@ -46,6 +46,7 @@ describe 'Group navbar' do
before do before do
stub_feature_flags(group_push_rules: false) stub_feature_flags(group_push_rules: false)
stub_feature_flags(group_iterations: false)
group.add_maintainer(user) group.add_maintainer(user)
sign_in(user) sign_in(user)
end end
......
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