Commit 4333e7a1 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '351070-fetch-recent-boards-using-graphql-endpoint' into 'master'

Issue boards - Fetch recent boards in GraphQL

See merge request gitlab-org/gitlab!80353
parents 85259e28 4cd0c645
...@@ -14,8 +14,6 @@ import { mapActions, mapGetters, mapState } from 'vuex'; ...@@ -14,8 +14,6 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
...@@ -23,6 +21,8 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql'; ...@@ -23,6 +21,8 @@ import groupBoardsQuery from '../graphql/group_boards.query.graphql';
import projectBoardsQuery from '../graphql/project_boards.query.graphql'; import projectBoardsQuery from '../graphql/project_boards.query.graphql';
import groupBoardQuery from '../graphql/group_board.query.graphql'; import groupBoardQuery from '../graphql/group_board.query.graphql';
import projectBoardQuery from '../graphql/project_board.query.graphql'; import projectBoardQuery from '../graphql/project_board.query.graphql';
import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql';
import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql';
const MIN_BOARDS_TO_VIEW_RECENT = 10; const MIN_BOARDS_TO_VIEW_RECENT = 10;
...@@ -40,7 +40,7 @@ export default { ...@@ -40,7 +40,7 @@ export default {
directives: { directives: {
GlModalDirective, GlModalDirective,
}, },
inject: ['fullPath', 'recentBoardsEndpoint'], inject: ['fullPath'],
props: { props: {
throttleDuration: { throttleDuration: {
type: Number, type: Number,
...@@ -158,6 +158,10 @@ export default { ...@@ -158,6 +158,10 @@ export default {
this.scrollFadeInitialized = false; this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade); this.$nextTick(this.setScrollFade);
}, },
recentBoards() {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
}, },
created() { created() {
eventHub.$on('showBoardModal', this.showPage); eventHub.$on('showBoardModal', this.showPage);
...@@ -173,11 +177,11 @@ export default { ...@@ -173,11 +177,11 @@ export default {
cancel() { cancel() {
this.showPage(''); this.showPage('');
}, },
boardUpdate(data) { boardUpdate(data, boardType) {
if (!data?.[this.parentType]) { if (!data?.[this.parentType]) {
return []; return [];
} }
return data[this.parentType].boards.edges.map(({ node }) => ({ return data[this.parentType][boardType].edges.map(({ node }) => ({
id: getIdFromGraphQLId(node.id), id: getIdFromGraphQLId(node.id),
name: node.name, name: node.name,
})); }));
...@@ -185,6 +189,9 @@ export default { ...@@ -185,6 +189,9 @@ export default {
boardQuery() { boardQuery() {
return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery; return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
}, },
recentBoardsQuery() {
return this.isGroupBoard ? groupRecentBoardsQuery : projectRecentBoardsQuery;
},
loadBoards(toggleDropdown = true) { loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) { if (toggleDropdown && this.boards.length > 0) {
return; return;
...@@ -196,39 +203,20 @@ export default { ...@@ -196,39 +203,20 @@ export default {
}, },
query: this.boardQuery, query: this.boardQuery,
loadingKey: 'loadingBoards', loadingKey: 'loadingBoards',
update: this.boardUpdate, update: (data) => this.boardUpdate(data, 'boards'),
}); });
this.loadRecentBoards(); this.loadRecentBoards();
}, },
loadRecentBoards() { loadRecentBoards() {
this.loadingRecentBoards = true; this.$apollo.addSmartQuery('recentBoards', {
// Follow up to fetch recent boards using GraphQL variables() {
// https://gitlab.com/gitlab-org/gitlab/-/issues/300985 return { fullPath: this.fullPath };
axios },
.get(this.recentBoardsEndpoint) query: this.recentBoardsQuery,
.then((res) => { loadingKey: 'loadingRecentBoards',
this.recentBoards = res.data; update: (data) => this.boardUpdate(data, 'recentIssueBoards'),
}) });
.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(() => {})
.finally(() => {
this.loadingRecentBoards = false;
});
}, },
isScrolledUp() { isScrolledUp() {
const { content } = this.$refs; const { content } = this.$refs;
......
#import "ee_else_ce/boards/graphql/board.fragment.graphql"
query group_recent_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
id
recentIssueBoards {
edges {
node {
...BoardFragment
}
}
}
}
}
#import "ee_else_ce/boards/graphql/board.fragment.graphql"
query project_recent_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
id
recentIssueBoards {
edges {
node {
...BoardFragment
}
}
}
}
}
...@@ -144,7 +144,6 @@ export default () => { ...@@ -144,7 +144,6 @@ export default () => {
mountMultipleBoardsSwitcher({ mountMultipleBoardsSwitcher({
fullPath: $boardApp.dataset.fullPath, fullPath: $boardApp.dataset.fullPath,
rootPath: $boardApp.dataset.boardsEndpoint, rootPath: $boardApp.dataset.boardsEndpoint,
recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
allowScopedLabels: $boardApp.dataset.scopedLabels, allowScopedLabels: $boardApp.dataset.scopedLabels,
labelsManagePath: $boardApp.dataset.labelsManagePath, labelsManagePath: $boardApp.dataset.labelsManagePath,
}); });
......
...@@ -24,7 +24,6 @@ export default (params = {}) => { ...@@ -24,7 +24,6 @@ export default (params = {}) => {
provide: { provide: {
fullPath: params.fullPath, fullPath: params.fullPath,
rootPath: params.rootPath, rootPath: params.rootPath,
recentBoardsEndpoint: params.recentBoardsEndpoint,
allowScopedLabels: params.allowScopedLabels, allowScopedLabels: params.allowScopedLabels,
labelsManagePath: params.labelsManagePath, labelsManagePath: params.labelsManagePath,
allowLabelCreate: parseBoolean(dataset.canAdminBoard), allowLabelCreate: parseBoolean(dataset.canAdminBoard),
......
...@@ -17,7 +17,6 @@ module BoardsHelper ...@@ -17,7 +17,6 @@ module BoardsHelper
can_update: can_update?.to_s, can_update: can_update?.to_s,
can_admin_list: can_admin_list?.to_s, can_admin_list: can_admin_list?.to_s,
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,
recent_boards_endpoint: recent_boards_path,
parent: current_board_parent.model_name.param_key, parent: current_board_parent.model_name.param_key,
group_id: group_id, group_id: group_id,
labels_filter_base_path: build_issue_link_base, labels_filter_base_path: build_issue_link_base,
...@@ -128,10 +127,6 @@ module BoardsHelper ...@@ -128,10 +127,6 @@ module BoardsHelper
} }
end end
def recent_boards_path
recent_project_boards_path(@project) if current_board_parent.is_a?(Project)
end
def serializer def serializer
CurrentBoardSerializer.new CurrentBoardSerializer.new
end end
......
...@@ -52,7 +52,8 @@ export default { ...@@ -52,7 +52,8 @@ export default {
}, },
query: this.isEpicBoard ? this.epicBoardQuery : this.boardQuery, query: this.isEpicBoard ? this.epicBoardQuery : this.boardQuery,
loadingKey: 'loadingBoards', loadingKey: 'loadingBoards',
update: this.isEpicBoard ? this.epicBoardUpdate : this.boardUpdate, update: (data) =>
this.isEpicBoard ? this.epicBoardUpdate(data) : this.boardUpdate(data, 'boards'),
}); });
if (!this.isEpicBoard) { if (!this.isEpicBoard) {
......
...@@ -84,12 +84,5 @@ module EE ...@@ -84,12 +84,5 @@ module EE
super super
end end
override :recent_boards_path
def recent_boards_path
return recent_group_boards_path(@group) if current_board_parent.is_a?(Group)
super
end
end end
end end
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; import { GlDropdown } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import Vuex from 'vuex'; import Vuex from 'vuex';
import BoardsSelector from 'ee/boards/components/boards_selector.vue'; import BoardsSelector from 'ee/boards/components/boards_selector.vue';
import { BoardType } from '~/boards/constants'; import { BoardType } from '~/boards/constants';
import epicBoardQuery from 'ee/boards/graphql/epic_board.query.graphql'; import epicBoardQuery from 'ee/boards/graphql/epic_board.query.graphql';
import epicBoardsQuery from 'ee/boards/graphql/epic_boards.query.graphql';
import groupBoardQuery from '~/boards/graphql/group_board.query.graphql'; import groupBoardQuery from '~/boards/graphql/group_board.query.graphql';
import projectBoardQuery from '~/boards/graphql/project_board.query.graphql'; import projectBoardQuery from '~/boards/graphql/project_board.query.graphql';
import groupBoardsQuery from '~/boards/graphql/group_boards.query.graphql';
import projectBoardsQuery from '~/boards/graphql/project_boards.query.graphql';
import defaultStore from '~/boards/stores'; import defaultStore from '~/boards/stores';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { mockGroupBoardResponse, mockProjectBoardResponse } from 'jest/boards/mock_data'; import {
import { mockEpicBoardResponse } from '../mock_data'; mockGroupBoardResponse,
mockProjectBoardResponse,
mockGroupAllBoardsResponse,
mockProjectAllBoardsResponse,
} from 'jest/boards/mock_data';
import { mockEpicBoardResponse, mockEpicBoardsResponse } from '../mock_data';
const throttleDuration = 1; const throttleDuration = 1;
Vue.use(VueApollo); Vue.use(VueApollo);
function boardGenerator(n) {
return new Array(n).fill().map((board, index) => {
const id = `${index}`;
const name = `board${id}`;
return {
id,
name,
};
});
}
describe('BoardsSelector', () => { describe('BoardsSelector', () => {
let wrapper; let wrapper;
let allBoardsResponse;
let recentBoardsResponse;
let mock;
let fakeApollo; let fakeApollo;
let store; let store;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
const createStore = ({ const createStore = ({
isGroupBoard = false, isGroupBoard = false,
...@@ -63,20 +52,26 @@ describe('BoardsSelector', () => { ...@@ -63,20 +52,26 @@ describe('BoardsSelector', () => {
}); });
}; };
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader);
const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdown = () => wrapper.findComponent(GlDropdown);
const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse); const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse);
const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse); const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse);
const epicBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockEpicBoardResponse); const epicBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockEpicBoardResponse);
const projectBoardsQueryHandlerSuccess = jest
.fn()
.mockResolvedValue(mockProjectAllBoardsResponse);
const groupBoardsQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupAllBoardsResponse);
const epicBoardsQueryHandlerSuccess = jest.fn().mockResolvedValue(mockEpicBoardsResponse);
const createComponent = () => { const createComponent = () => {
fakeApollo = createMockApollo([ fakeApollo = createMockApollo([
[projectBoardQuery, projectBoardQueryHandlerSuccess], [projectBoardQuery, projectBoardQueryHandlerSuccess],
[groupBoardQuery, groupBoardQueryHandlerSuccess], [groupBoardQuery, groupBoardQueryHandlerSuccess],
[epicBoardQuery, epicBoardQueryHandlerSuccess], [epicBoardQuery, epicBoardQueryHandlerSuccess],
[projectBoardsQuery, projectBoardsQueryHandlerSuccess],
[groupBoardsQuery, groupBoardsQueryHandlerSuccess],
[epicBoardsQuery, epicBoardsQueryHandlerSuccess],
]); ]);
wrapper = mount(BoardsSelector, { wrapper = mount(BoardsSelector, {
...@@ -94,92 +89,44 @@ describe('BoardsSelector', () => { ...@@ -94,92 +89,44 @@ describe('BoardsSelector', () => {
attachTo: document.body, attachTo: document.body,
provide: { provide: {
fullPath: '', fullPath: '',
recentBoardsEndpoint: `${TEST_HOST}/recent`,
}, },
}); });
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({
[options.loadingKey]: true,
});
});
}; };
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
fakeApollo = null; fakeApollo = null;
store = null; store = null;
mock.restore();
}); });
describe('fetching all board', () => { describe('fetching all boards', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); it.each`
boardType | isEpicBoard | queryHandler | notCalledHandler
allBoardsResponse = Promise.resolve({ ${BoardType.group} | ${false} | ${groupBoardsQueryHandlerSuccess} | ${projectBoardsQueryHandlerSuccess}
data: { ${BoardType.project} | ${false} | ${projectBoardsQueryHandlerSuccess} | ${groupBoardsQueryHandlerSuccess}
group: { ${BoardType.group} | ${true} | ${epicBoardsQueryHandlerSuccess} | ${groupBoardsQueryHandlerSuccess}
boards: { `(
edges: boards.map((board) => ({ node: board })), 'fetches $boardType boards when isEpicBoard is $isEpicBoard',
}, async ({ boardType, isEpicBoard, queryHandler, notCalledHandler }) => {
}, createStore({
isProjectBoard: boardType === BoardType.project,
isGroupBoard: boardType === BoardType.group,
isEpicBoard,
});
createComponent();
await nextTick();
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
await nextTick();
expect(queryHandler).toHaveBeenCalled();
expect(notCalledHandler).not.toHaveBeenCalled();
}, },
}); );
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
createStore();
createComponent();
mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards);
});
describe('loading', () => {
beforeEach(async () => {
// Wait for current board to be loaded
await nextTick();
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
});
// we are testing loading state, so don't resolve responses until after the tests
afterEach(async () => {
await Promise.all([allBoardsResponse, recentBoardsResponse]);
await nextTick();
});
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
});
});
describe('loaded', () => {
beforeEach(async () => {
// Wait for current board to be loaded
await nextTick();
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
await wrapper.setData({
loadingBoards: false,
loadingRecentBoards: false,
});
});
it('hides loading spinner', async () => {
await nextTick();
expect(getLoadingIcon().exists()).toBe(false);
});
}); });
}); });
......
...@@ -21,6 +21,27 @@ export const mockEpicBoardResponse = { ...@@ -21,6 +21,27 @@ export const mockEpicBoardResponse = {
}, },
}; };
export const mockEpicBoardsResponse = {
data: {
group: {
id: 'gid://gitlab/Group/114',
epicBoards: {
nodes: [
{
id: 'gid://gitlab/Boards::EpicBoard/1',
name: 'Development',
},
{
id: 'gid://gitlab/Boards::EpicBoard/2',
name: 'Marketing',
},
],
},
__typename: 'Group',
},
},
};
export const mockLabel = { export const mockLabel = {
id: 'gid://gitlab/GroupLabel/121', id: 'gid://gitlab/GroupLabel/121',
title: 'To Do', title: 'To Do',
......
...@@ -29,6 +29,85 @@ export const listObj = { ...@@ -29,6 +29,85 @@ export const listObj = {
}, },
}; };
function boardGenerator(n) {
return new Array(n).fill().map((board, index) => {
const id = `${index}`;
const name = `board${id}`;
return {
node: {
id,
name,
weight: 0,
__typename: 'Board',
},
};
});
}
export const boards = boardGenerator(20);
export const recentIssueBoards = boardGenerator(5);
export const mockSmallProjectAllBoardsResponse = {
data: {
project: {
id: 'gid://gitlab/Project/114',
boards: { edges: boardGenerator(3) },
__typename: 'Project',
},
},
};
export const mockEmptyProjectRecentBoardsResponse = {
data: {
project: {
id: 'gid://gitlab/Project/114',
recentIssueBoards: { edges: [] },
__typename: 'Project',
},
},
};
export const mockGroupAllBoardsResponse = {
data: {
group: {
id: 'gid://gitlab/Group/114',
boards: { edges: boards },
__typename: 'Group',
},
},
};
export const mockProjectAllBoardsResponse = {
data: {
project: {
id: 'gid://gitlab/Project/1',
boards: { edges: boards },
__typename: 'Project',
},
},
};
export const mockGroupRecentBoardsResponse = {
data: {
group: {
id: 'gid://gitlab/Group/114',
recentIssueBoards: { edges: recentIssueBoards },
__typename: 'Group',
},
},
};
export const mockProjectRecentBoardsResponse = {
data: {
project: {
id: 'gid://gitlab/Project/1',
recentIssueBoards: { edges: recentIssueBoards },
__typename: 'Project',
},
},
};
export const mockGroupBoardResponse = { export const mockGroupBoardResponse = {
data: { data: {
workspace: { workspace: {
......
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