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