Commit 31a81957 authored by Simon Knox's avatar Simon Knox Committed by Kushal Pandya

Add basic iteration report view

Filtering iterations by ID, and display just info
No edit view for now
parent d30d93cb
fragment User on User {
id
avatarUrl
name
username
webUrl
}
...@@ -92,3 +92,5 @@ module Resolvers ...@@ -92,3 +92,5 @@ module Resolvers
end end
end end
end end
Resolvers::IssuesResolver.prepend_if_ee('::EE::Resolvers::IssuesResolver')
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class IssueConnectionType < GraphQL::Types::Relay::BaseConnection
field :count, Integer, null: false,
description: 'Total count of collection'
def count
object.items.size
end
end
end
...@@ -4,6 +4,8 @@ module Types ...@@ -4,6 +4,8 @@ module Types
class IssueType < BaseObject class IssueType < BaseObject
graphql_name 'Issue' graphql_name 'Issue'
connection_type_class(Types::IssueConnectionType)
implements(Types::Notes::NoteableType) implements(Types::Notes::NoteableType)
authorize :read_issue authorize :read_issue
......
...@@ -4370,6 +4370,11 @@ type EpicIssue implements Noteable { ...@@ -4370,6 +4370,11 @@ type EpicIssue implements Noteable {
The connection type for EpicIssue. The connection type for EpicIssue.
""" """
type EpicIssueConnection { type EpicIssueConnection {
"""
Total count of collection
"""
count: Int!
""" """
A list of edges. A list of edges.
""" """
...@@ -5043,6 +5048,11 @@ type Group { ...@@ -5043,6 +5048,11 @@ type Group {
""" """
iids: [String!] iids: [String!]
"""
Iterations applied to the issue
"""
iterationId: [ID]
""" """
Labels applied to this issue Labels applied to this issue
""" """
...@@ -5967,6 +5977,11 @@ type Issue implements Noteable { ...@@ -5967,6 +5977,11 @@ type Issue implements Noteable {
The connection type for Issue. The connection type for Issue.
""" """
type IssueConnection { type IssueConnection {
"""
Total count of collection
"""
count: Int!
""" """
A list of edges. A list of edges.
""" """
...@@ -9153,6 +9168,11 @@ type Project { ...@@ -9153,6 +9168,11 @@ type Project {
""" """
iids: [String!] iids: [String!]
"""
Iterations applied to the issue
"""
iterationId: [ID]
""" """
Labels applied to this issue Labels applied to this issue
""" """
...@@ -9248,6 +9268,11 @@ type Project { ...@@ -9248,6 +9268,11 @@ type Project {
""" """
iids: [String!] iids: [String!]
"""
Iterations applied to the issue
"""
iterationId: [ID]
""" """
Labels applied to this issue Labels applied to this issue
""" """
......
...@@ -12216,6 +12216,24 @@ ...@@ -12216,6 +12216,24 @@
"name": "EpicIssueConnection", "name": "EpicIssueConnection",
"description": "The connection type for EpicIssue.", "description": "The connection type for EpicIssue.",
"fields": [ "fields": [
{
"name": "count",
"description": "Total count of collection",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "edges", "name": "edges",
"description": "A list of edges.", "description": "A list of edges.",
...@@ -14041,6 +14059,20 @@ ...@@ -14041,6 +14059,20 @@
}, },
"defaultValue": "created_desc" "defaultValue": "created_desc"
}, },
{
"name": "iterationId",
"description": "Iterations applied to the issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"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.",
...@@ -16428,6 +16460,24 @@ ...@@ -16428,6 +16460,24 @@
"name": "IssueConnection", "name": "IssueConnection",
"description": "The connection type for Issue.", "description": "The connection type for Issue.",
"fields": [ "fields": [
{
"name": "count",
"description": "Total count of collection",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "edges", "name": "edges",
"description": "A list of edges.", "description": "A list of edges.",
...@@ -27282,6 +27332,20 @@ ...@@ -27282,6 +27332,20 @@
"ofType": null "ofType": null
}, },
"defaultValue": "created_desc" "defaultValue": "created_desc"
},
{
"name": "iterationId",
"description": "Iterations applied to the issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
} }
], ],
"type": { "type": {
...@@ -27462,6 +27526,20 @@ ...@@ -27462,6 +27526,20 @@
}, },
"defaultValue": "created_desc" "defaultValue": "created_desc"
}, },
{
"name": "iterationId",
"description": "Iterations applied to the issue",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"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.",
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import IterationForm from './iteration_form.vue'; import IterationForm from './iteration_form.vue';
import IterationReportTabs from './iteration_report_tabs.vue';
import query from '../queries/group_iteration.query.graphql'; import query from '../queries/group_iteration.query.graphql';
const iterationStates = { const iterationStates = {
...@@ -29,6 +30,7 @@ export default { ...@@ -29,6 +30,7 @@ export default {
GlNewDropdown, GlNewDropdown,
GlNewDropdownItem, GlNewDropdownItem,
IterationForm, IterationForm,
IterationReportTabs,
}, },
apollo: { apollo: {
group: { group: {
...@@ -154,6 +156,7 @@ export default { ...@@ -154,6 +156,7 @@ export default {
</div> </div>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3> <h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div ref="description" v-html="iteration.description"></div> <div ref="description" v-html="iteration.description"></div>
<iteration-report-tabs :group-path="groupPath" :iteration-id="iteration.id" />
</template> </template>
</div> </div>
</template> </template>
<script>
import {
GlAlert,
GlAvatar,
GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlTab,
GlTabs,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import query from '../queries/iteration_issues.query.graphql';
const states = {
opened: 'opened',
closed: 'closed',
};
const pageSize = 20;
export default {
fields: [
{
key: 'title',
label: __('Title'),
class: 'gl-bg-transparent! gl-border-b-1',
},
{
key: 'status',
label: __('Status'),
class: 'gl-bg-transparent! gl-text-truncate',
thClass: 'gl-w-eighth',
},
{
key: 'assignees',
label: __('Assignees'),
class: 'gl-bg-transparent! gl-text-right',
thClass: 'gl-w-eighth',
},
],
components: {
GlAlert,
GlAvatar,
GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlTab,
GlTabs,
GlTable,
},
directives: {
GlTooltip: GlTooltipDirective,
},
apollo: {
issues: {
query,
variables() {
return this.queryVariables;
},
update(data) {
const { nodes: issues = [], count, pageInfo = {} } = data?.group?.issues || {};
const list = issues.map(issue => ({
...issue,
labels: issue?.labels?.nodes || [],
assignees: issue?.assignees?.nodes || [],
}));
return {
pageInfo,
list,
count,
};
},
error() {
this.error = __('Error loading issues');
},
},
},
props: {
groupPath: {
type: String,
required: true,
},
iterationId: {
type: String,
required: true,
},
},
data() {
return {
issues: {
list: [],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
},
},
error: '',
pagination: {
currentPage: 1,
},
};
},
computed: {
queryVariables() {
const vars = {
groupPath: this.groupPath,
id: getIdFromGraphQLId(this.iterationId),
};
if (this.pagination.beforeCursor) {
vars.beforeCursor = this.pagination.beforeCursor;
vars.lastPageSize = pageSize;
} else {
vars.afterCursor = this.pagination.afterCursor;
vars.firstPageSize = pageSize;
}
return vars;
},
prevPage() {
return Number(this.issues.pageInfo.hasPreviousPage);
},
nextPage() {
return Number(this.issues.pageInfo.hasNextPage);
},
},
methods: {
tooltipText(assignee) {
return sprintf(__('Assigned to %{assigneeName}'), {
assigneeName: assignee.name,
});
},
issueState(state, assigneeCount) {
if (state === states.opened && assigneeCount === 0) {
return __('Open');
}
if (state === states.opened && assigneeCount > 0) {
return __('In progress');
}
return __('Closed');
},
handlePageChange(page) {
const { startCursor, endCursor } = this.issues.pageInfo;
if (page > this.pagination.currentPage) {
this.pagination = {
afterCursor: endCursor,
currentPage: page,
};
} else {
this.pagination = {
beforeCursor: startCursor,
currentPage: page,
};
}
},
},
};
</script>
<template>
<gl-tabs>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error }}
</gl-alert>
<gl-tab title="Issues">
<template #title>
<span>{{ __('Issues') }}</span
><gl-badge class="ml-2" variant="neutral">{{ issues.count }}</gl-badge>
</template>
<gl-loading-icon v-if="$apollo.queries.issues.loading" class="gl-my-9" size="md" />
<gl-table
v-else
:items="issues.list"
:fields="$options.fields"
:empty-text="__('No iterations found')"
:show-empty="true"
fixed
stacked="sm"
>
<template #cell(title)="{ item: { iid, title, webUrl } }">
<div class="gl-text-truncate">
<gl-link class="gl-text-gray-900 gl-font-weight-bold" :href="webUrl">{{
title
}}</gl-link>
<!-- TODO: add references.relative (project name) -->
<!-- Depends on https://gitlab.com/gitlab-org/gitlab/-/issues/222763 -->
<div class="gl-text-secondary">#{{ iid }}</div>
</div>
</template>
<template #cell(status)="{ item: { state, assignees = [] } }">
<span class="gl-w-6 gl-flex-shrink-0">{{ issueState(state, assignees.length) }}</span>
</template>
<template #cell(assignees)="{ item: { assignees } }">
<span class="assignee-icon gl-w-6">
<span
v-for="assignee in assignees"
:key="assignee.username"
v-gl-tooltip="tooltipText(assignee)"
>
<gl-avatar :src="assignee.avatarUrl" :size="16" />
</span>
</span>
</template>
</gl-table>
<div class="mt-3">
<gl-pagination
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</div>
</gl-tab>
</gl-tabs>
</template>
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query GroupIteration(
$groupPath: ID!
$id: ID!
$beforeCursor: String = ""
$afterCursor: String = ""
$firstPageSize: Int
$lastPageSize: Int
) {
group(fullPath: $groupPath) {
issues(
iterationId: [$id]
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
count
pageInfo {
...PageInfo
}
nodes {
iid
title
webUrl
state
assignees {
nodes {
...User
}
}
}
}
}
}
...@@ -18,7 +18,8 @@ module EE ...@@ -18,7 +18,8 @@ module EE
override :filter_items override :filter_items
def filter_items(items) def filter_items(items)
issues = by_weight(super) issues = by_weight(super)
by_epic(issues) issues = by_epic(issues)
by_iteration(issues)
end end
private private
...@@ -61,5 +62,18 @@ module EE ...@@ -61,5 +62,18 @@ module EE
items.in_epics(params.epics) items.in_epics(params.epics)
end end
end end
def by_iteration(items)
return items unless params.iterations
case params.iterations.to_s.downcase
when ::IssuableFinder::Params::FILTER_NONE
items.no_iteration
when ::IssuableFinder::Params::FILTER_ANY
items.any_iteration
else
items.in_iterations(params.iterations)
end
end
end end
end end
...@@ -50,6 +50,10 @@ module EE ...@@ -50,6 +50,10 @@ module EE
params[:epic_id] params[:epic_id]
end end
end end
def iterations
params[:iteration_id]
end
end end
end end
end end
# frozen_string_literal: true
module EE
module Resolvers
module IssuesResolver
extend ActiveSupport::Concern
prepended do
argument :iteration_id, ::GraphQL::ID_TYPE.to_list_type,
required: false,
description: 'Iterations applied to the issue'
end
end
end
end
...@@ -25,6 +25,9 @@ module EE ...@@ -25,6 +25,9 @@ module EE
issue_ids = EpicIssue.where(epic_id: epics).select(:issue_id) issue_ids = EpicIssue.where(epic_id: epics).select(:issue_id)
id_in(issue_ids) id_in(issue_ids)
end end
scope :no_iteration, -> { where(sprint_id: nil) }
scope :any_iteration, -> { where.not(sprint_id: nil) }
scope :in_iterations, ->(iterations) { where(sprint_id: iterations) }
scope :on_status_page, -> do scope :on_status_page, -> do
joins(project: :status_page_setting) joins(project: :status_page_setting)
.where(status_page_settings: { enabled: true }) .where(status_page_settings: { enabled: true })
......
...@@ -5,11 +5,19 @@ require 'spec_helper' ...@@ -5,11 +5,19 @@ require 'spec_helper'
RSpec.describe 'User views iteration' do RSpec.describe 'User views iteration' do
let_it_be(:now) { Time.now } let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:iteration) { create(:iteration, :skip_future_date_validation, group: group, start_date: now - 1.day, due_date: now) } let_it_be(:project) { create(:project, group: 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, iid: 1, id: 2, group: group, title: 'Correct Iteration', start_date: now - 1.day, due_date: now) }
let_it_be(:other_iteration) { create(:iteration, :skip_future_date_validation, iid: 2, id: 1, group: group, title: 'Wrong Iteration', start_date: now - 4.days, due_date: now - 3.days) }
let_it_be(:issue) { create(:issue, project: project, iteration: iteration) }
let_it_be(:assigned_issue) { create(:issue, project: project, iteration: iteration, assignees: [user]) }
let_it_be(:closed_issue) { create(:closed_issue, project: project, iteration: iteration) }
let_it_be(:other_issue) { create(:issue, project: project, iteration: other_iteration) }
context 'with license' do context 'with license' do
before do before do
stub_licensed_features(iterations: true) stub_licensed_features(iterations: true)
sign_in(user)
end end
context 'view an iteration', :js do context 'view an iteration', :js do
...@@ -23,6 +31,13 @@ RSpec.describe 'User views iteration' do ...@@ -23,6 +31,13 @@ RSpec.describe 'User views iteration' do
expect(page).to have_content(iteration.start_date.strftime('%b %-d, %Y')) expect(page).to have_content(iteration.start_date.strftime('%b %-d, %Y'))
expect(page).to have_content(iteration.due_date.strftime('%b %-d, %Y')) expect(page).to have_content(iteration.due_date.strftime('%b %-d, %Y'))
end end
it 'shows correct issues for issue' do
expect(page).to have_content(issue.title)
expect(page).to have_content(assigned_issue.title)
expect(page).to have_content(closed_issue.title)
expect(page).not_to have_content(other_issue.title)
end
end end
end end
end end
...@@ -8,6 +8,8 @@ RSpec.describe IssuesFinder do ...@@ -8,6 +8,8 @@ RSpec.describe IssuesFinder do
include_context 'IssuesFinder#execute context' include_context 'IssuesFinder#execute context'
context 'scope: all' do context 'scope: all' do
let_it_be(:group) { create(:group) }
let(:scope) { 'all' } let(:scope) { 'all' }
describe 'filter by weight' do describe 'filter by weight' do
...@@ -76,8 +78,6 @@ RSpec.describe IssuesFinder do ...@@ -76,8 +78,6 @@ RSpec.describe IssuesFinder do
end end
context 'filter by epic' do context 'filter by epic' do
let_it_be(:group) { create(:group) }
let_it_be(:epic_1) { create(:epic, group: group) } let_it_be(:epic_1) { create(:epic, group: group) }
let_it_be(:epic_2) { create(:epic, group: group) } let_it_be(:epic_2) { create(:epic, group: group) }
let_it_be(:sub_epic) { create(:epic, group: group, parent: epic_1) } let_it_be(:sub_epic) { create(:epic, group: group, parent: epic_1) }
...@@ -122,6 +122,54 @@ RSpec.describe IssuesFinder do ...@@ -122,6 +122,54 @@ RSpec.describe IssuesFinder do
end end
end end
end end
context 'filter by iteration' do
let_it_be(:iteration_1) { create(:iteration, group: group) }
let_it_be(:iteration_2) { create(:iteration, group: group) }
let_it_be(:iteration_1_issue) { create(:issue, project: project1, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: project1, iteration: iteration_2) }
context 'filter issues with no iteration' do
let(:params) { { iteration_id: ::IssuableFinder::Params::FILTER_NONE } }
it 'returns all issues without iterations' do
expect(issues).to contain_exactly(issue1, issue2, issue3, issue4)
end
end
context 'filter issues with any iteration' do
let(:params) { { iteration_id: ::IssuableFinder::Params::FILTER_ANY } }
it 'returns filtered issues' do
expect(issues).to contain_exactly(iteration_1_issue, iteration_2_issue)
end
end
context 'filter issues by iteration' do
let(:params) { { iteration_id: iteration_1.id } }
it 'returns all issues with the iteration' do
expect(issues).to contain_exactly(iteration_1_issue)
end
end
context 'filter issues by multiple iterations' do
let(:params) { { iteration_id: [iteration_1.id, iteration_2.id] } }
it 'returns all issues with the iteration' do
expect(issues).to contain_exactly(iteration_1_issue, iteration_2_issue)
end
end
context 'without iteration_id param' do
let(:params) { { iteration_id: nil } }
it 'returns unfiltered issues' do
expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, iteration_1_issue, iteration_2_issue)
end
end
end
end end
end end
......
...@@ -58,6 +58,7 @@ describe('Iterations tabs', () => { ...@@ -58,6 +58,7 @@ describe('Iterations tabs', () => {
describe('item loaded', () => { describe('item loaded', () => {
const iteration = { const iteration = {
title: 'June week 1', title: 'June week 1',
id: 'gid://gitlab/Iteration/2',
description: 'The first week of June', description: 'The first week of June',
startDate: '2020-06-02', startDate: '2020-06-02',
dueDate: '2020-06-08', dueDate: '2020-06-08',
......
import IterationReportTabs from 'ee/iterations/components/iteration_report_tabs.vue';
import { mount } from '@vue/test-utils';
import { GlAlert, GlAvatar, GlLoadingIcon, GlPagination, GlTable, GlTab } from '@gitlab/ui';
describe('Iterations report tabs', () => {
let wrapper;
const id = 3;
const groupPath = 'gitlab-org';
const defaultProps = {
groupPath,
iterationId: `gid://gitlab/Iteration/${id}`,
};
const mountComponent = ({ props = defaultProps, loading = false, data = {} } = {}) => {
wrapper = mount(IterationReportTabs, {
propsData: props,
data() {
return data;
},
mocks: {
$apollo: {
queries: { issues: { loading } },
},
},
stubs: {
GlAvatar,
GlTab,
GlTable,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows spinner while loading', () => {
mountComponent({
loading: true,
});
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
expect(wrapper.contains(GlTable)).toBe(false);
});
it('shows iterations list when not loading', () => {
mountComponent({
loading: false,
});
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(wrapper.contains(GlTable)).toBe(true);
expect(wrapper.text()).toContain('No iterations found');
});
it('shows error in a gl-alert', () => {
const error = 'Oh no!';
mountComponent({
data: {
error,
},
});
expect(wrapper.find(GlAlert).text()).toContain(error);
});
describe('with issues', () => {
const pageSize = 20;
const totalIssues = pageSize + 1;
const assignees = Array(totalIssues)
.fill(null)
.map((_, i) => ({
id: i,
name: `User ${i}`,
username: `user${i}`,
state: 'active',
avatarUrl: 'http://invalid/avatar.png',
webUrl: `https://localhost:3000/user${i}`,
}));
const issues = Array(totalIssues)
.fill(null)
.map((_, i) => ({
id: i,
title: `Issue ${i}`,
assignees: assignees.slice(0, i),
}));
const findIssues = () => wrapper.findAll('table tbody tr');
const findAssigneesForIssue = index =>
findIssues()
.at(index)
.findAll(GlAvatar);
beforeEach(() => {
mountComponent();
wrapper.setData({
issues: {
list: issues,
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'first-item',
endCursor: 'last-item',
},
count: issues.length,
},
});
});
it('shows issue list in table', () => {
expect(wrapper.contains(GlTable)).toBe(true);
expect(findIssues()).toHaveLength(issues.length);
});
it('shows assignees', () => {
expect(findAssigneesForIssue(0)).toHaveLength(0);
expect(findAssigneesForIssue(1)).toHaveLength(1);
expect(findAssigneesForIssue(10)).toHaveLength(10);
});
describe('pagination', () => {
const findPagination = () => wrapper.find(GlPagination);
const setPage = page => {
findPagination().vm.$emit('input', page);
return findPagination().vm.$nextTick();
};
it('passes prev, next, and current page props', () => {
expect(findPagination().exists()).toBe(true);
expect(findPagination().props()).toEqual(
expect.objectContaining({
value: wrapper.vm.pagination.currentPage,
prevPage: wrapper.vm.prevPage,
nextPage: wrapper.vm.nextPage,
}),
);
});
it('updates query variables when going to previous page', () => {
return setPage(1).then(() => {
expect(wrapper.vm.queryVariables).toEqual({
beforeCursor: 'first-item',
groupPath,
id,
lastPageSize: 20,
});
});
});
it('updates query variables when going to next page', () => {
return setPage(2).then(() => {
expect(wrapper.vm.queryVariables).toEqual({
afterCursor: 'last-item',
groupPath,
id,
firstPageSize: 20,
});
});
});
});
});
});
...@@ -6,10 +6,15 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -6,10 +6,15 @@ RSpec.describe Resolvers::IssuesResolver do
include GraphqlHelpers include GraphqlHelpers
let_it_be(:current_user) { create(:user) } let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
context "with a project" do context "with a project" do
describe '#resolve' do describe '#resolve' do
before do
project.add_developer(current_user)
end
describe 'sorting' do describe 'sorting' do
context 'when sorting by weight' do context 'when sorting by weight' do
let_it_be(:weight_issue1) { create(:issue, project: project, weight: 5) } let_it_be(:weight_issue1) { create(:issue, project: project, weight: 5) }
...@@ -17,10 +22,6 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -17,10 +22,6 @@ RSpec.describe Resolvers::IssuesResolver do
let_it_be(:weight_issue3) { create(:issue, project: project, weight: 1) } let_it_be(:weight_issue3) { create(:issue, project: project, weight: 1) }
let_it_be(:weight_issue4) { create(:issue, project: project, weight: nil) } let_it_be(:weight_issue4) { create(:issue, project: project, weight: nil) }
before do
project.add_developer(current_user)
end
it 'sorts issues ascending' do it 'sorts issues ascending' do
expect(resolve_issues(sort: :weight_asc)).to eq [weight_issue3, weight_issue1, weight_issue4, weight_issue2] expect(resolve_issues(sort: :weight_asc)).to eq [weight_issue3, weight_issue1, weight_issue4, weight_issue2]
end end
...@@ -30,6 +31,16 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -30,6 +31,16 @@ RSpec.describe Resolvers::IssuesResolver do
end end
end end
end end
describe 'filtering by iteration' do
let_it_be(:iteration1) { create(:iteration, group: group) }
let_it_be(:issue_with_iteration) { create(:issue, project: project, iteration: iteration1) }
let_it_be(:issue_without_iteration) { create(:issue, project: project) }
it 'returns issues with iteration' do
expect(resolve_issues(iteration_id: iteration1.id)).to eq [issue_with_iteration]
end
end
end end
end end
......
...@@ -130,6 +130,39 @@ RSpec.describe Issue do ...@@ -130,6 +130,39 @@ RSpec.describe Issue do
end end
end end
end end
context 'iterations' do
let_it_be(:iteration1) { create(:iteration) }
let_it_be(:iteration2) { create(:iteration) }
let_it_be(:iteration1_issue) { create(:issue, iteration: iteration1) }
let_it_be(:iteration2_issue) { create(:issue, iteration: iteration2) }
let_it_be(:issue_no_iteration) { create(:issue) }
before do
stub_licensed_features(iterations: true)
end
describe '.no_iteration' do
it 'returns only issues without an iteration assigned' do
expect(described_class.count).to eq 3
expect(described_class.no_iteration).to eq [issue_no_iteration]
end
end
describe '.any_iteration' do
it 'returns only issues with an iteration assigned' do
expect(described_class.count).to eq 3
expect(described_class.any_iteration).to eq [iteration1_issue, iteration2_issue]
end
end
describe '.in_iterations' do
it 'returns only issues in selected iterations' do
expect(described_class.count).to eq 3
expect(described_class.in_iterations([iteration1])).to eq [iteration1_issue]
end
end
end
end end
describe 'validations' do describe 'validations' do
......
...@@ -3226,6 +3226,9 @@ msgstr "" ...@@ -3226,6 +3226,9 @@ msgstr ""
msgid "Assigned Merge Requests" msgid "Assigned Merge Requests"
msgstr "" msgstr ""
msgid "Assigned to %{assigneeName}"
msgstr ""
msgid "Assigned to %{assignee_name}" msgid "Assigned to %{assignee_name}"
msgstr "" msgstr ""
...@@ -9345,6 +9348,9 @@ msgstr "" ...@@ -9345,6 +9348,9 @@ msgstr ""
msgid "Error loading file viewer." msgid "Error loading file viewer."
msgstr "" msgstr ""
msgid "Error loading issues"
msgstr ""
msgid "Error loading last commit." msgid "Error loading last commit."
msgstr "" msgstr ""
...@@ -15691,6 +15697,9 @@ msgstr "" ...@@ -15691,6 +15697,9 @@ msgstr ""
msgid "No iteration" msgid "No iteration"
msgstr "" msgstr ""
msgid "No iterations found"
msgstr ""
msgid "No iterations to show" msgid "No iterations to show"
msgstr "" msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['IssueConnection'] do
it 'has the expected fields' do
expected_fields = %i[count page_info edges nodes]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
...@@ -22,6 +22,104 @@ RSpec.describe GitlabSchema.types['Issue'] do ...@@ -22,6 +22,104 @@ RSpec.describe GitlabSchema.types['Issue'] do
end end
end end
describe 'pagination and count' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:now) { Time.now.change(usec: 0) }
let_it_be(:issues) { create_list(:issue, 10, project: project, created_at: now) }
let(:count_path) { %w(data project issues count) }
let(:page_size) { 3 }
let(:query) do
<<~GRAPHQL
query project($fullPath: ID!, $first: Int, $after: String) {
project(fullPath: $fullPath) {
issues(first: $first, after: $after) {
count
edges {
node {
iid
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
GRAPHQL
end
subject do
GitlabSchema.execute(
query,
context: { current_user: user },
variables: {
fullPath: project.full_path,
first: page_size
}
).to_h
end
context 'when user does not have the permission' do
it 'returns no data' do
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(false)
expect(subject.dig(:data, :project)).to eq(nil)
end
end
context 'count' do
let(:end_cursor) { %w(data project issues pageInfo endCursor) }
let(:issues_edges) { %w(data project issues edges) }
it 'returns total count' do
expect(subject.dig(*count_path)).to eq(issues.count)
end
it 'total count does not change between pages' do
old_count = subject.dig(*count_path)
new_cursor = subject.dig(*end_cursor)
new_page = GitlabSchema.execute(
query,
context: { current_user: user },
variables: {
fullPath: project.full_path,
first: page_size,
after: new_cursor
}
).to_h
new_count = new_page.dig(*count_path)
expect(old_count).to eq(new_count)
end
context 'pagination' do
let(:page_size) { 9 }
it 'returns new ids during pagination' do
old_edges = subject.dig(*issues_edges)
new_cursor = subject.dig(*end_cursor)
new_edges = GitlabSchema.execute(
query,
context: { current_user: user },
variables: {
fullPath: project.full_path,
first: page_size,
after: new_cursor
}
).to_h.dig(*issues_edges)
expect(old_edges.count).to eq(9)
expect(new_edges.count).to eq(1)
end
end
end
end
describe "issue notes" do describe "issue notes" do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
......
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