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 Sortable from 'sortablejs';
import Vue from 'vue';
import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui';
import { n__, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
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 BoardBlankState from './board_blank_state.vue';
import BoardDelete from './board_delete';
import BoardList from './board_list.vue';
import IssueCount from './issue_count.vue';
import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
import { ListType } from '../constants';
export default Vue.extend({
components: {
......@@ -17,10 +21,15 @@ export default Vue.extend({
BoardDelete,
BoardList,
Icon,
GlButtonGroup,
IssueCount,
GlButton,
GlTooltip,
},
directives: {
Tooltip,
},
mixins: [isWipLimitsOn],
props: {
list: {
type: Object,
......@@ -53,6 +62,11 @@ export default Vue.extend({
isLoggedIn() {
return Boolean(gon.current_user_id);
},
showListHeaderButton() {
return (
!this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank
);
},
counterTooltip() {
const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`;
......@@ -61,11 +75,19 @@ export default Vue.extend({
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
},
isNewIssueShown() {
return this.list.type === ListType.backlog || this.showListHeaderButton;
},
isSettingsShown() {
return (
this.list.type === 'backlog' ||
(!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank')
this.list.type !== ListType.backlog &&
this.showListHeaderButton &&
this.list.isExpanded &&
this.isWipLimitsOn
);
},
showBoardListAndBoardInfo() {
return this.list.type !== ListType.blank && this.list.type !== ListType.promotion;
},
uniqueKey() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
......
......@@ -71,6 +71,9 @@ export default {
total: this.list.issuesSize,
});
},
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
},
},
watch: {
filters: {
......@@ -435,7 +438,7 @@ export default {
ref="list"
:data-board="list.id"
: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"
>
<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 = {
backlog: 'backlog',
closed: 'closed',
label: 'label',
promotion: 'promotion',
blank: 'blank',
};
export default {
......
export default {
computed: {
isWipLimitsOn() {
return false;
},
},
};
......@@ -52,6 +52,9 @@ class List {
this.loadingMore = false;
this.issues = obj.issues || [];
this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
this.maxIssueCount = Object.hasOwnProperty.call(obj, 'max_issue_count')
? obj.max_issue_count
: 0;
this.defaultAvatar = defaultAvatar;
if (obj.label) {
......
......@@ -72,7 +72,7 @@ export default {
{{ __('Related merge requests') }}
</span>
<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">
<svg class="s16 mr-1 text-secondary">
<icon name="merge-request" class="mr-1 text-secondary" />
......
......@@ -187,6 +187,10 @@
font-size: 1em;
border-bottom: 1px solid $border-color;
padding: $gl-padding-8 $gl-padding;
.js-max-issue-size::before {
content: '/';
}
}
.board-title-text {
......
......@@ -2,7 +2,6 @@
.mr-count-badge {
display: inline-flex;
border-radius: $border-radius-base;
border: 1px solid $border-color;
padding: 5px $gl-padding-8;
}
......
......@@ -9,6 +9,7 @@ module BoardsActions
before_action :boards, only: :index
before_action :board, only: :show
before_action :push_wip_limits, only: :index
end
def index
......@@ -24,6 +25,10 @@ module BoardsActions
private
# Noop on FOSS
def push_wip_limits
end
def boards
strong_memoize(:boards) do
Boards::ListService.new(parent, current_user).execute
......
......@@ -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" }
= 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.issue-count-badge-count
%icon.mr-1{ name: "issues" }
{{ list.issuesSize }}
%issue-count{ ":maxIssueCount" => "list.maxIssueCount",
":issuesSize" => "list.issuesSize" }
= 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" }
%gl-button.issue-count-badge-add-button.no-drag{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => "isNewIssueShown",
":class": "{ 'd-none': !list.isExpanded }",
":class": "{ 'd-none': !list.isExpanded, 'rounded-right': isNewIssueShown && !isSettingsShown }",
"aria-label" => _("New issue"),
"title" => _("New issue"),
data: { placement: "top", container: "body" } }
"ref" => "newIssueBtn" }
= 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",
":issues" => "list.issues",
":loading" => "list.loading",
......
export default {
computed: {
isWipLimitsOn() {
return gon.features.wipLimits;
},
},
};
......@@ -180,7 +180,7 @@ export default {
</a>
<div class="d-inline-flex lh-100 align-middle">
<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">
<icon :name="issuableTypeIcon" class="mr-1 text-secondary" />
......
......@@ -7,5 +7,13 @@ module EE
prepended do
include ::MultipleBoardsActions
end
private
def push_wip_limits
if parent.feature_available?(:wip_limits)
push_frontend_feature_flag(:wip_limits)
end
end
end
end
......@@ -5,6 +5,10 @@ module EE
module ListsController
extend ::Gitlab::Utils::Override
included do
before_action :push_wip_limits
end
override :list_creation_attrs
def list_creation_attrs
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
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)
find(".board[data-id='#{list.id}'] .issue-count-badge")
end
......
......@@ -10291,6 +10291,9 @@ msgstr ""
msgid "List"
msgstr ""
msgid "List Settings"
msgstr ""
msgid "List Your Gitea Repositories"
msgstr ""
......
......@@ -5,6 +5,7 @@ FactoryBot.define do
board
label
list_type { :label }
max_issue_count { 0 }
sequence(:position)
end
......
......@@ -72,7 +72,6 @@ describe 'Issue Boards', :js do
let!(:closed) { create(:label, project: project, name: 'Closed') }
let!(:accepting) { create(:label, project: project, name: 'Accepting Merge Requests') }
let!(:a_plus) { create(:label, project: project, name: 'A+') }
let!(:list1) { create(:list, board: board, label: planning, position: 0) }
let!(:list2) { create(:list, board: board, label: development, position: 1) }
......@@ -289,6 +288,17 @@ describe 'Issue Boards', :js do
expect(page).to have_selector('.avatar', count: 1)
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
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', () => {
.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