Commit 86aac4ae authored by Andrew Smith's avatar Andrew Smith

feat: Display epic and issue labels when viewing an epic

Fixes https://gitlab.com/gitlab-org/gitlab/-/issues/345205
Changelog: added
EE: true
parent 30cfc3c3
......@@ -45,6 +45,16 @@ fragment EpicNode on Epic {
confidential
hasChildren
hasIssues
labels {
__typename
nodes {
__typename
color
description
textColor
title
}
}
group {
__typename
id
......@@ -127,6 +137,16 @@ query childItems(
dueDate
}
healthStatus
labels {
__typename
nodes {
__typename
color
description
textColor
title
}
}
}
}
pageInfo {
......
......@@ -3,6 +3,7 @@ import { GlTooltip, GlIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { issuableTypesMap } from '~/related_issues/constants';
import ToggleLabels from '../../boards/components/toggle_labels.vue';
import EpicHealthStatus from './epic_health_status.vue';
import EpicActionsSplitButton from './epic_issue_actions_split_button.vue';
......@@ -13,6 +14,7 @@ export default {
GlIcon,
EpicHealthStatus,
EpicActionsSplitButton,
ToggleLabels,
},
computed: {
...mapState([
......@@ -120,6 +122,11 @@ export default {
</div>
<epic-health-status v-if="showHealthStatus" :health-status="healthStatus" />
</div>
<div class="gl-display-inline-flex gl-mr-3">
<toggle-labels />
</div>
<div
v-if="parentItem.userPermissions.adminEpic"
class="d-inline-flex flex-column flex-sm-row js-button-container"
......
<script>
import {
GlTooltipDirective,
GlModalDirective,
GlLink,
GlIcon,
GlButton,
GlIcon,
GlLabel,
GlLink,
GlModalDirective,
GlTooltip,
GlTooltipDirective,
} from '@gitlab/ui';
import { isEmpty, isNumber } from 'lodash';
import { mapState, mapActions } from 'vuex';
......@@ -13,6 +14,7 @@ import { mapState, mapActions } from 'vuex';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
import ItemDueDate from '~/boards/components/issue_due_date.vue';
import { __ } from '~/locale';
import { isScopedLabel } from '~/lib/utils/common_utils';
import ItemAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import ItemMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
......@@ -27,6 +29,7 @@ export default {
itemRemoveModalId,
components: {
GlIcon,
GlLabel,
GlLink,
GlTooltip,
GlButton,
......@@ -47,13 +50,27 @@ export default {
type: Object,
required: true,
},
labelsFilterParam: {
type: String,
required: false,
default: 'label_name',
},
item: {
type: Object,
required: true,
},
},
computed: {
...mapState(['childrenFlags', 'userSignedIn', 'allowSubEpics', 'allowIssuableHealthStatus']),
...mapState([
'allowIssuableHealthStatus',
'allowScopedLabels',
'allowSubEpics',
'childrenFlags',
'epicsWebUrl',
'isShowingLabels',
'issuesWebUrl',
'userSignedIn',
]),
itemReference() {
return this.item.reference;
},
......@@ -81,6 +98,9 @@ export default {
hasWeight() {
return isNumber(this.item.weight);
},
showLabels() {
return this.isShowingLabels && this.item.labels?.length > 0;
},
stateText() {
return this.isOpen ? __('Opened') : __('Closed');
},
......@@ -159,6 +179,18 @@ export default {
item,
});
},
showScopedLabel(label) {
return isScopedLabel(label) && this.allowScopedLabels;
},
labelFilterUrl(label) {
let basePath = this.issuesWebUrl;
if (this.isEpic) {
basePath = this.epicsWebUrl;
}
return `${basePath}?${this.labelsFilterParam}[]=${encodeURIComponent(label.title)}`;
},
},
};
</script>
......@@ -211,7 +243,7 @@ export default {
</div>
<div
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"
class="item-meta gl-display-flex gl-flex-wrap mt-xl-0 gl-align-items-center gl-py-2 gl-ml-6"
>
<span class="gl-mr-5">{{ itemHierarchy }}</span>
<gl-tooltip v-if="isEpic" :target="() => $refs.countBadge">
......@@ -288,14 +320,28 @@ export default {
v-if="showEpicHealthStatus"
:health-status="item.healthStatus"
data-testid="epic-health-status"
class="issuable-tag-valign"
class="issuable-tag-valign gl-mr-5"
/>
<issue-health-status
v-if="showIssueHealthStatus"
:health-status="item.healthStatus"
data-testid="issue-health-status"
class="issuable-tag-valign"
class="issuable-tag-valign gl-mr-5"
/>
<template v-if="showLabels">
<gl-label
v-for="label in item.labels"
:key="label.id"
:background-color="label.color"
:description="label.description"
:scoped="showScopedLabel(label)"
:target="labelFilterUrl(label)"
:title="label.title"
class="gl-mr-5 gl-mt-1 gl-mb-1 gl-label-sm"
tooltip-placement="top"
/>
</template>
</div>
</div>
......
......@@ -27,8 +27,9 @@ export default () => {
autoCompleteEpics,
autoCompleteIssues,
userSignedIn,
allowSubEpics,
allowIssuableHealthStatus,
allowScopedLabels,
allowSubEpics,
} = el.dataset;
const initialData = JSON.parse(el.dataset.initial);
......@@ -63,8 +64,11 @@ export default () => {
autoCompleteEpics: parseBoolean(autoCompleteEpics),
autoCompleteIssues: parseBoolean(autoCompleteIssues),
userSignedIn: parseBoolean(userSignedIn),
allowSubEpics: parseBoolean(allowSubEpics),
allowIssuableHealthStatus: parseBoolean(allowIssuableHealthStatus),
allowScopedLabels: parseBoolean(allowScopedLabels),
allowSubEpics: parseBoolean(allowSubEpics),
epicsWebUrl: initialData.epicsWebUrl,
issuesWebUrl: initialData.issuesWebUrl,
});
},
methods: {
......
......@@ -624,3 +624,7 @@ export const fetchDescendantGroups = ({ commit }, { groupId, search = '' }) => {
commit(types.RECEIVE_DESCENDANT_GROUPS_FAILURE);
});
};
export const setShowLabels = ({ commit }, val) => {
commit(types.SET_SHOW_LABELS, val);
};
......@@ -53,3 +53,5 @@ export const RECIEVE_PROJECTS_FAILURE = 'RECIEVE_PROJECTS_FAILURE';
export const REQUEST_DESCENDANT_GROUPS = 'REQUEST_DESCENDANT_GROUPS';
export const RECEIVE_DESCENDANT_GROUPS_SUCCESS = 'RECEIVE_DESCENDANT_GROUPS_SUCCESS';
export const RECEIVE_DESCENDANT_GROUPS_FAILURE = 'RECEIVE_DESCENDANT_GROUPS_FAILURE';
export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
......@@ -13,8 +13,12 @@ export default {
autoCompleteIssues,
projectsEndpoint,
userSignedIn,
allowSubEpics,
allowIssuableHealthStatus,
allowScopedLabels,
allowSubEpics,
epicsWebUrl,
isShowingLabels,
issuesWebUrl,
},
) {
state.epicsEndpoint = epicsEndpoint;
......@@ -23,8 +27,12 @@ export default {
state.autoCompleteIssues = autoCompleteIssues;
state.projectsEndpoint = projectsEndpoint;
state.userSignedIn = userSignedIn;
state.allowSubEpics = allowSubEpics;
state.allowIssuableHealthStatus = allowIssuableHealthStatus;
state.allowScopedLabels = allowScopedLabels;
state.allowSubEpics = allowSubEpics;
state.epicsWebUrl = epicsWebUrl;
state.isShowingLabels = isShowingLabels;
state.issuesWebUrl = issuesWebUrl;
},
[types.SET_INITIAL_PARENT_ITEM](state, data) {
......@@ -279,4 +287,7 @@ export default {
[types.RECEIVE_DESCENDANT_GROUPS_FAILURE](state) {
state.descendantGroupsFetchInProgress = false;
},
[types.SET_SHOW_LABELS](state, val) {
state.isShowingLabels = val;
},
};
......@@ -47,8 +47,12 @@ export default () => ({
showCreateIssueForm: false,
autoCompleteEpics: false,
autoCompleteIssues: false,
allowSubEpics: false,
allowIssuableHealthStatus: false,
allowScopedLabels: false,
allowSubEpics: false,
epicsWebUrl: '',
issuesWebUrl: '',
isShowingLabels: false,
removeItemModalProps: {
parentItem: {},
......
......@@ -51,6 +51,11 @@ export const applySorts = (array) => array.sort(sortChildren).sort(sortByState);
*/
export const formatChildItem = (item) => ({ ...item, pathIdSeparator: PathIdSeparator[item.type] });
export const extractLabels = (labels) =>
labels.nodes.map((labelNode) => ({
...labelNode,
}));
/**
* Returns formatted array of Epics that doesn't contain
* `edges`->`node` nesting
......@@ -63,6 +68,7 @@ export const extractChildEpics = (children) =>
...epicNode,
fullPath: epicNode.group.fullPath,
type: ChildType.Epic,
labels: extractLabels(epicNode.labels),
}),
);
......@@ -89,6 +95,7 @@ export const extractChildIssues = (issues) =>
...issueNode,
type: ChildType.Issue,
assignees: extractIssueAssignees(issueNode.assignees),
labels: extractLabels(issueNode.labels),
}),
);
......
......@@ -19,11 +19,13 @@ module EE
)
if parent.is_a?(Group)
data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable)
data[:confidential] = issuable.confidential
data[:epicLinksEndpoint] = group_epic_links_path(parent, issuable)
data[:epicsWebUrl] = group_epics_path(parent)
data[:fullPath] = parent.full_path
data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable)
data[:issuesWebUrl] = issues_group_path(parent)
data[:projectsEndpoint] = expose_path(api_v4_groups_projects_path(id: parent.id))
data[:confidential] = issuable.confidential
end
data
......
......@@ -5,9 +5,11 @@
- epic_reference = @epic.to_reference
- sub_epics_feature_available = @group.licensed_feature_available?(:subepics)
- issuable_health_status_feature_available = @group.licensed_feature_available?(:issuable_health_status)
- scoped_labels_feature_available = @group.licensed_feature_available?(:scoped_labels)
- allow_sub_epics = sub_epics_feature_available ? 'true' : 'false'
- allow_issuable_health_status = issuable_health_status_feature_available ? 'true' : 'false'
- allow_scoped_labels = scoped_labels_feature_available ? 'true' : 'false'
- add_to_breadcrumbs _("Epics"), group_epics_path(@group)
- breadcrumb_title epic_reference
......@@ -54,8 +56,9 @@
auto_complete_epics: allow_sub_epics,
auto_complete_issues: 'true',
user_signed_in: current_user.present? ? 'true' : 'false',
allow_sub_epics: allow_sub_epics,
allow_issuable_health_status: allow_issuable_health_status,
allow_scoped_labels: allow_scoped_labels,
allow_sub_epics: allow_sub_epics,
initial: issuable_initial_data(@epic).to_json } }
- if sub_epics_feature_available
#roadmap.tab-pane.gl-display-none
......
......@@ -5,6 +5,7 @@ import Vuex from 'vuex';
import EpicHealthStatus from 'ee/related_items_tree/components/epic_health_status.vue';
import EpicActionsSplitButton from 'ee/related_items_tree/components/epic_issue_actions_split_button.vue';
import RelatedItemsTreeHeader from 'ee/related_items_tree/components/related_items_tree_header.vue';
import ToggleLabels from 'ee/boards/components/toggle_labels.vue';
import createDefaultStore from 'ee/related_items_tree/store';
import * as epicUtils from 'ee/related_items_tree/utils/epic_utils';
......@@ -78,6 +79,16 @@ describe('RelatedItemsTree', () => {
});
});
describe('toggleLabels', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('toggle labels component is visible', () => {
expect(wrapper.find(ToggleLabels).isVisible()).toBe(true);
});
});
describe('epic issue actions split button', () => {
beforeEach(() => {
wrapper = createComponent();
......
import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import { GlButton, GlLabel, GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import ItemWeight from 'ee/boards/components/issue_card_weight.vue';
......@@ -39,6 +39,7 @@ const createIssueItem = (mockIssue = mockIssue1) => {
type: ChildType.Issue,
pathIdSeparator: PathIdSeparator.Issue,
assignees: epicUtils.extractIssueAssignees(mockIssue.assignees),
labels: epicUtils.extractLabels(mockIssue.labels),
};
};
......@@ -48,6 +49,7 @@ const createEpicItem = (mockEpic = mockOpenEpic, mockEpicMeta = mockEpicMeta1) =
type: ChildType.Epic,
pathIdSeparator: PathIdSeparator.Epic,
...mockEpicMeta,
labels: epicUtils.extractLabels(mockEpic.labels),
};
};
......@@ -80,9 +82,10 @@ describe('RelatedItemsTree', () => {
describe('TreeItemBody', () => {
let wrapper;
const findChildLabels = () => wrapper.findAll(GlLabel);
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 findIssueHealthStatus = () => wrapper.find('[data-testid="issue-health-status"]');
const findIssueIcon = () => wrapper.find({ ref: 'stateIconMd' });
const findLink = () => wrapper.findComponent(GlLink);
const enableHealthStatus = () => {
......@@ -91,6 +94,11 @@ describe('RelatedItemsTree', () => {
allowIssuableHealthStatus: true,
});
};
const setShowLabels = (isShowingLabels) => {
wrapper.vm.$store.dispatch('setShowLabels', isShowingLabels);
return nextTick();
};
beforeEach(() => {
mockItem = createIssueItem();
......@@ -180,6 +188,36 @@ describe('RelatedItemsTree', () => {
});
});
describe('when toggling labels on', () => {
it('returns true when `item.labels` is defined and has values', async () => {
expect(findChildLabels().length).toBe(0);
await setShowLabels(true);
const labels = findChildLabels();
expect(labels.length).toBe(1);
const firstLabel = labels.at(0);
expect(firstLabel.props('backgroundColor')).toBe(mockIssue1.labels.nodes[0].color);
expect(firstLabel.props('description')).toBe(mockIssue1.labels.nodes[0].description);
expect(firstLabel.props('title')).toBe(mockIssue1.labels.nodes[0].title);
});
});
describe('when toggling labels off', () => {
it('returns true when `item.labels` is defined and has values', async () => {
await setShowLabels(true);
expect(findChildLabels().length).toBe(1);
await setShowLabels(false);
expect(findChildLabels().length).toBe(0);
});
});
describe('stateText', () => {
it('returns string `Opened` when `item.state` value is `opened`', () => {
wrapper.setProps({
......@@ -316,6 +354,21 @@ describe('RelatedItemsTree', () => {
});
});
});
describe.each`
createItem | expectedFilterUrl | itemType
${createEpicItem} | ${`${mockInitialConfig.epicsWebUrl}?label_name[]=Label`} | ${'epic'}
${createIssueItem} | ${`${mockInitialConfig.issuesWebUrl}?label_name[]=Label`} | ${'issue'}
`('labelFilterUrl', ({ createItem, expectedFilterUrl, itemType }) => {
beforeEach(() => {
mockItem = createItem();
wrapper = createComponent();
});
it(`filterURL for ${itemType} should be ${expectedFilterUrl}`, () => {
expect(wrapper.vm.labelFilterUrl(mockItem.labels[0])).toBe(expectedFilterUrl);
});
});
});
describe('template', () => {
......
......@@ -9,6 +9,9 @@ export const mockInitialConfig = {
autoCompleteIssues: false,
userSignedIn: true,
allowSubEpics: true,
isShowingLabels: false,
epicsWebUrl: `${TEST_HOST}/groups/gitlab-org/-/epics`,
issuesWebUrl: `${TEST_HOST}/groups/gitlab-org/-/issues`,
};
export const mockParentItem = {
......@@ -36,6 +39,16 @@ export const mockParentItem = {
issuesAtRisk: 0,
issuesNeedingAttention: 1,
},
labels: {
nodes: [
{
color: '#ff0000',
description: 'Mock Label',
textColor: '#ffffff',
title: 'Label',
},
],
},
};
export const mockParentItem2 = {
......@@ -60,6 +73,16 @@ export const mockParentItem2 = {
issuesAtRisk: 0,
issuesNeedingAttention: 1,
},
labels: {
nodes: [
{
color: '#ff0000',
description: 'Mock Label',
textColor: '#ffffff',
title: 'Label',
},
],
},
};
export const mockEpic1 = {
......@@ -86,6 +109,16 @@ export const mockEpic1 = {
issuesNeedingAttention: 0,
issuesOnTrack: 0,
},
labels: {
nodes: [
{
color: '#ff0000',
description: 'Mock Label',
textColor: '#ffffff',
title: 'Label',
},
],
},
};
export const mockEpic2 = {
......@@ -112,6 +145,16 @@ export const mockEpic2 = {
issuesNeedingAttention: 0,
issuesOnTrack: 0,
},
labels: {
nodes: [
{
color: '#ff0000',
description: 'Mock Label',
textColor: '#ffffff',
title: 'Label',
},
],
},
};
// Epic meta data for having some open issues
......@@ -191,6 +234,16 @@ export const mockIssue1 = {
dueDate: '2019-06-30',
},
healthStatus: 'onTrack',
labels: {
nodes: [
{
color: '#ff0000',
description: 'Mock Label',
textColor: '#ffffff',
title: 'Label',
},
],
},
};
export const mockIssue2 = {
......@@ -211,6 +264,9 @@ export const mockIssue2 = {
},
milestone: null,
healthStatus: 'needsAttention',
labels: {
nodes: [],
},
};
export const mockClosedIssue = {
......@@ -231,6 +287,9 @@ export const mockClosedIssue = {
},
milestone: null,
healthStatus: 'atRisk',
labels: {
nodes: [],
},
};
export const mockEpics = [mockEpic1, mockEpic2];
......
......@@ -19,27 +19,29 @@ RSpec.describe IssuablesHelper do
@group = epic.group
expected_data = {
canAdmin: true,
canDestroy: true,
canUpdate: true,
confidential: epic.confidential,
endpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
epicLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/links",
updateEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json",
issueLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
canUpdate: true,
canDestroy: true,
canAdmin: true,
issuableRef: "&#{epic.iid}",
markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown",
markdownDocsPath: '/help/user/markdown',
issuableTemplateNamesPath: '',
lockVersion: epic.lock_version,
epicsWebUrl: "/groups/#{@group.full_path}/-/epics",
fullPath: @group.full_path,
groupPath: @group.path,
initialTitleHtml: epic.title,
initialTitleText: epic.title,
initialDescriptionHtml: '<p data-sourcepos="1:1-1:9" dir="auto">epic text</p>',
initialDescriptionText: 'epic text',
initialTaskStatus: '0 of 0 tasks completed',
initialTitleHtml: epic.title,
initialTitleText: epic.title,
issuableRef: "&#{epic.iid}",
issuableTemplateNamesPath: '',
issueLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
issuesWebUrl: "/groups/#{@group.full_path}/-/issues",
lockVersion: epic.lock_version,
markdownDocsPath: '/help/user/markdown',
markdownPreviewPath: "/groups/#{@group.full_path}/preview_markdown",
projectsEndpoint: "/api/v4/groups/#{@group.id}/projects",
confidential: epic.confidential
updateEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json"
}
expect(helper.issuable_initial_data(epic)).to eq(expected_data)
end
......
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