Commit 05090671 authored by Simon Knox's avatar Simon Knox Committed by Peter Leitzen

Add basic iteration report view

Filtering iterations by ID, and display just info
No edit view for now
parent f6082c02
...@@ -4819,6 +4819,11 @@ type Group { ...@@ -4819,6 +4819,11 @@ type Group {
""" """
first: Int first: Int
"""
The ID of the Iteration to look up
"""
id: ID
""" """
Returns the last _n_ elements from the list. Returns the last _n_ elements from the list.
""" """
......
...@@ -13275,6 +13275,16 @@ ...@@ -13275,6 +13275,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "id",
"description": "The ID of the Iteration to look up",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "after", "name": "after",
"description": "Returns the elements in the list that come after the specified cursor.", "description": "Returns the elements in the list that come after the specified cursor.",
<script>
import { GlAlert, GlBadge, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import query from '../queries/group_iteration.query.graphql';
const iterationStates = {
closed: 'closed',
upcoming: 'upcoming',
expired: 'expired',
};
export default {
components: {
GlAlert,
GlBadge,
GlLoadingIcon,
GlEmptyState,
},
apollo: {
group: {
query,
variables() {
return {
groupPath: this.groupPath,
id: getIdFromGraphQLId(this.iterationId),
};
},
update(data) {
const iteration = data?.group?.iterations?.nodes[0] || {};
return {
iteration,
};
},
error(err) {
this.error = err.message;
},
},
},
props: {
groupPath: {
type: String,
required: true,
},
iterationId: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
error: '',
group: {
iteration: {},
},
};
},
computed: {
iteration() {
return this.group.iteration;
},
hasIteration() {
return !this.$apollo.queries.group.loading && this.iteration?.title;
},
status() {
switch (this.iteration.state) {
case iterationStates.closed:
return {
text: __('Closed'),
variant: 'danger',
};
case iterationStates.expired:
return { text: __('Past due'), variant: 'warning' };
case iterationStates.upcoming:
return { text: __('Upcoming'), variant: 'neutral' };
default:
return { text: __('Open'), variant: 'success' };
}
},
},
methods: {
formatDate(date) {
return formatDate(date, 'mmm d, yyyy');
},
},
};
</script>
<template>
<div>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error }}
</gl-alert>
<gl-loading-icon v-if="$apollo.queries.group.loading" class="gl-py-5" size="lg" />
<gl-empty-state
v-else-if="!hasIteration"
:title="__('Could not find iteration')"
:compact="false"
/>
<template v-else>
<div
ref="topbar"
class="gl-display-flex gl-justify-items-center gl-align-items-center gl-py-3 gl-border-1 gl-border-b-solid gl-border-gray-200"
>
<gl-badge :variant="status.variant">
{{ status.text }}
</gl-badge>
<span class="gl-ml-4"
>{{ formatDate(iteration.startDate) }}{{ formatDate(iteration.dueDate) }}</span
>
</div>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div ref="description" v-html="iteration.description"></div>
</template>
</div>
</template>
<script> <script>
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import dateFormat from 'dateformat'; import { formatDate } from '~/lib/utils/datetime_utility';
export default { export default {
components: { components: {
GlLink, GlLink,
}, },
filters: {
date: value => {
const date = new Date(value);
return dateFormat(date, 'mmm d, yyyy', true);
},
},
props: { props: {
iterations: { iterations: {
type: Array, type: Array,
...@@ -19,6 +13,11 @@ export default { ...@@ -19,6 +13,11 @@ export default {
default: () => [], default: () => [],
}, },
}, },
methods: {
formatDate(date) {
return formatDate(date, 'mmm d, yyyy');
},
},
}; };
</script> </script>
...@@ -32,7 +31,7 @@ export default { ...@@ -32,7 +31,7 @@ export default {
> >
</div> </div>
<div class="text-secondary gl-mb-3"> <div class="text-secondary gl-mb-3">
{{ iteration.startDate | date }}{{ iteration.dueDate | date }} {{ formatDate(iteration.startDate) }}{{ formatDate(iteration.dueDate) }}
</div> </div>
</li> </li>
</ul> </ul>
......
...@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; ...@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import Iterations from './components/iterations.vue'; import Iterations from './components/iterations.vue';
import IterationForm from './components/iteration_form.vue'; import IterationForm from './components/iteration_form.vue';
import IterationReport from './components/iteration_report.vue';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -47,4 +48,26 @@ export function initIterationForm() { ...@@ -47,4 +48,26 @@ export function initIterationForm() {
}); });
} }
export function initIterationReport() {
const el = document.querySelector('.js-iteration');
const { groupPath, iterationId, editIterationPath } = el.dataset;
const canEdit = parseBoolean(el.dataset.canEdit);
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(IterationReport, {
props: {
groupPath,
iterationId,
canEdit,
editIterationPath,
},
});
},
});
}
export default {}; export default {};
query GroupIteration($groupPath: ID!, $id: ID!) {
group(fullPath: $groupPath) {
iterations(id: $id, first: 1) {
nodes {
title
state
id
description
webPath
startDate
dueDate
}
}
}
}
import { initIterationReport } from 'ee/iterations';
document.addEventListener('DOMContentLoaded', () => {
initIterationReport();
});
...@@ -21,6 +21,7 @@ class IterationsFinder ...@@ -21,6 +21,7 @@ class IterationsFinder
def execute def execute
items = Iteration.all items = Iteration.all
items = by_id(items)
items = by_groups_and_projects(items) items = by_groups_and_projects(items)
items = by_title(items) items = by_title(items)
items = by_search_title(items) items = by_search_title(items)
...@@ -36,6 +37,14 @@ class IterationsFinder ...@@ -36,6 +37,14 @@ class IterationsFinder
items.for_projects_and_groups(params[:project_ids], params[:group_ids]) items.for_projects_and_groups(params[:project_ids], params[:group_ids])
end end
def by_id(items)
if params[:id]
items.id_in(params[:id])
else
items
end
end
def by_title(items) def by_title(items)
if params[:title] if params[:title]
items.with_title(params[:title]) items.with_title(params[:title])
......
...@@ -11,6 +11,9 @@ module Resolvers ...@@ -11,6 +11,9 @@ module Resolvers
argument :title, GraphQL::STRING_TYPE, argument :title, GraphQL::STRING_TYPE,
required: false, required: false,
description: 'Fuzzy search by title' description: 'Fuzzy search by title'
argument :id, GraphQL::ID_TYPE,
required: false,
description: 'The ID of the Iteration to look up'
type Types::IterationType, null: true type Types::IterationType, null: true
...@@ -28,6 +31,7 @@ module Resolvers ...@@ -28,6 +31,7 @@ module Resolvers
def iterations_finder_params(args) def iterations_finder_params(args)
{ {
id: args[:id],
state: args[:state] || 'all', state: args[:state] || 'all',
start_date: args[:start_date], start_date: args[:start_date],
end_date: args[:end_date], end_date: args[:end_date],
......
...@@ -2,5 +2,5 @@ ...@@ -2,5 +2,5 @@
- breadcrumb_title params[:id] - breadcrumb_title params[:id]
- page_title _("Iterations") - page_title _("Iterations")
- if Feature.enabled?(:group_iterations, @group)
.js-iteration{ data: { group_full_path: @group.full_path, iteration_id: params[:iteration_id] } } .js-iteration{ data: { group_path: @group.full_path, iteration_id: params[:id], can_edit: can?(current_user, :admin_iteration, @group).to_s, preview_markdown_path: preview_markdown_path(@group) } }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User views iteration' do
let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
let_it_be(:iteration) { create(:iteration, :skip_future_date_validation, group: group, start_date: now - 1.day, due_date: now) }
around do |example|
Timecop.freeze { example.run }
end
before do
sign_in(user)
end
context 'view an iteration', :js do
before do
visit group_iteration_path(iteration.group, iteration)
end
it 'shows iteration info and dates' do
expect(page).to have_content(iteration.title)
expect(page).to have_content(iteration.description)
end
end
end
...@@ -91,6 +91,12 @@ RSpec.describe IterationsFinder do ...@@ -91,6 +91,12 @@ RSpec.describe IterationsFinder do
expect(subject.to_a).to contain_exactly(started_group_iteration) expect(subject.to_a).to contain_exactly(started_group_iteration)
end end
it 'filters by ID' do
params[:id] = iteration_from_project_1.id
expect(subject).to contain_exactly(iteration_from_project_1)
end
context 'by timeframe' do context 'by timeframe' do
it 'returns iterations with start_date and due_date between timeframe' do it 'returns iterations with start_date and due_date between timeframe' do
params.merge!(start_date: now - 1.day, end_date: 3.days.from_now) params.merge!(start_date: now - 1.day, end_date: 3.days.from_now)
......
import IterationReport from 'ee/iterations/components/iteration_report.vue';
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
describe('Iterations tabs', () => {
let wrapper;
const defaultProps = {
groupPath: 'gitlab-org',
iterationId: '3',
};
const findTopbar = () => wrapper.find({ ref: 'topbar' });
const findTitle = () => wrapper.find({ ref: 'title' });
const findDescription = () => wrapper.find({ ref: 'description' });
const mountComponent = ({ props = defaultProps, loading = false } = {}) => {
wrapper = shallowMount(IterationReport, {
propsData: props,
mocks: {
$apollo: {
queries: { group: { loading } },
},
},
stubs: {
GlLoadingIcon,
GlTab,
GlTabs,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows spinner while loading', () => {
mountComponent({
loading: true,
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
describe('empty state', () => {
it('shows empty state if no item loaded', () => {
mountComponent({
loading: false,
});
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
expect(wrapper.find(GlEmptyState).props('title')).toEqual('Could not find iteration');
expect(findTitle().exists()).toBe(false);
expect(findDescription().exists()).toBe(false);
});
});
describe('item loaded', () => {
const iteration = {
title: 'June week 1',
description: 'The first week of June',
startDate: '2020-06-02',
dueDate: '2020-06-08',
};
beforeEach(() => {
mountComponent({
loading: false,
});
wrapper.setData({
group: {
iteration,
},
});
});
it('shows status and date in header', () => {
expect(findTopbar().text()).toContain('Open');
expect(findTopbar().text()).toContain('Jun 2, 2020');
expect(findTopbar().text()).toContain('Jun 8, 2020');
});
it('hides empty region and loading spinner', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
expect(wrapper.find(GlEmptyState).exists()).toBe(false);
});
it('shows title and description', () => {
expect(findTitle().text()).toContain(iteration.title);
expect(findDescription().text()).toContain(iteration.description);
});
});
});
...@@ -31,7 +31,7 @@ RSpec.describe Resolvers::IterationsResolver do ...@@ -31,7 +31,7 @@ RSpec.describe Resolvers::IterationsResolver do
context 'without parameters' do context 'without parameters' do
it 'calls IterationsFinder to retrieve all iterations' do it 'calls IterationsFinder to retrieve all iterations' do
expect(IterationsFinder).to receive(:new) expect(IterationsFinder).to receive(:new)
.with(group_ids: group.id, state: 'all', start_date: nil, end_date: nil, search_title: nil) .with(group_ids: group.id, state: 'all', start_date: nil, end_date: nil, search_title: nil, id: nil)
.and_call_original .and_call_original
resolve_group_iterations resolve_group_iterations
...@@ -43,12 +43,13 @@ RSpec.describe Resolvers::IterationsResolver do ...@@ -43,12 +43,13 @@ RSpec.describe Resolvers::IterationsResolver do
start_date = now start_date = now
end_date = start_date + 1.hour end_date = start_date + 1.hour
search = 'wow' search = 'wow'
id = 1
expect(IterationsFinder).to receive(:new) expect(IterationsFinder).to receive(:new)
.with(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date, search_title: search) .with(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date, search_title: search, id: id)
.and_call_original .and_call_original
resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', title: search) resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', title: search, id: id)
end end
end end
......
...@@ -6432,6 +6432,9 @@ msgstr "" ...@@ -6432,6 +6432,9 @@ msgstr ""
msgid "Could not find design." msgid "Could not find design."
msgstr "" msgstr ""
msgid "Could not find iteration"
msgstr ""
msgid "Could not remove the trigger." msgid "Could not remove the trigger."
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