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;
max-width: 85%;
}
.related-items-tree {
.card-header {
.gl-label {
line-height: $gl-line-height;
}
}
}
.item-body {
position: relative;
line-height: $gl-line-height;
......@@ -49,6 +57,10 @@ $item-weight-max-width: 48px;
color: $orange-600;
}
.item-title-wrapper {
max-width: 100%;
}
.item-title {
flex-basis: 100%;
font-size: $gl-font-size-small;
......@@ -72,15 +84,62 @@ $item-weight-max-width: 48px;
overflow: hidden;
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 {
flex-basis: 100%;
font-size: $gl-font-size-small;
font-size: $gl-font-size;
color: $gl-text-color-secondary;
.item-meta-child {
flex-basis: 100%;
.item-due-date,
.board-card-weight {
&.board-card-info {
margin-right: 0;
}
}
.item-attributes-area {
......@@ -88,10 +147,6 @@ $item-weight-max-width: 48px;
margin-left: 8px;
}
.board-card-info {
margin-right: 0;
}
@include media-breakpoint-down(sm) {
margin-left: -8px;
}
......@@ -107,13 +162,21 @@ $item-weight-max-width: 48px;
max-width: $item-milestone-max-width;
.ic-clock {
color: $gl-text-color-tertiary;
color: $gl-text-color-secondary;
margin-right: $gl-padding-4;
}
}
.item-weight {
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;
.sortable-link {
max-width: 90%;
}
.item-body,
.card-header {
.health-label-short {
max-width: 30px;
}
}
}
/* Small devices (landscape phones, 768px and up) */
......@@ -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) */
......@@ -245,6 +322,17 @@ $item-weight-max-width: 48px;
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) */
......@@ -264,11 +352,23 @@ $item-weight-max-width: 48px;
}
}
.item-title-wrapper {
max-width: calc(100% - 440px);
}
.item-info-area {
flex-basis: auto;
}
}
.health-label-short {
display: initial;
}
.health-label-long {
display: none;
}
.item-contents {
overflow: hidden;
}
......@@ -306,3 +406,20 @@ $item-weight-max-width: 48px;
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
## 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.
- 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.
![epics list view](img/epics_list_view_v12.5.png)
......@@ -62,7 +62,7 @@ An epic's page contains the following tabs:
## 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
......@@ -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.
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.
To add an issue to 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 **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)**
> [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:
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.
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
the maximum depth being 5.
......
......@@ -52,7 +52,7 @@ must be set.
<li>State</li>
<ul>
<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>Tasks (completed vs. outstanding)</li>
</ul>
......@@ -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.
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)
- **Needs attention** (amber)
......@@ -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)
---
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 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>
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 Icon from '~/vue_shared/components/icon.vue';
import EpicActionsSplitButton from './epic_actions_split_button.vue';
import EpicHealthStatus from './epic_health_status.vue';
export default {
components: {
Icon,
GlDeprecatedButton,
GlTooltip,
GlIcon,
EpicHealthStatus,
EpicActionsSplitButton,
},
computed: {
...mapState(['parentItem', 'descendantCounts', 'allowSubEpics']),
...mapState(['parentItem', 'descendantCounts', 'healthStatus', 'allowSubEpics']),
totalEpicsCount() {
return this.descendantCounts.openedEpics + this.descendantCounts.closedEpics;
},
......@@ -74,16 +74,19 @@ export default {
</span>
</p>
</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">
<icon :size="16" name="epic" class="text-secondary mr-1" />
<gl-icon name="epic" class="mr-1" />
{{ totalEpicsCount }}
<span class="ml-2 bullet-separator">&bull;</span>
</span>
<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 }}
<span class="ml-2 bullet-separator">&bull;</span>
</span>
</div>
<epic-health-status v-if="healthStatus" :health-status="healthStatus" />
</div>
<div class="d-inline-flex js-button-container">
<template v-if="parentItem.userPermissions.adminEpic">
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlTooltipDirective, GlModalDirective, GlLink, GlDeprecatedButton } from '@gitlab/ui';
import {
GlTooltipDirective,
GlModalDirective,
GlLink,
GlIcon,
GlDeprecatedButton,
GlTooltip,
} from '@gitlab/ui';
import _ from 'underscore';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import ItemMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import ItemAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import ItemDueDate from '~/boards/components/issue_due_date.vue';
import EpicHealthStatus from './epic_health_status.vue';
import IssueHealthStatus from './issue_health_status.vue';
import StateTooltip from './state_tooltip.vue';
import { ChildType, ChildState, itemRemoveModalId } from '../constants';
......@@ -18,14 +27,17 @@ import { ChildType, ChildState, itemRemoveModalId } from '../constants';
export default {
itemRemoveModalId,
components: {
Icon,
GlIcon,
GlLink,
GlTooltip,
GlDeprecatedButton,
StateTooltip,
ItemMilestone,
ItemAssignees,
ItemDueDate,
ItemWeight,
EpicHealthStatus,
IssueHealthStatus,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -42,7 +54,7 @@ export default {
},
},
computed: {
...mapState(['childrenFlags', 'userSignedIn']),
...mapState(['childrenFlags', 'userSignedIn', 'allowSubEpics']),
itemReference() {
return this.item.reference;
},
......@@ -73,9 +85,6 @@ export default {
stateIconClass() {
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
},
itemPath() {
return this.itemReference.split(this.item.pathIdSeparator)[0];
},
itemId() {
return this.itemReference.split(this.item.pathIdSeparator).pop();
},
......@@ -94,6 +103,22 @@ export default {
showEmptySpacer() {
return !this.parentItem.userPermissions.adminEpic && this.userSignedIn;
},
totalEpicsCount() {
const { descendantCounts: { openedEpics = 0, closedEpics = 0 } = {} } = this.item;
return openedEpics + closedEpics;
},
totalIssuesCount() {
const { descendantCounts: { openedIssues = 0, closedIssues = 0 } = {} } = this.item;
return openedIssues + closedIssues;
},
isEpic() {
return this.item.type === ChildType.Epic;
},
isIssue() {
return this.item.type === ChildType.Issue;
},
},
methods: {
...mapActions(['setRemoveItemModalProps']),
......@@ -120,26 +145,25 @@ export default {
}"
>
<div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
<div class="d-flex flex-column flex-grow-1 item-title-wrapper">
<div class="item-title d-flex align-items-center mb-1 mb-xl-0">
<icon
ref="stateIconLg"
<gl-icon
ref="stateIconMd"
:class="stateIconClass"
:name="stateIconName"
:size="16"
:aria-label="stateText"
/>
<state-tooltip
:get-target-ref="() => $refs.stateIconLg"
:get-target-ref="() => $refs.stateIconMd"
:path="itemHierarchy"
:is-open="isOpen"
:state="item.state"
:created-at="item.createdAt"
:closed-at="item.closedAt || ''"
/>
<icon
<gl-icon
v-if="item.confidential"
v-gl-tooltip.hover
:size="16"
:title="__('Confidential')"
:aria-label="__('Confidential')"
name="eye-slash"
......@@ -147,52 +171,109 @@ export default {
/>
<gl-link :href="computedPath" class="sortable-link">{{ item.title }}</gl-link>
</div>
<div class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap">
</div>
<div
class="d-flex align-items-center item-path-id order-md-0 mt-md-0 mt-1 ml-xl-2 mr-xl-auto"
class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap align-items-center"
>
<icon
ref="stateIconMd"
:class="stateIconClass"
:name="stateIconName"
:size="16"
:aria-label="stateText"
class="d-xl-none"
/>
<state-tooltip
:get-target-ref="() => $refs.stateIconMd"
:path="itemHierarchy"
:is-open="isOpen"
:state="item.state"
:created-at="item.createdAt"
:closed-at="item.closedAt || ''"
/>
<gl-tooltip v-if="isEpic" :target="() => $refs.countBadge">
<p v-if="allowSubEpics" class="font-weight-bold m-0">
{{ __('Epics') }} &#8226;
<span class="text-secondary-400 font-weight-normal"
>{{
sprintf(__('%{openedEpics} open, %{closedEpics} closed'), {
openedEpics: item.descendantCounts && item.descendantCounts.openedEpics,
closedEpics: item.descendantCounts && item.descendantCounts.closedEpics,
})
}}
</span>
</p>
<p class="font-weight-bold m-0">
{{ __('Issues') }} &#8226;
<span class="text-secondary-400 font-weight-normal"
>{{
sprintf(__('%{openedIssues} open, %{closedIssues} closed'), {
openedIssues: item.descendantCounts && item.descendantCounts.openedIssues,
closedIssues: item.descendantCounts && item.descendantCounts.closedIssues,
})
}}
</span>
</p>
</gl-tooltip>
<div v-if="isEpic" ref="countBadge" class="issue-count-badge text-secondary">
<span v-if="allowSubEpics" class="d-inline-flex align-items-center">
<gl-icon name="epic" class="mr-1" />
{{ totalEpicsCount }}
</span>
<span class="ml-2 bullet-separator">&bull;</span>
<span class="d-inline-flex align-items-center" :class="{ 'ml-2': allowSubEpics }">
<gl-icon name="issues" class="mr-1" />
{{ totalIssuesCount }}
</span>
<span class="ml-2 bullet-separator">&bull;</span>
</div>
<div v-if="item.healthStatus" class="item-health-status mr-2">
<epic-health-status v-if="isEpic" :health-status="item.healthStatus" />
<issue-health-status v-else-if="isIssue" :health-status="item.healthStatus" />
</div>
<div
class="item-meta-child d-flex align-items-center order-0 flex-wrap mr-md-1 ml-md-auto ml-xl-2 mt-2 mt-md-0 flex-xl-nowrap"
class="item-meta-child d-flex align-items-center order-0 flex-wrap mt-2 mt-md-0 flex-xl-nowrap"
>
<!-- This bullet is for Milestone -->
<span v-if="item.healthStatus && hasMilestone" class="bullet-separator mr-2"
>&bull;</span
>
<item-milestone
v-if="hasMilestone"
:milestone="item.milestone"
class="d-flex align-items-center item-milestone mr-2 mr-md-0"
class="d-flex align-items-center item-milestone mr-2"
/>
<!-- This bullet is for Due Date -->
<span
v-if="(hasMilestone || item.healthStatus) && item.dueDate"
class="mr-2 bullet-separator"
>&bull;</span
>
<item-due-date
v-if="item.dueDate"
:date="item.dueDate"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center ml-0 mr-2 ml-md-2 ml-sm-0 mr-sm-0"
css-class="item-due-date d-flex align-items-center mr-2"
/>
<!-- This bullet is for Weight -->
<span
v-if="(item.dueDate || hasMilestone || item.healthStatus) && item.weight"
class="mr-2 bullet-separator"
>&bull;</span
>
<item-weight
v-if="item.weight"
:weight="item.weight"
class="item-weight d-flex align-items-center ml-2 mr-0 ml-md-2"
class="item-weight d-flex align-items-center mr-2"
tag-name="span"
/>
</div>
<!-- This bullet is for Assignees -->
<span
v-if="
(item.dueDate || hasMilestone || item.healthStatus || item.weight) && hasAssignees
"
class="mr-2 bullet-separator"
>&bull;</span
>
<item-assignees
v-if="hasAssignees"
:assignees="item.assignees"
class="item-assignees d-inline-flex align-items-center align-self-end ml-0 ml-md-2 mt-2 mt-md-0 mt-xl-0 mr-xl-1 mb-md-0 order-2 flex-xl-grow-0"
class="item-assignees d-inline-flex align-items-center align-self-end mr-2 mt-2 mt-md-0 mt-xl-0 mr-xl-1 mb-md-0 order-2 flex-xl-grow-0"
/>
</div>
<gl-deprecated-button
......@@ -204,7 +285,7 @@ export default {
class="btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button"
@click="handleRemoveClick"
>
<icon :size="16" name="close" class="btn-item-remove-icon" />
<gl-icon name="close" class="btn-item-remove-icon" />
</gl-deprecated-button>
<span v-if="showEmptySpacer" class="p-3"></span>
</div>
......
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
export const ChildType = {
// eslint-disable-next-line @gitlab/require-i18n-strings
......@@ -44,3 +44,15 @@ export const SEARCH_DEBOUNCE = 500;
export const itemRemoveModalId = 'item-remove-confirmation';
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 {
openedIssues
closedIssues
}
healthStatus {
issuesAtRisk
issuesOnTrack
issuesNeedingAttention
}
}
fragment EpicNode on Epic {
......
......@@ -25,6 +25,7 @@ query childItems(
...PageInfo
}
}
issues(first: $pageSize, after: $issueEndCursor) {
edges {
node {
......
......@@ -27,4 +27,5 @@ fragment IssueNode on EpicIssue {
startDate
dueDate
}
healthStatus
}
......@@ -27,6 +27,9 @@ export const setInitialParentItem = ({ commit }, data) =>
export const setChildrenCount = ({ commit, state }, 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 }) => {
const descendantCounts = {};
......@@ -120,6 +123,7 @@ export const fetchItems = ({ dispatch }, { parentItem, isSubItem = false }) => {
if (!isSubItem) {
dispatch('setChildrenCount', data.group.epic.descendantCounts);
dispatch('setHealthStatus', data.group.epic.healthStatus);
}
})
.catch(() => {
......
......@@ -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_EPIC_PAGE_INFO = 'SET_EPIC_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 RECEIVE_ITEMS_SUCCESS = 'RECEIVE_ITEMS_SUCCESS';
......
......@@ -34,6 +34,10 @@ export default {
state.descendantCounts = data;
},
[types.SET_HEALTH_STATUS](state, data) {
state.healthStatus = data;
},
[types.SET_ITEM_CHILDREN](state, { parentItem, children, append }) {
if (append) {
state.children[parentItem.reference].push(...children);
......
......@@ -16,6 +16,11 @@ export default () => ({
openedIssues: 0,
closedIssues: 0,
},
healthStatus: {
issuesAtRisk: 0,
issuesOnTrack: 0,
issuesNeedingAttention: 0,
},
// Add Item Form Data
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
expect(page).to have_selector('.related-items-tree-container')
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(2) .sortable-link')).to have_content('Child epic A')
end
......@@ -109,7 +109,7 @@ describe 'Epic show', :js do
expect(page).to have_selector('.related-items-tree-container')
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
......
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 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 createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
import { issuableTypesMap } from 'ee/related_issues/constants';
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';
......@@ -172,11 +171,11 @@ describe('RelatedItemsTree', () => {
});
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 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.props('name')).toBe('epic');
});
......@@ -196,9 +195,9 @@ describe('RelatedItemsTree', () => {
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 badgeIcon = countBadgesEl.at(0).find(Icon);
const badgeIcon = countBadgesEl.at(0).find(GlIcon);
expect(countBadgesEl.length).toBe(1);
expect(badgeIcon.props('name')).toBe('issues');
......@@ -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 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.props('name')).toBe('issues');
});
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
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';
......@@ -14,7 +14,6 @@ import { PathIdSeparator } from 'ee/related_issues/constants';
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 Icon from '~/vue_shared/components/icon.vue';
import { mockParentItem, mockInitialConfig, mockQueryResponse, mockIssue1 } from '../mock_data';
......@@ -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('TreeItemBody', () => {
let wrapper;
......@@ -169,18 +184,6 @@ describe('RelatedItemsTree', () => {
});
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`', () => {
wrapper.setProps({
item: Object.assign({}, mockItem, {
......@@ -192,6 +195,14 @@ describe('RelatedItemsTree', () => {
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', () => {
......@@ -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', () => {
it('returns string containing item id', () => {
expect(wrapper.vm.itemId).toBe('8');
......@@ -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', () => {
......@@ -276,7 +295,7 @@ describe('RelatedItemsTree', () => {
});
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');
});
......@@ -297,7 +316,7 @@ describe('RelatedItemsTree', () => {
});
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.props('name')).toBe('eye-slash');
......@@ -310,14 +329,8 @@ describe('RelatedItemsTree', () => {
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', () => {
const stateTooltip = wrapper.findAll(StateTooltip).at(1);
const stateTooltip = wrapper.findAll(StateTooltip).at(0);
expect(stateTooltip.props('state')).toBe(mockItem.state);
});
......@@ -352,6 +365,18 @@ describe('RelatedItemsTree', () => {
expect(removeButton.isVisible()).toBe(true);
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 = {
openedIssues: 1,
closedIssues: 1,
},
healthStatus: {
issuesOnTrack: 1,
issuesAtRisk: 0,
issuesNeedingAttention: 1,
},
};
export const mockEpic1 = {
......@@ -47,6 +52,11 @@ export const mockEpic1 = {
group: {
fullPath: 'gitlab-org',
},
healthStatus: {
issuesAtRisk: 0,
issuesNeedingAttention: 0,
issuesOnTrack: 0,
},
};
export const mockEpic2 = {
......@@ -68,6 +78,11 @@ export const mockEpic2 = {
group: {
fullPath: 'gitlab-org',
},
healthStatus: {
issuesAtRisk: 0,
issuesNeedingAttention: 0,
issuesOnTrack: 0,
},
};
export const mockIssue1 = {
......@@ -101,6 +116,7 @@ export const mockIssue1 = {
startDate: '2019-02-01',
dueDate: '2019-06-30',
},
healthStatus: 'onTrack',
};
export const mockIssue2 = {
......@@ -120,6 +136,7 @@ export const mockIssue2 = {
edges: [],
},
milestone: null,
healthStatus: 'needsAttention',
};
export const mockIssue3 = {
......@@ -139,6 +156,7 @@ export const mockIssue3 = {
edges: [],
},
milestone: null,
healthStatus: 'atRisk',
};
export const mockEpics = [mockEpic1, mockEpic2];
......@@ -189,6 +207,11 @@ export const mockQueryResponse = {
},
},
descendantCounts: mockParentItem.descendantCounts,
healthStatus: {
atRisk: 1,
needsAttention: 1,
onTrack: 0,
},
},
},
},
......
......@@ -359,9 +359,13 @@ describe('RelatedItemTree', () => {
);
const children = epicUtils.processQueryResponse(mockQueryResponse.data.group);
const epicPageInfo = mockQueryResponse.data.group.epic.children.pageInfo;
const issuesPageInfo = mockQueryResponse.data.group.epic.issues.pageInfo;
const epicDescendantCounts = mockQueryResponse.data.group.epic.descendantCounts;
const {
children: { pageInfo: epicPageInfo },
issues: { pageInfo: issuesPageInfo },
descendantCounts: epicDescendantCounts,
healthStatus,
} = mockQueryResponse.data.group.epic;
testAction(
actions.fetchItems,
......@@ -416,6 +420,12 @@ describe('RelatedItemTree', () => {
...epicDescendantCounts,
},
},
{
type: 'setHealthStatus',
payload: {
...healthStatus,
},
},
],
);
});
......
......@@ -29,6 +29,11 @@ describe('RelatedItemsTree', () => {
expect(state).toHaveProperty('autoCompleteEpics', true);
expect(state).toHaveProperty('autoCompleteIssues', false);
expect(state).toHaveProperty('allowSubEpics', true);
expect(state).toHaveProperty('healthStatus', {
issuesNeedingAttention: 0,
issuesAtRisk: 0,
issuesOnTrack: 0,
});
});
});
......
......@@ -23861,6 +23861,9 @@ msgstr ""
msgid "assign yourself"
msgstr ""
msgid "at risk"
msgstr ""
msgid "attach a new file"
msgstr ""
......@@ -24364,6 +24367,15 @@ msgstr ""
msgid "issue"
msgstr ""
msgid "issues at risk"
msgstr ""
msgid "issues need attention"
msgstr ""
msgid "issues on track"
msgstr ""
msgid "it is stored externally"
msgstr ""
......@@ -24744,6 +24756,9 @@ msgstr ""
msgid "n/a"
msgstr ""
msgid "need attention"
msgstr ""
msgid "needs to be between 10 minutes and 1 month"
msgstr ""
......@@ -24777,6 +24792,9 @@ msgstr ""
msgid "nounSeries|%{item}, and %{lastItem}"
msgstr ""
msgid "on track"
msgstr ""
msgid "opened %{timeAgoString} by %{user}"
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