Commit 2dd6e24c authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'iteration-lists-frontend' into 'master'

Iteration lists frontend

See merge request gitlab-org/gitlab!52178
parents 406642f7 d1d2eb67
...@@ -86,16 +86,16 @@ export default { ...@@ -86,16 +86,16 @@ export default {
return !this.disabled && this.listType !== ListType.closed; return !this.disabled && this.listType !== ListType.closed;
}, },
showMilestoneListDetails() { showMilestoneListDetails() {
return ( return this.listType === ListType.milestone && this.list.milestone && this.showListDetails;
this.listType === ListType.milestone &&
this.list.milestone &&
(!this.list.collapsed || !this.isSwimlanesHeader)
);
}, },
showAssigneeListDetails() { showAssigneeListDetails() {
return ( return this.listType === ListType.assignee && this.showListDetails;
this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader) },
); showIterationListDetails() {
return this.listType === ListType.iteration && this.showListDetails;
},
showListDetails() {
return !this.list.collapsed || !this.isSwimlanesHeader;
}, },
issuesCount() { issuesCount() {
return this.list.issuesCount; return this.list.issuesCount;
...@@ -218,6 +218,17 @@ export default { ...@@ -218,6 +218,17 @@ export default {
<gl-icon name="timer" /> <gl-icon name="timer" />
</span> </span>
<span
v-if="showIterationListDetails"
aria-hidden="true"
:class="{
'gl-mt-3 gl-rotate-90': list.collapsed,
'gl-mr-2': !list.collapsed,
}"
>
<gl-icon name="iteration" />
</span>
<a <a
v-if="showAssigneeListDetails" v-if="showAssigneeListDetails"
:href="list.assignee.webUrl" :href="list.assignee.webUrl"
......
...@@ -78,14 +78,16 @@ export default { ...@@ -78,14 +78,16 @@ export default {
return !this.disabled && this.listType !== ListType.closed; return !this.disabled && this.listType !== ListType.closed;
}, },
showMilestoneListDetails() { showMilestoneListDetails() {
return ( return this.list.type === 'milestone' && this.list.milestone && this.showListDetails;
this.list.type === 'milestone' &&
this.list.milestone &&
(this.list.isExpanded || !this.isSwimlanesHeader)
);
}, },
showAssigneeListDetails() { showAssigneeListDetails() {
return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader); return this.list.type === 'assignee' && this.showListDetails;
},
showIterationListDetails() {
return this.listType === ListType.iteration && this.showListDetails;
},
showListDetails() {
return this.list.isExpanded || !this.isSwimlanesHeader;
}, },
issuesCount() { issuesCount() {
return this.list.issuesSize; return this.list.issuesSize;
...@@ -203,6 +205,17 @@ export default { ...@@ -203,6 +205,17 @@ export default {
<gl-icon name="timer" /> <gl-icon name="timer" />
</span> </span>
<span
v-if="showIterationListDetails"
aria-hidden="true"
:class="{
'gl-mt-3 gl-rotate-90': !list.isExpanded,
'gl-mr-2': list.isExpanded,
}"
>
<gl-icon name="iteration" />
</span>
<a <a
v-if="showAssigneeListDetails" v-if="showAssigneeListDetails"
:href="list.assignee.path" :href="list.assignee.path"
......
...@@ -5,17 +5,13 @@ import { __ } from '~/locale'; ...@@ -5,17 +5,13 @@ import { __ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import { isScopedLabel } from '~/lib/utils/common_utils'; import { isScopedLabel } from '~/lib/utils/common_utils';
import { LIST } from '~/boards/constants'; import { LIST, ListType, ListTypeTitles } from '~/boards/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
// NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. // NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options.
export default { export default {
headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px', headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px',
listSettingsText: __('List settings'), listSettingsText: __('List settings'),
assignee: 'assignee',
milestone: 'milestone',
label: 'label',
labelListText: __('Label'),
components: { components: {
GlButton, GlButton,
GlDrawer, GlDrawer,
...@@ -33,6 +29,11 @@ export default { ...@@ -33,6 +29,11 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
ListType,
};
},
computed: { computed: {
...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']),
...mapState(['activeId', 'sidebarType', 'boardLists']), ...mapState(['activeId', 'sidebarType', 'boardLists']),
...@@ -56,7 +57,7 @@ export default { ...@@ -56,7 +57,7 @@ export default {
return this.activeList.type || this.activeList.listType || null; return this.activeList.type || this.activeList.listType || null;
}, },
listTypeTitle() { listTypeTitle() {
return this.$options.labelListText; return ListTypeTitles[ListType.label];
}, },
showSidebar() { showSidebar() {
return this.sidebarType === LIST; return this.sidebarType === LIST;
...@@ -98,7 +99,7 @@ export default { ...@@ -98,7 +99,7 @@ export default {
> >
<template #header>{{ $options.listSettingsText }}</template> <template #header>{{ $options.listSettingsText }}</template>
<template v-if="isSidebarOpen"> <template v-if="isSidebarOpen">
<div v-if="boardListType === $options.label"> <div v-if="boardListType === ListType.label">
<label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label>
<gl-label <gl-label
:title="activeListLabel.title" :title="activeListLabel.title"
......
import { __ } from '~/locale';
export const BoardType = { export const BoardType = {
project: 'project', project: 'project',
group: 'group', group: 'group',
...@@ -6,11 +8,19 @@ export const BoardType = { ...@@ -6,11 +8,19 @@ export const BoardType = {
export const ListType = { export const ListType = {
assignee: 'assignee', assignee: 'assignee',
milestone: 'milestone', milestone: 'milestone',
iteration: 'iteration',
backlog: 'backlog', backlog: 'backlog',
closed: 'closed', closed: 'closed',
label: 'label', label: 'label',
}; };
export const ListTypeTitles = {
assignee: __('Assignee'),
milestone: __('Milestone'),
iteration: __('Iteration'),
label: __('Label'),
};
export const inactiveId = 0; export const inactiveId = 0;
export const ISSUABLE = 'issuable'; export const ISSUABLE = 'issuable';
......
export default class ListIteration {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
this.state = obj.state;
this.webUrl = obj.web_url || obj.webUrl;
this.description = obj.description;
}
}
...@@ -5,6 +5,7 @@ import boardsStore from '../stores/boards_store'; ...@@ -5,6 +5,7 @@ import boardsStore from '../stores/boards_store';
import ListLabel from './label'; import ListLabel from './label';
import ListAssignee from './assignee'; import ListAssignee from './assignee';
import ListMilestone from './milestone'; import ListMilestone from './milestone';
import ListIteration from './iteration';
import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/issue';
const TYPES = { const TYPES = {
...@@ -57,6 +58,9 @@ class List { ...@@ -57,6 +58,9 @@ class List {
} else if (IS_EE && obj.milestone) { } else if (IS_EE && obj.milestone) {
this.milestone = new ListMilestone(obj.milestone); this.milestone = new ListMilestone(obj.milestone);
this.title = this.milestone.title; this.title = this.milestone.title;
} else if (IS_EE && obj.iteration) {
this.iteration = new ListIteration(obj.iteration);
this.title = this.iteration.title;
} }
// doNotFetchIssues is a temporary workaround until issues are fetched using GraphQL on issue boards // doNotFetchIssues is a temporary workaround until issues are fetched using GraphQL on issue boards
......
...@@ -49,7 +49,8 @@ Example response: ...@@ -49,7 +49,8 @@ Example response:
"created_at": "2020-01-27T05:07:12.573Z", "created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z", "updated_at": "2020-01-27T05:07:12.573Z",
"due_date": "2020-02-01", "due_date": "2020-02-01",
"start_date": "2020-02-14" "start_date": "2020-02-14",
"web_url": "http://gitlab.example.com/groups/my-group/-/iterations/13"
} }
] ]
``` ```
...@@ -51,7 +51,8 @@ Example response: ...@@ -51,7 +51,8 @@ Example response:
"created_at": "2020-01-27T05:07:12.573Z", "created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z", "updated_at": "2020-01-27T05:07:12.573Z",
"due_date": "2020-02-01", "due_date": "2020-02-01",
"start_date": "2020-02-14" "start_date": "2020-02-14",
"web_url": "http://gitlab.example.com/groups/my-group/-/iterations/13"
} }
] ]
``` ```
<script> <script>
import { GlAvatarLink, GlAvatarLabeled, GlLink } from '@gitlab/ui'; import { GlAvatarLink, GlAvatarLabeled, GlLink } from '@gitlab/ui';
import { __ } from '~/locale'; import { ListType, ListTypeTitles } from '~/boards/constants';
export default { export default {
milestone: 'milestone',
assignee: 'assignee',
labelMilestoneText: __('Milestone'),
labelAssigneeText: __('Assignee'),
components: { components: {
GlLink, GlLink,
GlAvatarLink, GlAvatarLink,
...@@ -22,25 +18,17 @@ export default { ...@@ -22,25 +18,17 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
ListType,
};
},
computed: { computed: {
activeListAssignee() { activeListObject() {
return this.activeList.assignee; return this.activeList[this.boardListType];
},
activeListMilestone() {
return this.activeList.milestone;
}, },
listTypeTitle() { listTypeHeader() {
switch (this.boardListType) { return ListTypeTitles[this.boardListType] || '';
case this.$options.milestone: {
return this.$options.labelMilestoneText;
}
case this.$options.assignee: {
return this.$options.labelAssigneeText;
}
default: {
return '';
}
}
}, },
}, },
}; };
...@@ -48,24 +36,21 @@ export default { ...@@ -48,24 +36,21 @@ export default {
<template> <template>
<div> <div>
<label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> <label class="js-list-label gl-display-block">{{ listTypeHeader }}</label>
<gl-link
v-if="boardListType === $options.milestone"
class="js-milestone"
:href="activeListMilestone.webUrl"
>{{ activeListMilestone.title }}</gl-link
>
<gl-avatar-link <gl-avatar-link
v-else-if="boardListType === $options.assignee" v-if="boardListType === ListType.assignee"
class="js-assignee" class="js-assignee"
:href="activeListAssignee.webUrl" :href="activeListObject.webUrl"
> >
<gl-avatar-labeled <gl-avatar-labeled
:size="32" :size="32"
:label="activeListAssignee.name" :label="activeListObject.name"
:sub-label="`@${activeListAssignee.username}`" :sub-label="`@${activeListObject.username}`"
:src="activeListAssignee.avatar" :src="activeListObject.avatar"
/> />
</gl-avatar-link> </gl-avatar-link>
<gl-link v-else class="js-list-title" :href="activeListObject.webUrl">
{{ activeListObject.title }}
</gl-link>
</div> </div>
</template> </template>
---
title: Add web_url to iterations API
merge_request: 52178
author:
type: added
...@@ -10,6 +10,10 @@ module API ...@@ -10,6 +10,10 @@ module API
expose :state_enum, as: :state expose :state_enum, as: :state
expose :created_at, :updated_at expose :created_at, :updated_at
expose :start_date, :due_date expose :start_date, :due_date
expose :web_url do |iteration, _options|
Gitlab::UrlBuilder.build(iteration)
end
end end
end end
end end
...@@ -54,7 +54,8 @@ ...@@ -54,7 +54,8 @@
"id", "id",
"title", "title",
"description", "description",
"state" "state",
"web_url"
], ],
"properties": { "properties": {
"id": { "id": {
...@@ -71,6 +72,9 @@ ...@@ -71,6 +72,9 @@
}, },
"state": { "state": {
"type": "integer" "type": "integer"
},
"web_url": {
"type": "string"
} }
} }
} }
......
...@@ -81,7 +81,7 @@ describe('Board List Header Component', () => { ...@@ -81,7 +81,7 @@ describe('Board List Header Component', () => {
const findSettingsButton = () => wrapper.find({ ref: 'settingsBtn' }); const findSettingsButton = () => wrapper.find({ ref: 'settingsBtn' });
describe('Settings Button', () => { describe('Settings Button', () => {
const hasSettings = [ListType.assignee, ListType.milestone, ListType.label]; const hasSettings = [ListType.assignee, ListType.milestone, ListType.iteration, ListType.label];
const hasNoSettings = [ListType.backlog, ListType.closed]; const hasNoSettings = [ListType.backlog, ListType.closed];
it.each(hasSettings)('does render for List Type `%s`', (listType) => { it.each(hasSettings)('does render for List Type `%s`', (listType) => {
......
...@@ -72,7 +72,7 @@ describe('Board List Header Component', () => { ...@@ -72,7 +72,7 @@ describe('Board List Header Component', () => {
const findSettingsButton = () => wrapper.find({ ref: 'settingsBtn' }); const findSettingsButton = () => wrapper.find({ ref: 'settingsBtn' });
describe('Settings Button', () => { describe('Settings Button', () => {
const hasSettings = [ListType.assignee, ListType.milestone, ListType.label]; const hasSettings = [ListType.assignee, ListType.milestone, ListType.iteration, ListType.label];
const hasNoSettings = [ListType.backlog, ListType.closed]; const hasNoSettings = [ListType.backlog, ListType.closed];
it.each(hasSettings)('does render for List Type `%s`', (listType) => { it.each(hasSettings)('does render for List Type `%s`', (listType) => {
......
...@@ -9,6 +9,10 @@ describe('BoardSettingsListType', () => { ...@@ -9,6 +9,10 @@ describe('BoardSettingsListType', () => {
webUrl: 'https://gitlab.com/h5bp/html5-boilerplate/-/milestones/1', webUrl: 'https://gitlab.com/h5bp/html5-boilerplate/-/milestones/1',
title: 'Backlog', title: 'Backlog',
}, },
iteration: {
webUrl: 'https://gitlab.com/h5bp/-/iterations/1',
title: 'Sprint 1',
},
assignee: { webUrl: 'https://gitlab.com/root', name: 'root', username: 'root' }, assignee: { webUrl: 'https://gitlab.com/root', name: 'root', username: 'root' },
}; };
const createComponent = (props) => { const createComponent = (props) => {
...@@ -25,7 +29,7 @@ describe('BoardSettingsListType', () => { ...@@ -25,7 +29,7 @@ describe('BoardSettingsListType', () => {
it('renders the correct milestone text', () => { it('renders the correct milestone text', () => {
createComponent({ activeId: 1, boardListType: 'milestone' }); createComponent({ activeId: 1, boardListType: 'milestone' });
expect(wrapper.find('.js-milestone').text()).toBe('Backlog'); expect(wrapper.find('.js-list-title').text()).toBe('Backlog');
}); });
it('renders the correct list type text', () => { it('renders the correct list type text', () => {
...@@ -35,6 +39,20 @@ describe('BoardSettingsListType', () => { ...@@ -35,6 +39,20 @@ describe('BoardSettingsListType', () => {
}); });
}); });
describe('when list type is "iteration"', () => {
it('renders the correct milestone text', () => {
createComponent({ activeId: 1, boardListType: 'iteration' });
expect(wrapper.find('.js-list-title').text()).toBe('Sprint 1');
});
it('renders the correct list type text', () => {
createComponent({ activeId: 1, boardListType: 'iteration' });
expect(wrapper.find('.js-list-label').text()).toBe('Iteration');
});
});
describe('when list type is "assignee"', () => { describe('when list type is "assignee"', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
......
...@@ -4,6 +4,7 @@ import Issue from 'ee/boards/models/issue'; ...@@ -4,6 +4,7 @@ import Issue from 'ee/boards/models/issue';
import List from 'ee/boards/models/list'; import List from 'ee/boards/models/list';
import { listObj } from 'jest/boards/mock_data'; import { listObj } from 'jest/boards/mock_data';
import CeList from '~/boards/models/list'; import CeList from '~/boards/models/list';
import { ListType } from '~/boards/constants';
describe('List model', () => { describe('List model', () => {
let list; let list;
...@@ -15,16 +16,6 @@ describe('List model', () => { ...@@ -15,16 +16,6 @@ describe('List model', () => {
// We need to mock axios since `new List` below makes a network request // We need to mock axios since `new List` below makes a network request
axiosMock.onGet().replyOnce(200); axiosMock.onGet().replyOnce(200);
list = new List(listObj);
issue = new Issue({
title: 'Testing',
id: 2,
iid: 2,
labels: [],
assignees: [],
weight: 5,
});
}); });
afterEach(() => { afterEach(() => {
...@@ -33,67 +24,98 @@ describe('List model', () => { ...@@ -33,67 +24,98 @@ describe('List model', () => {
axiosMock.restore(); axiosMock.restore();
}); });
it('inits totalWeight', () => { describe('label lists', () => {
expect(list.totalWeight).toBe(0); beforeEach(() => {
}); list = new List(listObj);
issue = new Issue({
title: 'Testing',
id: 2,
iid: 2,
labels: [],
assignees: [],
weight: 5,
});
});
it('inits totalWeight', () => {
expect(list.totalWeight).toBe(0);
});
describe('getIssues', () => { describe('getIssues', () => {
it('calls CE getIssues', () => { it('calls CE getIssues', () => {
const ceGetIssues = jest const ceGetIssues = jest
.spyOn(CeList.prototype, 'getIssues') .spyOn(CeList.prototype, 'getIssues')
.mockReturnValue(Promise.resolve({})); .mockReturnValue(Promise.resolve({}));
return list.getIssues().then(() => { return list.getIssues().then(() => {
expect(ceGetIssues).toHaveBeenCalled(); expect(ceGetIssues).toHaveBeenCalled();
});
}); });
});
it('sets total weight', () => { it('sets total weight', () => {
jest.spyOn(CeList.prototype, 'getIssues').mockReturnValue( jest.spyOn(CeList.prototype, 'getIssues').mockReturnValue(
Promise.resolve({ Promise.resolve({
total_weight: 11, total_weight: 11,
}), }),
); );
return list.getIssues().then(() => { return list.getIssues().then(() => {
expect(list.totalWeight).toBe(11); expect(list.totalWeight).toBe(11);
});
}); });
}); });
});
describe('addIssue', () => { describe('addIssue', () => {
it('updates totalWeight', () => { it('updates totalWeight', () => {
list.addIssue(issue); list.addIssue(issue);
expect(list.totalWeight).toBe(5); expect(list.totalWeight).toBe(5);
}); });
it('calls CE addIssue with all args', () => { it('calls CE addIssue with all args', () => {
const ceAddIssue = jest.spyOn(CeList.prototype, 'addIssue'); const ceAddIssue = jest.spyOn(CeList.prototype, 'addIssue');
list.addIssue(issue, list, 2); list.addIssue(issue, list, 2);
expect(ceAddIssue).toHaveBeenCalledWith(issue, list, 2); expect(ceAddIssue).toHaveBeenCalledWith(issue, list, 2);
});
}); });
});
describe('removeIssue', () => { describe('removeIssue', () => {
beforeEach(() => { beforeEach(() => {
list.addIssue(issue); list.addIssue(issue);
}); });
it('updates totalWeight', () => { it('updates totalWeight', () => {
list.removeIssue(issue); list.removeIssue(issue);
expect(list.totalWeight).toBe(0); expect(list.totalWeight).toBe(0);
});
it('calls CE removeIssue', () => {
const ceRemoveIssue = jest.spyOn(CeList.prototype, 'removeIssue');
list.removeIssue(issue);
expect(ceRemoveIssue).toHaveBeenCalledWith(issue);
});
}); });
});
it('calls CE removeIssue', () => { describe('iteration lists', () => {
const ceRemoveIssue = jest.spyOn(CeList.prototype, 'removeIssue'); const iteration = {
id: 1000,
title: 'Sprint 1',
webUrl: 'https://gitlab.com/h5bp/-/iterations/1',
};
list.removeIssue(issue); beforeEach(() => {
list = new List({ list_type: ListType.iteration, iteration });
});
expect(ceRemoveIssue).toHaveBeenCalledWith(issue); it('sets the iteration and title', () => {
expect(list.iteration.id).toBe(iteration.id);
expect(list.title).toBe(iteration.title);
}); });
}); });
}); });
...@@ -74,7 +74,13 @@ describe('Board List Header Component', () => { ...@@ -74,7 +74,13 @@ describe('Board List Header Component', () => {
describe('Add issue button', () => { describe('Add issue button', () => {
const hasNoAddButton = [ListType.closed]; const hasNoAddButton = [ListType.closed];
const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; const hasAddButton = [
ListType.backlog,
ListType.label,
ListType.milestone,
ListType.iteration,
ListType.assignee,
];
it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => { it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => {
createComponent({ listType }); createComponent({ listType });
......
...@@ -78,7 +78,13 @@ describe('Board List Header Component', () => { ...@@ -78,7 +78,13 @@ describe('Board List Header Component', () => {
describe('Add issue button', () => { describe('Add issue button', () => {
const hasNoAddButton = [ListType.closed]; const hasNoAddButton = [ListType.closed];
const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; const hasAddButton = [
ListType.backlog,
ListType.label,
ListType.milestone,
ListType.iteration,
ListType.assignee,
];
it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => { it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => {
createComponent({ listType }); createComponent({ listType });
...@@ -167,7 +173,7 @@ describe('Board List Header Component', () => { ...@@ -167,7 +173,7 @@ describe('Board List Header Component', () => {
describe('user can drag', () => { describe('user can drag', () => {
const cannotDragList = [ListType.backlog, ListType.closed]; const cannotDragList = [ListType.backlog, ListType.closed];
const canDragList = [ListType.label, ListType.milestone, ListType.assignee]; const canDragList = [ListType.label, ListType.milestone, ListType.iteration, ListType.assignee];
it.each(cannotDragList)( it.each(cannotDragList)(
'does not have user-can-drag-class so user cannot drag list', 'does not have user-can-drag-class so user cannot drag list',
......
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