Commit 8946726a authored by Rajat Jain's avatar Rajat Jain

Display Recent Boards in Board switcher

Add a new Recent section in the board switcher along with an
All section which like old times displays all the available boards.
Recent section lists the last 5 boards you have visited for a quick
switch.
parent 2858dc5e
...@@ -65,6 +65,7 @@ export default () => { ...@@ -65,6 +65,7 @@ export default () => {
state: boardsStore.state, state: boardsStore.state,
loading: true, loading: true,
boardsEndpoint: $boardApp.dataset.boardsEndpoint, boardsEndpoint: $boardApp.dataset.boardsEndpoint,
recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
listsEndpoint: $boardApp.dataset.listsEndpoint, listsEndpoint: $boardApp.dataset.listsEndpoint,
boardId: $boardApp.dataset.boardId, boardId: $boardApp.dataset.boardId,
disabled: parseBoolean($boardApp.dataset.disabled), disabled: parseBoolean($boardApp.dataset.disabled),
...@@ -82,6 +83,7 @@ export default () => { ...@@ -82,6 +83,7 @@ export default () => {
created() { created() {
gl.boardService = new BoardService({ gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint, boardsEndpoint: this.boardsEndpoint,
recentBoardsEndpoint: this.recentBoardsEndpoint,
listsEndpoint: this.listsEndpoint, listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath, bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId, boardId: this.boardId,
......
...@@ -2,12 +2,13 @@ import axios from '../../lib/utils/axios_utils'; ...@@ -2,12 +2,13 @@ import axios from '../../lib/utils/axios_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility'; import { mergeUrlParams } from '../../lib/utils/url_utility';
export default class BoardService { export default class BoardService {
constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) { constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
this.boardsEndpoint = boardsEndpoint; this.boardsEndpoint = boardsEndpoint;
this.boardId = boardId; this.boardId = boardId;
this.listsEndpoint = listsEndpoint; this.listsEndpoint = listsEndpoint;
this.listsEndpointGenerate = `${listsEndpoint}/generate.json`; this.listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.bulkUpdatePath = bulkUpdatePath; this.bulkUpdatePath = bulkUpdatePath;
this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`;
} }
generateBoardsPath(id) { generateBoardsPath(id) {
......
...@@ -16,6 +16,7 @@ const httpStatusCodes = { ...@@ -16,6 +16,7 @@ const httpStatusCodes = {
IM_USED: 226, IM_USED: 226,
MULTIPLE_CHOICES: 300, MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400, BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403, FORBIDDEN: 403,
NOT_FOUND: 404, NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422, UNPROCESSABLE_ENTITY: 422,
......
...@@ -14,6 +14,9 @@ import boardsStore from '~/boards/stores/boards_store'; ...@@ -14,6 +14,9 @@ import boardsStore from '~/boards/stores/boards_store';
import BoardForm from './board_form.vue'; import BoardForm from './board_form.vue';
import AssigneeList from './assignees_list_slector'; import AssigneeList from './assignees_list_slector';
import MilestoneList from './milestone_list_selector'; import MilestoneList from './milestone_list_selector';
import httpStatusCodes from '~/lib/utils/http_status';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default { export default {
name: 'BoardsSelector', name: 'BoardsSelector',
...@@ -85,6 +88,7 @@ export default { ...@@ -85,6 +88,7 @@ export default {
hasMilestoneListMounted: false, hasMilestoneListMounted: false,
scrollFadeInitialized: false, scrollFadeInitialized: false,
boards: [], boards: [],
recentBoards: [],
state: boardsStore.state, state: boardsStore.state,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration), throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0, contentClientHeight: 0,
...@@ -121,6 +125,13 @@ export default { ...@@ -121,6 +125,13 @@ export default {
'fade-out': !this.hasScrollFade, 'fade-out': !this.hasScrollFade,
}; };
}, },
showRecentSection() {
return (
this.recentBoards.length &&
this.boards.length > MIN_BOARDS_TO_VIEW_RECENT &&
!this.filterTerm.length
);
},
}, },
watch: { watch: {
filteredBoards() { filteredBoards() {
...@@ -130,6 +141,7 @@ export default { ...@@ -130,6 +141,7 @@ export default {
reload() { reload() {
if (this.reload) { if (this.reload) {
this.boards = []; this.boards = [];
this.recentBoards = [];
this.loading = true; this.loading = true;
this.reload = false; this.reload = false;
...@@ -154,12 +166,29 @@ export default { ...@@ -154,12 +166,29 @@ export default {
return; return;
} }
gl.boardService const recentBoardsPromise = new Promise((resolve, reject) =>
.allBoards() gl.boardService
.then(res => res.data) .recentBoards()
.then(json => { .then(resolve)
.catch(err => {
/**
* If user is unauthorized we'd still want to resolve the
* request to display all boards.
*/
if (err.response.status === httpStatusCodes.UNAUTHORIZED) {
resolve({ data: [] }); // recent boards are empty
return;
}
reject(err);
}),
);
Promise.all([gl.boardService.allBoards(), recentBoardsPromise])
.then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
.then(([allBoardsJson, recentBoardsJson]) => {
this.loading = false; this.loading = false;
this.boards = json; this.boards = allBoardsJson;
this.recentBoards = recentBoardsJson;
}) })
.then(() => this.$nextTick()) // Wait for boards list in DOM .then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => { .then(() => {
...@@ -244,6 +273,25 @@ export default { ...@@ -244,6 +273,25 @@ export default {
{{ s__('IssueBoards|No matching boards found') }} {{ s__('IssueBoards|No matching boards found') }}
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-header v-if="showRecentSection" class="mt-0">
Recent
</gl-dropdown-header>
<template v-if="showRecentSection">
<gl-dropdown-item
v-for="recentBoard in recentBoards"
:key="`recent-${recentBoard.id}`"
class="js-dropdown-item"
:href="`${boardBaseUrl}/${recentBoard.id}`"
>
{{ recentBoard.name }}
</gl-dropdown-item>
</template>
<gl-dropdown-header v-if="showRecentSection" class="mt-0">
All
</gl-dropdown-header>
<gl-dropdown-item <gl-dropdown-item
v-for="otherBoard in filteredBoards" v-for="otherBoard in filteredBoards"
:key="otherBoard.id" :key="otherBoard.id"
......
...@@ -6,6 +6,10 @@ export default class BoardServiceEE extends BoardService { ...@@ -6,6 +6,10 @@ export default class BoardServiceEE extends BoardService {
return axios.get(this.generateBoardsPath()); return axios.get(this.generateBoardsPath());
} }
recentBoards() {
return axios.get(this.recentBoardsEndpoint);
}
createBoard(board) { createBoard(board) {
const boardPayload = { ...board }; const boardPayload = { ...board };
boardPayload.label_ids = (board.labels || []).map(b => b.id); boardPayload.label_ids = (board.labels || []).map(b => b.id);
......
...@@ -8,6 +8,7 @@ module EE ...@@ -8,6 +8,7 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
before_action :authenticate_user!, only: [:recent]
before_action :authorize_create_board!, only: [:create] before_action :authorize_create_board!, only: [:create]
before_action :authorize_admin_board!, only: [:create, :update, :destroy] before_action :authorize_admin_board!, only: [:create, :update, :destroy]
end end
......
...@@ -22,6 +22,7 @@ module EE ...@@ -22,6 +22,7 @@ module EE
!@project.feature_available?(:issue_board_focus_mode))) !@project.feature_available?(:issue_board_focus_mode)))
data = { data = {
recent_boards_endpoint: recent_boards_path,
board_milestone_title: board.milestone&.name, board_milestone_title: board.milestone&.name,
board_milestone_id: board.milestone_id, board_milestone_id: board.milestone_id,
board_assignee_username: board.assignee&.username, board_assignee_username: board.assignee&.username,
...@@ -36,6 +37,10 @@ module EE ...@@ -36,6 +37,10 @@ module EE
super.merge(data) super.merge(data)
end end
def recent_boards_path
parent.is_a?(Group) ? recent_group_boards_path(@group) : recent_project_boards_path(@project)
end
def current_board_json def current_board_json
board = @board || @boards.first board = @board || @boards.first
......
---
title: Display Recent Boards in Board switcher
merge_request: 9808
author:
type: added
...@@ -6,10 +6,8 @@ import { TEST_HOST } from 'spec/test_constants'; ...@@ -6,10 +6,8 @@ import { TEST_HOST } from 'spec/test_constants';
const throttleDuration = 1; const throttleDuration = 1;
describe('BoardsSelector', () => { function boardGenerator(n) {
let vm; return new Array(n).fill().map((board, id) => {
let boardServiceResponse;
const boards = new Array(20).fill().map((board, id) => {
const name = `board${id}`; const name = `board${id}`;
return { return {
...@@ -17,6 +15,15 @@ describe('BoardsSelector', () => { ...@@ -17,6 +15,15 @@ describe('BoardsSelector', () => {
name, name,
}; };
}); });
}
describe('BoardsSelector', () => {
let vm;
let allBoardsResponse;
let recentBoardsResponse;
let fillSearchBox;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
beforeEach(done => { beforeEach(done => {
setFixtures('<div class="js-boards-selector"></div>'); setFixtures('<div class="js-boards-selector"></div>');
...@@ -24,16 +31,21 @@ describe('BoardsSelector', () => { ...@@ -24,16 +31,21 @@ describe('BoardsSelector', () => {
window.gl.boardService = new BoardService({ window.gl.boardService = new BoardService({
boardsEndpoint: '', boardsEndpoint: '',
recentBoardsEndpoint: '',
listsEndpoint: '', listsEndpoint: '',
bulkUpdatePath: '', bulkUpdatePath: '',
boardId: '', boardId: '',
}); });
boardServiceResponse = Promise.resolve({ allBoardsResponse = Promise.resolve({
data: boards, data: boards,
}); });
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
spyOn(BoardService.prototype, 'allBoards').and.returnValue(boardServiceResponse); spyOn(BoardService.prototype, 'allBoards').and.returnValue(allBoardsResponse);
spyOn(BoardService.prototype, 'recentBoards').and.returnValue(recentBoardsResponse);
const Component = Vue.extend(BoardsSelector); const Component = Vue.extend(BoardsSelector);
vm = mountComponent( vm = mountComponent(
...@@ -64,10 +76,17 @@ describe('BoardsSelector', () => { ...@@ -64,10 +76,17 @@ describe('BoardsSelector', () => {
vm.$el.querySelector('.js-dropdown-toggle').click(); vm.$el.querySelector('.js-dropdown-toggle').click();
boardServiceResponse Promise.all([allBoardsResponse, recentBoardsResponse])
.then(() => vm.$nextTick()) .then(() => vm.$nextTick())
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
fillSearchBox = filterTerm => {
const { searchBox } = vm.$refs;
const searchBoxInput = searchBox.$el.querySelector('input');
searchBoxInput.value = filterTerm;
searchBoxInput.dispatchEvent(new Event('input'));
};
}); });
afterEach(() => { afterEach(() => {
...@@ -76,19 +95,12 @@ describe('BoardsSelector', () => { ...@@ -76,19 +95,12 @@ describe('BoardsSelector', () => {
}); });
describe('filtering', () => { describe('filtering', () => {
const fillSearchBox = filterTerm => {
const { searchBox } = vm.$refs;
const searchBoxInput = searchBox.$el.querySelector('input');
searchBoxInput.value = filterTerm;
searchBoxInput.dispatchEvent(new Event('input'));
};
it('shows all boards without filtering', done => { it('shows all boards without filtering', done => {
vm.$nextTick() vm.$nextTick()
.then(() => { .then(() => {
const dropdownItemCount = vm.$el.querySelectorAll('.js-dropdown-item'); const dropdownItem = vm.$el.querySelectorAll('.js-dropdown-item');
expect(dropdownItemCount.length).toBe(boards.length); expect(dropdownItem.length).toBe(boards.length + recentBoards.length);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -124,4 +136,68 @@ describe('BoardsSelector', () => { ...@@ -124,4 +136,68 @@ describe('BoardsSelector', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('recent boards section', () => {
it('shows only when boards are greater than 10', done => {
vm.$nextTick()
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-header');
const expectedCount = 3; // Search + Recent + All
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
it('does not show when boards are less than 10', done => {
spyOn(vm, 'initScrollFade');
spyOn(vm, 'setScrollFade');
vm.$nextTick()
.then(() => {
vm.boards = vm.boards.slice(0, 5);
})
.then(vm.$nextTick)
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-header');
const expectedCount = 1; // Only Search is present
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
it('does not show when recentBoards api returns empty array', done => {
vm.$nextTick()
.then(() => {
vm.recentBoards = [];
})
.then(vm.$nextTick)
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-header');
const expectedCount = 1; // Only search is present
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
it('does not show when search is active', done => {
fillSearchBox('Random string');
vm.$nextTick()
.then(() => {
const headerEls = vm.$el.querySelectorAll('.dropdown-header');
const expectedCount = 1; // Only search is present
expect(expectedCount).toBe(headerEls.length);
})
.then(done)
.catch(done.fail);
});
});
}); });
...@@ -5,6 +5,16 @@ require 'spec_helper' ...@@ -5,6 +5,16 @@ require 'spec_helper'
shared_examples 'returns recently visited boards' do shared_examples 'returns recently visited boards' do
let(:boards) { create_list(:board, 8, parent: parent) } let(:boards) { create_list(:board, 8, parent: parent) }
context 'unauthenticated' do
it 'returns a 401' do
sign_out(user)
get_recent_boards
expect(response).to have_gitlab_http_status(401)
end
end
it 'returns last 5 visited boards' do it 'returns last 5 visited boards' do
[0, 2, 5, 3, 7, 1].each_with_index do |board_index, i| [0, 2, 5, 3, 7, 1].each_with_index do |board_index, i|
visit_board(boards[board_index], Time.now + i.minutes) visit_board(boards[board_index], Time.now + i.minutes)
...@@ -31,6 +41,6 @@ shared_examples 'returns recently visited boards' do ...@@ -31,6 +41,6 @@ shared_examples 'returns recently visited boards' do
{ namespace_id: parent.namespace, project_id: parent } { namespace_id: parent.namespace, project_id: parent }
end end
get :recent, params: params get :recent, params: params, format: :json
end end
end end
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