Commit 92e160e6 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'psi-boardql-fe' into 'master'

Use GraphQL for boards selector

See merge request gitlab-org/gitlab!24905
parents dbd7db1c 9bc6db19
......@@ -10,6 +10,11 @@ import {
} from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import projectQuery from '../queries/project_boards.query.graphql';
import groupQuery from '../queries/group_boards.query.graphql';
import boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue';
......@@ -88,8 +93,9 @@ export default {
},
data() {
return {
loading: true,
hasScrollFade: false,
loadingBoards: 0,
loadingRecentBoards: false,
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
......@@ -102,6 +108,12 @@ export default {
};
},
computed: {
parentType() {
return this.groupId ? 'group' : 'project';
},
loading() {
return this.loadingRecentBoards && this.loadingBoards;
},
currentPage() {
return this.state.currentPage;
},
......@@ -147,49 +159,71 @@ export default {
return;
}
const recentBoardsPromise = new Promise((resolve, reject) =>
boardsStore
.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);
}),
);
this.$apollo.addSmartQuery('boards', {
variables() {
return { fullPath: this.state.endpoints.fullPath };
},
query() {
return this.groupId ? groupQuery : projectQuery;
},
loadingKey: 'loadingBoards',
update(data) {
if (!data?.[this.parentType]) {
return [];
}
return data[this.parentType].boards.edges.map(({ node }) => ({
id: getIdFromGraphQLId(node.id),
name: node.name,
}));
},
});
Promise.all([boardsStore.allBoards(), recentBoardsPromise])
.then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
.then(([allBoardsJson, recentBoardsJson]) => {
this.loading = false;
this.boards = allBoardsJson;
this.recentBoards = recentBoardsJson;
this.loadingRecentBoards = true;
boardsStore
.recentBoards()
.then(res => {
this.recentBoards = res.data;
})
.catch(err => {
/**
* If user is unauthorized we'd still want to resolve the
* request to display all boards.
*/
if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
this.recentBoards = []; // recent boards are empty
return;
}
throw err;
})
.then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => {
this.setScrollFade();
})
.catch(() => {
this.loading = false;
.catch(() => {})
.finally(() => {
this.loadingRecentBoards = false;
});
},
isScrolledUp() {
const { content } = this.$refs;
if (!content) {
return false;
}
const currentPosition = this.contentClientHeight + content.scrollTop;
return content && currentPosition < this.maxPosition;
return currentPosition < this.maxPosition;
},
initScrollFade() {
this.scrollFadeInitialized = true;
const { content } = this.$refs;
if (!content) {
return;
}
this.scrollFadeInitialized = true;
this.contentClientHeight = content.clientHeight;
this.maxPosition = content.scrollHeight;
},
......
......@@ -98,6 +98,7 @@ export default () => {
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
fullPath: $boardApp.dataset.fullPath,
});
boardsStore.rootPath = this.boardsEndpoint;
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import BoardsSelector from '~/boards/components/boards_selector.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
return new Vue({
......@@ -9,6 +17,7 @@ export default () => {
components: {
BoardsSelector,
},
apolloProvider,
data() {
const { dataset } = boardsSwitcherElement;
......
fragment BoardFragment on Board {
id,
name
}
#import "ee_else_ce/boards/queries/board.fragment.graphql"
query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
boards {
edges {
node {
...BoardFragment
}
}
}
}
}
#import "ee_else_ce/boards/queries/board.fragment.graphql"
query project_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
boards {
edges {
node {
...BoardFragment
}
}
}
}
}
......@@ -45,7 +45,14 @@ const boardsStore = {
},
multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
setEndpoints({
boardsEndpoint,
listsEndpoint,
bulkUpdatePath,
boardId,
recentBoardsEndpoint,
fullPath,
}) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.state.endpoints = {
boardsEndpoint,
......@@ -53,6 +60,7 @@ const boardsStore = {
listsEndpoint,
listsEndpointGenerate,
bulkUpdatePath,
fullPath,
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
};
},
......@@ -542,10 +550,6 @@ const boardsStore = {
return axios.post(endpoint);
},
allBoards() {
return axios.get(this.generateBoardsPath());
},
recentBoards() {
return axios.get(this.state.endpoints.recentBoardsEndpoint);
},
......
......@@ -13,6 +13,7 @@ module BoardsHelper
disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s,
issue_link_base: build_issue_link_base,
root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar),
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
......@@ -20,6 +21,14 @@ module BoardsHelper
}
end
def full_path
if board.group_board?
@group.full_path
else
@project.full_path
end
end
def build_issue_link_base
if board.group_board?
"#{group_path(@board.group)}/:project_path/issues"
......
fragment BoardFragment on Board {
id,
name,
weight
}
......@@ -440,23 +440,6 @@ describe('boardsStore', () => {
});
});
describe('allBoards', () => {
const url = `${endpoints.boardsEndpoint}.json`;
it('makes a request to fetch all boards', () => {
axiosMock.onGet(url).replyOnce(200, dummyResponse);
const expectedResponse = expect.objectContaining({ data: dummyResponse });
return expect(boardsStore.allBoards()).resolves.toEqual(expectedResponse);
});
it('fails for error response', () => {
axiosMock.onGet(url).replyOnce(500);
return expect(boardsStore.allBoards()).rejects.toThrow();
});
});
describe('recentBoards', () => {
const url = `${endpoints.recentBoardsEndpoint}.json`;
......
import Vue from 'vue';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { GlDropdown } from '@gitlab/ui';
import { GlDropdown, GlLoadingIcon } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import boardsStore from '~/boards/stores/boards_store';
......@@ -8,7 +8,8 @@ import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1;
function boardGenerator(n) {
return new Array(n).fill().map((board, id) => {
return new Array(n).fill().map((board, index) => {
const id = `${index}`;
const name = `board${id}`;
return {
......@@ -34,8 +35,17 @@ describe('BoardsSelector', () => {
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header');
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
const $apollo = {
queries: {
boards: {
loading: false,
},
},
};
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
......@@ -45,7 +55,13 @@ describe('BoardsSelector', () => {
});
allBoardsResponse = Promise.resolve({
data: boards,
data: {
group: {
boards: {
edges: boards.map(board => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
......@@ -54,8 +70,7 @@ describe('BoardsSelector', () => {
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
const Component = Vue.extend(BoardsSelector);
wrapper = mount(Component, {
wrapper = mount(BoardsSelector, {
propsData: {
throttleDuration,
currentBoard: {
......@@ -77,13 +92,18 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
mocks: { $apollo },
attachToDocument: true,
});
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
wrapper.setData({
[options.loadingKey]: true,
});
});
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
wrapper.find(GlDropdown).vm.$emit('show');
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => Vue.nextTick());
});
afterEach(() => {
......@@ -91,64 +111,99 @@ describe('BoardsSelector', () => {
wrapper = null;
});
describe('filtering', () => {
it('shows all boards without filtering', () => {
expect(getDropdownItems().length).toBe(boards.length + recentBoards.length);
describe('loading', () => {
// we are testing loading state, so don't resolve responses until after the tests
afterEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
});
});
fillSearchBox(filterTerm);
describe('loaded', () => {
beforeEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
return Vue.nextTick().then(() => {
expect(getDropdownItems().length).toBe(expectedCount);
});
it('hides loading spinner', () => {
expect(getLoadingIcon().exists()).toBe(false);
});
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
describe('filtering', () => {
beforeEach(() => {
wrapper.setData({
boards,
});
return Vue.nextTick().then(() => {
expect(getDropdownItems().length).toBe(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
return nextTick();
});
});
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => {
const expectedCount = 2; // Recent + All
it('shows all boards without filtering', () => {
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
});
expect(getDropdownHeaders().length).toBe(expectedCount);
});
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
fillSearchBox(filterTerm);
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(expectedCount);
});
});
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
return nextTick().then(() => {
expect(getDropdownItems()).toHaveLength(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
});
});
});
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => {
wrapper.setData({
boards,
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(2);
});
});
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
});
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
});
it('does not show when search is active', () => {
fillSearchBox('Random string');
it('does not show when search is active', () => {
fillSearchBox('Random string');
return Vue.nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0);
return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(0);
});
});
});
});
......
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