Commit 874c0cc2 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Olena Horal-Koretska

Replace out bootstrap utility classes

Show health status only for open issues

Update the mock data as well

Update the doc

Update the spec

Replace more utility classes

Update the display condition for epics

Use parameterized tests in the spec
Update the mock data

Apply 1 suggestion(s) to 1 file(s)
parent fbcd3261
......@@ -65,7 +65,8 @@ to add an issue to an epic, reorder issues, move issues between epics, or promot
## Issue health status in Epic tree **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/199184) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.10.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/199184) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.10.
> - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/220867) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.3. The health status of a closed issue will be hidden.
You can report on and quickly respond to the health of individual issues and epics by setting a
red, amber, or green [health status on an issue](../../project/issues/index.md#health-status-ultimate),
......
......@@ -127,6 +127,13 @@ export default {
showHealthStatus() {
return this.item.healthStatus && this.allowIssuableHealthStatus;
},
showIssueHealthStatus() {
return this.isIssue && this.isOpen && this.showHealthStatus;
},
showEpicHealthStatus() {
const { descendantCounts: { openedIssues = 0 } = {} } = this.item;
return this.isEpic && this.showHealthStatus && openedIssues > 0;
},
},
methods: {
...mapActions(['setRemoveItemModalProps']),
......@@ -143,20 +150,22 @@ export default {
</script>
<template>
<div class="card card-slim sortable-row flex-grow-1">
<div class="card card-slim sortable-row gl-flex-grow-1">
<div
class="item-body card-body d-flex align-items-center pr-1 pl-2 py-1"
class="item-body card-body gl-display-flex gl-align-items-center gl-pr-2 gl-pl-3 gl-py-2"
:class="{
'item-logged-out': !userSignedIn,
'item-closed': isClosed,
}"
>
<div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
<div class="item-title-wrapper flex-grow-1 mr-2">
<div class="item-title d-flex mb-0 pt-1 pb-1">
<div
class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap"
>
<div class="item-title-wrapper gl-flex-grow-1 gl-mr-3">
<div class="item-title gl-display-flex gl-mb-0 gl-pt-2 gl-pb-2">
<gl-icon
ref="stateIconMd"
class="d-block mr-2"
class="gl-display-block gl-mr-3"
:class="stateIconClass"
:name="stateIconName"
:aria-label="stateText"
......@@ -188,13 +197,13 @@ export default {
</div>
<div
class="item-meta d-flex flex-wrap mt-xl-0 flex-xl-nowrap align-items-center pb-1 pt-1 ml-4"
class="item-meta gl-display-flex gl-flex-wrap mt-xl-0 flex-xl-nowrap gl-align-items-center gl-py-2 gl-ml-6"
>
<span class="mr-3">{{ itemHierarchy }}</span>
<span class="gl-mr-4">{{ itemHierarchy }}</span>
<gl-tooltip v-if="isEpic" :target="() => $refs.countBadge">
<p v-if="allowSubEpics" class="font-weight-bold m-0">
<p v-if="allowSubEpics" class="gl-font-weight-bold gl-m-0">
{{ __('Epics') }} &#8226;
<span class="text-secondary-400 font-weight-normal"
<span class="text-secondary-400 gl-font-weight-normal"
>{{
sprintf(__('%{openedEpics} open, %{closedEpics} closed'), {
openedEpics: item.descendantCounts && item.descendantCounts.openedEpics,
......@@ -203,9 +212,9 @@ export default {
}}
</span>
</p>
<p class="font-weight-bold m-0">
<p class="gl-font-weight-bold gl-m-0">
{{ __('Issues') }} &#8226;
<span class="text-secondary-400 font-weight-normal"
<span class="text-secondary-400 gl-font-weight-normal"
>{{
sprintf(__('%{openedIssues} open, %{closedIssues} closed'), {
openedIssues: item.descendantCounts && item.descendantCounts.openedIssues,
......@@ -219,17 +228,20 @@ export default {
<div
v-if="isEpic"
ref="countBadge"
class="issue-count-badge gl-display-inline-flex text-secondary py-0 p-lg-0"
class="issue-count-badge text-secondary gl-display-inline-flex gl-py-0 p-lg-0"
>
<span v-if="allowSubEpics" class="d-inline-flex align-items-center mr-2">
<gl-icon name="epic" class="mr-1" />
<span
v-if="allowSubEpics"
class="gl-display-inline-flex gl-align-items-center gl-mr-3"
>
<gl-icon name="epic" class="gl-mr-2" />
{{ totalEpicsCount }}
</span>
<span
class="d-inline-flex align-items-center mr-2"
class="gl-display-inline-flex gl-align-items-center gl-mr-3"
:class="{ 'ml-2': allowSubEpics }"
>
<gl-icon name="issues" class="mr-1" />
<gl-icon name="issues" class="gl-mr-2" />
{{ totalIssuesCount }}
</span>
</div>
......@@ -237,33 +249,39 @@ export default {
<item-milestone
v-if="hasMilestone"
:milestone="item.milestone"
class="d-flex align-items-center item-milestone mr-3"
class="item-milestone gl-display-flex gl-align-items-center gl-mr-4"
/>
<item-due-date
v-if="item.dueDate"
:date="item.dueDate"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center mr-3"
css-class="item-due-date gl-display-flex gl-align-items-center gl-mr-4"
/>
<item-weight
v-if="item.weight"
:weight="item.weight"
class="item-weight d-flex align-items-center mr-3"
class="item-weight gl-display-flex gl-align-items-center gl-mr-4"
tag-name="span"
/>
<item-assignees
v-if="hasAssignees"
:assignees="item.assignees"
class="item-assignees d-inline-flex align-items-center mr-3 mb-md-0 flex-xl-grow-0"
class="item-assignees gl-display-inline-flex gl-align-items-center gl-mr-4 mb-md-0 flex-xl-grow-0"
/>
<div v-if="showHealthStatus" class="item-health-status">
<epic-health-status v-if="isEpic" :health-status="item.healthStatus" />
<issue-health-status v-else-if="isIssue" :health-status="item.healthStatus" />
</div>
<epic-health-status
v-if="showEpicHealthStatus"
:health-status="item.healthStatus"
data-testid="epic-health-status"
/>
<issue-health-status
v-if="showIssueHealthStatus"
:health-status="item.healthStatus"
data-testid="issue-health-status"
/>
</div>
</div>
......@@ -273,12 +291,12 @@ export default {
v-gl-modal-directive="$options.itemRemoveModalId"
:title="__('Remove')"
:disabled="itemActionInProgress"
class="btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button align-self-start"
class="btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button gl-align-self-start"
@click="handleRemoveClick"
>
<gl-icon name="close" class="btn-item-remove-icon" />
</gl-deprecated-button>
<span v-if="showEmptySpacer" class="p-3"></span>
<span v-if="showEmptySpacer" class="gl-p-3"></span>
</div>
</div>
</div>
......
---
title: Hide health status labels for closed issues on epics tree
merge_request: 37561
author:
type: changed
......@@ -61,7 +61,7 @@ describe('RelatedItemsTree', () => {
it('returns string containing issue count based on available direct children within state', () => {
expect(wrapper.find(GlTooltip).text()).toContain(`Issues •
1 open, 1 closed`);
2 open, 1 closed`);
});
});
......@@ -232,7 +232,7 @@ describe('RelatedItemsTree', () => {
const issuesEl = wrapper.findAll('.issue-count-badge > span').at(1);
const issueIcon = issuesEl.find(GlIcon);
expect(issuesEl.text().trim()).toContain('2');
expect(issuesEl.text().trim()).toContain('3');
expect(issueIcon.isVisible()).toBe(true);
expect(issueIcon.props('name')).toBe('issues');
});
......
......@@ -15,16 +15,40 @@ import ItemAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import ItemDueDate from '~/boards/components/issue_due_date.vue';
import ItemMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import { mockParentItem, mockInitialConfig, mockQueryResponse, mockIssue1 } from '../mock_data';
import {
mockParentItem,
mockInitialConfig,
mockQueryResponse,
mockIssue1,
mockClosedIssue,
mockEpic1 as mockOpenEpic,
mockEpic2 as mockClosedEpic,
mockEpicMeta1,
mockEpicMeta2,
mockEpicMeta3,
} from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const mockItem = {
...mockIssue1,
type: ChildType.Issue,
pathIdSeparator: PathIdSeparator.Issue,
assignees: epicUtils.extractIssueAssignees(mockIssue1.assignees),
let mockItem;
const createIssueItem = (mockIssue = mockIssue1) => {
return {
...mockIssue,
type: ChildType.Issue,
pathIdSeparator: PathIdSeparator.Issue,
assignees: epicUtils.extractIssueAssignees(mockIssue.assignees),
};
};
const createEpicItem = (mockEpic = mockOpenEpic, mockEpicMeta = mockEpicMeta1) => {
return {
...mockEpic,
type: ChildType.Epic,
pathIdSeparator: PathIdSeparator.Epic,
...mockEpicMeta,
};
};
const createComponent = (parentItem = mockParentItem, item = mockItem) => {
......@@ -53,27 +77,22 @@ const createComponent = (parentItem = mockParentItem, item = mockItem) => {
});
};
const createEpicComponent = () => {
const mockEpicItem = {
...mockItem,
type: ChildType.Epic,
healthStatus: mockParentItem.healthStatus,
descendantCounts: {
openedEpics: 0,
closedEpics: 0,
openedIssues: 0,
closedIssues: 0,
},
};
return createComponent(mockParentItem, mockEpicItem);
};
describe('RelatedItemsTree', () => {
describe('TreeItemBody', () => {
let wrapper;
const findCountBadge = () => wrapper.find({ ref: 'countBadge' });
const findIssueHealthStatus = () => wrapper.find('[data-testid="issue-health-status"]');
const findEpicHealthStatus = () => wrapper.find('[data-testid="epic-health-status"]');
const enableHealthStatus = () => {
wrapper.vm.$store.commit('SET_INITIAL_CONFIG', {
...mockInitialConfig,
allowIssuableHealthStatus: true,
});
};
beforeEach(() => {
mockItem = createIssueItem();
wrapper = createComponent();
});
......@@ -170,26 +189,6 @@ describe('RelatedItemsTree', () => {
});
});
describe('stateIconName', () => {
it('returns string `issues` when `item.type` value is `issue`', () => {
wrapper.setProps({
item: { ...mockItem, type: ChildType.Issue },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconName).toBe('issues');
});
});
it('returns string `epic` when `item.type` value is `epic`', () => {
const epicItemWrapper = createEpicComponent();
expect(epicItemWrapper.vm.stateIconName).toBe('epic');
epicItemWrapper.destroy();
});
});
describe('stateIconClass', () => {
it('returns string `issue-token-state-icon-open gl-text-green-500` when `item.state` value is `opened`', () => {
wrapper.setProps({
......@@ -242,17 +241,28 @@ describe('RelatedItemsTree', () => {
});
});
describe('isEpic', () => {
it('returns false when item type is issue', () => {
expect(wrapper.vm.isEpic).toBe(false);
describe.each`
createItem | itemType | isEpic | stateIconName
${createEpicItem} | ${'epic'} | ${true} | ${'epic'}
${createIssueItem} | ${'issue'} | ${false} | ${'issues'}
`(`when dependent on item type`, ({ createItem, isEpic, stateIconName, itemType }) => {
beforeEach(() => {
mockItem = createItem();
wrapper = createComponent();
});
it('returns true when item type is epic', () => {
const epicItemWrapper = createEpicComponent();
describe('isEpic', () => {
it(`returns ${isEpic} when item type is ${itemType}`, () => {
expect(wrapper.vm.isEpic).toBe(isEpic);
});
});
expect(epicItemWrapper.vm.isEpic).toBe(true);
describe('stateIconName', () => {
it(`returns string ${stateIconName}`, async () => {
await wrapper.vm.$nextTick();
epicItemWrapper.destroy();
expect(wrapper.vm.stateIconName).toBe(stateIconName);
});
});
});
});
......@@ -354,35 +364,72 @@ describe('RelatedItemsTree', () => {
expect(removeButton.attributes('title')).toBe('Remove');
});
it('does not render issue count badge when item is issue', () => {
expect(wrapper.find('.issue-count-badge').exists()).toBe(false);
});
it('render issue count badge when item is epic', () => {
const epicWrapper = createEpicComponent();
expect(epicWrapper.find('.issue-count-badge').exists()).toBe(true);
describe.each`
createItem | countBadgeExists | itemType
${createEpicItem} | ${true} | ${'epic'}
${createIssueItem} | ${false} | ${'issue'}
`('issue count badge', ({ createItem, countBadgeExists, itemType }) => {
beforeEach(() => {
mockItem = createItem();
wrapper = createComponent();
});
epicWrapper.destroy();
it(`${
countBadgeExists ? 'renders' : 'does not render'
} issue count badge when item type is ${itemType}`, () => {
expect(findCountBadge().exists()).toBe(countBadgeExists);
});
});
it('renders health status when feature', () => {
function testExistence(exists) {
const healthStatus = wrapper.find('.item-health-status').exists();
describe('health status', () => {
it('renders when feature is available', async () => {
expect(findIssueHealthStatus().exists()).toBe(false);
expect(healthStatus).toBe(exists);
}
enableHealthStatus();
testExistence(false);
await wrapper.vm.$nextTick();
wrapper.vm.$store.commit('SET_INITIAL_CONFIG', {
...mockInitialConfig,
allowIssuableHealthStatus: true,
expect(findIssueHealthStatus().exists()).toBe(true);
});
return wrapper.vm.$nextTick(() => {
testExistence(true);
describe.each`
mockIssue | showHealthStatus
${mockIssue1} | ${true}
${mockClosedIssue} | ${false}
`("for '$mockIssue.state' issue", ({ mockIssue, showHealthStatus }) => {
beforeEach(() => {
mockItem = createIssueItem(mockIssue);
wrapper = createComponent();
enableHealthStatus();
});
it(`${showHealthStatus ? 'renders' : 'does not render'} health status`, () => {
expect(findIssueHealthStatus().exists()).toBe(showHealthStatus);
});
});
describe.each`
mockEpic | mockEpicMeta | childIssues | showHealthStatus
${mockOpenEpic} | ${mockEpicMeta1} | ${'open issue(s)'} | ${true}
${mockOpenEpic} | ${mockEpicMeta2} | ${'closed'} | ${false}
${mockClosedEpic} | ${mockEpicMeta1} | ${'open issue(s)'} | ${true}
${mockClosedEpic} | ${mockEpicMeta2} | ${'closed issues'} | ${false}
${mockClosedEpic} | ${mockEpicMeta3} | ${'no issues'} | ${false}
`(
"for '$mockEpic.state' epic with '$childIssues'",
({ mockEpic, mockEpicMeta, showHealthStatus }) => {
beforeEach(() => {
mockItem = createEpicItem(mockEpic, mockEpicMeta);
wrapper = createComponent();
enableHealthStatus();
});
it(`${showHealthStatus ? 'renders' : 'does not render'} health status`, () => {
expect(findEpicHealthStatus().exists()).toBe(showHealthStatus);
});
},
);
});
});
});
......
import { TEST_HOST } from 'spec/test_constants';
import { ChildState } from 'ee/related_items_tree/constants';
export const mockInitialConfig = {
epicsEndpoint: `${TEST_HOST}/epics`,
......@@ -24,7 +25,7 @@ export const mockParentItem = {
descendantCounts: {
openedEpics: 1,
closedEpics: 1,
openedIssues: 1,
openedIssues: 2,
closedIssues: 1,
},
healthStatus: {
......@@ -62,7 +63,7 @@ export const mockEpic1 = {
id: 'gid://gitlab/Epic/4',
iid: '4',
title: 'Quo ea ipsa enim perferendis at omnis officia.',
state: 'opened',
state: ChildState.Open,
webPath: '/groups/gitlab-org/-/epics/4',
reference: '&4',
relationPath: '/groups/gitlab-org/-/epics/1/links/4',
......@@ -88,7 +89,7 @@ export const mockEpic2 = {
id: 'gid://gitlab/Epic/3',
iid: '3',
title: 'A nisi mollitia explicabo quam soluta dolor hic.',
state: 'closed',
state: ChildState.Closed,
webPath: '/groups/gitlab-org/-/epics/3',
reference: '&3',
relationPath: '/groups/gitlab-org/-/epics/1/links/3',
......@@ -110,12 +111,57 @@ export const mockEpic2 = {
},
};
// Epic meta data for having some open issues
export const mockEpicMeta1 = {
descendantCounts: {
openedEpics: 1,
closedEpics: 1,
openedIssues: 2,
closedIssues: 1,
},
healthStatus: {
issuesOnTrack: 1,
issuesAtRisk: 0,
issuesNeedingAttention: 1,
},
};
// Epic meta data for having no open issues
export const mockEpicMeta2 = {
descendantCounts: {
openedEpics: 0,
closedEpics: 1,
openedIssues: 0,
closedIssues: 2,
},
healthStatus: {
issuesOnTrack: 0,
issuesAtRisk: 0,
issuesNeedingAttention: 0,
},
};
// Epic meta data for having no child issues
export const mockEpicMeta3 = {
descendantCounts: {
openedEpics: 0,
closedEpics: 1,
openedIssues: 0,
closedIssues: 0,
},
healthStatus: {
issuesOnTrack: 0,
issuesAtRisk: 0,
issuesNeedingAttention: 0,
},
};
export const mockIssue1 = {
iid: '8',
epicIssueId: 'gid://gitlab/EpicIssue/3',
title: 'Nostrum cum mollitia quia recusandae fugit deleniti voluptatem delectus.',
closedAt: null,
state: 'opened',
state: ChildState.Open,
createdAt: '2019-02-18T14:06:41Z',
confidential: true,
dueDate: '2019-06-14',
......@@ -149,7 +195,7 @@ export const mockIssue2 = {
epicIssueId: 'gid://gitlab/EpicIssue/4',
title: 'Dismiss Cipher with no integrity',
closedAt: null,
state: 'opened',
state: ChildState.Open,
createdAt: '2019-02-18T14:13:05Z',
confidential: false,
dueDate: null,
......@@ -164,12 +210,12 @@ export const mockIssue2 = {
healthStatus: 'needsAttention',
};
export const mockIssue3 = {
export const mockClosedIssue = {
iid: '42',
epicIssueId: 'gid://gitlab/EpicIssue/5',
title: 'View closed issues in epic',
closedAt: null,
state: 'closed',
state: ChildState.Closed,
createdAt: '2019-02-18T14:13:05Z',
confidential: false,
dueDate: null,
......@@ -186,7 +232,7 @@ export const mockIssue3 = {
export const mockEpics = [mockEpic1, mockEpic2];
export const mockIssues = [mockIssue1, mockIssue2];
export const mockIssues = [mockIssue1, mockIssue2, mockClosedIssue];
export const mockQueryResponse = {
data: {
......@@ -225,6 +271,9 @@ export const mockQueryResponse = {
{
node: mockIssue2,
},
{
node: mockClosedIssue,
},
],
pageInfo: {
endCursor: 'def',
......@@ -233,9 +282,9 @@ export const mockQueryResponse = {
},
descendantCounts: mockParentItem.descendantCounts,
healthStatus: {
atRisk: 1,
atRisk: 0,
needsAttention: 1,
onTrack: 0,
onTrack: 1,
},
},
},
......@@ -274,7 +323,7 @@ export const mockQueryResponse2 = {
issues: {
edges: [
{
node: mockIssue3,
node: mockClosedIssue,
},
{
node: mockIssue1,
......
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