Commit c9a80dc0 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'ss/add-max-issue-count-wip-limits' into 'master'

Add functionality for when user edits max issue count

See merge request gitlab-org/gitlab!21962
parents e6aea599 a3c79ce0
......@@ -573,6 +573,7 @@ img.emoji {
.gl-font-size-large { font-size: $gl-font-size-large; }
.gl-line-height-24 { line-height: $gl-line-height-24; }
.gl-line-height-14 { line-height: $gl-line-height-14; }
.gl-font-size-12 { font-size: $gl-font-size-12; }
.gl-font-size-14 { font-size: $gl-font-size-14; }
......
<script>
import { GlDrawer, GlLabel } from '@gitlab/ui';
import { __ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store';
import { GlDrawer, GlLabel, GlButton, GlFormInput } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import boardsStoreEE from '../stores/boards_store_ee';
import flash from '~/flash';
// NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options.
export default {
headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px',
listSettingsText: __('List Settings'),
labelListText: __('List Label'),
editLinkText: __('Edit'),
noneText: __('None'),
wipLimitText: __('Work in Progress Limit'),
components: {
GlDrawer,
GlLabel,
GlButton,
GlFormInput,
},
directives: {
autofocusonshow,
},
data() {
return {
edit: false,
currentWipLimit: 0,
updating: false,
};
},
computed: {
...mapState(['activeListId']),
isOpen() {
return this.activeListId > 0;
},
activeList() {
return boardsStore.state.lists.find(({ id }) => id === this.activeListId);
/*
Warning: Though a computed property it is not reactive because we are
referencing a List Model class. Reactivity only applies to plain JS objects
*/
return boardsStoreEE.store.state.lists.find(({ id }) => id === this.activeListId);
},
isSidebarOpen() {
return this.activeListId > 0;
},
activeListLabel() {
if (this.activeList) {
......@@ -26,34 +50,100 @@ export default {
return { color: '', title: '' };
},
listSettingsText() {
return __('List Settings');
},
labelListText() {
return __('Label List');
activeListWipLimit() {
if (this.activeList) {
return this.activeList.maxIssueCount === 0
? this.$options.noneText
: this.activeList.maxIssueCount;
}
return this.$options.noneText;
},
},
methods: {
...mapActions(['setActiveListId']),
...mapActions(['setActiveListId', 'updateListWipLimit']),
closeSidebar() {
this.edit = false;
this.setActiveListId(0);
},
showInput() {
this.edit = true;
this.currentWipLimit = this.activeList.maxIssueCount;
},
resetStateAfterUpdate() {
this.edit = false;
this.updating = false;
this.currentWipLimit = 0;
},
offFocus() {
if (this.currentWipLimit !== this.activeList.maxIssueCount) {
this.updating = true;
this.updateListWipLimit({ maxIssueCount: this.currentWipLimit, id: this.activeListId })
.then(({ config }) => {
/*
Move to BoardsStore bc we are consolidating all List
Model code and then moving it from BoardsStore to Vuex.
*/
boardsStoreEE.store.findList('id', this.activeListId).maxIssueCount = JSON.parse(
config.data,
).list.max_issue_count;
this.resetStateAfterUpdate();
})
.catch(() => {
this.resetStateAfterUpdate();
this.setActiveListId(0);
flash(__('Something went wrong while updating your list settings'));
});
} else {
this.edit = false;
}
},
onEnter() {
this.offFocus();
},
},
};
</script>
<template>
<gl-drawer :open="isOpen" :header-height="$options.headerHeight" @close="closeSidebar">
<template #header>{{ listSettingsText }}</template>
<gl-drawer
class="js-board-settings-sidebar"
:open="isSidebarOpen"
:header-height="$options.headerHeight"
@close="closeSidebar"
>
<template #header>{{ $options.listSettingsText }}</template>
<template>
<div class="js-board-settings-sidebar d-flex flex-column align-items-start">
<label>{{ labelListText }}</label>
<div class="d-flex flex-column align-items-start">
<label>{{ $options.labelListText }}</label>
<gl-label
:title="activeListLabel.title"
:background-color="activeListLabel.color"
color="light"
/>
</div>
<div class="d-flex justify-content-between">
<div>
<label>{{ $options.wipLimitText }}</label>
<gl-form-input
v-if="edit"
v-model.number="currentWipLimit"
v-autofocusonshow
:disabled="updating"
type="number"
min="0"
trim
@keydown.enter.native="onEnter"
@blur="offFocus"
/>
<p v-else class="js-wip-limit bold">{{ activeListWipLimit }}</p>
</div>
<gl-button class="h-100 border-0 gl-line-height-14" variant="link" @click="showInput">
{{ $options.editLinkText }}
</gl-button>
</div>
</template>
</gl-drawer>
</template>
import axios from 'axios';
import actionsCE from '~/boards/stores/actions';
import boardsStoreEE from './boards_store_ee';
import * as types from './mutation_types';
const notImplemented = () => {
......@@ -16,6 +18,15 @@ export default {
setActiveListId({ commit }, listId) {
commit(types.SET_ACTIVE_LIST_ID, listId);
},
updateListWipLimit({ state }, { maxIssueCount }) {
const { activeListId } = state;
return axios.put(`${boardsStoreEE.store.state.endpoints.listsEndpoint}/${activeListId}`, {
list: {
max_issue_count: maxIssueCount,
},
});
},
fetchAllBoards: () => {
notImplemented();
......
......@@ -216,6 +216,64 @@ describe 'issue boards', :js do
expect(page.find('.js-board-settings-sidebar').find('.gl-label-text')).to have_text("Brount")
end
end
context 'when boards setting sidebar is open' do
before do
page.within(find(".board:nth-child(2)")) do
click_button('List Settings')
end
end
context "when user off clicks" do
it 'updates the max issue count wip limit' do
max_issue_count = 2
page.within(find('.js-board-settings-sidebar')) do
click_button("Edit")
find('input').set(max_issue_count)
end
# Off click
find('body').click
wait_for_requests
expect(page.find('.js-wip-limit')).to have_text(max_issue_count)
end
context "When user sets max issue count to 0" do
it 'updates the max issue count wip limit to None' do
max_issue_count = 0
page.within(find('.js-board-settings-sidebar')) do
click_button("Edit")
find('input').set(max_issue_count)
end
# Off click
find('body').click
wait_for_requests
expect(page.find('.js-wip-limit')).to have_text("None")
end
end
end
context "when user hits enter" do
it 'updates the max issue count wip limit' do
page.within(find('.js-board-settings-sidebar')) do
click_button("Edit")
find('input').set(1).native.send_keys(:return)
end
wait_for_requests
expect(page.find('.js-wip-limit')).to have_text(1)
end
end
end
end
context 'When FF is turned off' do
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BoardSettingsSideBar when activeList is present when activeListWipLimit is 0 renders "None" in the block 1`] = `"None"`;
exports[`BoardSettingsSideBar when activeList is present when activeListWipLimit is greater than 0 it renders 1 1`] = `"1"`;
exports[`BoardSettingsSideBar when activeList is present when activeListWipLimit is greater than 0 it renders 11 1`] = `"11"`;
import '~/boards/models/list';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlDrawer, GlLabel } from '@gitlab/ui';
import { GlDrawer, GlLabel, GlButton, GlFormInput } from '@gitlab/ui';
import BoardSettingsSidebar from 'ee/boards/components/board_settings_sidebar.vue';
import boardsStore from '~/boards/stores/boards_store';
import boardsStore from 'ee_else_ce/boards/stores/boards_store_ee';
import getters from 'ee_else_ce/boards/stores/getters';
import bs from '~/boards/stores/boards_store';
import flash from '~/flash';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/flash');
// NOTE: needed for calling boardsStore.addList
import '~/boards/models/list';
const localVue = createLocalVue();
......@@ -21,25 +26,51 @@ describe('BoardSettingsSideBar', () => {
const labelColor = '#FFFF';
const listId = 1;
const createComponent = (state = {}, actions = {}) => {
const createComponent = (state = {}, actions = {}, localState = {}) => {
storeActions = actions;
const store = new Vuex.Store({
state,
actions: storeActions,
getters,
});
wrapper = shallowMount(BoardSettingsSidebar, {
store,
localVue,
data() {
return localState;
},
sync: false,
});
};
const triggerBlur = type => {
if (type === 'blur') {
wrapper.find(GlFormInput).vm.$emit('blur');
}
if (type === 'enter') {
wrapper.find(GlFormInput).trigger('keydown.enter');
}
};
beforeEach(() => {
boardsStore.create();
// mock CE store
const storeMock = {
state: { lists: [] },
create() {},
setCurrentBoard: jest.fn(),
findList: bs.findList,
addList: bs.addList,
removeList: bs.removeList,
};
boardsStore.initEESpecific(storeMock);
});
afterEach(() => {
jest.restoreAllMocks();
wrapper.destroy();
});
......@@ -91,7 +122,11 @@ describe('BoardSettingsSideBar', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } });
boardsStore.store.addList({
id: listId,
label: { title: labelTitle, color: labelColor },
list_type: 'label',
});
createComponent({ activeListId: listId });
});
......@@ -113,7 +148,7 @@ describe('BoardSettingsSideBar', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } });
boardsStore.store.addList({ id: listId, label: { title: labelTitle, color: labelColor } });
createComponent({ activeListId: 0 });
});
......@@ -131,4 +166,214 @@ describe('BoardSettingsSideBar', () => {
});
});
});
describe('when activeList is present', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
boardsStore.store.removeList(listId);
});
describe('when activeListWipLimit is 0', () => {
beforeEach(() => {
boardsStore.store.addList({
id: listId,
label: { title: labelTitle, color: labelColor },
max_issue_count: 0,
list_type: 'label',
});
});
it('renders "None" in the block', () => {
createComponent({ activeListId: listId });
expect(wrapper.find('.js-wip-limit').text()).toMatchSnapshot();
});
});
describe('when activeListWipLimit is greater than 0', () => {
it.each`
num
${1}
${11}
`('it renders $num', ({ num }) => {
boardsStore.store.addList({
id: num,
label: { title: labelTitle, color: labelColor },
max_issue_count: num,
list_type: 'label',
});
createComponent({ activeListId: num });
expect(wrapper.find('.js-wip-limit').text()).toMatchSnapshot();
});
});
});
describe('when clicking edit', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
boardsStore.store.addList({
id: listId,
label: { title: labelTitle, color: labelColor },
max_issue_count: 4,
list_type: 'label',
});
createComponent({ activeListId: listId });
});
it('renders an input', done => {
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.find(GlFormInput).exists()).toBe(true);
})
.then(done)
.catch(done.fail);
});
it('does not render current wipLimit text', done => {
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.find('.js-wip-limit').exists()).toBe(false);
})
.then(done)
.catch(done.fail);
});
it('sets wipLimit to be the value of list.maxIssueCount', done => {
expect(wrapper.vm.currentWipLimit).toEqual(0);
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.vm.currentWipLimit).toBe(4);
})
.then(done)
.catch(done.fail);
});
});
describe('when edit is true', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
boardsStore.store.addList({
id: listId,
label: { title: labelTitle, color: labelColor },
max_issue_count: 2,
list_type: 'label',
});
});
afterEach(() => {
flash.mockReset();
boardsStore.store.removeList(listId, 'label');
});
describe.each`
blurMethod
${'enter'}
${'blur'}
`('$blurMethod', ({ blurMethod }) => {
describe(`when blur is triggered by ${blurMethod}`, () => {
it('calls updateListWipLimit', () => {
const spy = jest.fn().mockResolvedValue({
config: { data: JSON.stringify({ list: { max_issue_count: 'hello' } }) },
});
createComponent({ activeListId: 1 }, { updateListWipLimit: spy }, { edit: true });
triggerBlur(blurMethod);
return wrapper.vm.$nextTick().then(() => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('when component wipLimit and List.maxIssueCount are equal', () => {
it('doesnt call updateListWipLimit', () => {
const spy = jest.fn(() =>
Promise.resolve({
config: { data: JSON.stringify({ list: { max_issue_count: 0 } }) },
}),
);
createComponent(
{ activeListId: 1 },
{ updateListWipLimit: spy },
{ edit: true, currentWipLimit: 2 },
);
triggerBlur(blurMethod);
return wrapper.vm.$nextTick().then(() => {
expect(spy).toHaveBeenCalledTimes(0);
});
});
});
describe('when response is successful', () => {
const maxIssueCount = 11;
beforeEach(() => {
const spy = jest.fn(() =>
Promise.resolve({
config: { data: JSON.stringify({ list: { max_issue_count: maxIssueCount } }) },
}),
);
createComponent({ activeListId: 1 }, { updateListWipLimit: spy }, { edit: true });
triggerBlur(blurMethod);
return waitForPromises();
});
it('sets activeWipLimit to new maxIssueCount value', () => {
/*
* DANGER: bad coupling to the computed prop of the component because the
* computed prop relys on the list from boardStore, for now this is the way around
* stale values from boardsStore being updated, when we move List and BoardsStore to Vuex
* or Graphql we will be able to query the DOM for the new value.
*/
expect(wrapper.vm.activeList.maxIssueCount).toEqual(maxIssueCount);
});
it('toggles GlFormInput on blur', () => {
expect(wrapper.find(GlFormInput).exists()).toBe(false);
expect(wrapper.find('.js-wip-limit').exists()).toBe(true);
expect(wrapper.vm.updating).toBe(false);
});
});
describe('when response fails', () => {
beforeEach(() => {
const spy = jest.fn().mockRejectedValue();
createComponent(
{ activeListId: 1 },
{ updateListWipLimit: spy, setActiveListId: () => {} },
{ edit: true },
);
triggerBlur(blurMethod);
return waitForPromises();
});
it('calls flash with expected error', () => {
expect(flash).toHaveBeenCalledTimes(1);
});
});
});
});
});
});
import axios from 'axios';
import boardsStoreEE from 'ee/boards/stores/boards_store_ee';
import actions from 'ee/boards/stores/actions';
import * as types from 'ee/boards/stores/mutation_types';
import testAction from 'helpers/vuex_action_helper';
jest.mock('axios');
const expectNotImplemented = action => {
it('is not implemented', () => {
expect(action).toThrow(new Error('Not implemented!'));
......@@ -35,6 +39,33 @@ describe('setActiveListId', () => {
});
});
describe('updateListWipLimit', () => {
let storeMock;
beforeEach(() => {
storeMock = {
state: { endpoints: { listsEndpoint: '/test' } },
create: () => {},
setCurrentBoard: () => {},
};
boardsStoreEE.initEESpecific(storeMock);
});
it('should call the correct url', () => {
axios.put.mockResolvedValue({ data: {} });
const maxIssueCount = 0;
const activeListId = 1;
return actions.updateListWipLimit({ state: { activeListId } }, { maxIssueCount }).then(() => {
expect(axios.put).toHaveBeenCalledWith(
`${boardsStoreEE.store.state.endpoints.listsEndpoint}/${activeListId}`,
{ list: { max_issue_count: maxIssueCount } },
);
});
});
});
describe('fetchAllBoards', () => {
expectNotImplemented(actions.fetchAllBoards);
});
......
......@@ -10365,9 +10365,6 @@ msgstr ""
msgid "Label"
msgstr ""
msgid "Label List"
msgstr ""
msgid "Label actions dropdown"
msgstr ""
......@@ -10791,6 +10788,9 @@ msgstr ""
msgid "List"
msgstr ""
msgid "List Label"
msgstr ""
msgid "List Settings"
msgstr ""
......@@ -16882,6 +16882,9 @@ msgstr ""
msgid "Something went wrong while stopping this environment. Please try again."
msgstr ""
msgid "Something went wrong while updating your list settings"
msgstr ""
msgid "Something went wrong, unable to add %{project} to dashboard"
msgstr ""
......@@ -20653,6 +20656,9 @@ msgstr ""
msgid "Withdraw Access Request"
msgstr ""
msgid "Work in Progress Limit"
msgstr ""
msgid "Workflow Help"
msgstr ""
......
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