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 {
"""
first: Int
"""
The ID of the Iteration to look up
"""
id: ID
"""
Returns the last _n_ elements from the list.
"""
......
......@@ -13275,6 +13275,16 @@
},
"defaultValue": null
},
{
"name": "id",
"description": "The ID of the Iteration to look up",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"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>
import { GlLink } from '@gitlab/ui';
import dateFormat from 'dateformat';
import { formatDate } from '~/lib/utils/datetime_utility';
export default {
components: {
GlLink,
},
filters: {
date: value => {
const date = new Date(value);
return dateFormat(date, 'mmm d, yyyy', true);
},
},
props: {
iterations: {
type: Array,
......@@ -19,6 +13,11 @@ export default {
default: () => [],
},
},
methods: {
formatDate(date) {
return formatDate(date, 'mmm d, yyyy');
},
},
};
</script>
......@@ -32,7 +31,7 @@ export default {
>
</div>
<div class="text-secondary gl-mb-3">
{{ iteration.startDate | date }}{{ iteration.dueDate | date }}
{{ formatDate(iteration.startDate) }}{{ formatDate(iteration.dueDate) }}
</div>
</li>
</ul>
......
......@@ -4,6 +4,7 @@ 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 IterationReport from './components/iteration_report.vue';
Vue.use(VueApollo);
......@@ -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 {};
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
def execute
items = Iteration.all
items = by_id(items)
items = by_groups_and_projects(items)
items = by_title(items)
items = by_search_title(items)
......@@ -36,6 +37,14 @@ class IterationsFinder
items.for_projects_and_groups(params[:project_ids], params[:group_ids])
end
def by_id(items)
if params[:id]
items.id_in(params[:id])
else
items
end
end
def by_title(items)
if params[:title]
items.with_title(params[:title])
......
......@@ -11,6 +11,9 @@ module Resolvers
argument :title, GraphQL::STRING_TYPE,
required: false,
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
......@@ -28,6 +31,7 @@ module Resolvers
def iterations_finder_params(args)
{
id: args[:id],
state: args[:state] || 'all',
start_date: args[:start_date],
end_date: args[:end_date],
......
......@@ -2,5 +2,5 @@
- breadcrumb_title params[:id]
- page_title _("Iterations")
.js-iteration{ data: { group_full_path: @group.full_path, iteration_id: params[:iteration_id] } }
- if Feature.enabled?(:group_iterations, @group)
.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
expect(subject.to_a).to contain_exactly(started_group_iteration)
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
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)
......
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
context 'without parameters' do
it 'calls IterationsFinder to retrieve all iterations' do
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
resolve_group_iterations
......@@ -43,12 +43,13 @@ RSpec.describe Resolvers::IterationsResolver do
start_date = now
end_date = start_date + 1.hour
search = 'wow'
id = 1
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
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
......
......@@ -6432,6 +6432,9 @@ msgstr ""
msgid "Could not find design."
msgstr ""
msgid "Could not find iteration"
msgstr ""
msgid "Could not remove the trigger."
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