Commit 4e821852 authored by Florie Guibert's avatar Florie Guibert Committed by Kushal Pandya

Swimlanes - Display issues

- Add issues in lane for issues not assigned to an epic
- Display issues under epics
parent 9cbb10d6
......@@ -54,7 +54,7 @@ export default {
<div>
<div
v-if="!isSwimlanesOn"
class="boards-list w-100 py-3 px-2 text-nowrap"
class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap"
data-qa-selector="boards_list"
>
<board-column
......
......@@ -94,7 +94,8 @@
margin-bottom: 16px;
}
.boards-list {
.boards-list,
.board-swimlanes {
height: calc(100vh - #{$header-height + $breadcrumb-min-height + $performance-bar-height + $system-footer-height + $gl-padding-32});
}
}
......
......@@ -45,7 +45,8 @@
}
}
.boards-list {
.boards-list,
.board-swimlanes {
height: calc(100vh - #{$issue-board-list-difference-xs});
overflow-x: scroll;
min-height: 200px;
......@@ -576,29 +577,8 @@
}
}
.board-epics-swimlanes {
.board-swimlanes {
overflow-x: auto;
min-height: calc(100vh - #{$issue-board-list-difference-xs});
@include media-breakpoint-only(sm) {
min-height: calc(100vh - #{$issue-board-list-difference-sm});
}
@include media-breakpoint-up(md) {
min-height: calc(100vh - #{$issue-board-list-difference-md});
}
.with-performance-bar & {
min-height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height});
@include media-breakpoint-only(sm) {
min-height: calc(100vh - #{$issue-board-list-difference-sm} - #{$performance-bar-height});
}
@include media-breakpoint-up(md) {
min-height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height});
}
}
}
.board-header-collapsed-info-icon:hover {
......
......@@ -4,12 +4,14 @@ import { __, n__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { formatDate } from '~/lib/utils/datetime_utility';
import { statusType } from '../../epic/constants';
import IssuesLaneList from './issues_lane_list.vue';
export default {
components: {
GlIcon,
GlLink,
GlPopover,
IssuesLaneList,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -20,6 +22,10 @@ export default {
type: Object,
required: true,
},
lists: {
type: Array,
required: true,
},
},
computed: {
isOpen() {
......@@ -54,41 +60,58 @@ export default {
return formatDate(this.epic.createdAt);
},
},
methods: {
epicIssuesForList(listIssues) {
return this.epic.issues.filter(epicIssue =>
Boolean(listIssues.find(listIssue => String(listIssue.iid) === epicIssue.iid)),
);
},
},
};
</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="epicIcon"
:aria-label="stateText"
/>
<span
ref="epicTitle"
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
>
{{ epic.title }}
</span>
<gl-popover :target="() => $refs.epicTitle" triggers="hover" placement="top">
<template #title
>{{ epic.title }} &middot; {{ epic.reference }}</template
<div>
<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="epicIcon"
:aria-label="stateText"
/>
<span
ref="epicTitle"
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
>
{{ epic.title }}
</span>
<gl-popover :target="() => $refs.epicTitle" triggers="hover" placement="top">
<template #title
>{{ epic.title }} &middot; {{ epic.reference }}</template
>
<p class="gl-m-0">{{ epicTimeAgoString }}</p>
<p class="gl-mb-2">{{ epicDateString }}</p>
<gl-link :href="epic.webUrl" class="gl-font-sm">{{ __('Go to epic') }}</gl-link>
</gl-popover>
<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"
>
<p class="gl-m-0">{{ epicTimeAgoString }}</p>
<p class="gl-mb-2">{{ epicDateString }}</p>
<gl-link :href="epic.webUrl" class="gl-font-sm">{{ __('Go to epic') }}</gl-link>
</gl-popover>
<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>
<gl-icon class="gl-mr-2 gl-flex-shrink-0" name="issues" aria-hidden="true" />
<span aria-hidden="true">{{ issuesCount }}</span>
</span>
</div>
<div class="gl-display-flex">
<issues-lane-list
v-for="list in lists"
:key="`${list.id}-issues`"
:list="list"
:issues="epicIssuesForList(list.issues)"
/>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { n__ } from '~/locale';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import EpicLane from './epic_lane.vue';
import IssuesLaneList from './issues_lane_list.vue';
export default {
components: {
BoardListHeader,
EpicLane,
IssuesLaneList,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
lists: {
......@@ -29,13 +37,19 @@ export default {
},
computed: {
...mapState(['epics']),
issuesCount() {
return this.lists.reduce((total, list) => total + list.issues.length, 0);
},
issuesCountTooltipText() {
return n__(`%d unassigned issue`, `%d unassigned issues`, this.issuesCount);
},
},
};
</script>
<template>
<div
class="board-epics-swimlanes gl-white-space-nowrap gl-py-5 gl-px-3"
class="board-swimlanes gl-white-space-nowrap gl-py-5 gl-px-3"
data_qa_selector="board_epics_swimlanes"
>
<div
......@@ -55,13 +69,36 @@ export default {
:is-swimlanes-header="true"
/>
</div>
<epic-lane v-for="epic in epics" :key="epic.id" :epic="epic" />
<div class="board-lane-unassigned-issue gl-py-5 gl-px-3 gl-display-flex gl-align-items-center">
<span
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
<div class="board-epics-swimlanes">
<epic-lane v-for="epic in epics" :key="epic.id" :epic="epic" :lists="lists" />
<div
class="board-lane-unassigned-issues gl-py-5 gl-px-3 gl-display-flex gl-align-items-center"
>
{{ __('Issues with no epics assigned') }}
</span>
<span
class="gl-mr-3 gl-font-weight-bold gl-white-space-nowrap gl-text-overflow-ellipsis gl-overflow-hidden"
>
{{ __('Issues with no epic assigned') }}
</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="issues-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>
<div class="gl-display-flex">
<issues-lane-list
v-for="list in lists"
:key="`${list.id}-issues`"
:list="list"
:issues="list.issues"
/>
</div>
</div>
</div>
</template>
<script>
import BoardCard from '~/boards/components/board_card.vue';
export default {
components: {
BoardCard,
},
props: {
list: {
type: Object,
required: true,
},
issues: {
type: Array,
required: true,
},
},
};
</script>
<template>
<div
class="board gl-px-3 gl-vertical-align-top gl-white-space-normal gl-display-flex gl-flex-shrink-0 is-expandable"
:class="{ 'is-collapsed': !list.isExpanded }"
>
<div class="board-inner gl-p-2 gl-rounded-base gl-relative gl-w-full">
<ul v-if="list.isExpanded" class="gl-p-0 gl-m-0">
<board-card
v-for="(issue, index) in issues"
ref="issue"
:key="issue.id"
:index="index"
:list="list"
:issue="issue"
/>
</ul>
</div>
</div>
</template>
......@@ -14,6 +14,37 @@ query groupEpicsEE($fullPath: ID!) {
openedIssues
closedIssues
}
issues {
nodes {
id
iid
title
referencePath: reference
dueDate
timeEstimate
weight
confidential
path: webUrl
assignees {
nodes {
id
username
name
avatar: avatarUrl
webUrl
}
}
labels {
nodes {
id
title
color
description
}
}
}
}
}
}
}
......
......@@ -48,7 +48,15 @@ const fetchEpics = ({ endpoints }) => {
})
.then(({ data }) => {
const { group } = data;
return group?.epics.nodes || [];
const epics = group?.epics.nodes || [];
return epics.map(e => ({
...e,
issues: (e?.issues?.nodes || []).map(i => ({
...i,
labels: i.labels?.nodes || [],
assignees: i.assignees?.nodes || [],
})),
}));
});
};
......
import Vue from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { shallowMount } from '@vue/test-utils';
import EpicLane from 'ee/boards/components/epic_lane.vue';
import IssuesLaneList from 'ee/boards/components/issues_lane_list.vue';
import { GlIcon } from '@gitlab/ui';
import { mockEpic } from '../mock_data';
import { mockEpic, mockLists, mockIssues } from '../mock_data';
import List from '~/boards/models/list';
import { TEST_HOST } from 'helpers/test_constants';
describe('EpicLane', () => {
let wrapper;
let axiosMock;
const defaultProps = { epic: mockEpic };
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: mockIssues });
});
const createComponent = (props = {}) => {
const defaultProps = {
epic: mockEpic,
lists: mockLists.map(listMock => Vue.observable(new List(listMock))),
};
wrapper = shallowMount(EpicLane, {
propsData: {
...defaultProps,
......@@ -18,6 +33,7 @@ describe('EpicLane', () => {
};
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
});
......@@ -40,11 +56,15 @@ describe('EpicLane', () => {
});
it('displays 2 icons', () => {
expect(wrapper.findAll(GlIcon).length).toEqual(2);
expect(wrapper.findAll(GlIcon)).toHaveLength(2);
});
it('displays epic title', () => {
expect(wrapper.text()).toContain(mockEpic.title);
});
it('renders one IssuesLaneList component per list passed in props', () => {
expect(wrapper.findAll(IssuesLaneList)).toHaveLength(wrapper.props('lists').length);
});
});
});
import Vue from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { shallowMount } from '@vue/test-utils';
import IssuesLaneList from 'ee/boards/components/issues_lane_list.vue';
import BoardCard from '~/boards/components/board_card.vue';
import { mockIssues } from '../mock_data';
import List from '~/boards/models/list';
import { ListType } from '~/boards/constants';
import { listObj } from 'jest/boards/mock_data';
import { TEST_HOST } from 'helpers/test_constants';
describe('IssuesLaneList', () => {
let wrapper;
let axiosMock;
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
});
const createComponent = ({
listType = ListType.backlog,
collapsed = false,
withLocalStorage = true,
} = {}) => {
const boardId = '1';
const listMock = {
...listObj,
list_type: listType,
collapsed,
};
if (listType === ListType.assignee) {
delete listMock.label;
listMock.user = {};
}
// Making List reactive
const list = Vue.observable(new List(listMock));
if (withLocalStorage) {
localStorage.setItem(
`boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(),
);
}
wrapper = shallowMount(IssuesLaneList, {
propsData: {
list,
issues: mockIssues,
},
});
};
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
localStorage.clear();
});
describe('if list is expanded', () => {
beforeEach(() => {
createComponent();
});
it('does not have is-collapsed class', () => {
expect(wrapper.classes('is-collapsed')).toBe(false);
});
it('renders one BoardCard component per issue passed in props', () => {
expect(wrapper.findAll(BoardCard)).toHaveLength(wrapper.props('issues').length);
});
});
describe('if list is collapsed', () => {
beforeEach(() => {
createComponent({ collapsed: true });
});
it('has is-collapsed class', () => {
expect(wrapper.classes('is-collapsed')).toBe(true);
});
it('does not renders BoardCard components', () => {
expect(wrapper.findAll(BoardCard)).toHaveLength(0);
});
});
});
export const mockSwimlanes = [
export const mockLists = [
{
id: 'gid://gitlab/List/1',
id: 1,
title: 'Backlog',
position: null,
listType: 'backlog',
......@@ -11,7 +11,7 @@ export const mockSwimlanes = [
milestone: null,
},
{
id: 'gid://gitlab/List/10',
id: 10,
title: 'To Do',
position: 0,
listType: 'label',
......@@ -34,6 +34,56 @@ const defaultDescendantCounts = {
closedIssues: 0,
};
const assignees = [
{
id: 'gid://gitlab/User/2',
username: 'angelina.herman',
name: 'Bernardina Bosco',
avatar: 'https://www.gravatar.com/avatar/eb7b664b13a30ad9f9ba4b61d7075470?s=80&d=identicon',
webUrl: 'http://127.0.0.1:3000/angelina.herman',
},
];
const labels = [
{
id: 'gid://gitlab/GroupLabel/5',
title: 'Cosync',
color: '#34ebec',
description: null,
},
];
const mockIssue = {
id: 'gid://gitlab/Issue/436',
iid: 27,
title: 'Issue 1',
referencePath: '#27',
dueDate: null,
timeEstimate: 0,
weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/27',
assignees,
labels,
};
export const mockIssues = [
mockIssue,
{
id: 'gid://gitlab/Issue/437',
iid: 28,
title: 'Issue 2',
referencePath: '#28',
dueDate: null,
timeEstimate: 0,
weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
assignees,
labels,
},
];
export const mockEpic = {
id: 1,
iid: 1,
......@@ -44,6 +94,7 @@ export const mockEpic = {
openedIssues: 3,
closedIssues: 2,
},
issues: [mockIssue],
};
export const mockEpics = [
......
import mutations from 'ee/boards/stores/mutations';
import { inactiveListId } from '~/boards/constants';
import { mockSwimlanes, mockEpics } from '../mock_data';
import { mockLists, mockEpics } from '../mock_data';
const expectNotImplemented = action => {
it('is not implemented', () => {
......@@ -134,10 +134,10 @@ describe('RECEIVE_SWIMLANES_SUCCESS', () => {
epicsSwimlanes: {},
};
mutations.RECEIVE_SWIMLANES_SUCCESS(state, mockSwimlanes);
mutations.RECEIVE_SWIMLANES_SUCCESS(state, mockLists);
expect(state.epicsSwimlanesFetchInProgress).toBe(false);
expect(state.epicsSwimlanes).toEqual(mockSwimlanes);
expect(state.epicsSwimlanes).toEqual(mockLists);
});
});
......
......@@ -262,6 +262,11 @@ msgid_plural "%d tags"
msgstr[0] ""
msgstr[1] ""
msgid "%d unassigned issue"
msgid_plural "%d unassigned issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d unresolved thread"
msgid_plural "%d unresolved threads"
msgstr[0] ""
......@@ -12785,7 +12790,7 @@ msgstr ""
msgid "Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities"
msgstr ""
msgid "Issues with no epics assigned"
msgid "Issues with no epic assigned"
msgstr ""
msgid "Issues, merge requests, pushes, and comments."
......
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