Commit f32af2d0 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'epic-health' into 'master'

Add Health Status badge in Epic tree

See merge request gitlab-org/gitlab!27869
parents 9fd88264 0bd8fbe2
...@@ -15,6 +15,14 @@ $item-weight-max-width: 48px; ...@@ -15,6 +15,14 @@ $item-weight-max-width: 48px;
max-width: 85%; max-width: 85%;
} }
.related-items-tree {
.card-header {
.gl-label {
line-height: $gl-line-height;
}
}
}
.item-body { .item-body {
position: relative; position: relative;
line-height: $gl-line-height; line-height: $gl-line-height;
...@@ -49,6 +57,10 @@ $item-weight-max-width: 48px; ...@@ -49,6 +57,10 @@ $item-weight-max-width: 48px;
color: $orange-600; color: $orange-600;
} }
.item-title-wrapper {
max-width: 100%;
}
.item-title { .item-title {
flex-basis: 100%; flex-basis: 100%;
font-size: $gl-font-size-small; font-size: $gl-font-size-small;
...@@ -72,15 +84,62 @@ $item-weight-max-width: 48px; ...@@ -72,15 +84,62 @@ $item-weight-max-width: 48px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
@include media-breakpoint-down(lg) {
.issue-count-badge {
padding-left: 0;
}
}
}
.item-body,
.card-header {
.health-label-short {
display: initial;
max-width: 0;
}
.health-label-long {
display: none;
}
.status {
&-at-risk {
color: $red-500;
background-color: $red-100;
}
&-needs-attention {
color: $orange-700;
background-color: $orange-100;
}
&-on-track {
color: $green-600;
background-color: $green-100;
}
}
.gl-label-text {
font-weight: $gl-font-weight-bold;
}
.bullet-separator {
font-size: 9px;
color: $gray-400;
}
} }
.item-meta { .item-meta {
flex-basis: 100%; flex-basis: 100%;
font-size: $gl-font-size-small; font-size: $gl-font-size;
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
.item-meta-child { .item-due-date,
flex-basis: 100%; .board-card-weight {
&.board-card-info {
margin-right: 0;
}
} }
.item-attributes-area { .item-attributes-area {
...@@ -88,10 +147,6 @@ $item-weight-max-width: 48px; ...@@ -88,10 +147,6 @@ $item-weight-max-width: 48px;
margin-left: 8px; margin-left: 8px;
} }
.board-card-info {
margin-right: 0;
}
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-left: -8px; margin-left: -8px;
} }
...@@ -107,13 +162,21 @@ $item-weight-max-width: 48px; ...@@ -107,13 +162,21 @@ $item-weight-max-width: 48px;
max-width: $item-milestone-max-width; max-width: $item-milestone-max-width;
.ic-clock { .ic-clock {
color: $gl-text-color-tertiary; color: $gl-text-color-secondary;
margin-right: $gl-padding-4; margin-right: $gl-padding-4;
} }
} }
.item-weight { .item-weight {
max-width: $item-weight-max-width; max-width: $item-weight-max-width;
.ic-weight {
color: $gl-text-color-secondary;
}
}
.item-due-date .ic-calendar {
color: $gl-text-color-secondary;
} }
} }
...@@ -194,6 +257,13 @@ $item-weight-max-width: 48px; ...@@ -194,6 +257,13 @@ $item-weight-max-width: 48px;
.sortable-link { .sortable-link {
max-width: 90%; max-width: 90%;
} }
.item-body,
.card-header {
.health-label-short {
max-width: 30px;
}
}
} }
/* Small devices (landscape phones, 768px and up) */ /* Small devices (landscape phones, 768px and up) */
...@@ -232,6 +302,13 @@ $item-weight-max-width: 48px; ...@@ -232,6 +302,13 @@ $item-weight-max-width: 48px;
} }
} }
} }
.item-body,
.card-header {
.health-label-short {
max-width: 60px;
}
}
} }
/* Medium devices (desktops, 992px and up) */ /* Medium devices (desktops, 992px and up) */
...@@ -245,6 +322,17 @@ $item-weight-max-width: 48px; ...@@ -245,6 +322,17 @@ $item-weight-max-width: 48px;
font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small` font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small`
} }
} }
.item-body,
.card-header {
.health-label-short {
max-width: 100px;
}
}
.health-label-long {
display: none;
}
} }
/* Large devices (large desktops, 1200px and up) */ /* Large devices (large desktops, 1200px and up) */
...@@ -264,11 +352,23 @@ $item-weight-max-width: 48px; ...@@ -264,11 +352,23 @@ $item-weight-max-width: 48px;
} }
} }
.item-title-wrapper {
max-width: calc(100% - 440px);
}
.item-info-area { .item-info-area {
flex-basis: auto; flex-basis: auto;
} }
} }
.health-label-short {
display: initial;
}
.health-label-long {
display: none;
}
.item-contents { .item-contents {
overflow: hidden; overflow: hidden;
} }
...@@ -306,3 +406,20 @@ $item-weight-max-width: 48px; ...@@ -306,3 +406,20 @@ $item-weight-max-width: 48px;
line-height: 1.3; line-height: 1.3;
} }
} }
@media only screen and (min-width: 1400px) {
.card-header,
.item-body {
.health-label-short {
display: none;
}
.health-label-long {
display: initial;
}
}
.item-body .item-title-wrapper {
max-width: calc(100% - 570px);
}
}
...@@ -28,7 +28,7 @@ graph TD ...@@ -28,7 +28,7 @@ graph TD
## Use cases ## Use cases
- Suppose your team is working on a large feature that involves multiple discussions throughout different issues created in distinct projects within a [Group](../index.md). With Epics, you can track all the related activities that together contribute to that single feature. - Suppose your team is working on a large feature that involves multiple discussions throughout different issues created in distinct projects within a [Group](../index.md). With Epics, you can track all the related activities that together contribute to that single feature.
- Track when the work for the group of issues is targeted to begin, and when it is targeted to end. - Track when the work for the group of issues is targeted to begin, and when it's targeted to end.
- Discuss and collaborate on feature ideas and scope at a high level. - Discuss and collaborate on feature ideas and scope at a high level.
![epics list view](img/epics_list_view_v12.5.png) ![epics list view](img/epics_list_view_v12.5.png)
...@@ -62,7 +62,7 @@ An epic's page contains the following tabs: ...@@ -62,7 +62,7 @@ An epic's page contains the following tabs:
## Adding an issue to an epic ## Adding an issue to an epic
You can add an existing issue to an epic, or, from an epic's page, create a new issue that is automatically added to the epic. You can add an existing issue to an epic, or, from an epic's page, create a new issue that's automatically added to the epic.
### Adding an existing issue to an epic ### Adding an existing issue to an epic
...@@ -70,7 +70,7 @@ Existing issues that belong to a project in an epic's group, or any of the epic' ...@@ -70,7 +70,7 @@ Existing issues that belong to a project in an epic's group, or any of the epic'
subgroups, are eligible to be added to the epic. Newly added issues appear at the top of the list of issues in the **Epics and Issues** tab. subgroups, are eligible to be added to the epic. Newly added issues appear at the top of the list of issues in the **Epics and Issues** tab.
An epic contains a list of issues and an issue can be associated with at most An epic contains a list of issues and an issue can be associated with at most
one epic. When you add an issue that is already linked to an epic, one epic. When you add an issue that's already linked to an epic,
the issue is automatically unlinked from its current parent. the issue is automatically unlinked from its current parent.
To add an issue to an epic: To add an issue to an epic:
...@@ -101,6 +101,19 @@ To remove an issue from an epic: ...@@ -101,6 +101,19 @@ To remove an issue from an epic:
1. Click on the <kbd>x</kbd> button in the epic's issue list. 1. Click on the <kbd>x</kbd> button in the epic's issue list.
1. Click **Remove** in the **Remove issue** warning message. 1. Click **Remove** in the **Remove issue** warning message.
## 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.
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),
which will appear on your Epic tree.
### Disable Issue health status in Epic tree
This feature comes with a feature flag enabled by default. For steps to disable it, see
[Disable issue health status](../../project/issues/index.md#disable-issue-health-status).
## Multi-level child epics **(ULTIMATE)** ## Multi-level child epics **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/8333) in GitLab Ultimate 11.7. > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/8333) in GitLab Ultimate 11.7.
...@@ -108,7 +121,7 @@ To remove an issue from an epic: ...@@ -108,7 +121,7 @@ To remove an issue from an epic:
Any epic that belongs to a group, or subgroup of the parent epic's group, is Any epic that belongs to a group, or subgroup of the parent epic's group, is
eligible to be added. New child epics appear at the top of the list of epics in the **Epics and Issues** tab. eligible to be added. New child epics appear at the top of the list of epics in the **Epics and Issues** tab.
When you add an epic that is already linked to a parent epic, the link to its current parent is removed. When you add an epic that's already linked to a parent epic, the link to its current parent is removed.
An epic can have multiple child epics with An epic can have multiple child epics with
the maximum depth being 5. the maximum depth being 5.
......
...@@ -52,7 +52,7 @@ must be set. ...@@ -52,7 +52,7 @@ must be set.
<li>State</li> <li>State</li>
<ul> <ul>
<li>State (open or closed)</li> <li>State (open or closed)</li>
<li>Status (On track, Needs attention, or At risk)</li> <li>Health status (on track, needs attention, or at risk)</li>
<li>Confidentiality</li> <li>Confidentiality</li>
<li>Tasks (completed vs. outstanding)</li> <li>Tasks (completed vs. outstanding)</li>
</ul> </ul>
...@@ -166,11 +166,12 @@ requires [GraphQL](../../../api/graphql/index.md) to be enabled. ...@@ -166,11 +166,12 @@ requires [GraphQL](../../../api/graphql/index.md) to be enabled.
--- ---
### Status **(ULTIMATE)** ### Health status **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/36427) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.10. > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/36427) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.10.
To help you track the status of your issues, you can assign a status to each issue to flag work that's progressing as planned or needs attention to keep on schedule: To help you track the status of your issues, you can assign a status to each issue to flag work
that's progressing as planned or needs attention to keep on schedule:
- **On track** (green) - **On track** (green)
- **Needs attention** (amber) - **Needs attention** (amber)
...@@ -178,9 +179,10 @@ To help you track the status of your issues, you can assign a status to each iss ...@@ -178,9 +179,10 @@ To help you track the status of your issues, you can assign a status to each iss
!["On track" health status on an issue](img/issue_health_status_v12_10.png) !["On track" health status on an issue](img/issue_health_status_v12_10.png)
--- You can then see issue statuses on the
[Epic tree](../../group/epics/index.md#issue-health-status-in-epic-tree-ultimate).
#### Enable issue health status #### Disable issue health status
This feature comes with the `:save_issuable_health_status` feature flag enabled by default. However, in some cases This feature comes with the `:save_issuable_health_status` feature flag enabled by default. However, in some cases
this feature is incompatible with old configuration. To turn off the feature while configuration is this feature is incompatible with old configuration. To turn off the feature while configuration is
......
<script>
import { GlTooltip } from '@gitlab/ui';
export default {
components: {
GlTooltip,
},
props: {
healthStatus: {
type: Object,
required: true,
default: () => {},
},
},
};
</script>
<template>
<div ref="healthStatus" class="health-status d-inline-flex align-items-center">
<gl-tooltip :target="() => $refs.healthStatus" placement="top">
<span
><strong>{{ healthStatus.issuesOnTrack }}</strong
>&nbsp;<span>{{ __('issues on track') }}</span
>,</span
><br />
<span
><strong>{{ healthStatus.issuesNeedingAttention }}</strong
>&nbsp;<span>{{ __('issues need attention') }}</span
>,</span
><br />
<span
><strong>{{ healthStatus.issuesAtRisk }}</strong
>&nbsp;<span>{{ __('issues at risk') }}</span></span
>
</gl-tooltip>
<span class="gl-label gl-label-text-dark gl-label-sm status-on-track"
><span class="gl-label-text">
{{ healthStatus.issuesOnTrack }}
</span></span
>
<span class="ml-1 mr-2 text-secondary health-label-long">{{ __('issues on track') }}</span>
<span class="ml-1 mr-2 text-secondary text-truncate health-label-short">{{
__('on track')
}}</span>
<span class="gl-label gl-label-text-dark gl-label-sm status-needs-attention"
><span class="gl-label-text">
{{ healthStatus.issuesNeedingAttention }}
</span></span
>
<span class="ml-1 mr-2 text-secondary health-label-long">{{
__('issues need attention')
}}</span>
<span class="ml-1 mr-2 text-secondary text-truncate health-label-short">{{
__('need attention')
}}</span>
<span class="gl-label gl-label-text-dark gl-label-sm status-at-risk"
><span class="gl-label-text">
{{ healthStatus.issuesAtRisk }}
</span></span
>
<span class="ml-1 text-secondary health-label-long">{{ __('issues at risk') }}</span>
<span class="ml-1 text-secondary text-truncate health-label-short">{{ __('at risk') }}</span>
</div>
</template>
<script>
import { issueHealthStatus, issueHealthStatusCSSMapping } from '../constants';
export default {
props: {
healthStatus: {
type: String,
required: true,
default: '',
},
},
computed: {
getFormattedStatus() {
return issueHealthStatus[this.healthStatus];
},
cssMapping() {
return issueHealthStatusCSSMapping[this.healthStatus];
},
},
};
</script>
<template>
<div class="health-status d-inline-flex align-items-center">
<span class="gl-label gl-label-text-dark gl-label-sm" :class="cssMapping">
<span class="gl-label-text">
{{ getFormattedStatus }}
</span>
</span>
</div>
</template>
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlDeprecatedButton, GlTooltip } from '@gitlab/ui'; import { GlDeprecatedButton, GlTooltip, GlIcon } from '@gitlab/ui';
import { issuableTypesMap } from 'ee/related_issues/constants'; import { issuableTypesMap } from 'ee/related_issues/constants';
import Icon from '~/vue_shared/components/icon.vue';
import EpicActionsSplitButton from './epic_actions_split_button.vue'; import EpicActionsSplitButton from './epic_actions_split_button.vue';
import EpicHealthStatus from './epic_health_status.vue';
export default { export default {
components: { components: {
Icon,
GlDeprecatedButton, GlDeprecatedButton,
GlTooltip, GlTooltip,
GlIcon,
EpicHealthStatus,
EpicActionsSplitButton, EpicActionsSplitButton,
}, },
computed: { computed: {
...mapState(['parentItem', 'descendantCounts', 'allowSubEpics']), ...mapState(['parentItem', 'descendantCounts', 'healthStatus', 'allowSubEpics']),
totalEpicsCount() { totalEpicsCount() {
return this.descendantCounts.openedEpics + this.descendantCounts.closedEpics; return this.descendantCounts.openedEpics + this.descendantCounts.closedEpics;
}, },
...@@ -74,16 +74,19 @@ export default { ...@@ -74,16 +74,19 @@ export default {
</span> </span>
</p> </p>
</gl-tooltip> </gl-tooltip>
<div ref="countBadge" class="issue-count-badge"> <div ref="countBadge" class="issue-count-badge text-secondary">
<span v-if="allowSubEpics" class="d-inline-flex align-items-center"> <span v-if="allowSubEpics" class="d-inline-flex align-items-center">
<icon :size="16" name="epic" class="text-secondary mr-1" /> <gl-icon name="epic" class="mr-1" />
{{ totalEpicsCount }} {{ totalEpicsCount }}
<span class="ml-2 bullet-separator">&bull;</span>
</span> </span>
<span class="d-inline-flex align-items-center" :class="{ 'ml-2': allowSubEpics }"> <span class="d-inline-flex align-items-center" :class="{ 'ml-2': allowSubEpics }">
<icon :size="16" name="issues" class="text-secondary mr-1" /> <gl-icon name="issues" class="mr-1" />
{{ totalIssuesCount }} {{ totalIssuesCount }}
<span class="ml-2 bullet-separator">&bull;</span>
</span> </span>
</div> </div>
<epic-health-status v-if="healthStatus" :health-status="healthStatus" />
</div> </div>
<div class="d-inline-flex js-button-container"> <div class="d-inline-flex js-button-container">
<template v-if="parentItem.userPermissions.adminEpic"> <template v-if="parentItem.userPermissions.adminEpic">
......
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
export const ChildType = { export const ChildType = {
// eslint-disable-next-line @gitlab/require-i18n-strings // eslint-disable-next-line @gitlab/require-i18n-strings
...@@ -44,3 +44,15 @@ export const SEARCH_DEBOUNCE = 500; ...@@ -44,3 +44,15 @@ export const SEARCH_DEBOUNCE = 500;
export const itemRemoveModalId = 'item-remove-confirmation'; export const itemRemoveModalId = 'item-remove-confirmation';
export const treeItemChevronBtnClassName = 'btn-tree-item-chevron'; export const treeItemChevronBtnClassName = 'btn-tree-item-chevron';
export const issueHealthStatus = {
atRisk: __('At risk'),
onTrack: __('On track'),
needsAttention: __('Needs attention'),
};
export const issueHealthStatusCSSMapping = {
atRisk: 'status-at-risk',
onTrack: 'status-on-track',
needsAttention: 'status-needs-attention',
};
...@@ -14,6 +14,11 @@ fragment BaseEpic on Epic { ...@@ -14,6 +14,11 @@ fragment BaseEpic on Epic {
openedIssues openedIssues
closedIssues closedIssues
} }
healthStatus {
issuesAtRisk
issuesOnTrack
issuesNeedingAttention
}
} }
fragment EpicNode on Epic { fragment EpicNode on Epic {
......
...@@ -25,6 +25,7 @@ query childItems( ...@@ -25,6 +25,7 @@ query childItems(
...PageInfo ...PageInfo
} }
} }
issues(first: $pageSize, after: $issueEndCursor) { issues(first: $pageSize, after: $issueEndCursor) {
edges { edges {
node { node {
......
...@@ -27,4 +27,5 @@ fragment IssueNode on EpicIssue { ...@@ -27,4 +27,5 @@ fragment IssueNode on EpicIssue {
startDate startDate
dueDate dueDate
} }
healthStatus
} }
...@@ -27,6 +27,9 @@ export const setInitialParentItem = ({ commit }, data) => ...@@ -27,6 +27,9 @@ export const setInitialParentItem = ({ commit }, data) =>
export const setChildrenCount = ({ commit, state }, data) => export const setChildrenCount = ({ commit, state }, data) =>
commit(types.SET_CHILDREN_COUNT, { ...state.descendantCounts, ...data }); commit(types.SET_CHILDREN_COUNT, { ...state.descendantCounts, ...data });
export const setHealthStatus = ({ commit, state }, data) =>
commit(types.SET_HEALTH_STATUS, { ...state.healthStatus, ...data });
export const updateChildrenCount = ({ state, dispatch }, { item, isRemoved = false }) => { export const updateChildrenCount = ({ state, dispatch }, { item, isRemoved = false }) => {
const descendantCounts = {}; const descendantCounts = {};
...@@ -120,6 +123,7 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => { ...@@ -120,6 +123,7 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
if (!isSubItem) { if (!isSubItem) {
dispatch('setChildrenCount', data.group.epic.descendantCounts); dispatch('setChildrenCount', data.group.epic.descendantCounts);
dispatch('setHealthStatus', data.group.epic.healthStatus);
} }
}) })
.catch(() => { .catch(() => {
......
...@@ -7,6 +7,7 @@ export const SET_ITEM_CHILDREN = 'SET_ITEM_CHILDREN'; ...@@ -7,6 +7,7 @@ export const SET_ITEM_CHILDREN = 'SET_ITEM_CHILDREN';
export const SET_ITEM_CHILDREN_FLAGS = 'SET_ITEM_CHILDREN_FLAGS'; export const SET_ITEM_CHILDREN_FLAGS = 'SET_ITEM_CHILDREN_FLAGS';
export const SET_EPIC_PAGE_INFO = 'SET_EPIC_PAGE_INFO'; export const SET_EPIC_PAGE_INFO = 'SET_EPIC_PAGE_INFO';
export const SET_ISSUE_PAGE_INFO = 'SET_ISSUE_PAGE_INFO'; export const SET_ISSUE_PAGE_INFO = 'SET_ISSUE_PAGE_INFO';
export const SET_HEALTH_STATUS = 'SET_HEALTH_STATUS';
export const REQUEST_ITEMS = 'REQUEST_ITEMS'; export const REQUEST_ITEMS = 'REQUEST_ITEMS';
export const RECEIVE_ITEMS_SUCCESS = 'RECEIVE_ITEMS_SUCCESS'; export const RECEIVE_ITEMS_SUCCESS = 'RECEIVE_ITEMS_SUCCESS';
......
...@@ -34,6 +34,10 @@ export default { ...@@ -34,6 +34,10 @@ export default {
state.descendantCounts = data; state.descendantCounts = data;
}, },
[types.SET_HEALTH_STATUS](state, data) {
state.healthStatus = data;
},
[types.SET_ITEM_CHILDREN](state, { parentItem, children, append }) { [types.SET_ITEM_CHILDREN](state, { parentItem, children, append }) {
if (append) { if (append) {
state.children[parentItem.reference].push(...children); state.children[parentItem.reference].push(...children);
......
...@@ -16,6 +16,11 @@ export default () => ({ ...@@ -16,6 +16,11 @@ export default () => ({
openedIssues: 0, openedIssues: 0,
closedIssues: 0, closedIssues: 0,
}, },
healthStatus: {
issuesAtRisk: 0,
issuesOnTrack: 0,
issuesNeedingAttention: 0,
},
// Add Item Form Data // Add Item Form Data
issuableType: null, issuableType: null,
......
---
title: Add Health Status badge in Epic tree
merge_request: 27869
author:
type: added
...@@ -52,7 +52,7 @@ describe 'Epic show', :js do ...@@ -52,7 +52,7 @@ describe 'Epic show', :js do
expect(page).to have_selector('.related-items-tree-container') expect(page).to have_selector('.related-items-tree-container')
page.within('.related-items-tree-container') do page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('2') expect(page.find('.issue-count-badge', text: '2')).to be_present
expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B') expect(find('.tree-item:nth-child(1) .sortable-link')).to have_content('Child epic B')
expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A') expect(find('.tree-item:nth-child(2) .sortable-link')).to have_content('Child epic A')
end end
...@@ -109,7 +109,7 @@ describe 'Epic show', :js do ...@@ -109,7 +109,7 @@ describe 'Epic show', :js do
expect(page).to have_selector('.related-items-tree-container') expect(page).to have_selector('.related-items-tree-container')
page.within('.related-items-tree-container') do page.within('.related-items-tree-container') do
expect(page.find('.issue-count-badge')).to have_content('1') expect(page.find('.issue-count-badge', text: '1')).to be_present
end end
end end
end end
......
import { shallowMount } from '@vue/test-utils';
import { GlTooltip } from '@gitlab/ui';
import { mockEpic1 } from '../mock_data';
import EpicHealthStatus from 'ee/related_items_tree/components/epic_health_status.vue';
const createComponent = () => {
const { healthStatus } = mockEpic1;
return shallowMount(EpicHealthStatus, {
propsData: {
healthStatus,
},
});
};
describe('EpicHealthStatus', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders tooltip', () => {
const tooltip = wrapper.find(GlTooltip);
expect(tooltip).toExist();
});
it('renders with label with both short and long text', () => {
const longLabels = wrapper.findAll('.health-label-long');
const shortLabels = wrapper.findAll('.health-label-short');
expect(longLabels.length).toBe(3);
expect(shortLabels.length).toBe(3);
const expectedLongLabels = ['issues on track', 'issues need attention', 'issues at risk'];
expect(longLabels.length).toBe(expectedLongLabels.length);
longLabels.wrappers.forEach((longLabelWrapper, index) => {
expect(longLabelWrapper.text()).toEqual(expectedLongLabels[index]);
});
const expectedShortLabels = ['on track', 'need attention', 'at risk'];
expect(shortLabels.length).toBe(expectedShortLabels.length);
shortLabels.wrappers.forEach((shortLabelWrapper, index) => {
expect(shortLabelWrapper.text()).toEqual(expectedShortLabels[index]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { mockIssue1 } from '../mock_data';
import IssueHealthStatus from 'ee/related_items_tree/components/issue_health_status.vue';
import { issueHealthStatus, issueHealthStatusCSSMapping } from 'ee/related_items_tree/constants';
const createComponent = () => {
const { healthStatus } = mockIssue1;
return shallowMount(IssueHealthStatus, {
propsData: {
healthStatus,
},
});
};
describe('IssueHealthStatus', () => {
let wrapper;
const { healthStatus } = mockIssue1;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders passed in healthStatus', () => {
const expectedValue = issueHealthStatus[healthStatus];
expect(wrapper.text()).toBe(expectedValue);
});
it('applies correct class for passed in healthStatus', () => {
const expectedValue = issueHealthStatusCSSMapping[healthStatus];
expect(wrapper.find(`.${expectedValue}`)).toExist();
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlDeprecatedButton, GlTooltip } from '@gitlab/ui'; import { GlDeprecatedButton, GlTooltip, GlIcon } from '@gitlab/ui';
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue'; import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import createDefaultStore from 'ee/related_items_tree/store'; import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils'; import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { issuableTypesMap } from 'ee/related_issues/constants'; import { issuableTypesMap } from 'ee/related_issues/constants';
import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_actions_split_button.vue'; import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_actions_split_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { mockInitialConfig, mockParentItem, mockQueryResponse } from '../mock_data'; import { mockInitialConfig, mockParentItem, mockQueryResponse } from '../mock_data';
...@@ -172,11 +171,11 @@ describe('RelatedItemsTree', () => { ...@@ -172,11 +171,11 @@ describe('RelatedItemsTree', () => {
}); });
describe('when sub-epics feature is available', () => { describe('when sub-epics feature is available', () => {
it('renders epics count and icon', () => { it('renders epics count and gl-icon', () => {
const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0); const epicsEl = wrapper.findAll('.issue-count-badge > span').at(0);
const epicIcon = epicsEl.find(Icon); const epicIcon = epicsEl.find(GlIcon);
expect(epicsEl.text().trim()).toBe('2'); expect(epicsEl.text().trim()).toContain('2');
expect(epicIcon.isVisible()).toBe(true); expect(epicIcon.isVisible()).toBe(true);
expect(epicIcon.props('name')).toBe('epic'); expect(epicIcon.props('name')).toBe('epic');
}); });
...@@ -196,9 +195,9 @@ describe('RelatedItemsTree', () => { ...@@ -196,9 +195,9 @@ describe('RelatedItemsTree', () => {
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
it('does not render epics count and icon', () => { it('does not render epics count and gl-icon', () => {
const countBadgesEl = wrapper.findAll('.issue-count-badge > span'); const countBadgesEl = wrapper.findAll('.issue-count-badge > span');
const badgeIcon = countBadgesEl.at(0).find(Icon); const badgeIcon = countBadgesEl.at(0).find(GlIcon);
expect(countBadgesEl.length).toBe(1); expect(countBadgesEl.length).toBe(1);
expect(badgeIcon.props('name')).toBe('issues'); expect(badgeIcon.props('name')).toBe('issues');
...@@ -209,11 +208,11 @@ describe('RelatedItemsTree', () => { ...@@ -209,11 +208,11 @@ describe('RelatedItemsTree', () => {
}); });
}); });
it('renders issues count and icon', () => { it('renders issues count and gl-icon', () => {
const issuesEl = wrapper.findAll('.issue-count-badge > span').at(1); const issuesEl = wrapper.findAll('.issue-count-badge > span').at(1);
const issueIcon = issuesEl.find(Icon); const issueIcon = issuesEl.find(GlIcon);
expect(issuesEl.text().trim()).toBe('2'); expect(issuesEl.text().trim()).toContain('2');
expect(issueIcon.isVisible()).toBe(true); expect(issueIcon.isVisible()).toBe(true);
expect(issueIcon.props('name')).toBe('issues'); expect(issueIcon.props('name')).toBe('issues');
}); });
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlDeprecatedButton, GlLink } from '@gitlab/ui'; import { GlDeprecatedButton, GlLink, GlIcon } from '@gitlab/ui';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue'; import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
...@@ -14,7 +14,6 @@ import { PathIdSeparator } from 'ee/related_issues/constants'; ...@@ -14,7 +14,6 @@ import { PathIdSeparator } from 'ee/related_issues/constants';
import ItemAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import ItemAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import ItemDueDate from '~/boards/components/issue_due_date.vue'; import ItemDueDate from '~/boards/components/issue_due_date.vue';
import ItemMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; import ItemMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { mockParentItem, mockInitialConfig, mockQueryResponse, mockIssue1 } from '../mock_data'; import { mockParentItem, mockInitialConfig, mockQueryResponse, mockIssue1 } from '../mock_data';
...@@ -53,6 +52,22 @@ const createComponent = (parentItem = mockParentItem, item = mockItem) => { ...@@ -53,6 +52,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('RelatedItemsTree', () => {
describe('TreeItemBody', () => { describe('TreeItemBody', () => {
let wrapper; let wrapper;
...@@ -169,18 +184,6 @@ describe('RelatedItemsTree', () => { ...@@ -169,18 +184,6 @@ describe('RelatedItemsTree', () => {
}); });
describe('stateIconName', () => { describe('stateIconName', () => {
it('returns string `epic` when `item.type` value is `epic`', () => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
type: ChildType.Epic,
}),
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconName).toBe('epic');
});
});
it('returns string `issues` when `item.type` value is `issue`', () => { it('returns string `issues` when `item.type` value is `issue`', () => {
wrapper.setProps({ wrapper.setProps({
item: Object.assign({}, mockItem, { item: Object.assign({}, mockItem, {
...@@ -192,6 +195,14 @@ describe('RelatedItemsTree', () => { ...@@ -192,6 +195,14 @@ describe('RelatedItemsTree', () => {
expect(wrapper.vm.stateIconName).toBe('issues'); 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', () => { describe('stateIconClass', () => {
...@@ -220,12 +231,6 @@ describe('RelatedItemsTree', () => { ...@@ -220,12 +231,6 @@ describe('RelatedItemsTree', () => {
}); });
}); });
describe('itemPath', () => {
it('returns string containing item path', () => {
expect(wrapper.vm.itemPath).toBe('gitlab-org/gitlab-shell');
});
});
describe('itemId', () => { describe('itemId', () => {
it('returns string containing item id', () => { it('returns string containing item id', () => {
expect(wrapper.vm.itemId).toBe('8'); expect(wrapper.vm.itemId).toBe('8');
...@@ -249,6 +254,20 @@ describe('RelatedItemsTree', () => { ...@@ -249,6 +254,20 @@ describe('RelatedItemsTree', () => {
}); });
}); });
}); });
describe('isEpic', () => {
it('returns false when item type is issue', () => {
expect(wrapper.vm.isEpic).toBe(false);
});
it('returns true when item type is epic', () => {
const epicItemWrapper = createEpicComponent();
expect(epicItemWrapper.vm.isEpic).toBe(true);
epicItemWrapper.destroy();
});
});
}); });
describe('methods', () => { describe('methods', () => {
...@@ -276,7 +295,7 @@ describe('RelatedItemsTree', () => { ...@@ -276,7 +295,7 @@ describe('RelatedItemsTree', () => {
}); });
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(GlIcon).at(0);
expect(statusIcon.props('name')).toBe('issues'); expect(statusIcon.props('name')).toBe('issues');
}); });
...@@ -297,7 +316,7 @@ describe('RelatedItemsTree', () => { ...@@ -297,7 +316,7 @@ describe('RelatedItemsTree', () => {
}); });
it('renders confidential icon when `item.confidential` is true', () => { it('renders confidential icon when `item.confidential` is true', () => {
const confidentialIcon = wrapper.findAll(Icon).at(1); const confidentialIcon = wrapper.findAll(GlIcon).at(1);
expect(confidentialIcon.isVisible()).toBe(true); expect(confidentialIcon.isVisible()).toBe(true);
expect(confidentialIcon.props('name')).toBe('eye-slash'); expect(confidentialIcon.props('name')).toBe('eye-slash');
...@@ -310,14 +329,8 @@ describe('RelatedItemsTree', () => { ...@@ -310,14 +329,8 @@ describe('RelatedItemsTree', () => {
expect(link.text()).toBe(mockItem.title); expect(link.text()).toBe(mockItem.title);
}); });
it('renders item state icon for medium and small screens', () => {
const statusIcon = wrapper.findAll(Icon).at(2);
expect(statusIcon.props('name')).toBe('issues');
});
it('renders item state tooltip for medium and small screens', () => { it('renders item state tooltip for medium and small screens', () => {
const stateTooltip = wrapper.findAll(StateTooltip).at(1); const stateTooltip = wrapper.findAll(StateTooltip).at(0);
expect(stateTooltip.props('state')).toBe(mockItem.state); expect(stateTooltip.props('state')).toBe(mockItem.state);
}); });
...@@ -352,6 +365,18 @@ describe('RelatedItemsTree', () => { ...@@ -352,6 +365,18 @@ describe('RelatedItemsTree', () => {
expect(removeButton.isVisible()).toBe(true); expect(removeButton.isVisible()).toBe(true);
expect(removeButton.attributes('title')).toBe('Remove'); 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);
epicWrapper.destroy();
});
}); });
}); });
}); });
...@@ -26,6 +26,11 @@ export const mockParentItem = { ...@@ -26,6 +26,11 @@ export const mockParentItem = {
openedIssues: 1, openedIssues: 1,
closedIssues: 1, closedIssues: 1,
}, },
healthStatus: {
issuesOnTrack: 1,
issuesAtRisk: 0,
issuesNeedingAttention: 1,
},
}; };
export const mockEpic1 = { export const mockEpic1 = {
...@@ -47,6 +52,11 @@ export const mockEpic1 = { ...@@ -47,6 +52,11 @@ export const mockEpic1 = {
group: { group: {
fullPath: 'gitlab-org', fullPath: 'gitlab-org',
}, },
healthStatus: {
issuesAtRisk: 0,
issuesNeedingAttention: 0,
issuesOnTrack: 0,
},
}; };
export const mockEpic2 = { export const mockEpic2 = {
...@@ -68,6 +78,11 @@ export const mockEpic2 = { ...@@ -68,6 +78,11 @@ export const mockEpic2 = {
group: { group: {
fullPath: 'gitlab-org', fullPath: 'gitlab-org',
}, },
healthStatus: {
issuesAtRisk: 0,
issuesNeedingAttention: 0,
issuesOnTrack: 0,
},
}; };
export const mockIssue1 = { export const mockIssue1 = {
...@@ -101,6 +116,7 @@ export const mockIssue1 = { ...@@ -101,6 +116,7 @@ export const mockIssue1 = {
startDate: '2019-02-01', startDate: '2019-02-01',
dueDate: '2019-06-30', dueDate: '2019-06-30',
}, },
healthStatus: 'onTrack',
}; };
export const mockIssue2 = { export const mockIssue2 = {
...@@ -120,6 +136,7 @@ export const mockIssue2 = { ...@@ -120,6 +136,7 @@ export const mockIssue2 = {
edges: [], edges: [],
}, },
milestone: null, milestone: null,
healthStatus: 'needsAttention',
}; };
export const mockIssue3 = { export const mockIssue3 = {
...@@ -139,6 +156,7 @@ export const mockIssue3 = { ...@@ -139,6 +156,7 @@ export const mockIssue3 = {
edges: [], edges: [],
}, },
milestone: null, milestone: null,
healthStatus: 'atRisk',
}; };
export const mockEpics = [mockEpic1, mockEpic2]; export const mockEpics = [mockEpic1, mockEpic2];
...@@ -189,6 +207,11 @@ export const mockQueryResponse = { ...@@ -189,6 +207,11 @@ export const mockQueryResponse = {
}, },
}, },
descendantCounts: mockParentItem.descendantCounts, descendantCounts: mockParentItem.descendantCounts,
healthStatus: {
atRisk: 1,
needsAttention: 1,
onTrack: 0,
},
}, },
}, },
}, },
......
...@@ -359,9 +359,13 @@ describe('RelatedItemTree', () => { ...@@ -359,9 +359,13 @@ describe('RelatedItemTree', () => {
); );
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group); const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
const epicPageInfo = mockQueryResponse.data.group.epic.children.pageInfo;
const issuesPageInfo = mockQueryResponse.data.group.epic.issues.pageInfo; const {
const epicDescendantCounts = mockQueryResponse.data.group.epic.descendantCounts; children: { pageInfo: epicPageInfo },
issues: { pageInfo: issuesPageInfo },
descendantCounts: epicDescendantCounts,
healthStatus,
} = mockQueryResponse.data.group.epic;
testAction( testAction(
actions.fetchItems, actions.fetchItems,
...@@ -416,6 +420,12 @@ describe('RelatedItemTree', () => { ...@@ -416,6 +420,12 @@ describe('RelatedItemTree', () => {
...epicDescendantCounts, ...epicDescendantCounts,
}, },
}, },
{
type: 'setHealthStatus',
payload: {
...healthStatus,
},
},
], ],
); );
}); });
......
...@@ -29,6 +29,11 @@ describe('RelatedItemsTree', () => { ...@@ -29,6 +29,11 @@ describe('RelatedItemsTree', () => {
expect(state).toHaveProperty('autoCompleteEpics', true); expect(state).toHaveProperty('autoCompleteEpics', true);
expect(state).toHaveProperty('autoCompleteIssues', false); expect(state).toHaveProperty('autoCompleteIssues', false);
expect(state).toHaveProperty('allowSubEpics', true); expect(state).toHaveProperty('allowSubEpics', true);
expect(state).toHaveProperty('healthStatus', {
issuesNeedingAttention: 0,
issuesAtRisk: 0,
issuesOnTrack: 0,
});
}); });
}); });
......
...@@ -23861,6 +23861,9 @@ msgstr "" ...@@ -23861,6 +23861,9 @@ msgstr ""
msgid "assign yourself" msgid "assign yourself"
msgstr "" msgstr ""
msgid "at risk"
msgstr ""
msgid "attach a new file" msgid "attach a new file"
msgstr "" msgstr ""
...@@ -24364,6 +24367,15 @@ msgstr "" ...@@ -24364,6 +24367,15 @@ msgstr ""
msgid "issue" msgid "issue"
msgstr "" msgstr ""
msgid "issues at risk"
msgstr ""
msgid "issues need attention"
msgstr ""
msgid "issues on track"
msgstr ""
msgid "it is stored externally" msgid "it is stored externally"
msgstr "" msgstr ""
...@@ -24744,6 +24756,9 @@ msgstr "" ...@@ -24744,6 +24756,9 @@ msgstr ""
msgid "n/a" msgid "n/a"
msgstr "" msgstr ""
msgid "need attention"
msgstr ""
msgid "needs to be between 10 minutes and 1 month" msgid "needs to be between 10 minutes and 1 month"
msgstr "" msgstr ""
...@@ -24777,6 +24792,9 @@ msgstr "" ...@@ -24777,6 +24792,9 @@ msgstr ""
msgid "nounSeries|%{item}, and %{lastItem}" msgid "nounSeries|%{item}, and %{lastItem}"
msgstr "" msgstr ""
msgid "on track"
msgstr ""
msgid "opened %{timeAgoString} by %{user}" msgid "opened %{timeAgoString} by %{user}"
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