Commit 0000d668 authored by Simon Knox's avatar Simon Knox

Merge branch '341331-refactor-board-scope-to-graphql' into 'master'

Refactor fetching board scope to GraphQL [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!73604
parents bf668226 703f89df
......@@ -2,10 +2,10 @@
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
import { TYPE_USER, TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { convertToGraphQLId, getZeroBasedIdFromGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { fullLabelId, fullBoardId } from '../boards_util';
import { fullLabelId } from '../boards_util';
import { formType } from '../constants';
import createBoardMutation from '../graphql/board_create.mutation.graphql';
......@@ -18,11 +18,11 @@ const boardDefaults = {
name: '',
labels: [],
milestone: {},
iteration_id: undefined,
iteration: {},
assignee: {},
weight: null,
hide_backlog_list: false,
hide_closed_list: false,
hideBacklogList: false,
hideClosedList: false,
};
export default {
......@@ -144,17 +144,16 @@ export default {
return destroyBoardMutation;
},
baseMutationVariables() {
const { board } = this;
const variables = {
name: board.name,
hideBacklogList: board.hide_backlog_list,
hideClosedList: board.hide_closed_list,
};
const {
board: { name, hideBacklogList, hideClosedList, id },
} = this;
const variables = { name, hideBacklogList, hideClosedList };
return board.id
return id
? {
...variables,
id: fullBoardId(board.id),
id,
}
: {
...variables,
......@@ -168,11 +167,13 @@ export default {
assigneeId: this.board.assignee?.id
? convertToGraphQLId(TYPE_USER, this.board.assignee.id)
: null,
// Temporarily converting to milestone ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
milestoneId: this.board.milestone?.id
? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id)
? convertToGraphQLId(TYPE_MILESTONE, getZeroBasedIdFromGraphQLId(this.board.milestone.id))
: null,
iterationId: this.board.iteration_id
? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id)
// Temporarily converting to iteration ID due to https://gitlab.com/gitlab-org/gitlab/-/issues/344779
iterationId: this.board.iteration?.id
? convertToGraphQLId(TYPE_ITERATION, getZeroBasedIdFromGraphQLId(this.board.iteration.id))
: null,
};
},
......@@ -226,7 +227,7 @@ export default {
await this.$apollo.mutate({
mutation: this.deleteMutation,
variables: {
id: fullBoardId(this.board.id),
id: this.board.id,
},
});
},
......@@ -262,7 +263,9 @@ export default {
}
},
setIteration(iterationId) {
this.board.iteration_id = iterationId;
this.$set(this.board, 'iteration', {
id: iterationId,
});
},
setBoardLabels(labels) {
this.board.labels = labels;
......@@ -329,8 +332,8 @@ export default {
</div>
<board-configuration-options
:hide-backlog-list.sync="board.hide_backlog_list"
:hide-closed-list.sync="board.hide_closed_list"
:hide-backlog-list.sync="board.hideBacklogList"
:hide-closed-list.sync="board.hideClosedList"
:readonly="readonly"
/>
......
......@@ -9,17 +9,20 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { throttle } from 'lodash';
import { mapGetters, mapState } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
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 eventHub from '../eventhub';
import groupQuery from '../graphql/group_boards.query.graphql';
import projectQuery from '../graphql/project_boards.query.graphql';
import groupBoardsQuery from '../graphql/group_boards.query.graphql';
import projectBoardsQuery from '../graphql/project_boards.query.graphql';
import groupBoardQuery from '../graphql/group_board.query.graphql';
import projectBoardQuery from '../graphql/project_board.query.graphql';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
......@@ -39,10 +42,6 @@ export default {
},
inject: ['fullPath', 'recentBoardsEndpoint'],
props: {
currentBoard: {
type: Object,
required: true,
},
throttleDuration: {
type: Number,
default: 200,
......@@ -86,14 +85,47 @@ export default {
maxPosition: 0,
filterTerm: '',
currentPage: '',
board: {},
};
},
apollo: {
board: {
query() {
return this.currentBoardQuery;
},
variables() {
return {
fullPath: this.fullPath,
boardId: this.fullBoardId,
};
},
update(data) {
const board = data.workspace?.board;
return {
...board,
labels: board?.labels?.nodes,
};
},
error() {
this.setError({ message: this.$options.i18n.errorFetchingBoard });
},
},
},
computed: {
...mapState(['boardType']),
...mapGetters(['isGroupBoard']),
...mapState(['boardType', 'fullBoardId']),
...mapGetters(['isGroupBoard', 'isProjectBoard']),
parentType() {
return this.boardType;
},
currentBoardQueryCE() {
return this.isGroupBoard ? groupBoardQuery : projectBoardQuery;
},
currentBoardQuery() {
return this.currentBoardQueryCE;
},
isBoardLoading() {
return this.$apollo.queries.board.loading;
},
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
},
......@@ -102,9 +134,6 @@ export default {
board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
board() {
return this.currentBoard;
},
showCreate() {
return this.multipleIssueBoardsAvailable;
},
......@@ -137,6 +166,7 @@ export default {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
...mapActions(['setError']),
showPage(page) {
this.currentPage = page;
},
......@@ -153,7 +183,7 @@ export default {
}));
},
boardQuery() {
return this.isGroupBoard ? groupQuery : projectQuery;
return this.isGroupBoard ? groupBoardsQuery : projectBoardsQuery;
},
loadBoards(toggleDropdown = true) {
if (toggleDropdown && this.boards.length > 0) {
......@@ -229,13 +259,18 @@ export default {
this.hasScrollFade = this.isScrolledUp();
},
},
i18n: {
errorFetchingBoard: s__('Board|An error occurred while fetching the board, please try again.'),
},
};
</script>
<template>
<div class="boards-switcher js-boards-selector gl-mr-3">
<span class="boards-selector-wrapper js-boards-selector-wrapper">
<gl-loading-icon v-if="isBoardLoading" size="md" class="gl-mt-2" />
<gl-dropdown
v-else
data-qa-selector="boards_dropdown"
toggle-class="dropdown-menu-toggle js-dropdown-toggle"
menu-class="flex-column dropdown-extended-height"
......@@ -336,7 +371,7 @@ export default {
:can-admin-board="canAdminBoard"
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
:current-board="currentBoard"
:current-board="board"
:current-page="currentPage"
@cancel="cancel"
/>
......
fragment BoardScopeFragment on Board {
id
name
hideBacklogList
hideClosedList
}
#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql"
query GroupBoard($fullPath: ID!, $boardId: ID!) {
workspace: group(fullPath: $fullPath) {
board(id: $boardId) {
...BoardScopeFragment
}
}
}
#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql"
query ProjectBoard($fullPath: ID!, $boardId: ID!) {
workspace: project(fullPath: $fullPath) {
board(id: $boardId) {
...BoardScopeFragment
}
}
}
......@@ -32,7 +32,6 @@ export default (params = {}) => {
data() {
const boardsSelectorProps = {
...dataset,
currentBoard: JSON.parse(dataset.currentBoard),
hasMissingBoards: parseBoolean(dataset.hasMissingBoards),
canAdminBoard: parseBoolean(dataset.canAdminBoard),
multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable),
......
......@@ -15,6 +15,8 @@ export const isGid = (id) => {
return false;
};
const parseGid = (gid) => parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10);
/**
* Ids generated by GraphQL endpoints are usually in the format
* gid://gitlab/Environments/123. This method extracts Id number
......@@ -23,8 +25,12 @@ export const isGid = (id) => {
* @param {String} gid GraphQL global ID
* @returns {Number}
*/
export const getIdFromGraphQLId = (gid = '') =>
parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
export const getIdFromGraphQLId = (gid = '') => parseGid(gid) || null;
export const getZeroBasedIdFromGraphQLId = (gid = '') => {
const parsedGid = parseGid(gid);
return Number.isInteger(parsedGid) ? parsedGid : null;
};
export const MutationOperationMode = {
Append: 'APPEND',
......
......@@ -3,8 +3,7 @@
- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board?
- weights = Gitlab.ee? ? ([Issue::WEIGHT_ANY] + Issue.weight_options) : []
#js-multiple-boards-switcher.inline.boards-switcher{ data: { current_board: current_board_json.to_json,
milestone_path: milestones_filter_path(milestone_filter_opts),
#js-multiple-boards-switcher.inline.boards-switcher{ data: { milestone_path: milestones_filter_path(milestone_filter_opts),
board_base_url: board_base_url,
has_missing_boards: (!multiple_boards_available? && current_board_parent.boards.size > 1).to_s,
can_admin_board: can?(current_user, :admin_issue_board, parent).to_s,
......
......@@ -3,9 +3,7 @@
// extends a valid Vue single file component.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import { mapGetters } from 'vuex';
import { fullBoardId } from '~/boards/boards_util';
import BoardFormFoss from '~/boards/components/board_form.vue';
import { fullEpicBoardId } from '../boards_util';
import createEpicBoardMutation from '../graphql/epic_board_create.mutation.graphql';
import destroyEpicBoardMutation from '../graphql/epic_board_destroy.mutation.graphql';
import updateEpicBoardMutation from '../graphql/epic_board_update.mutation.graphql';
......@@ -18,11 +16,8 @@ export default {
return this.board.id ? updateEpicBoardMutation : createEpicBoardMutation;
},
mutationVariables() {
const { board } = this;
return {
...this.baseMutationVariables,
...(board.id && this.isEpicBoard && { id: fullEpicBoardId(board.id) }),
...(this.scopedIssueBoardFeatureEnabled || this.isEpicBoard
? this.boardScopeMutationVariables
: {}),
......@@ -56,7 +51,7 @@ export default {
await this.$apollo.mutate({
mutation: this.isEpicBoard ? destroyEpicBoardMutation : this.deleteMutation,
variables: {
id: this.isEpicBoard ? fullEpicBoardId(this.board.id) : fullBoardId(this.board.id),
id: this.board.id,
},
});
},
......
<script>
import { mapGetters } from 'vuex';
import { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import AssigneeSelect from './assignee_select.vue';
import BoardScopeCurrentIteration from './board_scope_current_iteration.vue';
import BoardLabelsSelect from './labels_select.vue';
......@@ -51,6 +52,9 @@ export default {
? __('Board scope affects which issues are displayed for anyone who visits this board')
: __('Board scope affects which epics are displayed for anyone who visits this board');
},
iterationId() {
return getIdFromGraphQLId(this.board.iteration?.id) || null;
},
},
methods: {
......@@ -87,7 +91,7 @@ export default {
<board-scope-current-iteration
v-if="isIssueBoard"
:can-admin-board="canAdminBoard"
:iteration-id="board.iteration_id"
:iteration-id="iterationId"
@set-iteration="$emit('set-iteration', $event)"
/>
......
......@@ -7,6 +7,7 @@ import BoardsSelectorFoss from '~/boards/components/boards_selector.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import Tracking from '~/tracking';
import epicBoardsQuery from '../graphql/epic_boards.query.graphql';
import epicBoardQuery from '../graphql/epic_board.query.graphql';
export default {
extends: BoardsSelectorFoss,
......@@ -19,6 +20,9 @@ export default {
showDelete() {
return this.boards.length > 1;
},
currentBoardQuery() {
return this.isEpicBoard ? epicBoardQuery : this.currentBoardQueryCE;
},
},
methods: {
epicBoardUpdate(data) {
......
......@@ -2,7 +2,6 @@
import { GlButton } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
import searchGroupLabels from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql';
import searchProjectLabels from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
......@@ -60,10 +59,7 @@ export default {
return !this.isEditing;
},
update(data) {
return data.workspace?.labels?.nodes.map((label) => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
return data.workspace?.labels?.nodes;
},
error() {
this.setError({ message: this.$options.i18n.errorSearchingLabels });
......@@ -123,7 +119,7 @@ export default {
this.$emit('set-labels', labels);
},
onLabelRemove(labelId) {
const labels = this.selected.filter(({ id }) => getIdFromGraphQLId(id) !== labelId);
const labels = this.selected.filter(({ id }) => id !== labelId);
this.selected = labels;
this.$emit('set-labels', labels);
},
......
#import "~/graphql_shared/fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/label.fragment.graphql"
#import "~/graphql_shared/fragments/iteration.fragment.graphql"
fragment BoardScopeFragment on Board {
id
name
hideBacklogList
hideClosedList
assignee {
...User
}
milestone {
id
title
}
labels {
nodes {
...Label
}
}
iteration {
...Iteration
}
weight
}
#import "~/graphql_shared/fragments/label.fragment.graphql"
query EpicBoard($fullPath: ID!, $boardId: BoardsEpicBoardID!) {
workspace: group(fullPath: $fullPath) {
board: epicBoard(id: $boardId) {
id
name
hideBacklogList
hideClosedList
labels {
nodes {
...Label
}
}
}
}
}
......@@ -225,6 +225,7 @@ RSpec.describe 'Scoped issue boards', :js do
it 'prefills fields' do
visit project_boards_path(project_2)
wait_for_all_requests
edit_board.click
......@@ -332,6 +333,7 @@ RSpec.describe 'Scoped issue boards', :js do
it 'adds label to board' do
label_title = issue.labels.first.title
visit project_boards_path(project)
wait_for_all_requests
update_board_label(label_title)
......@@ -346,6 +348,7 @@ RSpec.describe 'Scoped issue boards', :js do
label_2_title = issue_2.labels.first.title
visit project_boards_path(project)
wait_for_all_requests
update_board_label(label_title)
......@@ -365,6 +368,7 @@ RSpec.describe 'Scoped issue boards', :js do
label_2_title = issue_2.labels.first.title
visit project_boards_path(project)
wait_for_all_requests
update_board_label(label_title)
......@@ -378,6 +382,7 @@ RSpec.describe 'Scoped issue boards', :js do
stub_licensed_features(multiple_group_issue_boards: true)
visit group_boards_path(group)
wait_for_all_requests
edit_board.click
page.within('.labels') do
......
......@@ -23,15 +23,19 @@ jest.mock('~/lib/utils/url_utility', () => ({
Vue.use(Vuex);
const currentBoard = {
id: 1,
id: 'gid://gitlab/Board/1',
name: 'test',
labels: [],
milestone_id: undefined,
milestone: {},
assignee: {},
assignee_id: undefined,
weight: null,
hide_backlog_list: false,
hide_closed_list: false,
hideBacklogList: false,
hideClosedList: false,
};
const currentEpicBoard = {
...currentBoard,
id: 'gid://gitlab/Boards::EpicBoard/321',
};
const defaultProps = {
......@@ -206,7 +210,9 @@ describe('BoardForm', () => {
milestone: {
id: 2,
},
iteration_id: 3,
iteration: {
id: 'gid://gitlab/Iteration/3',
},
},
canAdminBoard: true,
currentPage: formType.edit,
......@@ -221,7 +227,7 @@ describe('BoardForm', () => {
mutation: updateBoardMutation,
variables: {
input: expect.objectContaining({
id: `gid://gitlab/Board/${currentBoard.id}`,
id: currentBoard.id,
assigneeId: 'gid://gitlab/User/1',
milestoneId: 'gid://gitlab/Milestone/2',
iterationId: 'gid://gitlab/Iteration/3',
......@@ -240,11 +246,15 @@ describe('BoardForm', () => {
mutate = jest.fn().mockResolvedValue({
data: {
epicBoardUpdate: {
epicBoard: { id: 'gid://gitlab/Boards::EpicBoard/321', webPath: 'test-path' },
epicBoard: { id: currentEpicBoard.id, webPath: 'test-path' },
},
},
});
createComponent({ canAdminBoard: true, currentPage: formType.edit });
createComponent({
canAdminBoard: true,
currentPage: formType.edit,
currentBoard: currentEpicBoard,
});
findInput().trigger('keyup.enter', { metaKey: true });
......@@ -254,7 +264,7 @@ describe('BoardForm', () => {
mutation: updateEpicBoardMutation,
variables: {
input: expect.objectContaining({
id: `gid://gitlab/Boards::EpicBoard/${currentBoard.id}`,
id: currentEpicBoard.id,
}),
},
});
......@@ -265,7 +275,11 @@ describe('BoardForm', () => {
it('shows a GlAlert if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ canAdminBoard: true, currentPage: formType.edit });
createComponent({
canAdminBoard: true,
currentPage: formType.edit,
currentBoard: currentEpicBoard,
});
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findInput().trigger('keyup.enter', { metaKey: true });
......@@ -285,19 +299,31 @@ describe('BoardForm', () => {
});
it('passes correct primary action text and variant', () => {
createComponent({ canAdminBoard: true, currentPage: formType.delete });
createComponent({
canAdminBoard: true,
currentPage: formType.delete,
currentBoard: currentEpicBoard,
});
expect(findModalActionPrimary().text).toBe('Delete');
expect(findModalActionPrimary().attributes[0].variant).toBe('danger');
});
it('renders delete confirmation message', () => {
createComponent({ canAdminBoard: true, currentPage: formType.delete });
createComponent({
canAdminBoard: true,
currentPage: formType.delete,
currentBoard: currentEpicBoard,
});
expect(findDeleteConfirmation().exists()).toBe(true);
});
it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => {
mutate = jest.fn().mockResolvedValue({});
createComponent({ canAdminBoard: true, currentPage: formType.delete });
createComponent({
canAdminBoard: true,
currentPage: formType.delete,
currentBoard: currentEpicBoard,
});
findModal().vm.$emit('primary');
await waitForPromises();
......@@ -305,7 +331,7 @@ describe('BoardForm', () => {
expect(mutate).toHaveBeenCalledWith({
mutation: destroyEpicBoardMutation,
variables: {
id: 'gid://gitlab/Boards::EpicBoard/1',
id: currentEpicBoard.id,
},
});
......@@ -315,7 +341,11 @@ describe('BoardForm', () => {
it('shows a GlAlert if GraphQL mutation fails', async () => {
mutate = jest.fn().mockRejectedValue('Houston, we have a problem');
createComponent({ canAdminBoard: true, currentPage: formType.delete });
createComponent({
canAdminBoard: true,
currentPage: formType.delete,
currentBoard: currentEpicBoard,
});
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findModal().vm.$emit('primary');
......
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import BoardsSelector from 'ee/boards/components/boards_selector.vue';
import { BoardType } from '~/boards/constants';
import epicBoardQuery from 'ee/boards/graphql/epic_board.query.graphql';
import groupBoardQuery from '~/boards/graphql/group_board.query.graphql';
import projectBoardQuery from '~/boards/graphql/project_board.query.graphql';
import defaultStore from '~/boards/stores';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockGroupBoardResponse, mockProjectBoardResponse } from 'jest/boards/mock_data';
import { mockEpicBoardResponse } from '../mock_data';
const throttleDuration = 1;
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(VueApollo);
function boardGenerator(n) {
return new Array(n).fill().map((board, index) => {
......@@ -29,13 +37,28 @@ describe('BoardsSelector', () => {
let allBoardsResponse;
let recentBoardsResponse;
let mock;
let fakeApollo;
let store;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
const createStore = () => {
return new Vuex.Store({
const createStore = ({
isGroupBoard = false,
isProjectBoard = false,
isEpicBoard = false,
} = {}) => {
store = new Vuex.Store({
...defaultStore,
actions: {
setError: jest.fn(),
},
getters: {
isEpicBoard: () => false,
isEpicBoard: () => isEpicBoard,
isGroupBoard: () => isGroupBoard,
isProjectBoard: () => isProjectBoard,
},
state: {
boardType: isGroupBoard ? BoardType.group : BoardType.project,
},
});
};
......@@ -45,43 +68,22 @@ describe('BoardsSelector', () => {
const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findDropdown = () => wrapper.findComponent(GlDropdown);
beforeEach(() => {
mock = new MockAdapter(axios);
const $apollo = {
queries: {
boards: {
loading: false,
},
},
};
allBoardsResponse = Promise.resolve({
data: {
group: {
boards: {
edges: boards.map((board) => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse);
const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse);
const epicBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockEpicBoardResponse);
const store = createStore();
const createComponent = () => {
fakeApollo = createMockApollo([
[projectBoardQuery, projectBoardQueryHandlerSuccess],
[groupBoardQuery, groupBoardQueryHandlerSuccess],
[epicBoardQuery, epicBoardQueryHandlerSuccess],
]);
wrapper = mount(BoardsSelector, {
localVue,
store,
apolloProvider: fakeApollo,
propsData: {
throttleDuration,
currentBoard: {
id: 1,
name: 'Development',
milestone_id: null,
weight: null,
assignee_id: null,
labels: [],
},
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: true,
......@@ -89,13 +91,11 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
mocks: { $apollo },
attachTo: document.body,
provide: {
fullPath: '',
recentBoardsEndpoint: `${TEST_HOST}/recent`,
},
store,
});
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
......@@ -103,48 +103,103 @@ describe('BoardsSelector', () => {
[options.loadingKey]: true,
});
});
mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards);
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
fakeApollo = null;
store = null;
mock.restore();
});
describe('loading', () => {
// we are testing loading state, so don't resolve responses until after the tests
afterEach(async () => {
await Promise.all([allBoardsResponse, recentBoardsResponse]);
return nextTick();
});
describe('fetching all board', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
allBoardsResponse = Promise.resolve({
data: {
group: {
boards: {
edges: boards.map((board) => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
createStore();
createComponent();
mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards);
});
});
describe('loaded', () => {
beforeEach(async () => {
await wrapper.setData({
loadingBoards: false,
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);
});
// NOTE: Due to timing issues, this `return` of `Promise.all` is required because
// `app/assets/javascripts/boards/components/boards_selector.vue` does a `$nextTick()` in
// loadRecentBoards. For some unknown reason it doesn't work with `await`, the `return`
// is required.
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('hides loading spinner', async () => {
await wrapper.vm.$nextTick();
expect(getLoadingIcon().exists()).toBe(false);
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');
await wrapper.setData({
loadingBoards: false,
loadingRecentBoards: false,
});
});
it('hides loading spinner', async () => {
await nextTick();
expect(getLoadingIcon().exists()).toBe(false);
});
});
});
describe('fetching current board', () => {
it.each`
boardType | isEpicBoard | queryHandler | notCalledHandler
${BoardType.group} | ${false} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
${BoardType.project} | ${false} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
${BoardType.group} | ${true} | ${epicBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
`(
'fetches $boardType board when isEpicBoard is $isEpicBoard',
async ({ boardType, isEpicBoard, queryHandler, notCalledHandler }) => {
createStore({
isProjectBoard: boardType === BoardType.project,
isGroupBoard: boardType === BoardType.group,
isEpicBoard,
});
createComponent();
await nextTick();
expect(queryHandler).toHaveBeenCalled();
expect(notCalledHandler).not.toHaveBeenCalled();
},
);
});
});
......@@ -7,6 +7,18 @@ import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
export const mockEpicBoardResponse = {
data: {
workspace: {
epicBoard: {
id: 'gid://gitlab/Boards::EpicBoard/1',
name: 'Development',
},
__typename: 'Group',
},
},
};
export const mockLabel = {
id: 'gid://gitlab/GroupLabel/121',
title: 'To Do',
......
......@@ -5638,6 +5638,9 @@ msgstr ""
msgid "Boards|View scope"
msgstr ""
msgid "Board|An error occurred while fetching the board, please try again."
msgstr ""
msgid "Board|Are you sure you want to delete this board?"
msgstr ""
......
......@@ -17,15 +17,14 @@ jest.mock('~/lib/utils/url_utility', () => ({
}));
const currentBoard = {
id: 1,
id: 'gid://gitlab/Board/1',
name: 'test',
labels: [],
milestone_id: undefined,
milestone: {},
assignee: {},
assignee_id: undefined,
weight: null,
hide_backlog_list: false,
hide_closed_list: false,
hideBacklogList: false,
hideClosedList: false,
};
const defaultProps = {
......@@ -249,7 +248,7 @@ describe('BoardForm', () => {
mutation: updateBoardMutation,
variables: {
input: expect.objectContaining({
id: `gid://gitlab/Board/${currentBoard.id}`,
id: currentBoard.id,
}),
},
});
......@@ -275,7 +274,7 @@ describe('BoardForm', () => {
mutation: updateBoardMutation,
variables: {
input: expect.objectContaining({
id: `gid://gitlab/Board/${currentBoard.id}`,
id: currentBoard.id,
}),
},
});
......@@ -323,7 +322,7 @@ describe('BoardForm', () => {
expect(mutate).toHaveBeenCalledWith({
mutation: destroyBoardMutation,
variables: {
id: 'gid://gitlab/Board/1',
id: currentBoard.id,
},
});
......
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import groupBoardQuery from '~/boards/graphql/group_board.query.graphql';
import projectBoardQuery from '~/boards/graphql/project_board.query.graphql';
import defaultStore from '~/boards/stores';
import axios from '~/lib/utils/axios_utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockGroupBoardResponse, mockProjectBoardResponse } from '../mock_data';
const throttleDuration = 1;
Vue.use(VueApollo);
function boardGenerator(n) {
return new Array(n).fill().map((board, index) => {
const id = `${index}`;
......@@ -25,9 +34,27 @@ describe('BoardsSelector', () => {
let allBoardsResponse;
let recentBoardsResponse;
let mock;
let fakeApollo;
let store;
const boards = boardGenerator(20);
const recentBoards = boardGenerator(5);
const createStore = ({ isGroupBoard = false, isProjectBoard = false } = {}) => {
store = new Vuex.Store({
...defaultStore,
actions: {
setError: jest.fn(),
},
getters: {
isGroupBoard: () => isGroupBoard,
isProjectBoard: () => isProjectBoard,
},
state: {
boardType: isGroupBoard ? 'group' : 'project',
},
});
};
const fillSearchBox = (filterTerm) => {
const searchBox = wrapper.find({ ref: 'searchBox' });
const searchBoxInput = searchBox.find('input');
......@@ -40,40 +67,20 @@ describe('BoardsSelector', () => {
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findDropdown = () => wrapper.find(GlDropdown);
beforeEach(() => {
mock = new MockAdapter(axios);
const $apollo = {
queries: {
boards: {
loading: false,
},
},
};
const projectBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockProjectBoardResponse);
const groupBoardQueryHandlerSuccess = jest.fn().mockResolvedValue(mockGroupBoardResponse);
allBoardsResponse = Promise.resolve({
data: {
group: {
boards: {
edges: boards.map((board) => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
const createComponent = () => {
fakeApollo = createMockApollo([
[projectBoardQuery, projectBoardQueryHandlerSuccess],
[groupBoardQuery, groupBoardQueryHandlerSuccess],
]);
wrapper = mount(BoardsSelector, {
store,
apolloProvider: fakeApollo,
propsData: {
throttleDuration,
currentBoard: {
id: 1,
name: 'Development',
milestone_id: null,
weight: null,
assignee_id: null,
labels: [],
},
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: true,
......@@ -81,7 +88,6 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
mocks: { $apollo },
attachTo: document.body,
provide: {
fullPath: '',
......@@ -94,12 +100,7 @@ describe('BoardsSelector', () => {
[options.loadingKey]: true,
});
});
mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards);
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
findDropdown().vm.$emit('show');
});
};
afterEach(() => {
wrapper.destroy();
......@@ -107,104 +108,158 @@ describe('BoardsSelector', () => {
mock.restore();
});
describe('loading', () => {
// we are testing loading state, so don't resolve responses until after the tests
afterEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
describe('fetching all boards', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
allBoardsResponse = Promise.resolve({
data: {
group: {
boards: {
edges: boards.map((board) => ({ node: board })),
},
},
},
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
});
createStore();
createComponent();
mock.onGet(`${TEST_HOST}/recent`).replyOnce(200, recentBoards);
});
});
describe('loaded', () => {
beforeEach(async () => {
await wrapper.setData({
loadingBoards: false,
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();
});
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('hides loading spinner', async () => {
await wrapper.vm.$nextTick();
expect(getLoadingIcon().exists()).toBe(false);
it('shows loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
});
});
describe('filtering', () => {
beforeEach(() => {
wrapper.setData({
boards,
});
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');
return nextTick();
await wrapper.setData({
loadingBoards: false,
loadingRecentBoards: false,
});
await Promise.all([allBoardsResponse, recentBoardsResponse]);
await nextTick();
});
it('shows all boards without filtering', () => {
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
it('hides loading spinner', async () => {
await nextTick();
expect(getLoadingIcon().exists()).toBe(false);
});
it('shows only matching boards when filtering', () => {
const filterTerm = 'board1';
const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length;
describe('filtering', () => {
beforeEach(async () => {
wrapper.setData({
boards,
});
await nextTick();
});
fillSearchBox(filterTerm);
it('shows all boards without filtering', () => {
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
});
return nextTick().then(() => {
it('shows only matching boards when filtering', async () => {
const filterTerm = 'board1';
const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length;
fillSearchBox(filterTerm);
await nextTick();
expect(getDropdownItems()).toHaveLength(expectedCount);
});
});
it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist');
it('shows message if there are no matching boards', async () => {
fillSearchBox('does not exist');
return nextTick().then(() => {
await nextTick();
expect(getDropdownItems()).toHaveLength(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true);
});
});
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => {
wrapper.setData({
boards,
});
describe('recent boards section', () => {
it('shows only when boards are greater than 10', async () => {
wrapper.setData({
boards,
});
return nextTick().then(() => {
await nextTick();
expect(getDropdownHeaders()).toHaveLength(2);
});
});
it('does not show when boards are less than 10', () => {
wrapper.setData({
boards: boards.slice(0, 5),
});
it('does not show when boards are less than 10', async () => {
wrapper.setData({
boards: boards.slice(0, 5),
});
return nextTick().then(() => {
await nextTick();
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when recentBoards api returns empty array', () => {
wrapper.setData({
recentBoards: [],
});
it('does not show when recentBoards api returns empty array', async () => {
wrapper.setData({
recentBoards: [],
});
return nextTick().then(() => {
await nextTick();
expect(getDropdownHeaders()).toHaveLength(0);
});
});
it('does not show when search is active', () => {
fillSearchBox('Random string');
it('does not show when search is active', async () => {
fillSearchBox('Random string');
return nextTick().then(() => {
await nextTick();
expect(getDropdownHeaders()).toHaveLength(0);
});
});
});
});
describe('fetching current board', () => {
it.each`
boardType | queryHandler | notCalledHandler
${'group'} | ${groupBoardQueryHandlerSuccess} | ${projectBoardQueryHandlerSuccess}
${'project'} | ${projectBoardQueryHandlerSuccess} | ${groupBoardQueryHandlerSuccess}
`('fetches $boardType board', async ({ boardType, queryHandler, notCalledHandler }) => {
createStore({
isProjectBoard: boardType === 'project',
isGroupBoard: boardType === 'group',
});
createComponent();
await nextTick();
expect(queryHandler).toHaveBeenCalled();
expect(notCalledHandler).not.toHaveBeenCalled();
});
});
});
......@@ -30,17 +30,27 @@ export const listObj = {
},
};
export const listObjDuplicate = {
id: listObj.id,
position: 1,
title: 'Test',
list_type: 'label',
weight: 3,
label: {
id: listObj.label.id,
title: 'Test',
color: '#ff0000',
description: 'testing;',
export const mockGroupBoardResponse = {
data: {
workspace: {
board: {
id: 'gid://gitlab/Board/1',
name: 'Development',
},
__typename: 'Group',
},
},
};
export const mockProjectBoardResponse = {
data: {
workspace: {
board: {
id: 'gid://gitlab/Board/2',
name: 'Development',
},
__typename: 'Project',
},
},
};
......@@ -634,8 +644,8 @@ export const mockProjectLabelsResponse = {
labels: {
nodes: [mockLabel1, mockLabel2],
},
__typename: 'Project',
},
__typename: 'Project',
},
};
......@@ -646,7 +656,7 @@ export const mockGroupLabelsResponse = {
labels: {
nodes: [mockLabel1, mockLabel2],
},
__typename: 'Group',
},
__typename: 'Group',
},
};
import {
isGid,
getIdFromGraphQLId,
getZeroBasedIdFromGraphQLId,
convertToGraphQLId,
convertToGraphQLIds,
convertFromGraphQLIds,
......@@ -51,6 +52,10 @@ describe('getIdFromGraphQLId', () => {
input: 'gid://gitlab/Environments/',
output: null,
},
{
input: 'gid://gitlab/Environments/0',
output: null,
},
{
input: 'gid://gitlab/Environments/123',
output: 123,
......@@ -66,6 +71,55 @@ describe('getIdFromGraphQLId', () => {
});
});
describe('getZeroBasedIdFromGraphQLId', () => {
[
{
input: '',
output: null,
},
{
input: null,
output: null,
},
{
input: 2,
output: 2,
},
{
input: 'gid://',
output: null,
},
{
input: 'gid://gitlab/',
output: null,
},
{
input: 'gid://gitlab/Environments',
output: null,
},
{
input: 'gid://gitlab/Environments/',
output: null,
},
{
input: 'gid://gitlab/Environments/0',
output: 0,
},
{
input: 'gid://gitlab/Environments/123',
output: 123,
},
{
input: 'gid://gitlab/DesignManagement::Version/2',
output: 2,
},
].forEach(({ input, output }) => {
it(`getZeroBasedIdFromGraphQLId returns ${output} when passed ${input}`, () => {
expect(getZeroBasedIdFromGraphQLId(input)).toBe(output);
});
});
});
describe('convertToGraphQLId', () => {
it('combines $type and $id into $result', () => {
expect(convertToGraphQLId(mockType, mockId)).toBe(mockGid);
......
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