Commit 4e380c30 authored by Florie Guibert's avatar Florie Guibert Committed by Kushal Pandya

Epic Boards - Collapse list

- Save collapsed state of list on localStorage
- Hide new button in header
- Hide Delete Board option
parent 0852e6bb
...@@ -10,7 +10,7 @@ import { ...@@ -10,7 +10,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { isListDraggable } from '~/boards/boards_util'; import { isListDraggable } from '~/boards/boards_util';
import { isScopedLabel } from '~/lib/utils/common_utils'; import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale'; import { n__, s__, __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
...@@ -69,7 +69,7 @@ export default { ...@@ -69,7 +69,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['activeId']), ...mapState(['activeId', 'isEpicBoard']),
isLoggedIn() { isLoggedIn() {
return Boolean(this.currentUserId); return Boolean(this.currentUserId);
}, },
...@@ -97,11 +97,14 @@ export default { ...@@ -97,11 +97,14 @@ export default {
showListDetails() { showListDetails() {
return !this.list.collapsed || !this.isSwimlanesHeader; return !this.list.collapsed || !this.isSwimlanesHeader;
}, },
issuesCount() { itemsCount() {
return this.list.issuesCount; return this.list.issuesCount;
}, },
issuesTooltipLabel() { countIcon() {
return n__(`%d issue`, `%d issues`, this.issuesCount); return 'issues';
},
itemsTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.itemsCount);
}, },
chevronTooltip() { chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
...@@ -110,7 +113,7 @@ export default { ...@@ -110,7 +113,7 @@ export default {
return this.list.collapsed ? 'chevron-down' : 'chevron-right'; return this.list.collapsed ? 'chevron-down' : 'chevron-right';
}, },
isNewIssueShown() { isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton; return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
}, },
isSettingsShown() { isSettingsShown() {
return ( return (
...@@ -131,8 +134,14 @@ export default { ...@@ -131,8 +134,14 @@ export default {
return !this.disabled && isListDraggable(this.list); return !this.disabled && isListDraggable(this.list);
}, },
}, },
created() {
const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`));
if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) {
this.toggleListCollapsed({ listId: this.list.id, collapsed: true });
}
},
methods: { methods: {
...mapActions(['updateList', 'setActiveId']), ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']),
openSidebarSettings() { openSidebarSettings() {
if (this.activeId === inactiveId) { if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll'); sidebarEventHub.$emit('sidebar.closeAll');
...@@ -148,10 +157,10 @@ export default { ...@@ -148,10 +157,10 @@ export default {
eventHub.$emit(`toggle-issue-form-${this.list.id}`); eventHub.$emit(`toggle-issue-form-${this.list.id}`);
}, },
toggleExpanded() { toggleExpanded() {
// eslint-disable-next-line vue/no-mutating-props const collapsed = !this.list.collapsed;
this.list.collapsed = !this.list.collapsed; this.toggleListCollapsed({ listId: this.list.id, collapsed });
if (!this.isLoggedIn) { if (!this.isLoggedIn || this.isEpicBoard) {
this.addToLocalStorage(); this.addToLocalStorage();
} else { } else {
this.updateListFunction(); this.updateListFunction();
...@@ -163,7 +172,7 @@ export default { ...@@ -163,7 +172,7 @@ export default {
}, },
addToLocalStorage() { addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) { if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed); localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed);
} }
}, },
updateListFunction() { updateListFunction() {
...@@ -203,6 +212,7 @@ export default { ...@@ -203,6 +212,7 @@ export default {
class="board-title-caret no-drag gl-cursor-pointer" class="board-title-caret no-drag gl-cursor-pointer"
category="tertiary" category="tertiary"
size="small" size="small"
data-testid="board-title-caret"
@click="toggleExpanded" @click="toggleExpanded"
/> />
<!-- EE start --> <!-- EE start -->
...@@ -301,11 +311,11 @@ export default { ...@@ -301,11 +311,11 @@ export default {
<div v-if="list.maxIssueCount !== 0"> <div v-if="list.maxIssueCount !== 0">
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
<template #issuesSize>{{ issuesTooltipLabel }}</template> <template #issuesSize>{{ itemsTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf> </gl-sprintf>
</div> </div>
<div v-else>• {{ issuesTooltipLabel }}</div> <div v-else>• {{ itemsTooltipLabel }}</div>
<div v-if="weightFeatureAvailable"> <div v-if="weightFeatureAvailable">
<gl-sprintf :message="__('%{totalWeight} total weight')"> <gl-sprintf :message="__('%{totalWeight} total weight')">
...@@ -323,13 +333,13 @@ export default { ...@@ -323,13 +333,13 @@ export default {
}" }"
> >
<span class="gl-display-inline-flex"> <span class="gl-display-inline-flex">
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
<span ref="issueCount" class="issue-count-badge-count"> <span ref="itemCount" class="issue-count-badge-count">
<gl-icon class="gl-mr-2" name="issues" /> <gl-icon class="gl-mr-2" :name="countIcon" />
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> <issue-count :issues-size="itemsCount" :max-issue-count="list.maxIssueCount" />
</span> </span>
<!-- EE start --> <!-- EE start -->
<template v-if="weightFeatureAvailable"> <template v-if="weightFeatureAvailable && !isEpicBoard">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
<gl-icon class="gl-mr-2" name="weight" /> <gl-icon class="gl-mr-2" name="weight" />
......
...@@ -256,6 +256,10 @@ export default { ...@@ -256,6 +256,10 @@ export default {
}); });
}, },
toggleListCollapsed: ({ commit }, { listId, collapsed }) => {
commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed });
},
removeList: ({ state, commit }, listId) => { removeList: ({ state, commit }, listId) => {
const listsBackup = { ...state.boardLists }; const listsBackup = { ...state.boardLists };
......
...@@ -14,6 +14,7 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; ...@@ -14,6 +14,7 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
export const MOVE_LIST = 'MOVE_LIST'; export const MOVE_LIST = 'MOVE_LIST';
export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED';
export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST = 'REMOVE_LIST';
export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
......
...@@ -105,6 +105,10 @@ export default { ...@@ -105,6 +105,10 @@ export default {
Vue.set(state, 'boardLists', backupList); Vue.set(state, 'boardLists', backupList);
}, },
[mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => {
Vue.set(state.boardLists[listId], 'collapsed', collapsed);
},
[mutationTypes.REMOVE_LIST]: (state, listId) => { [mutationTypes.REMOVE_LIST]: (state, listId) => {
Vue.delete(state.boardLists, listId); Vue.delete(state.boardLists, listId);
}, },
......
<script> <script>
import { mapState } from 'vuex';
// This is a false violation of @gitlab/no-runtime-template-compiler, since it // This is a false violation of @gitlab/no-runtime-template-compiler, since it
// extends a valid Vue single file component. // extends a valid Vue single file component.
/* eslint-disable @gitlab/no-runtime-template-compiler */ /* eslint-disable @gitlab/no-runtime-template-compiler */
import BoardListHeaderFoss from '~/boards/components/board_list_header.vue'; import BoardListHeaderFoss from '~/boards/components/board_list_header.vue';
import { __, sprintf, s__ } from '~/locale'; import { n__, __, sprintf, s__ } from '~/locale';
export default { export default {
extends: BoardListHeaderFoss, extends: BoardListHeaderFoss,
inject: ['weightFeatureAvailable'], inject: ['weightFeatureAvailable'],
computed: { computed: {
issuesTooltip() { ...mapState(['isEpicBoard']),
countIcon() {
return this.isEpicBoard ? 'epic' : 'issues';
},
itemsCount() {
return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount;
},
itemsTooltipLabel() {
const { maxIssueCount } = this.list; const { maxIssueCount } = this.list;
if (maxIssueCount > 0) { if (maxIssueCount > 0) {
return sprintf(__('%{issuesCount} issues with a limit of %{maxIssueCount}'), { return sprintf(__('%{itemsCount} issues with a limit of %{maxIssueCount}'), {
issuesCount: this.issuesCount, itemsCount: this.itemsCount,
maxIssueCount, maxIssueCount,
}); });
} }
// TODO: Remove this pattern. return this.isEpicBoard
return BoardListHeaderFoss.computed.issuesTooltip.call(this); ? n__(`%d epic`, `%d epics`, this.itemsCount)
: n__(`%d issue`, `%d issues`, this.itemsCount);
}, },
weightCountToolTip() { weightCountToolTip() {
const { totalWeight } = this.list; const { totalWeight } = this.list;
......
...@@ -14,6 +14,9 @@ export default { ...@@ -14,6 +14,9 @@ export default {
showCreate() { showCreate() {
return this.isEpicBoard || this.multipleIssueBoardsAvailable; return this.isEpicBoard || this.multipleIssueBoardsAvailable;
}, },
showDelete() {
return this.boards.length > 1 && !this.isEpicBoard;
},
}, },
methods: { methods: {
epicBoardUpdate(data) { epicBoardUpdate(data) {
......
...@@ -5,6 +5,8 @@ fragment EpicBoardListFragment on EpicList { ...@@ -5,6 +5,8 @@ fragment EpicBoardListFragment on EpicList {
title title
position position
listType listType
collapsed
epicsCount
label { label {
...Label ...Label
} }
......
...@@ -10,8 +10,8 @@ RSpec.describe 'epic boards', :js do ...@@ -10,8 +10,8 @@ RSpec.describe 'epic boards', :js do
let_it_be(:label) { create(:group_label, group: group, name: 'Label1') } let_it_be(:label) { create(:group_label, group: group, name: 'Label1') }
let_it_be(:label2) { create(:group_label, group: group, name: 'Label2') } let_it_be(:label2) { create(:group_label, group: group, name: 'Label2') }
let_it_be(:label_list) { create(:epic_list, epic_board: epic_board, label: label, position: 0) } let_it_be(:label_list) { create(:epic_list, epic_board: epic_board, label: label, position: 0) }
let_it_be(:backlog_list) { create(:epic_list, epic_board: epic_board, list_type: :closed) } let_it_be(:backlog_list) { create(:epic_list, epic_board: epic_board, list_type: :backlog) }
let_it_be(:closed_list) { create(:epic_list, epic_board: epic_board, list_type: :backlog) } let_it_be(:closed_list) { create(:epic_list, epic_board: epic_board, list_type: :closed) }
let_it_be(:epic1) { create(:epic, group: group, labels: [label], title: 'Epic1') } let_it_be(:epic1) { create(:epic, group: group, labels: [label], title: 'Epic1') }
let_it_be(:epic2) { create(:epic, group: group, title: 'Epic2') } let_it_be(:epic2) { create(:epic, group: group, title: 'Epic2') }
...@@ -38,6 +38,8 @@ RSpec.describe 'epic boards', :js do ...@@ -38,6 +38,8 @@ RSpec.describe 'epic boards', :js do
end end
it 'displays two epics in Open list' do it 'displays two epics in Open list' do
expect(list_header(backlog_list)).to have_content('2')
page.within("[data-board-type='backlog']") do page.within("[data-board-type='backlog']") do
expect(page).to have_selector('.board-card', count: 2) expect(page).to have_selector('.board-card', count: 2)
page.within(first('.board-card')) do page.within(first('.board-card')) do
...@@ -51,6 +53,8 @@ RSpec.describe 'epic boards', :js do ...@@ -51,6 +53,8 @@ RSpec.describe 'epic boards', :js do
end end
it 'displays one epic in Label list' do it 'displays one epic in Label list' do
expect(list_header(label_list)).to have_content('1')
page.within("[data-board-type='label']") do page.within("[data-board-type='label']") do
expect(page).to have_selector('.board-card', count: 1) expect(page).to have_selector('.board-card', count: 1)
page.within(first('.board-card')) do page.within(first('.board-card')) do
...@@ -79,4 +83,8 @@ RSpec.describe 'epic boards', :js do ...@@ -79,4 +83,8 @@ RSpec.describe 'epic boards', :js do
visit group_epic_boards_path(group) visit group_epic_boards_path(group)
wait_for_requests wait_for_requests
end end
def list_header(list)
find(".board[data-id='gid://gitlab/Boards::EpicList/#{list.id}'] .board-header")
end
end end
...@@ -173,6 +173,11 @@ msgid_plural "%d days" ...@@ -173,6 +173,11 @@ msgid_plural "%d days"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d epic"
msgid_plural "%d epics"
msgstr[0] ""
msgstr[1] ""
msgid "%d error" msgid "%d error"
msgid_plural "%d errors" msgid_plural "%d errors"
msgstr[0] "" msgstr[0] ""
...@@ -559,6 +564,9 @@ msgstr "" ...@@ -559,6 +564,9 @@ msgstr ""
msgid "%{issuesSize} with a limit of %{maxIssueCount}" msgid "%{issuesSize} with a limit of %{maxIssueCount}"
msgstr "" msgstr ""
msgid "%{itemsCount} issues with a limit of %{maxIssueCount}"
msgstr ""
msgid "%{labelStart}Actual response:%{labelEnd} %{headers}" msgid "%{labelStart}Actual response:%{labelEnd} %{headers}"
msgstr "" msgstr ""
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockLabelList } from 'jest/boards/mock_data'; import { mockLabelList } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header.vue'; import BoardListHeader from '~/boards/components/board_list_header.vue';
...@@ -14,6 +15,7 @@ describe('Board List Header Component', () => { ...@@ -14,6 +15,7 @@ describe('Board List Header Component', () => {
let store; let store;
const updateListSpy = jest.fn(); const updateListSpy = jest.fn();
const toggleListCollapsedSpy = jest.fn();
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -43,18 +45,19 @@ describe('Board List Header Component', () => { ...@@ -43,18 +45,19 @@ describe('Board List Header Component', () => {
if (withLocalStorage) { if (withLocalStorage) {
localStorage.setItem( localStorage.setItem(
`boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, `boards.${boardId}.${listMock.listType}.${listMock.id}.collapsed`,
(!collapsed).toString(), collapsed.toString(),
); );
} }
store = new Vuex.Store({ store = new Vuex.Store({
state: {}, state: {},
actions: { updateList: updateListSpy }, actions: { updateList: updateListSpy, toggleListCollapsed: toggleListCollapsedSpy },
getters: {}, getters: {},
}); });
wrapper = shallowMount(BoardListHeader, { wrapper = extendedWrapper(
shallowMount(BoardListHeader, {
store, store,
localVue, localVue,
propsData: { propsData: {
...@@ -66,15 +69,15 @@ describe('Board List Header Component', () => { ...@@ -66,15 +69,15 @@ describe('Board List Header Component', () => {
weightFeatureAvailable: false, weightFeatureAvailable: false,
currentUserId, currentUserId,
}, },
}); }),
);
}; };
const isCollapsed = () => wrapper.vm.list.collapsed; const isCollapsed = () => wrapper.vm.list.collapsed;
const isExpanded = () => !isCollapsed;
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
const findTitle = () => wrapper.find('.board-title'); const findTitle = () => wrapper.find('.board-title');
const findCaret = () => wrapper.find('.board-title-caret'); const findCaret = () => wrapper.findByTestId('board-title-caret');
describe('Add issue button', () => { describe('Add issue button', () => {
const hasNoAddButton = [ListType.closed]; const hasNoAddButton = [ListType.closed];
...@@ -114,40 +117,29 @@ describe('Board List Header Component', () => { ...@@ -114,40 +117,29 @@ describe('Board List Header Component', () => {
}); });
describe('expanding / collapsing the column', () => { describe('expanding / collapsing the column', () => {
it('does not collapse when clicking the header', async () => { it('should display collapse icon when column is expanded', async () => {
createComponent(); createComponent();
expect(isCollapsed()).toBe(false); const icon = findCaret();
wrapper.find('[data-testid="board-list-header"]').trigger('click');
await wrapper.vm.$nextTick();
expect(isCollapsed()).toBe(false); expect(icon.props('icon')).toBe('chevron-right');
}); });
it('collapses expanded Column when clicking the collapse icon', async () => { it('should display expand icon when column is collapsed', async () => {
createComponent(); createComponent({ collapsed: true });
expect(isCollapsed()).toBe(false);
findCaret().vm.$emit('click');
await wrapper.vm.$nextTick(); const icon = findCaret();
expect(isCollapsed()).toBe(true); expect(icon.props('icon')).toBe('chevron-down');
}); });
it('expands collapsed Column when clicking the expand icon', async () => { it('should dispatch toggleListCollapse when clicking the collapse icon', async () => {
createComponent({ collapsed: true }); createComponent();
expect(isCollapsed()).toBe(true);
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(toggleListCollapsedSpy).toHaveBeenCalledTimes(1);
expect(isCollapsed()).toBe(false);
}); });
it("when logged in it calls list update and doesn't set localStorage", async () => { it("when logged in it calls list update and doesn't set localStorage", async () => {
...@@ -157,7 +149,7 @@ describe('Board List Header Component', () => { ...@@ -157,7 +149,7 @@ describe('Board List Header Component', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(updateListSpy).toHaveBeenCalledTimes(1); expect(updateListSpy).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(null);
}); });
it("when logged out it doesn't call list update and sets localStorage", async () => { it("when logged out it doesn't call list update and sets localStorage", async () => {
...@@ -167,7 +159,7 @@ describe('Board List Header Component', () => { ...@@ -167,7 +159,7 @@ describe('Board List Header Component', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(updateListSpy).not.toHaveBeenCalled(); expect(updateListSpy).not.toHaveBeenCalled();
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(String(isCollapsed()));
}); });
}); });
......
...@@ -452,6 +452,22 @@ describe('updateList', () => { ...@@ -452,6 +452,22 @@ describe('updateList', () => {
}); });
}); });
describe('toggleListCollapsed', () => {
it('should commit TOGGLE_LIST_COLLAPSED mutation', async () => {
const payload = { listId: 'gid://gitlab/List/1', collapsed: true };
await testAction({
action: actions.toggleListCollapsed,
payload,
expectedMutations: [
{
type: types.TOGGLE_LIST_COLLAPSED,
payload,
},
],
});
});
});
describe('removeList', () => { describe('removeList', () => {
let state; let state;
const list = mockLists[0]; const list = mockLists[0];
......
...@@ -202,6 +202,24 @@ describe('Board Store Mutations', () => { ...@@ -202,6 +202,24 @@ describe('Board Store Mutations', () => {
}); });
}); });
describe('TOGGLE_LIST_COLLAPSED', () => {
it('updates collapsed attribute of list in boardLists state', () => {
const listId = 'gid://gitlab/List/1';
state = {
...state,
boardLists: {
[listId]: mockLists[0],
},
};
expect(state.boardLists[listId].collapsed).toEqual(false);
mutations.TOGGLE_LIST_COLLAPSED(state, { listId, collapsed: true });
expect(state.boardLists[listId].collapsed).toEqual(true);
});
});
describe('REMOVE_LIST', () => { describe('REMOVE_LIST', () => {
it('removes list from boardLists', () => { it('removes list from boardLists', () => {
const [list, secondList] = mockLists; const [list, secondList] = mockLists;
......
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