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 () => {
state: boardsStore.state,
loading: true,
boardsEndpoint: $boardApp.dataset.boardsEndpoint,
recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
listsEndpoint: $boardApp.dataset.listsEndpoint,
boardId: $boardApp.dataset.boardId,
disabled: parseBoolean($boardApp.dataset.disabled),
......@@ -82,6 +83,7 @@ export default () => {
created() {
gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint,
recentBoardsEndpoint: this.recentBoardsEndpoint,
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
......
......@@ -2,12 +2,13 @@ import axios from '../../lib/utils/axios_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility';
export default class BoardService {
constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
this.boardsEndpoint = boardsEndpoint;
this.boardId = boardId;
this.listsEndpoint = listsEndpoint;
this.listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.bulkUpdatePath = bulkUpdatePath;
this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`;
}
generateBoardsPath(id) {
......
......@@ -16,6 +16,7 @@ const httpStatusCodes = {
IM_USED: 226,
MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422,
......
......@@ -14,6 +14,9 @@ import boardsStore from '~/boards/stores/boards_store';
import BoardForm from './board_form.vue';
import AssigneeList from './assignees_list_slector';
import MilestoneList from './milestone_list_selector';
import httpStatusCodes from '~/lib/utils/http_status';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
name: 'BoardsSelector',
......@@ -85,6 +88,7 @@ export default {
hasMilestoneListMounted: false,
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
state: boardsStore.state,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0,
......@@ -121,6 +125,13 @@ export default {
'fade-out': !this.hasScrollFade,
};
},
showRecentSection() {
return (
this.recentBoards.length &&
this.boards.length > MIN_BOARDS_TO_VIEW_RECENT &&
!this.filterTerm.length
);
},
},
watch: {
filteredBoards() {
......@@ -130,6 +141,7 @@ export default {
reload() {
if (this.reload) {
this.boards = [];
this.recentBoards = [];
this.loading = true;
this.reload = false;
......@@ -154,12 +166,29 @@ export default {
return;
}
gl.boardService
.allBoards()
.then(res => res.data)
.then(json => {
const recentBoardsPromise = new Promise((resolve, reject) =>
gl.boardService
.recentBoards()
.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.boards = json;
this.boards = allBoardsJson;
this.recentBoards = recentBoardsJson;
})
.then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => {
......@@ -244,6 +273,25 @@ export default {
{{ s__('IssueBoards|No matching boards found') }}
</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
v-for="otherBoard in filteredBoards"
:key="otherBoard.id"
......
......@@ -6,6 +6,10 @@ export default class BoardServiceEE extends BoardService {
return axios.get(this.generateBoardsPath());
}
recentBoards() {
return axios.get(this.recentBoardsEndpoint);
}
createBoard(board) {
const boardPayload = { ...board };
boardPayload.label_ids = (board.labels || []).map(b => b.id);
......
......@@ -8,6 +8,7 @@ module EE
extend ActiveSupport::Concern
prepended do
before_action :authenticate_user!, only: [:recent]
before_action :authorize_create_board!, only: [:create]
before_action :authorize_admin_board!, only: [:create, :update, :destroy]
end
......
......@@ -22,6 +22,7 @@ module EE
!@project.feature_available?(:issue_board_focus_mode)))
data = {
recent_boards_endpoint: recent_boards_path,
board_milestone_title: board.milestone&.name,
board_milestone_id: board.milestone_id,
board_assignee_username: board.assignee&.username,
......@@ -36,6 +37,10 @@ module EE
super.merge(data)
end
def recent_boards_path
parent.is_a?(Group) ? recent_group_boards_path(@group) : recent_project_boards_path(@project)
end
def current_board_json
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';
const throttleDuration = 1;
describe('BoardsSelector', () => {
let vm;
let boardServiceResponse;
const boards = new Array(20).fill().map((board, id) => {
function boardGenerator(n) {
return new Array(n).fill().map((board, id) => {
const name = `board${id}`;
return {
......@@ -17,6 +15,15 @@ describe('BoardsSelector', () => {
name,
};
});
}
describe('BoardsSelector', () => {
let vm;
let allBoardsResponse;
let recentBoardsResponse;
let fillSearchBox;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
beforeEach(done => {
setFixtures('<div class="js-boards-selector"></div>');
......@@ -24,16 +31,21 @@ describe('BoardsSelector', () => {
window.gl.boardService = new BoardService({
boardsEndpoint: '',
recentBoardsEndpoint: '',
listsEndpoint: '',
bulkUpdatePath: '',
boardId: '',
});
boardServiceResponse = Promise.resolve({
allBoardsResponse = Promise.resolve({
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);
vm = mountComponent(
......@@ -64,10 +76,17 @@ describe('BoardsSelector', () => {
vm.$el.querySelector('.js-dropdown-toggle').click();
boardServiceResponse
Promise.all([allBoardsResponse, recentBoardsResponse])
.then(() => vm.$nextTick())
.then(done)
.catch(done.fail);
fillSearchBox = filterTerm => {
const { searchBox } = vm.$refs;
const searchBoxInput = searchBox.$el.querySelector('input');
searchBoxInput.value = filterTerm;
searchBoxInput.dispatchEvent(new Event('input'));
};
});
afterEach(() => {
......@@ -76,19 +95,12 @@ describe('BoardsSelector', () => {
});
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 => {
vm.$nextTick()
.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)
.catch(done.fail);
......@@ -124,4 +136,68 @@ describe('BoardsSelector', () => {
.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'
shared_examples 'returns recently visited boards' do
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
[0, 2, 5, 3, 7, 1].each_with_index do |board_index, i|
visit_board(boards[board_index], Time.now + i.minutes)
......@@ -31,6 +41,6 @@ shared_examples 'returns recently visited boards' do
{ namespace_id: parent.namespace, project_id: parent }
end
get :recent, params: params
get :recent, params: params, format: :json
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