Commit c61afc91 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '321770-allow-user-to-save-collapsed-status-of-epic-board-list-frontend' into 'master'

Epic Boards - Collapse list and polish [RUN AS-IF-FOSS]

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