Commit d41ea710 authored by Stan Hu's avatar Stan Hu

Merge branch 'ss/wip-board-issue-limits-show-limit' into 'master'

Show issue limits in board lists

See merge request gitlab-org/gitlab!16867
parents 2fc23fff e5d0d00e
import $ from 'jquery'; import $ from 'jquery';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import Vue from 'vue'; import Vue from 'vue';
import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui';
import { n__, s__ } from '~/locale'; import { n__, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip'; import Tooltip from '~/vue_shared/directives/tooltip';
import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
import AccessorUtilities from '../../lib/utils/accessor'; import AccessorUtilities from '../../lib/utils/accessor';
import BoardBlankState from './board_blank_state.vue'; import BoardBlankState from './board_blank_state.vue';
import BoardDelete from './board_delete'; import BoardDelete from './board_delete';
import BoardList from './board_list.vue'; import BoardList from './board_list.vue';
import IssueCount from './issue_count.vue';
import boardsStore from '../stores/boards_store'; import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
import { ListType } from '../constants';
export default Vue.extend({ export default Vue.extend({
components: { components: {
...@@ -17,10 +21,15 @@ export default Vue.extend({ ...@@ -17,10 +21,15 @@ export default Vue.extend({
BoardDelete, BoardDelete,
BoardList, BoardList,
Icon, Icon,
GlButtonGroup,
IssueCount,
GlButton,
GlTooltip,
}, },
directives: { directives: {
Tooltip, Tooltip,
}, },
mixins: [isWipLimitsOn],
props: { props: {
list: { list: {
type: Object, type: Object,
...@@ -53,6 +62,11 @@ export default Vue.extend({ ...@@ -53,6 +62,11 @@ export default Vue.extend({
isLoggedIn() { isLoggedIn() {
return Boolean(gon.current_user_id); return Boolean(gon.current_user_id);
}, },
showListHeaderButton() {
return (
!this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank
);
},
counterTooltip() { counterTooltip() {
const { issuesSize } = this.list; const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`; return `${n__('%d issue', '%d issues', issuesSize)}`;
...@@ -61,11 +75,19 @@ export default Vue.extend({ ...@@ -61,11 +75,19 @@ export default Vue.extend({
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
}, },
isNewIssueShown() { isNewIssueShown() {
return this.list.type === ListType.backlog || this.showListHeaderButton;
},
isSettingsShown() {
return ( return (
this.list.type === 'backlog' || this.list.type !== ListType.backlog &&
(!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank') this.showListHeaderButton &&
this.list.isExpanded &&
this.isWipLimitsOn
); );
}, },
showBoardListAndBoardInfo() {
return this.list.type !== ListType.blank && this.list.type !== ListType.promotion;
},
uniqueKey() { uniqueKey() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
......
...@@ -71,6 +71,9 @@ export default { ...@@ -71,6 +71,9 @@ export default {
total: this.list.issuesSize, total: this.list.issuesSize,
}); });
}, },
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
},
}, },
watch: { watch: {
filters: { filters: {
...@@ -435,7 +438,7 @@ export default { ...@@ -435,7 +438,7 @@ export default {
ref="list" ref="list"
:data-board="list.id" :data-board="list.id"
:data-board-type="list.type" :data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }" :class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list" class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
> >
<board-card <board-card
......
<script>
export default {
name: 'IssueCount',
props: {
maxIssueCount: {
type: Number,
required: false,
default: 0,
},
issuesSize: {
type: Number,
required: false,
default: 0,
},
},
computed: {
isMaxLimitSet() {
return this.maxIssueCount !== 0;
},
issuesExceedMax() {
return this.isMaxLimitSet && this.issuesSize > this.maxIssueCount;
},
},
};
</script>
<template>
<div class="issue-count">
<span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }">
{{ issuesSize }}
</span>
<span v-if="isMaxLimitSet" class="js-max-issue-size">
{{ maxIssueCount }}
</span>
</div>
</template>
...@@ -4,6 +4,8 @@ export const ListType = { ...@@ -4,6 +4,8 @@ export const ListType = {
backlog: 'backlog', backlog: 'backlog',
closed: 'closed', closed: 'closed',
label: 'label', label: 'label',
promotion: 'promotion',
blank: 'blank',
}; };
export default { export default {
......
export default {
computed: {
isWipLimitsOn() {
return false;
},
},
};
...@@ -52,6 +52,9 @@ class List { ...@@ -52,6 +52,9 @@ class List {
this.loadingMore = false; this.loadingMore = false;
this.issues = obj.issues || []; this.issues = obj.issues || [];
this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
this.maxIssueCount = Object.hasOwnProperty.call(obj, 'max_issue_count')
? obj.max_issue_count
: 0;
this.defaultAvatar = defaultAvatar; this.defaultAvatar = defaultAvatar;
if (obj.label) { if (obj.label) {
......
...@@ -72,7 +72,7 @@ export default { ...@@ -72,7 +72,7 @@ export default {
{{ __('Related merge requests') }} {{ __('Related merge requests') }}
</span> </span>
<div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> <div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
<div class="mr-count-badge"> <div class="mr-count-badge border-width-1px border-style-solid border-color-default">
<div class="mr-count-badge-count"> <div class="mr-count-badge-count">
<svg class="s16 mr-1 text-secondary"> <svg class="s16 mr-1 text-secondary">
<icon name="merge-request" class="mr-1 text-secondary" /> <icon name="merge-request" class="mr-1 text-secondary" />
......
...@@ -187,6 +187,10 @@ ...@@ -187,6 +187,10 @@
font-size: 1em; font-size: 1em;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
padding: $gl-padding-8 $gl-padding; padding: $gl-padding-8 $gl-padding;
.js-max-issue-size::before {
content: '/';
}
} }
.board-title-text { .board-title-text {
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
.mr-count-badge { .mr-count-badge {
display: inline-flex; display: inline-flex;
border-radius: $border-radius-base; border-radius: $border-radius-base;
border: 1px solid $border-color;
padding: 5px $gl-padding-8; padding: 5px $gl-padding-8;
} }
......
...@@ -9,6 +9,7 @@ module BoardsActions ...@@ -9,6 +9,7 @@ module BoardsActions
before_action :boards, only: :index before_action :boards, only: :index
before_action :board, only: :show before_action :board, only: :show
before_action :push_wip_limits, only: :index
end end
def index def index
...@@ -24,6 +25,10 @@ module BoardsActions ...@@ -24,6 +25,10 @@ module BoardsActions
private private
# Noop on FOSS
def push_wip_limits
end
def boards def boards
strong_memoize(:boards) do strong_memoize(:boards) do
Boards::ListService.new(parent, current_user).execute Boards::ListService.new(parent, current_user).execute
......
...@@ -42,23 +42,27 @@ ...@@ -42,23 +42,27 @@
%button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } %button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash") = icon("trash")
.issue-count-badge.no-drag.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo", ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
%span.d-inline-flex %span.d-inline-flex
%span.issue-count-badge-count %span.issue-count-badge-count
%icon.mr-1{ name: "issues" } %icon.mr-1{ name: "issues" }
{{ list.issuesSize }} %issue-count{ ":maxIssueCount" => "list.maxIssueCount",
":issuesSize" => "list.issuesSize" }
= render_if_exists "shared/boards/components/list_weight" = render_if_exists "shared/boards/components/list_weight"
%button.issue-count-badge-add-button.no-drag.btn.btn-sm.btn-default.ml-1.has-tooltip{ type: "button", %gl-button-group.board-list-button-group.pl-2{ "v-if" => "isNewIssueShown || isSettingsShown" }
"@click" => "showNewIssueForm", %gl-button.issue-count-badge-add-button.no-drag{ type: "button",
"v-if" => "isNewIssueShown", "@click" => "showNewIssueForm",
":class": "{ 'd-none': !list.isExpanded }", "v-if" => "isNewIssueShown",
"aria-label" => _("New issue"), ":class": "{ 'd-none': !list.isExpanded, 'rounded-right': isNewIssueShown && !isSettingsShown }",
"title" => _("New issue"), "aria-label" => _("New issue"),
data: { placement: "top", container: "body" } } "ref" => "newIssueBtn" }
= icon("plus") = icon("plus")
%gl-tooltip{ ":target" => "() => $refs.newIssueBtn" }
= _("New Issue")
= render_if_exists 'shared/boards/components/list_settings'
%board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', %board-list{ "v-if" => "showBoardListAndBoardInfo",
":list" => "list", ":list" => "list",
":issues" => "list.issues", ":issues" => "list.issues",
":loading" => "list.loading", ":loading" => "list.loading",
......
export default {
computed: {
isWipLimitsOn() {
return gon.features.wipLimits;
},
},
};
...@@ -180,7 +180,7 @@ export default { ...@@ -180,7 +180,7 @@ export default {
</a> </a>
<div class="d-inline-flex lh-100 align-middle"> <div class="d-inline-flex lh-100 align-middle">
<div <div
class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge mx-1" class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge mx-1 border-width-1px border-style-solid border-color-default"
> >
<span class="issue-count-badge-count"> <span class="issue-count-badge-count">
<icon :name="issuableTypeIcon" class="mr-1 text-secondary" /> <icon :name="issuableTypeIcon" class="mr-1 text-secondary" />
......
...@@ -7,5 +7,13 @@ module EE ...@@ -7,5 +7,13 @@ module EE
prepended do prepended do
include ::MultipleBoardsActions include ::MultipleBoardsActions
end end
private
def push_wip_limits
if parent.feature_available?(:wip_limits)
push_frontend_feature_flag(:wip_limits)
end
end
end end
end end
...@@ -5,6 +5,10 @@ module EE ...@@ -5,6 +5,10 @@ module EE
module ListsController module ListsController
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
included do
before_action :push_wip_limits
end
override :list_creation_attrs override :list_creation_attrs
def list_creation_attrs def list_creation_attrs
additional_attrs = %i[assignee_id milestone_id] additional_attrs = %i[assignee_id milestone_id]
......
- if current_board_parent.feature_available?(:wip_limits)
%gl-button.no-drag.rounded-right{ type: "button",
"v-if" => "isSettingsShown",
"aria-label" => _("List Settings"),
"ref" => "settingsBtn",
"title" => _("List Settings") }
= sprite_icon("settings")
%gl-tooltip{ ":target" => "() => $refs.settingsBtn" }
= _("List Settings")
...@@ -161,6 +161,63 @@ describe 'issue boards', :js do ...@@ -161,6 +161,63 @@ describe 'issue boards', :js do
end end
end end
context 'list header' do
let(:max_issue_count) { 2 }
let!(:label) { create(:label, project: project, name: 'Label 2') }
let!(:list) { create(:list, board: board, label: label, position: 0, max_issue_count: max_issue_count) }
let!(:issue) { create(:issue, project: project, labels: [label]) }
before do
project.add_developer(user)
login_as(user)
visit_board_page
end
context 'When FF is turned on' do
before do
stub_licensed_features(wip_limits: true)
end
context 'when max issue count is set' do
let(:total_development_issues) { "1" }
it 'displays issue and max issue size' do
page.within(find(".board:nth-child(2)")) do
expect(page.find('.js-issue-size')).to have_text(total_development_issues)
expect(page.find('.js-max-issue-size')).to have_text(max_issue_count)
end
end
end
end
end
context 'list settings' do
let!(:label) { create(:label, project: project, name: 'Label') }
let!(:list) { create(:list, board: board, label: label, position: 1) }
before do
project.add_developer(user)
login_as(user)
visit project_boards_path(project)
end
context 'When FF is turned on' do
before do
stub_licensed_features(wip_limits: true)
end
it 'shows the list settings button' do
expect(page).to have_selector(:button, "List Settings")
end
end
context 'When FF is turned off' do
it 'shows the list settings button' do
expect(page).to have_no_selector(:button, "List Settings")
end
end
end
def badge(list) def badge(list)
find(".board[data-id='#{list.id}'] .issue-count-badge") find(".board[data-id='#{list.id}'] .issue-count-badge")
end end
......
...@@ -10291,6 +10291,9 @@ msgstr "" ...@@ -10291,6 +10291,9 @@ msgstr ""
msgid "List" msgid "List"
msgstr "" msgstr ""
msgid "List Settings"
msgstr ""
msgid "List Your Gitea Repositories" msgid "List Your Gitea Repositories"
msgstr "" msgstr ""
......
...@@ -5,6 +5,7 @@ FactoryBot.define do ...@@ -5,6 +5,7 @@ FactoryBot.define do
board board
label label
list_type { :label } list_type { :label }
max_issue_count { 0 }
sequence(:position) sequence(:position)
end end
......
...@@ -72,7 +72,6 @@ describe 'Issue Boards', :js do ...@@ -72,7 +72,6 @@ describe 'Issue Boards', :js do
let!(:closed) { create(:label, project: project, name: 'Closed') } let!(:closed) { create(:label, project: project, name: 'Closed') }
let!(:accepting) { create(:label, project: project, name: 'Accepting Merge Requests') } let!(:accepting) { create(:label, project: project, name: 'Accepting Merge Requests') }
let!(:a_plus) { create(:label, project: project, name: 'A+') } let!(:a_plus) { create(:label, project: project, name: 'A+') }
let!(:list1) { create(:list, board: board, label: planning, position: 0) } let!(:list1) { create(:list, board: board, label: planning, position: 0) }
let!(:list2) { create(:list, board: board, label: development, position: 1) } let!(:list2) { create(:list, board: board, label: development, position: 1) }
...@@ -289,6 +288,17 @@ describe 'Issue Boards', :js do ...@@ -289,6 +288,17 @@ describe 'Issue Boards', :js do
expect(page).to have_selector('.avatar', count: 1) expect(page).to have_selector('.avatar', count: 1)
end end
end end
context 'list header' do
let(:total_planning_issues) { "8" }
it 'shows issue count on the list' do
page.within(find(".board:nth-child(2)")) do
expect(page.find('.js-issue-size')).to have_text(total_planning_issues)
expect(page).not_to have_selector('.js-max-issue-size')
end
end
end
end end
context 'new list' do context 'new list' do
......
import { shallowMount } from '@vue/test-utils';
import IssueCount from '~/boards/components/issue_count.vue';
describe('IssueCount', () => {
let vm;
let maxIssueCount;
let issuesSize;
const createComponent = props => {
vm = shallowMount(IssueCount, { propsData: props });
};
afterEach(() => {
maxIssueCount = 0;
issuesSize = 0;
if (vm) vm.destroy();
});
describe('when maxIssueCount is zero', () => {
beforeEach(() => {
issuesSize = 3;
createComponent({ maxIssueCount: 0, issuesSize });
});
it('contains issueSize in the template', () => {
expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
});
it('does not contains maxIssueCount in the template', () => {
expect(vm.contains('.js-max-issue-size')).toBe(false);
});
});
describe('when maxIssueCount is greater than zero', () => {
beforeEach(() => {
maxIssueCount = 2;
issuesSize = 1;
createComponent({ maxIssueCount, issuesSize });
});
afterEach(() => {
vm.destroy();
});
it('contains issueSize in the template', () => {
expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
});
it('contains maxIssueCount in the template', () => {
expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount));
});
it('does not have text-danger class when issueSize is less than maxIssueCount', () => {
expect(vm.classes('.text-danger')).toBe(false);
});
});
describe('when issueSize is greater than maxIssueCount', () => {
beforeEach(() => {
issuesSize = 3;
maxIssueCount = 2;
createComponent({ maxIssueCount, issuesSize });
});
afterEach(() => {
vm.destroy();
});
it('contains issueSize in the template', () => {
expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
});
it('contains maxIssueCount in the template', () => {
expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount));
});
it('has text-danger class', () => {
expect(vm.find('.text-danger').text()).toEqual(String(issuesSize));
});
});
});
...@@ -207,4 +207,56 @@ describe('Board list component', () => { ...@@ -207,4 +207,56 @@ describe('Board list component', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('max issue count warning', () => {
beforeEach(done => {
({ mock, component } = createComponent({
done,
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
}));
});
afterEach(() => {
mock.restore();
component.$destroy();
});
describe('when issue count exceeds max issue count', () => {
it('sets background to bg-danger-100', done => {
component.list.issuesSize = 4;
component.list.maxIssueCount = 3;
Vue.nextTick(() => {
expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
done();
});
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to bg-danger-100', done => {
component.list.issuesSize = 2;
component.list.maxIssueCount = 3;
Vue.nextTick(() => {
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
done();
});
});
});
describe('when list max issue count is 0', () => {
it('does not sets background to bg-danger-100', done => {
component.list.maxIssueCount = 0;
Vue.nextTick(() => {
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
done();
});
});
});
});
}); });
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