Skip to content
Snippets Groups Projects
Verified Commit 02411899 authored by Florie Guibert's avatar Florie Guibert :two: Committed by GitLab
Browse files

Merge branch 'nl-boards-cutline' into 'master'

Add cut line for board lists with WIP limit

See merge request !142847



Merged-by: default avatarFlorie Guibert <fguibert@gitlab.com>
Approved-by: default avatarNick Brandt <nbrandt@gitlab.com>
Approved-by: default avatarFlorie Guibert <fguibert@gitlab.com>
Approved-by: Rajan Mistry's avatarRajan Mistry <rmistry@gitlab.com>
Reviewed-by: default avatarFlorie Guibert <fguibert@gitlab.com>
Reviewed-by: default avatarMarcin Sedlak-Jakubowski <msedlakjakubowski@gitlab.com>
Co-authored-by: Nick Leonard's avatarNick Leonard <nleonard@gitlab.com>
parents bf6f6eee e9b4c718
No related branches found
No related tags found
2 merge requests!144312Change service start (cut-off) date for code suggestions to March 15th,!142847Add cut line for board lists with WIP limit
Pipeline #1166669046 failed
Showing
with 155 additions and 34 deletions
<script>
export default {
name: 'BoardCutLine',
props: {
cutLineText: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="board-cut-line gl-display-flex gl-mb-3 gl-text-red-700 gl-align-items-center">
<span class="gl-px-2 gl-font-sm gl-font-weight-bold" data-testid="cut-line-text">{{
cutLineText
}}</span>
</div>
</template>
......@@ -29,6 +29,7 @@ import { shouldCloneCard, moveItemVariables } from '../boards_util';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue';
import BoardCutLine from './board_cut_line.vue';
export default {
draggableItemTypes: DraggableItemTypes,
......@@ -42,6 +43,7 @@ export default {
components: {
BoardCard,
BoardNewIssue,
BoardCutLine,
BoardNewEpic: () => import('ee_component/boards/components/board_new_epic.vue'),
GlLoadingIcon,
GlIntersectionObserver,
......@@ -154,6 +156,16 @@ export default {
boardListItems() {
return this.currentList?.[`${this.issuableType}s`].nodes || [];
},
beforeCutLine() {
return this.boardItemsSizeExceedsMax
? this.boardListItems.slice(0, this.list.maxIssueCount)
: this.boardListItems;
},
afterCutLine() {
return this.boardItemsSizeExceedsMax
? this.boardListItems.slice(this.list.maxIssueCount)
: [];
},
listQueryVariables() {
return {
fullPath: this.fullPath,
......@@ -174,6 +186,11 @@ export default {
issuableType: this.isEpicBoard ? 'epics' : 'issues',
});
},
wipLimitText() {
return sprintf(__('Work in progress limit: %{wipLimit}'), {
wipLimit: this.list.maxIssueCount,
});
},
toggleFormEventPrefix() {
return this.isEpicBoard ? toggleFormEventPrefix.epic : toggleFormEventPrefix.issue;
},
......@@ -653,7 +670,7 @@ export default {
:data-board="list.id"
:data-board-type="list.listType"
:class="{
'gl-bg-red-100 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': boardItemsSizeExceedsMax,
'gl-bg-red-50 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': boardItemsSizeExceedsMax,
'gl-overflow-hidden': disableScrollingWhenMutationInProgress,
'gl-overflow-y-auto': !disableScrollingWhenMutationInProgress,
}"
......@@ -664,7 +681,32 @@ export default {
@end="handleDragOnEnd"
>
<board-card
v-for="(item, index) in boardListItems"
v-for="(item, index) in beforeCutLine"
ref="issue"
:key="item.id"
:index="index"
:list="list"
:item="item"
:data-draggable-item-type="$options.draggableItemTypes.card"
:show-work-item-type-icon="!isEpicBoard"
>
<board-card-move-to-position
v-if="showMoveToPosition"
:item="item"
:index="index"
:list="list"
:list-items-length="boardListItems.length"
@moveToPosition="moveToPosition($event, index, item)"
/>
<gl-intersection-observer
v-if="isObservableItem(index)"
data-testid="board-card-gl-io"
@appear="onReachingListBottom"
/>
</board-card>
<board-cut-line v-if="boardItemsSizeExceedsMax" :cut-line-text="wipLimitText" />
<board-card
v-for="(item, index) in afterCutLine"
ref="issue"
:key="item.id"
:index="index"
......
......@@ -110,6 +110,9 @@ export default {
itemsCount() {
return this.isEpicBoard ? this.list.metadata.epicsCount : this.boardList?.issuesCount;
},
boardItemsSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.itemsCount > this.list.maxIssueCount;
},
listAssignee() {
return this.list?.assignee?.username || '';
},
......@@ -333,6 +336,7 @@ export default {
'gl-h-full': list.collapsed,
'gl-bg-gray-50': isSwimlanesHeader,
'gl-border-t-solid gl-border-4 gl-rounded-top-left-base gl-rounded-top-right-base': isLabelList,
'gl-bg-red-50 gl-rounded-top-left-base gl-rounded-top-right-base': boardItemsSizeExceedsMax,
}"
:style="headerStyle"
class="board-header gl-relative"
......
......@@ -26,7 +26,7 @@ export default {
<template>
<div class="item-count text-nowrap">
<span :class="{ 'text-danger': issuesExceedMax }" data-testid="board-items-count">
<span :class="{ 'gl-text-red-700': issuesExceedMax }" data-testid="board-items-count">
{{ itemsSize }}
</span>
<span v-if="isMaxLimitSet" class="max-issue-size">
......
......@@ -242,3 +242,12 @@
height: 100px;
}
}
.board-cut-line {
&::before, &::after {
content: '';
height: 1px;
flex: 1;
border-top: 1px dashed $red-700;
}
}
\ No newline at end of file
......@@ -438,7 +438,7 @@ DETAILS:
> - Moved to GitLab Premium in 13.9.
You can set a work in progress (WIP) limit for each issue list on an issue board. When a limit is
set, the list's header shows the number of issues in the list and the soft limit of issues.
set, the list's header shows the number of issues in the list and the soft limit of issues. A line in the list separates items within the limit from those in excess of the limit.
You cannot set a WIP limit on the default lists (**Open** and **Closed**).
Examples:
......@@ -446,7 +446,7 @@ Examples:
- When you have a list with four issues and a limit of five, the header shows **4/5**.
If you exceed the limit, the current number of issues is shown in red.
- You have a list with five issues with a limit of five. When you move another issue to that list,
the list's header displays **6/5**, with the six shown in red.
the list's header displays **6/5**, with the six shown in red. The work in progress line is shown before the sixth issue.
Prerequisites:
......
......@@ -457,7 +457,7 @@ export default {
class="board-cell gl-p-2 gl-m-0 gl-h-full gl-list-style-none"
:class="{
'board-column-highlighted': highlighted,
'gl-bg-red-100 gl-rounded-base': boardItemsSizeExceedsMax,
'gl-bg-red-50 gl-rounded-base': boardItemsSizeExceedsMax,
}"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
......
......@@ -30,6 +30,8 @@ describe('IssuesLaneList', () => {
let wrapper;
let mockApollo;
const maxIssueCountWarningClass = '.gl-bg-red-50';
const findNewIssueForm = () => wrapper.findComponent(BoardNewIssue);
const listIssuesQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupIssuesResponse());
......@@ -231,19 +233,19 @@ describe('IssuesLaneList', () => {
describe('max issue count warning', () => {
describe('when issue count exceeds max issue count', () => {
it('sets background to red-100', () => {
it('sets background to warning color', () => {
createComponent({ listProps: { maxIssueCount: 3 }, totalIssuesCount: 4 });
const block = wrapper.find('.gl-bg-red-100');
const block = wrapper.find(maxIssueCountWarningClass);
expect(block.exists()).toBe(true);
expect(block.attributes('class')).toContain('gl-rounded-base');
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not set background to red-100', () => {
it('does not set background to warning color', () => {
createComponent({ listProps: { maxIssueCount: 3 }, totalIssuesCount: 2 });
expect(wrapper.find('.gl-bg-red-100').exists()).toBe(false);
expect(wrapper.find(maxIssueCountWarningClass).exists()).toBe(false);
});
});
});
......
......@@ -55947,6 +55947,9 @@ msgstr ""
msgid "Work in progress limit"
msgstr ""
 
msgid "Work in progress limit: %{wipLimit}"
msgstr ""
msgid "Work item parent removed successfully"
msgstr ""
 
......@@ -7,6 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createComponent from 'jest/boards/board_list_helper';
import { ESC_KEY_CODE } from '~/lib/utils/keycodes';
import BoardCard from '~/boards/components/board_card.vue';
import BoardCutLine from '~/boards/components/board_cut_line.vue';
import eventHub from '~/boards/eventhub';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
import listIssuesQuery from '~/boards/graphql/lists_issues.query.graphql';
......@@ -22,6 +23,8 @@ describe('Board list component', () => {
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findBoardListCount = () => wrapper.find('.board-list-count');
const maxIssueCountWarningClass = '.gl-bg-red-50';
const triggerInfiniteScroll = () => findIntersectionObserver().vm.$emit('appear');
const startDrag = (
......@@ -143,34 +146,48 @@ describe('Board list component', () => {
describe('max issue count warning', () => {
describe('when issue count exceeds max issue count', () => {
it('sets background to gl-bg-red-100', async () => {
wrapper = createComponent({ listProps: { issuesCount: 4, maxIssueCount: 3 } });
beforeEach(async () => {
wrapper = createComponent({ listProps: { issuesCount: 4, maxIssueCount: 2 } });
await waitForPromises();
const block = wrapper.find('.gl-bg-red-100');
});
it('sets background to warning color', () => {
const block = wrapper.find(maxIssueCountWarningClass);
expect(block.exists()).toBe(true);
expect(block.attributes('class')).toContain(
'gl-rounded-bottom-left-base gl-rounded-bottom-right-base',
);
});
it('shows cut line', () => {
const cutline = wrapper.findComponent(BoardCutLine);
expect(cutline.exists()).toBe(true);
expect(cutline.props('cutLineText')).toEqual('Work in progress limit: 2');
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to gl-bg-red-100', async () => {
beforeEach(async () => {
wrapper = createComponent({ list: { issuesCount: 2, maxIssueCount: 3 } });
await waitForPromises();
expect(wrapper.find('.gl-bg-red-100').exists()).toBe(false);
});
it('does not sets background to warning color', () => {
expect(wrapper.find(maxIssueCountWarningClass).exists()).toBe(false);
});
it('does not show cut line', () => {
expect(wrapper.findComponent(BoardCutLine).exists()).toBe(false);
});
});
describe('when list max issue count is 0', () => {
it('does not sets background to gl-bg-red-100', async () => {
beforeEach(async () => {
wrapper = createComponent({ list: { maxIssueCount: 0 } });
await waitForPromises();
expect(wrapper.find('.gl-bg-red-100').exists()).toBe(false);
});
it('does not sets background to warning color', () => {
expect(wrapper.find(maxIssueCountWarningClass).exists()).toBe(false);
});
it('does not show cut line', () => {
expect(wrapper.findComponent(BoardCutLine).exists()).toBe(false);
});
});
});
......
import { shallowMount } from '@vue/test-utils';
import BoardCutLine from '~/boards/components/board_cut_line.vue';
describe('BoardCutLine', () => {
let wrapper;
const cutLineText = 'Work in progress limit: 3';
const createComponent = (props) => {
wrapper = shallowMount(BoardCutLine, { propsData: props });
};
describe('when cut line is shown', () => {
beforeEach(() => {
createComponent({ cutLineText });
});
it('contains cut line text in the template', () => {
expect(wrapper.find('[data-testid="cut-line-text"]').text()).toContain(
`Work in progress limit: 3`,
);
});
it('does not contain other text in the template', () => {
expect(wrapper.find('[data-testid="cut-line-text"]').text()).not.toContain(`unexpected`);
});
});
});
......@@ -2,19 +2,17 @@ import { shallowMount } from '@vue/test-utils';
import IssueCount from '~/boards/components/item_count.vue';
describe('IssueCount', () => {
let vm;
let wrapper;
let maxIssueCount;
let itemsSize;
const createComponent = (props) => {
vm = shallowMount(IssueCount, { propsData: props });
wrapper = shallowMount(IssueCount, { propsData: props });
};
afterEach(() => {
maxIssueCount = 0;
itemsSize = 0;
if (vm) vm.destroy();
});
describe('when maxIssueCount is zero', () => {
......@@ -25,11 +23,11 @@ describe('IssueCount', () => {
});
it('contains issueSize in the template', () => {
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
expect(wrapper.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('does not contains maxIssueCount in the template', () => {
expect(vm.find('.max-issue-size').exists()).toBe(false);
expect(wrapper.find('.max-issue-size').exists()).toBe(false);
});
});
......@@ -42,15 +40,15 @@ describe('IssueCount', () => {
});
it('contains issueSize in the template', () => {
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
expect(wrapper.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('contains maxIssueCount in the template', () => {
expect(vm.find('.max-issue-size').text()).toContain(String(maxIssueCount));
expect(wrapper.find('.max-issue-size').text()).toContain(String(maxIssueCount));
});
it('does not have text-danger class when issueSize is less than maxIssueCount', () => {
expect(vm.classes('.text-danger')).toBe(false);
it('does not have red text when issueSize is less than maxIssueCount', () => {
expect(wrapper.classes('.gl-text-red-700')).toBe(false);
});
});
......@@ -63,15 +61,15 @@ describe('IssueCount', () => {
});
it('contains issueSize in the template', () => {
expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
expect(wrapper.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('contains maxIssueCount in the template', () => {
expect(vm.find('.max-issue-size').text()).toContain(String(maxIssueCount));
expect(wrapper.find('.max-issue-size').text()).toContain(String(maxIssueCount));
});
it('has text-danger class', () => {
expect(vm.find('.text-danger').text()).toEqual(String(itemsSize));
it('has red text', () => {
expect(wrapper.find('.gl-text-red-700').text()).toEqual(String(itemsSize));
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment