Commit c74f114e authored by Florie Guibert's avatar Florie Guibert Committed by Kushal Pandya

Display Swimlanes Epic Title

- Fetch epics on board swimlanes
- Display epic information
parent 7b4340f1
......@@ -75,10 +75,7 @@ export default {
ref="swimlanes"
:lists="lists"
:can-admin-list="canAdminList"
:group-id="groupId"
:disabled="disabled"
:issue-link-base="issueLinkBase"
:root-path="rootPath"
:board-id="boardId"
/>
</div>
......
......@@ -2,7 +2,6 @@
import {
GlButton,
GlButtonGroup,
GlDeprecatedButton,
GlLabel,
GlTooltip,
GlIcon,
......@@ -23,7 +22,6 @@ export default {
BoardDelete,
GlButtonGroup,
GlButton,
GlDeprecatedButton,
GlLabel,
GlTooltip,
GlIcon,
......@@ -89,9 +87,12 @@ export default {
return sprintf(__('%{issuesSize} issues'), { issuesSize });
},
caretTooltip() {
chevronTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
},
chevronIcon() {
return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
},
isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton;
},
......@@ -160,20 +161,16 @@ export default {
}"
class="board-title gl-m-0 gl-display-flex js-board-handle"
>
<div
<gl-button
v-if="list.isExpandable"
v-gl-tooltip.hover.bottom
:aria-label="caretTooltip"
:title="caretTooltip"
aria-hidden="true"
v-gl-tooltip.hover
:aria-label="chevronTooltip"
:title="chevronTooltip"
:icon="chevronIcon"
class="board-title-caret no-drag"
variant="link"
@click="toggleExpanded"
>
<i
:class="{ 'fa-caret-right': list.isExpanded, 'fa-caret-down': !list.isExpanded }"
class="fa fa-fw"
></i>
</div>
/>
<!-- The following is only true in EE and if it is a milestone -->
<span
v-if="list.type === 'milestone' && list.milestone"
......@@ -232,7 +229,7 @@ export default {
v-gl-tooltip.hover.bottom
:class="{ 'gl-display-none': !list.isExpanded }"
:aria-label="__('Delete list')"
class="board-delete no-drag gl-pr-0 gl-shadow-none"
class="board-delete no-drag gl-pr-0 gl-shadow-none gl-mr-3"
:title="__('Delete list')"
icon="remove"
size="small"
......@@ -263,32 +260,30 @@ export default {
v-if="isNewIssueShown || isSettingsShown"
class="board-list-button-group pl-2"
>
<gl-deprecated-button
<gl-button
v-if="isNewIssueShown"
ref="newIssueBtn"
v-gl-tooltip.hover
:class="{
'gl-display-none': !list.isExpanded,
}"
:aria-label="__(`New issue`)"
:aria-label="__('New issue')"
:title="__('New issue')"
class="issue-count-badge-add-button no-drag"
type="button"
icon="plus"
@click="showNewIssueForm"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-plus"></i>
</gl-deprecated-button>
<gl-tooltip :target="() => $refs.newIssueBtn">{{ __('New Issue') }}</gl-tooltip>
/>
<gl-deprecated-button
<gl-button
v-if="isSettingsShown"
ref="settingsBtn"
:aria-label="__(`List settings`)"
v-gl-tooltip.hover
:aria-label="__('List settings')"
class="no-drag js-board-settings-button"
title="List settings"
type="button"
:title="__('List settings')"
icon="settings"
@click="openSidebarSettings"
>
<gl-icon name="settings" />
</gl-deprecated-button>
/>
<gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
</gl-button-group>
</h3>
......
......@@ -144,7 +144,9 @@ const mixins = {
return 'merge-request-status closed issue-token-state-icon-closed';
}
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
return this.isOpen
? 'issue-token-state-icon-open gl-text-green-500'
: 'issue-token-state-icon-closed gl-text-blue-500';
},
computedLinkElementType() {
return this.path.length > 0 ? 'a' : 'span';
......
......@@ -34,14 +34,6 @@ $item-remove-button-space: 42px;
position: relative;
line-height: $gl-line-height;
.issue-token-state-icon-open {
color: $green-500;
}
.issue-token-state-icon-closed {
color: $blue-500;
}
.merge-request-status.closed {
color: $red-500;
}
......
......@@ -84,17 +84,22 @@
.board-title-caret {
cursor: pointer;
border-radius: $border-radius-default;
padding: 4px;
line-height: $gl-spacing-scale-5;
height: $gl-spacing-scale-5;
&.btn svg {
top: 0;
}
&:hover {
background-color: $gray-dark;
background-color: $gray-50;
transition: background-color 0.1s linear;
}
}
&:not(.is-collapsed) {
.board-title-caret {
margin: 0 $gl-padding-4 0 -10px;
margin-right: $gl-padding-4;
}
}
......@@ -155,7 +160,7 @@
.board-inner {
font-size: $issue-boards-font-size;
background: $gray-light;
border: 1px solid $border-color;
border: 1px solid $gray-100;
}
.board-header {
......@@ -186,8 +191,8 @@
.board-title {
align-items: center;
font-size: 1em;
border-bottom: 1px solid $border-color;
padding: $gl-padding-8 $gl-padding;
border-bottom: 1px solid $gray-100;
padding: $gl-padding-8;
.js-max-issue-size::before {
content: '/';
......@@ -199,7 +204,6 @@
}
.board-delete {
margin-right: 10px;
color: $gray-darkest;
background-color: transparent;
outline: 0;
......@@ -247,7 +251,7 @@
.board-card {
background: $white;
border: 1px solid $gray-200;
border: 1px solid $gray-100;
box-shadow: 0 1px 2px $issue-boards-card-shadow;
line-height: $gl-padding;
list-style: none;
......
---
title: Update board header icons
merge_request: 34366
author:
type: changed
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
epic: {
type: Object,
required: true,
},
},
computed: {
stateText() {
return this.epic.state === 'opened' ? __('Opened') : __('Closed');
},
stateIconClass() {
return this.epic.state === 'opened' ? 'gl-text-green-500' : 'gl-text-blue-500';
},
issuesCount() {
const { openedIssues, closedIssues } = this.epic.descendantCounts;
return openedIssues + closedIssues;
},
issuesCountTooltipText() {
return sprintf(__(`%{issuesCount} issues in this group`), { issuesCount: this.issuesCount });
},
},
};
</script>
<template>
<div class="board-epic-lane gl-py-5 gl-px-3 gl-display-flex gl-align-items-center">
<gl-icon
class="gl-mr-2 gl-flex-shrink-0"
:class="stateIconClass"
name="epic"
:aria-label="stateText"
/>
<span
v-gl-tooltip.hover
:title="epic.title"
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
>
{{ epic.title }}
</span>
<span
v-gl-tooltip.hover
:title="issuesCountTooltipText"
class="gl-display-flex gl-align-items-center gl-text-gray-700"
tabindex="0"
:aria-label="issuesCountTooltipText"
data-testid="epic-lane-issue-count"
>
<gl-icon class="gl-mr-2 gl-flex-shrink-0" name="issues" aria-hidden="true" />
<span aria-hidden="true">{{ issuesCount }}</span>
</span>
</div>
</template>
<script>
import { mapState } from 'vuex';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import EpicLane from './epic_lane.vue';
export default {
components: {
BoardListHeader,
EpicLane,
},
props: {
lists: {
......@@ -14,14 +17,6 @@ export default {
type: Boolean,
required: true,
},
issueLinkBase: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
boardId: {
type: String,
required: true,
......@@ -31,11 +26,9 @@ export default {
required: false,
default: false,
},
groupId: {
type: Number,
required: false,
default: null,
},
},
computed: {
...mapState(['epics']),
},
};
</script>
......@@ -62,5 +55,6 @@ export default {
:is-swimlanes-header="true"
/>
</div>
<epic-lane v-for="epic in epics" :key="epic.id" :epic="epic" />
</div>
</template>
query groupEpics($fullPath: ID!) {
group(fullPath: $fullPath) {
epics(first: 10) {
nodes {
id
iid
title
state
webUrl
descendantCounts {
openedIssues
closedIssues
}
}
}
}
}
......@@ -5,6 +5,7 @@ import * as types from './mutation_types';
import createDefaultClient from '~/lib/graphql';
import epicsSwimlanes from '../queries/epics_swimlanes.query.graphql';
import groupEpics from '../queries/group_epics.query.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -32,6 +33,25 @@ const fetchEpicsSwimlanes = ({ endpoints }) => {
});
};
const fetchEpics = ({ endpoints }) => {
const { fullPath } = endpoints;
const query = groupEpics;
const variables = {
fullPath,
};
return gqlClient
.query({
query,
variables,
})
.then(({ data }) => {
const { group } = data;
return group?.epics.nodes || [];
});
};
export default {
...actionsCE,
......@@ -80,9 +100,15 @@ export default {
commit(types.TOGGLE_EPICS_SWIMLANES);
if (state.isShowingEpicsSwimlanes) {
fetchEpicsSwimlanes(state)
.then(swimlanes => {
dispatch('receiveSwimlanesSuccess', swimlanes);
Promise.all([fetchEpicsSwimlanes(state), fetchEpics(state)])
.then(([swimlanes, epics]) => {
if (swimlanes) {
dispatch('receiveSwimlanesSuccess', swimlanes);
}
if (epics) {
dispatch('receiveEpicsSuccess', epics);
}
})
.catch(() => dispatch('receiveSwimlanesFailure'));
}
......@@ -95,4 +121,8 @@ export default {
receiveSwimlanesFailure: ({ commit }) => {
commit(types.RECEIVE_SWIMLANES_FAILURE);
},
receiveEpicsSuccess: ({ commit }, swimlanes) => {
commit(types.RECEIVE_EPICS_SUCCESS, swimlanes);
},
};
......@@ -15,4 +15,5 @@ export const TOGGLE_LABELS = 'TOGGLE_LABELS';
export const TOGGLE_EPICS_SWIMLANES = 'TOGGLE_EPICS_SWIMLANES';
export const RECEIVE_SWIMLANES_SUCCESS = 'RECEIVE_SWIMLANES_SUCCESS';
export const RECEIVE_SWIMLANES_FAILURE = 'RECEIVE_SWIMLANES_FAILURE';
export const RECEIVE_EPICS_SUCCESS = 'RECEIVE_EPICS_SUCCESS';
export const SET_ACTIVE_LIST_ID = 'SET_ACTIVE_LIST_ID';
......@@ -81,4 +81,8 @@ export default {
state.epicsSwimlanesFetchFailure = true;
state.epicsSwimlanesFetchInProgress = false;
},
[mutationTypes.RECEIVE_EPICS_SUCCESS]: (state, epics) => {
state.epics = epics;
},
};
......@@ -7,4 +7,5 @@ export default () => ({
epicsSwimlanesFetchInProgress: false,
epicsSwimlanesFetchFailure: false,
epicsSwimlanes: {},
epics: {},
});
......@@ -83,7 +83,9 @@ export default {
return this.item.type === ChildType.Epic ? 'epic' : 'issues';
},
stateIconClass() {
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
return this.isOpen
? 'issue-token-state-icon-open gl-text-green-500'
: 'issue-token-state-icon-closed gl-text-blue-500';
},
itemId() {
return this.itemReference.split(this.item.pathIdSeparator).pop();
......
import { shallowMount } from '@vue/test-utils';
import EpicLane from 'ee/boards/components/epic_lane.vue';
import { GlIcon } from '@gitlab/ui';
import { mockEpic } from '../mock_data';
describe('EpicLane', () => {
let wrapper;
const defaultProps = { epic: mockEpic };
const createComponent = (props = {}) => {
wrapper = shallowMount(EpicLane, {
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('icon aria label is Opened when epic is opened', () => {
expect(wrapper.find(GlIcon).attributes('aria-label')).toEqual('Opened');
});
it('icon aria label is Closed when epic is closed', () => {
createComponent({ epic: { ...mockEpic, state: 'closed' } });
expect(wrapper.find(GlIcon).attributes('aria-label')).toEqual('Closed');
});
it('displays total count of issues in epic', () => {
expect(wrapper.find('[data-testid="epic-lane-issue-count"]').text()).toContain(5);
});
it('displays 2 icons', () => {
expect(wrapper.findAll(GlIcon).length).toEqual(2);
});
it('displays epic title', () => {
expect(wrapper.text()).toContain(mockEpic.title);
});
});
});
export const mockSwimlanes = [
{
id: 'gid://gitlab/List/1',
title: 'Backlog',
position: null,
listType: 'backlog',
collapsed: false,
label: null,
maxIssueCount: 0,
assignee: null,
milestone: null,
},
{
id: 'gid://gitlab/List/10',
title: 'To Do',
position: 0,
listType: 'label',
collapsed: false,
label: {
id: 'gid://gitlab/GroupLabel/121',
title: 'To Do',
color: '#F0AD4E',
textColor: '#FFFFFF',
description: null,
},
maxIssueCount: 0,
assignee: null,
milestone: null,
},
];
const defaultDescendantCounts = {
openedIssues: 0,
closedIssues: 0,
};
export const mockEpic = {
id: 1,
iid: 1,
title: 'Epic title',
state: 'opened',
webUrl: '/groups/gitlab-org/-/epics/1',
descendantCounts: {
openedIssues: 3,
closedIssues: 2,
},
};
export const mockEpics = [
{
id: 41,
iid: 2,
description: null,
title: 'Another marketing',
group_id: 56,
group_name: 'Marketing',
group_full_name: 'Gitlab Org / Marketing',
start_date: '2017-12-26',
end_date: '2018-03-10',
web_url: '/groups/gitlab-org/marketing/-/epics/2',
descendantCounts: defaultDescendantCounts,
hasParent: true,
parent: {
id: '40',
},
},
{
id: 40,
iid: 1,
description: null,
title: 'Marketing epic',
group_id: 56,
group_name: 'Marketing',
group_full_name: 'Gitlab Org / Marketing',
start_date: '2017-12-25',
end_date: '2018-03-09',
web_url: '/groups/gitlab-org/marketing/-/epics/1',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 39,
iid: 12,
description: null,
title: 'Epic with end in first timeframe month',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2017-04-02',
end_date: '2017-11-30',
web_url: '/groups/gitlab-org/-/epics/12',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 38,
iid: 11,
description: null,
title: 'Epic with end date out of range',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2018-01-15',
end_date: '2020-01-03',
web_url: '/groups/gitlab-org/-/epics/11',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
{
id: 37,
iid: 10,
description: null,
title: 'Epic with timeline in same month',
group_id: 2,
group_name: 'Gitlab Org',
group_full_name: 'Gitlab Org',
start_date: '2018-01-01',
end_date: '2018-01-31',
web_url: '/groups/gitlab-org/-/epics/10',
descendantCounts: defaultDescendantCounts,
hasParent: false,
},
];
import mutations from 'ee/boards/stores/mutations';
import { inactiveListId } from '~/boards/constants';
import { mockSwimlanes, mockEpics } from '../mock_data';
const expectNotImplemented = action => {
it('is not implemented', () => {
......@@ -114,4 +115,54 @@ describe('TOGGLE_EPICS_SWIMLANES', () => {
expect(state.isShowingEpicsSwimlanes).toBe(true);
});
it('sets epicsSwimlanesFetchInProgress to true', () => {
const state = {
epicsSwimlanesFetchInProgress: false,
};
mutations.TOGGLE_EPICS_SWIMLANES(state);
expect(state.epicsSwimlanesFetchInProgress).toBe(true);
});
});
describe('RECEIVE_SWIMLANES_SUCCESS', () => {
it('sets epicsSwimlanesFetchInProgress to false and populates epicsSwimlanes with payload', () => {
const state = {
epicsSwimlanesFetchInProgress: true,
epicsSwimlanes: {},
};
mutations.RECEIVE_SWIMLANES_SUCCESS(state, mockSwimlanes);
expect(state.epicsSwimlanesFetchInProgress).toBe(false);
expect(state.epicsSwimlanes).toEqual(mockSwimlanes);
});
});
describe('RECEIVE_SWIMLANES_FAILURE', () => {
it('sets epicsSwimlanesFetchInProgress to false and epicsSwimlanesFetchFailure to true', () => {
const state = {
epicsSwimlanesFetchInProgress: true,
epicsSwimlanesFetchFailure: false,
};
mutations.RECEIVE_SWIMLANES_FAILURE(state);
expect(state.epicsSwimlanesFetchInProgress).toBe(false);
expect(state.epicsSwimlanesFetchFailure).toBe(true);
});
});
describe('RECEIVE_EPICS_SUCCESS', () => {
it('populates epics with payload', () => {
const state = {
epics: {},
};
mutations.RECEIVE_EPICS_SUCCESS(state, mockEpics);
expect(state.epics).toEqual(mockEpics);
});
});
......@@ -191,23 +191,25 @@ describe('RelatedItemsTree', () => {
});
describe('stateIconClass', () => {
it('returns string `issue-token-state-icon-open` when `item.state` value is `opened`', () => {
it('returns string `issue-token-state-icon-open gl-text-green-500` when `item.state` value is `opened`', () => {
wrapper.setProps({
item: { ...mockItem, state: ChildState.Open },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconClass).toBe('issue-token-state-icon-open');
expect(wrapper.vm.stateIconClass).toBe('issue-token-state-icon-open gl-text-green-500');
});
});
it('returns string `issue-token-state-icon-closed` when `item.state` value is `closed`', () => {
it('returns string `issue-token-state-icon-closed gl-text-blue-500` when `item.state` value is `closed`', () => {
wrapper.setProps({
item: { ...mockItem, state: ChildState.Closed },
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.stateIconClass).toBe('issue-token-state-icon-closed');
expect(wrapper.vm.stateIconClass).toBe(
'issue-token-state-icon-closed gl-text-blue-500',
);
});
});
});
......
......@@ -380,6 +380,9 @@ msgstr ""
msgid "%{issuableType} will be removed! Are you sure?"
msgstr ""
msgid "%{issuesCount} issues in this group"
msgstr ""
msgid "%{issuesSize} issues"
msgstr ""
......
......@@ -107,7 +107,7 @@ describe('Board List Header Component', () => {
createComponent();
expect(isCollapsed()).toBe(false);
wrapper.find('[data-testid="board-list-header"]').trigger('click');
wrapper.find('[data-testid="board-list-header"]').vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(false);
......@@ -118,7 +118,7 @@ describe('Board List Header Component', () => {
createComponent();
expect(isExpanded()).toBe(true);
findCaret().trigger('click');
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(true);
......@@ -129,7 +129,7 @@ describe('Board List Header Component', () => {
createComponent({ collapsed: true });
expect(isCollapsed()).toBe(true);
findCaret().trigger('click');
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(false);
......@@ -142,7 +142,7 @@ describe('Board List Header Component', () => {
createComponent({ withLocalStorage: false });
findCaret().trigger('click');
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
......@@ -155,7 +155,7 @@ describe('Board List Header Component', () => {
createComponent();
findCaret().trigger('click');
findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.list.update).not.toHaveBeenCalled();
......
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