Commit 072da402 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '3695-view-closed-issues-in-epic' into 'master'

View closed issues in epic

See merge request gitlab-org/gitlab!19741
parents f4d32264 468d4683
---
title: View closed issues in epic
merge_request: 19741
author:
type: added
...@@ -110,7 +110,11 @@ export default { ...@@ -110,7 +110,11 @@ export default {
<div class="card card-slim sortable-row flex-grow-1"> <div class="card card-slim sortable-row flex-grow-1">
<div <div
class="item-body card-body d-flex align-items-center p-2 pl-xl-3" class="item-body card-body d-flex align-items-center p-2 pl-xl-3"
:class="{ 'p-xl-1': userSignedIn, 'item-logged-out pt-xl-2 pb-xl-2': !userSignedIn }" :class="{
'p-xl-1': userSignedIn,
'item-logged-out pt-xl-2 pb-xl-2': !userSignedIn,
'item-closed': isClosed,
}"
> >
<div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap"> <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
<div class="item-title d-flex align-items-center mb-1 mb-xl-0"> <div class="item-title d-flex align-items-center mb-1 mb-xl-0">
......
...@@ -9,14 +9,35 @@ export const gqClient = createGqClient(); ...@@ -9,14 +9,35 @@ export const gqClient = createGqClient();
* Returns a numeric representation of item * Returns a numeric representation of item
* order in an array. * order in an array.
* *
* This method is to be used as comparision * This method is to be used as comparison
* function for Array.sort * function for Array.sort
* *
* @param {cbject} childA * @param {Object} childA
* @param {object} childB * @param {Object} childB
*/ */
export const sortChildren = (childA, childB) => childA.relativePosition - childB.relativePosition; export const sortChildren = (childA, childB) => childA.relativePosition - childB.relativePosition;
/**
* Returns a numeric representation of item, by state,
* opened items first, closed items last
* Used to sort epics and issues
*
* This method is to be used as comparison
* function for Array.sort
*
* @param {Array} items
*/
const stateOrder = ['opened', 'closed'];
export const sortByState = (a, b) => stateOrder.indexOf(a.state) - stateOrder.indexOf(b.state);
/**
* Returns sorted array, using sortChildren and sortByState
* Used to sort epics and issues
*
* @param {Array} items
*/
export const applySorts = array => array.sort(sortChildren).sort(sortByState);
/** /**
* Returns formatted child item to include additional * Returns formatted child item to include additional
* flags and properties to use while rendering tree. * flags and properties to use while rendering tree.
...@@ -72,8 +93,9 @@ export const extractChildIssues = issues => ...@@ -72,8 +93,9 @@ export const extractChildIssues = issues =>
* Parses Graph query response and updates * Parses Graph query response and updates
* children array to include issues within it * children array to include issues within it
* and then sorts everything based on `relativePosition` * and then sorts everything based on `relativePosition`
* and state
* *
* @param {Object} responseRoot * @param {Object} responseRoot
*/ */
export const processQueryResponse = ({ epic }) => export const processQueryResponse = ({ epic }) =>
[].concat(extractChildEpics(epic.children), extractChildIssues(epic.issues)).sort(sortChildren); applySorts([...extractChildEpics(epic.children), ...extractChildIssues(epic.issues)]);
...@@ -24,6 +24,10 @@ ...@@ -24,6 +24,10 @@
cursor: default; cursor: default;
min-height: $grid-size * 5; min-height: $grid-size * 5;
} }
&.item-closed {
background-color: $gray-50;
}
} }
.btn-tree-item-chevron { .btn-tree-item-chevron {
......
...@@ -297,6 +297,10 @@ describe('RelatedItemsTree', () => { ...@@ -297,6 +297,10 @@ describe('RelatedItemsTree', () => {
expect(wrapper.find('.item-body').classes()).not.toContain('item-logged-out'); expect(wrapper.find('.item-body').classes()).not.toContain('item-logged-out');
}); });
it('renders item body element without class `item-closed` when item state is opened', () => {
expect(wrapper.find('.item-body').classes()).not.toContain('item-closed');
});
it('renders item state icon for large screens', () => { it('renders item state icon for large screens', () => {
const statusIcon = wrapper.findAll(Icon).at(0); const statusIcon = wrapper.findAll(Icon).at(0);
......
...@@ -4,7 +4,7 @@ import { PathIdSeparator } from 'ee/related_issues/constants'; ...@@ -4,7 +4,7 @@ import { PathIdSeparator } from 'ee/related_issues/constants';
import { ChildType } from 'ee/related_items_tree/constants'; import { ChildType } from 'ee/related_items_tree/constants';
import { import {
mockQueryResponse, mockQueryResponse2,
mockEpic1, mockEpic1,
mockIssue1, mockIssue1,
} from '../../../javascripts/related_items_tree/mock_data'; } from '../../../javascripts/related_items_tree/mock_data';
...@@ -44,6 +44,57 @@ describe('RelatedItemsTree', () => { ...@@ -44,6 +44,57 @@ describe('RelatedItemsTree', () => {
}); });
}); });
describe('sortByState', () => {
const items = [
{
state: 'closed',
},
{
state: 'opened',
},
{
state: 'closed',
},
];
const paramA = {};
const paramB = {};
it('returns non-zero positive integer when paramA.state is closed and paramB.state is opened', () => {
paramA.state = 'closed';
paramB.state = 'opened';
expect(epicUtils.sortByState(paramA, paramB) > -1).toBe(true);
});
it('returns non-zero negative integer when paramA.state is opened and paramB.state is closed', () => {
paramA.state = 'opened';
paramB.state = 'closed';
expect(epicUtils.sortByState(paramA, paramB) < 0).toBe(true);
});
it('returns zero when paramA.state is same as paramB.state', () => {
paramA.state = 'opened';
paramB.state = 'opened';
expect(epicUtils.sortByState(paramA, paramB)).toBe(0);
});
it('reorders items by state, opened first, closed last', () => {
expect(items.sort(epicUtils.sortByState)).toEqual([
{
state: 'opened',
},
{
state: 'closed',
},
{
state: 'closed',
},
]);
});
});
describe('formatChildItem', () => { describe('formatChildItem', () => {
it('returns new object from provided item object with pathIdSeparator assigned', () => { it('returns new object from provided item object with pathIdSeparator assigned', () => {
const item = { const item = {
...@@ -61,11 +112,11 @@ describe('RelatedItemsTree', () => { ...@@ -61,11 +112,11 @@ describe('RelatedItemsTree', () => {
describe('extractChildEpics', () => { describe('extractChildEpics', () => {
it('returns updated epics array with `type` and `pathIdSeparator` assigned and `edges->node` nesting removed', () => { it('returns updated epics array with `type` and `pathIdSeparator` assigned and `edges->node` nesting removed', () => {
const formattedChildren = epicUtils.extractChildEpics( const formattedChildren = epicUtils.extractChildEpics(
mockQueryResponse.data.group.epic.children, mockQueryResponse2.data.group.epic.children,
); );
expect(formattedChildren.length).toBe( expect(formattedChildren.length).toBe(
mockQueryResponse.data.group.epic.children.edges.length, mockQueryResponse2.data.group.epic.children.edges.length,
); );
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Epic); expect(formattedChildren[0]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[0]).toHaveProperty('pathIdSeparator', PathIdSeparator.Epic); expect(formattedChildren[0]).toHaveProperty('pathIdSeparator', PathIdSeparator.Epic);
...@@ -88,11 +139,11 @@ describe('RelatedItemsTree', () => { ...@@ -88,11 +139,11 @@ describe('RelatedItemsTree', () => {
describe('extractChildIssues', () => { describe('extractChildIssues', () => {
it('returns updated issues array with `type` and `pathIdSeparator` assigned and `edges->node` nesting removed', () => { it('returns updated issues array with `type` and `pathIdSeparator` assigned and `edges->node` nesting removed', () => {
const formattedChildren = epicUtils.extractChildIssues( const formattedChildren = epicUtils.extractChildIssues(
mockQueryResponse.data.group.epic.issues, mockQueryResponse2.data.group.epic.issues,
); );
expect(formattedChildren.length).toBe( expect(formattedChildren.length).toBe(
mockQueryResponse.data.group.epic.issues.edges.length, mockQueryResponse2.data.group.epic.issues.edges.length,
); );
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Issue); expect(formattedChildren[0]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[0]).toHaveProperty('pathIdSeparator', PathIdSeparator.Issue); expect(formattedChildren[0]).toHaveProperty('pathIdSeparator', PathIdSeparator.Issue);
...@@ -100,14 +151,20 @@ describe('RelatedItemsTree', () => { ...@@ -100,14 +151,20 @@ describe('RelatedItemsTree', () => {
}); });
describe('processQueryResponse', () => { describe('processQueryResponse', () => {
it('returns array of issues and epics from query response with issues being on top of the list', () => { it('returns array of issues and epics from query response with open epics and issues being on top of the list', () => {
const formattedChildren = epicUtils.processQueryResponse(mockQueryResponse.data.group); const formattedChildren = epicUtils.processQueryResponse(mockQueryResponse2.data.group);
expect(formattedChildren.length).toBe(4); // 2 Epics and 2 Issues expect(formattedChildren.length).toBe(5); // 2 Epics and 3 Issues
expect(formattedChildren[0]).toHaveProperty('type', ChildType.Epic); expect(formattedChildren[0]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[1]).toHaveProperty('type', ChildType.Epic); expect(formattedChildren[0]).toHaveProperty('state', 'opened');
expect(formattedChildren[1]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[1]).toHaveProperty('state', 'opened');
expect(formattedChildren[2]).toHaveProperty('type', ChildType.Issue); expect(formattedChildren[2]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[3]).toHaveProperty('type', ChildType.Issue); expect(formattedChildren[2]).toHaveProperty('state', 'opened');
expect(formattedChildren[3]).toHaveProperty('type', ChildType.Epic);
expect(formattedChildren[3]).toHaveProperty('state', 'closed');
expect(formattedChildren[4]).toHaveProperty('type', ChildType.Issue);
expect(formattedChildren[4]).toHaveProperty('state', 'closed');
}); });
}); });
}); });
......
...@@ -13,7 +13,7 @@ import { ...@@ -13,7 +13,7 @@ import {
mockInitialConfig, mockInitialConfig,
mockParentItem, mockParentItem,
mockEpic1, mockEpic1,
mockIssue1, mockIssue2,
} from '../mock_data'; } from '../mock_data';
const { epic } = mockQueryResponse.data.group; const { epic } = mockQueryResponse.data.group;
...@@ -138,7 +138,7 @@ describe('RelatedItemsTree', () => { ...@@ -138,7 +138,7 @@ describe('RelatedItemsTree', () => {
}); });
it('returns value of `epicIssueId` prop when item is an Issue', () => { it('returns value of `epicIssueId` prop when item is an Issue', () => {
expect(wrapper.vm.getItemId(wrapper.vm.children[2])).toBe(mockIssue1.epicIssueId); expect(wrapper.vm.getItemId(wrapper.vm.children[2])).toBe(mockIssue2.epicIssueId);
}); });
}); });
...@@ -166,7 +166,7 @@ describe('RelatedItemsTree', () => { ...@@ -166,7 +166,7 @@ describe('RelatedItemsTree', () => {
}), }),
).toEqual( ).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
id: mockIssue1.epicIssueId, id: mockIssue2.epicIssueId,
}), }),
); );
}); });
...@@ -192,7 +192,7 @@ describe('RelatedItemsTree', () => { ...@@ -192,7 +192,7 @@ describe('RelatedItemsTree', () => {
}), }),
).toEqual( ).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
adjacentReferenceId: mockIssue1.epicIssueId, adjacentReferenceId: mockIssue2.epicIssueId,
}), }),
); );
}); });
......
...@@ -111,6 +111,25 @@ export const mockIssue2 = { ...@@ -111,6 +111,25 @@ export const mockIssue2 = {
milestone: null, milestone: null,
}; };
export const mockIssue3 = {
iid: '42',
epicIssueId: 'gid://gitlab/EpicIssue/5',
title: 'View closed issues in epic',
closedAt: null,
state: 'closed',
createdAt: '2019-02-18T14:13:05Z',
confidential: false,
dueDate: null,
weight: null,
webPath: '/gitlab-org/gitlab-shell/issues/42',
reference: 'gitlab-org/gitlab-shell#42',
relationPath: '/groups/gitlab-org/-/epics/1/issues/27',
assignees: {
edges: [],
},
milestone: null,
};
export const mockEpics = [mockEpic1, mockEpic2]; export const mockEpics = [mockEpic1, mockEpic2];
export const mockIssues = [mockIssue1, mockIssue2]; export const mockIssues = [mockIssue1, mockIssue2];
...@@ -163,6 +182,57 @@ export const mockQueryResponse = { ...@@ -163,6 +182,57 @@ export const mockQueryResponse = {
}, },
}; };
export const mockQueryResponse2 = {
data: {
group: {
id: 1,
path: 'gitlab-org',
fullPath: 'gitlab-org',
epic: {
id: 1,
iid: 1,
title: 'Foo bar',
webPath: '/groups/gitlab-org/-/epics/1',
userPermissions: {
adminEpic: true,
createEpic: true,
},
children: {
edges: [
{
node: mockEpic1,
},
{
node: mockEpic2,
},
],
pageInfo: {
endCursor: 'abc',
hasNextPage: true,
},
},
issues: {
edges: [
{
node: mockIssue3,
},
{
node: mockIssue1,
},
{
node: mockIssue2,
},
],
pageInfo: {
endCursor: 'def',
hasNextPage: true,
},
},
},
},
},
};
export const mockReorderMutationResponse = { export const mockReorderMutationResponse = {
epicTreeReorder: { epicTreeReorder: {
clientMutationId: null, clientMutationId: null,
......
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