Commit cb85dab0 authored by Scott Stern's avatar Scott Stern Committed by Kushal Pandya

Add foundation for saving collapsed state based on current user

Add boilerplate and update backend to
support saving board list state to backend
parent 5bd93d1e
......@@ -50,6 +50,9 @@ export default Vue.extend({
};
},
computed: {
isLoggedIn() {
return Boolean(gon.current_user_id);
},
counterTooltip() {
const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`;
......@@ -106,7 +109,11 @@ export default Vue.extend({
Sortable.create(this.$el.parentNode, sortableOptions);
},
created() {
if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) {
if (
this.list.isExpandable &&
AccessorUtilities.isLocalStorageAccessSafe() &&
!this.isLoggedIn
) {
const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false';
this.list.isExpanded = !isCollapsed;
......@@ -120,10 +127,14 @@ export default Vue.extend({
if (this.list.isExpandable) {
this.list.isExpanded = !this.list.isExpanded;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) {
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
}
if (this.isLoggedIn) {
this.list.update();
}
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
// Close all tooltips manually to prevent dangling tooltips.
$('.tooltip').tooltip('hide');
......
......@@ -45,7 +45,7 @@ class List {
const typeInfo = this.getTypeInfo(this.type);
this.preset = Boolean(typeInfo.isPreset);
this.isExpandable = Boolean(typeInfo.isExpandable);
this.isExpanded = true;
this.isExpanded = !obj.collapsed;
this.page = 1;
this.loading = true;
this.loadingMore = false;
......@@ -113,7 +113,8 @@ class List {
}
update() {
gl.boardService.updateList(this.id, this.position).catch(() => {
const collapsed = !this.isExpanded;
return gl.boardService.updateList(this.id, this.position, collapsed).catch(() => {
// TODO: handle request error
});
}
......
......@@ -32,8 +32,8 @@ export default class BoardService {
return boardsStore.createList(entityId, entityType);
}
updateList(id, position) {
return boardsStore.updateList(id, position);
updateList(id, position, collapsed) {
return boardsStore.updateList(id, position, collapsed);
}
destroyList(id) {
......
......@@ -278,10 +278,11 @@ const boardsStore = {
});
},
updateList(id, position) {
updateList(id, position, collapsed) {
return axios.put(`${this.state.endpoints.listsEndpoint}/${id}`, {
list: {
position,
collapsed,
},
});
},
......
......@@ -25,7 +25,7 @@ module Boards
end
def update
list = board.lists.movable.find(params[:id])
list = board.lists.find(params[:id])
service = Boards::Lists::UpdateService.new(board_parent, current_user, update_list_params)
result = service.execute(list)
......
---
title: Frontend support for saving issue board preferences on the current user
merge_request: 16421
author:
type: changed
......@@ -185,6 +185,24 @@ describe Boards::ListsController do
end
end
context 'with a list_type other than :label' do
let!(:closed) { create(:closed_list, board: board, position: 2) }
it 'saves collapsed preference for user' do
save_setting user: user, board: board, list: closed, setting: { collapsed: true }
expect(closed.preferences_for(user).collapsed).to eq(true)
expect(response).to have_gitlab_http_status(200)
end
it 'saves not collapsed preference for user' do
save_setting user: user, board: board, list: closed, setting: { collapsed: false }
expect(closed.preferences_for(user).collapsed).to eq(false)
expect(response).to have_gitlab_http_status(200)
end
end
def move(user:, board:, list:, position:)
sign_in(user)
......
......@@ -97,8 +97,9 @@ describe('BoardService', () => {
describe('updateList', () => {
const id = 'David Webb';
const position = 'unknown';
const collapsed = false;
const expectedRequest = expect.objectContaining({
data: JSON.stringify({ list: { position } }),
data: JSON.stringify({ list: { position, collapsed } }),
});
let requestSpy;
......@@ -112,7 +113,7 @@ describe('BoardService', () => {
requestSpy.mockReturnValue([200, dummyResponse]);
const expectedResponse = expect.objectContaining({ data: dummyResponse });
return expect(service.updateList(id, position))
return expect(service.updateList(id, position, collapsed))
.resolves.toEqual(expectedResponse)
.then(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
......@@ -122,7 +123,7 @@ describe('BoardService', () => {
it('fails for error response', () => {
requestSpy.mockReturnValue([500]);
return expect(service.updateList(id, position))
return expect(service.updateList(id, position, collapsed))
.rejects.toThrow()
.then(() => {
expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
......
......@@ -5,21 +5,16 @@ import { mockBoardService } from '../mock_data';
describe('Board component', () => {
let vm;
let el;
beforeEach(done => {
loadFixtures('boards/show.html');
el = document.createElement('div');
const createComponent = ({ gon = {}, collapsed = false, listType = 'backlog' } = {}) => {
if (Object.prototype.hasOwnProperty.call(gon, 'current_user_id')) {
window.gon = gon;
} else {
window.gon = {};
}
const el = document.createElement('div');
document.body.appendChild(el);
gl.boardService = mockBoardService({
boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
vm = new Board({
propsData: {
boardId: '1',
......@@ -30,94 +25,244 @@ describe('Board component', () => {
id: 1,
position: 0,
title: 'test',
list_type: 'backlog',
list_type: listType,
collapsed,
}),
},
}).$mount(el);
};
const setUpTests = (done, opts = {}) => {
loadFixtures('boards/show.html');
gl.boardService = mockBoardService({
boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
createComponent(opts);
Vue.nextTick(done);
});
};
const cleanUpTests = spy => {
if (spy) {
spy.calls.reset();
}
afterEach(() => {
vm.$destroy();
// remove the component from the DOM
document.querySelector('.board').remove();
localStorage.removeItem(`boards.${vm.boardId}.${vm.list.type}.expanded`);
});
localStorage.removeItem(`${vm.uniqueKey}.expanded`);
};
it('board is expandable when list type is backlog', () => {
expect(vm.$el.classList.contains('is-expandable')).toBe(true);
});
describe('List', () => {
beforeEach(() => {
gl.boardService = mockBoardService({
boardsEndpoint: '/',
listsEndpoint: '/',
bulkUpdatePath: '/',
boardId: 1,
});
});
it('board is expandable when list type is closed', () => {
expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true);
});
it('board is expandable when list type is label', () => {
expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true);
});
it('board is expandable when list type is closed', () => {
expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true);
it('board is not expandable when list type is blank', () => {
expect(new List({ id: 1, list_type: 'blank' }).isExpandable).toBe(false);
});
});
it('board is expandable when list type is label', () => {
expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true);
describe('when clicking the header', () => {
beforeEach(done => {
setUpTests(done);
});
afterEach(() => {
cleanUpTests();
});
it('does not collapse', done => {
vm.list.isExpanded = true;
vm.$el.querySelector('.board-header').click();
Vue.nextTick()
.then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
it('board is not expandable when list type is blank', () => {
expect(new List({ id: 1, list_type: 'blank' }).isExpandable).toBe(false);
describe('when clicking the collapse icon', () => {
beforeEach(done => {
setUpTests(done);
});
afterEach(() => {
cleanUpTests();
});
it('collapses', done => {
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
it('does not collapse when clicking header', done => {
vm.list.isExpanded = true;
vm.$el.querySelector('.board-header').click();
describe('when clicking the expand icon', () => {
beforeEach(done => {
setUpTests(done);
});
Vue.nextTick(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(false);
afterEach(() => {
cleanUpTests();
});
it('expands', done => {
vm.list.isExpanded = false;
done();
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
it('collapses when clicking the collapse icon', done => {
vm.list.isExpanded = true;
describe('when collapsed is false', () => {
beforeEach(done => {
setUpTests(done);
});
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(true);
done();
})
.catch(done.fail);
afterEach(() => {
cleanUpTests();
});
it('is expanded when collapsed is false', () => {
expect(vm.list.isExpanded).toBe(true);
expect(vm.$el.classList.contains('is-collapsed')).toBe(false);
});
});
it('expands when clicking the expand icon', done => {
vm.list.isExpanded = false;
describe('when list type is blank', () => {
beforeEach(done => {
setUpTests(done, { listType: 'blank' });
});
afterEach(() => {
cleanUpTests();
});
it('does not render add issue button when list type is blank', done => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.issue-count-badge-add-button')).toBeNull();
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(false);
done();
})
.catch(done.fail);
});
});
});
it('is expanded when created', () => {
expect(vm.list.isExpanded).toBe(true);
expect(vm.$el.classList.contains('is-collapsed')).toBe(false);
describe('when list type is backlog', () => {
beforeEach(done => {
setUpTests(done);
});
afterEach(() => {
cleanUpTests();
});
it('board is expandable', () => {
expect(vm.$el.classList.contains('is-expandable')).toBe(true);
});
});
it('does render add issue button', () => {
expect(vm.$el.querySelector('.issue-count-badge-add-button')).not.toBeNull();
describe('when logged in', () => {
let spy;
beforeEach(done => {
spy = spyOn(List.prototype, 'update');
setUpTests(done, { gon: { current_user_id: 1 } });
});
afterEach(() => {
cleanUpTests(spy);
});
it('calls list update', done => {
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.list.update).toHaveBeenCalledTimes(1);
})
.then(done)
.catch(done.fail);
});
});
it('does not render add issue button when list type is blank', done => {
vm.list.type = 'blank';
describe('when logged out', () => {
let spy;
beforeEach(done => {
spy = spyOn(List.prototype, 'update');
setUpTests(done, { collapsed: false });
});
afterEach(() => {
cleanUpTests(spy);
});
Vue.nextTick(() => {
expect(vm.$el.querySelector('.issue-count-badge-add-button')).toBeNull();
// can only be one or the other cant toggle window.gon.current_user_id states.
it('clicking on the caret does not call list update', done => {
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.list.update).toHaveBeenCalledTimes(0);
})
.then(done)
.catch(done.fail);
});
it('sets expanded to be the opposite of its value when toggleExpanded is called', done => {
const expanded = true;
vm.list.isExpanded = expanded;
vm.toggleExpanded();
Vue.nextTick()
.then(() => {
expect(vm.list.isExpanded).toBe(!expanded);
expect(localStorage.getItem(`${vm.uniqueKey}.expanded`)).toBe(String(!expanded));
})
.then(done)
.catch(done.fail);
});
done();
it('does render add issue button', () => {
expect(vm.$el.querySelector('.issue-count-badge-add-button')).not.toBeNull();
});
});
});
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