From 2d8bb4a1e33c600dfce97d039359a45d6d405c36 Mon Sep 17 00:00:00 2001 From: Simon Knox <simon@gitlab.com> Date: Thu, 2 Dec 2021 11:16:40 +0000 Subject: [PATCH] Add iteration to issues created on scoped boards --- .../javascripts/boards/stores/actions.js | 16 +- .../assets/javascripts/boards/boards_util.js | 69 +++++++- .../board_current_iteration.query.graphql | 25 +++ .../boards/graphql/issue.fragment.graphql | 8 + .../javascripts/boards/stores/actions.js | 43 ++++- .../boards/scoped_issue_board_spec.rb | 25 ++- ee/spec/frontend/boards/boards_util_spec.js | 67 ++++++++ .../frontend/boards/stores/actions_spec.js | 152 +++++++++++++++++- spec/frontend/boards/stores/actions_spec.js | 2 +- 9 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 ee/app/assets/javascripts/boards/graphql/board_current_iteration.query.graphql diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index c8cc94c4d0068..901461ae603f8 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -16,24 +16,24 @@ import { ListTypeTitles, DraggableItemTypes, } from 'ee_else_ce/boards/constants'; -import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; -import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { queryToObject } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; import { + formatIssueInput, formatBoardLists, formatListIssues, formatListsPageInfo, formatIssue, - formatIssueInput, updateListPosition, moveItemListHelper, getMoveData, FiltersInfo, filterVariables, -} from '../boards_util'; +} from 'ee_else_ce/boards/boards_util'; +import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; +import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import { gqlClient } from '../graphql'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; diff --git a/ee/app/assets/javascripts/boards/boards_util.js b/ee/app/assets/javascripts/boards/boards_util.js index 0e21823273564..d2a741c5bb794 100644 --- a/ee/app/assets/javascripts/boards/boards_util.js +++ b/ee/app/assets/javascripts/boards/boards_util.js @@ -1,4 +1,8 @@ -import { FiltersInfo as FiltersInfoCE } from '~/boards/boards_util'; +import { + FiltersInfo as FiltersInfoCE, + formatIssueInput as formatIssueInputCe, +} from '~/boards/boards_util'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import { EPIC_LANE_BASE_HEIGHT, @@ -11,6 +15,17 @@ import { EpicFilterType, } from './constants'; +export { + formatBoardLists, + formatListIssues, + formatListsPageInfo, + formatIssue, + updateListPosition, + moveItemListHelper, + getMoveData, + filterVariables, +} from '~/boards/boards_util'; + export function getMilestone({ milestone }) { return milestone || null; } @@ -23,6 +38,30 @@ export function fullMilestoneId(milestoneId) { return `gid://gitlab/Milestone/${milestoneId}`; } +function fullIterationId(id) { + if (!id) { + return null; + } + + if (id === IterationIDs.CURRENT) { + return 'CURRENT'; + } + + if (id === IterationIDs.UPCOMING) { + return 'UPCOMING'; + } + + return `gid://gitlab/Iteration/${id}`; +} + +function fullIterationCadenceId(id) { + if (!id) { + return null; + } + + return `gid://gitlab/Iterations::Cadence/${getIdFromGraphQLId(id)}`; +} + export function fullUserId(userId) { return `gid://gitlab/User/${userId}`; } @@ -96,6 +135,34 @@ export function formatEpicInput(epicInput, boardConfig) { }; } +function iterationObj(iterationId) { + const isWildcard = Object.values(IterationIDs).includes(iterationId); + const key = isWildcard ? 'iterationWildcardId' : 'iterationId'; + + return { + [key]: fullIterationId(iterationId), + }; +} + +export function formatIssueInput(issueInput, boardConfig) { + const { iterationId, iterationCadenceId } = boardConfig; + + const iteration = gon.features?.iterationCadences + ? { + iterationCadenceId: fullIterationCadenceId(iterationCadenceId), + ...iterationObj(iterationId), + } + : { + iterationCadenceId, + ...iterationObj(iterationId), + }; + + return { + ...formatIssueInputCe(issueInput, boardConfig), + ...iteration, + }; +} + export function transformBoardConfig(boardConfig) { const updatedBoardConfig = {}; const passedFilterParams = queryToObject(window.location.search, { gatherArrays: true }); diff --git a/ee/app/assets/javascripts/boards/graphql/board_current_iteration.query.graphql b/ee/app/assets/javascripts/boards/graphql/board_current_iteration.query.graphql new file mode 100644 index 0000000000000..95bb70645f1a9 --- /dev/null +++ b/ee/app/assets/javascripts/boards/graphql/board_current_iteration.query.graphql @@ -0,0 +1,25 @@ +query BoardCurrentIteration($fullPath: ID!, $isGroup: Boolean = true) { + group(fullPath: $fullPath) @include(if: $isGroup) { + id + iterations(state: current, first: 1, includeAncestors: true) { + nodes { + id + iterationCadence { + id + } + } + } + } + + project(fullPath: $fullPath) @skip(if: $isGroup) { + id + iterations(state: current, first: 1, includeAncestors: true) { + nodes { + id + iterationCadence { + id + } + } + } + } +} diff --git a/ee/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/ee/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 8f2249f0f8640..bcbe322711f39 100644 --- a/ee/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/ee/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -29,6 +29,14 @@ fragment IssueNode on Issue { milestone { ...MilestoneFragment } + iteration { + id + title + iterationCadence { + id + title + } + } labels { nodes { id diff --git a/ee/app/assets/javascripts/boards/stores/actions.js b/ee/app/assets/javascripts/boards/stores/actions.js index 44b9610d4215d..b59a8a898f198 100644 --- a/ee/app/assets/javascripts/boards/stores/actions.js +++ b/ee/app/assets/javascripts/boards/stores/actions.js @@ -27,7 +27,7 @@ import { FiltersInfo, } from '../boards_util'; -import { EpicFilterType, GroupByParamType, FilterFields } from '../constants'; +import { EpicFilterType, GroupByParamType, FilterFields, IterationIDs } from '../constants'; import createEpicBoardListMutation from '../graphql/epic_board_list_create.mutation.graphql'; import epicCreateMutation from '../graphql/epic_create.mutation.graphql'; import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql'; @@ -35,6 +35,7 @@ import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql'; import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql'; import listsEpicsQuery from '../graphql/lists_epics.query.graphql'; import subGroupsQuery from '../graphql/sub_groups.query.graphql'; +import currentIterationQuery from '../graphql/board_current_iteration.query.graphql'; import updateBoardEpicUserPreferencesMutation from '../graphql/update_board_epic_user_preferences.mutation.graphql'; import * as types from './mutation_types'; @@ -94,6 +95,46 @@ export { gqlClient }; export default { ...actionsCE, + addListNewIssue: async ( + { state: { boardConfig, boardType, fullPath }, dispatch, commit }, + issueInputObj, + ) => { + const { iterationId } = boardConfig; + let { iterationCadenceId } = boardConfig; + + if (!iterationCadenceId && iterationId === IterationIDs.CURRENT) { + const iteration = await gqlClient + .query({ + query: currentIterationQuery, + context: { + isSingleRequest: true, + }, + variables: { + isGroup: boardType === BoardType.group, + fullPath, + }, + }) + .then(({ data }) => { + return data[boardType]?.iterations?.nodes?.[0]; + }); + + iterationCadenceId = iteration.iterationCadence.id; + } + + return actionsCE.addListNewIssue( + { + state: { + boardConfig: { ...boardConfig, iterationId, iterationCadenceId }, + boardType, + fullPath, + }, + dispatch, + commit, + }, + issueInputObj, + ); + }, + setFilters: ({ commit, dispatch, state: { issuableType } }, filters) => { if (filters.groupBy === GroupByParamType.epic) { dispatch('setEpicSwimlanes'); diff --git a/ee/spec/features/boards/scoped_issue_board_spec.rb b/ee/spec/features/boards/scoped_issue_board_spec.rb index fa1ee9b19bdb3..21cc0eb5b3fb6 100644 --- a/ee/spec/features/boards/scoped_issue_board_spec.rb +++ b/ee/spec/features/boards/scoped_issue_board_spec.rb @@ -278,7 +278,7 @@ context 'iteration' do context 'group with iterations' do let_it_be(:cadence) { create(:iterations_cadence, group: group) } - let_it_be(:iteration) { create(:iteration, group: group, iterations_cadence: cadence) } + let_it_be(:iteration) { create(:current_iteration, :skip_future_date_validation, iterations_cadence: cadence, group: group, start_date: 1.day.ago, due_date: Date.today) } context 'board not scoped to iteration' do it 'sets board to current iteration' do @@ -293,6 +293,29 @@ end context 'board scoped to current iteration' do + before do + stub_feature_flags(iteration_cadences: false) + end + + it 'adds current iteration to new issues' do + update_board_scope('current_iteration', true) + + wait_for_requests + + page.within(first('.board')) do + click_button 'New issue' + end + + page.within(first('.board-new-issue-form')) do + find('.form-control').set('issue in current iteration') + click_button 'Create issue' + end + + wait_for_requests + + expect(find('[data-testid="issue-boards-sidebar"]')).to have_text(iteration.title) + end + it 'removes current iteration from board' do create_board_scope('current_iteration', true) diff --git a/ee/spec/frontend/boards/boards_util_spec.js b/ee/spec/frontend/boards/boards_util_spec.js index e143b2714ec9b..f9d4dde182619 100644 --- a/ee/spec/frontend/boards/boards_util_spec.js +++ b/ee/spec/frontend/boards/boards_util_spec.js @@ -3,7 +3,9 @@ import { formatListEpics, formatEpicListsPageInfo, transformBoardConfig, + formatIssueInput, } from 'ee/boards/boards_util'; +import { IterationIDs } from 'ee/boards/constants'; import setWindowLocation from 'helpers/set_window_location_helper'; import { mockLabel } from './mock_data'; @@ -105,6 +107,71 @@ describe('formatEpicListsPageInfo', () => { }); }); +describe('formatIssueInput', () => { + const issueInput = { + labelIds: ['gid://gitlab/GroupLabel/5'], + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + }; + + const expected = { + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + labelIds: ['gid://gitlab/GroupLabel/5'], + assigneeIds: [], + milestoneId: undefined, + }; + + it('adds iterationIds to input', () => { + const boardConfig = { + iterationId: 66, + }; + + const result = formatIssueInput(issueInput, boardConfig); + + expect(result).toEqual({ + ...expected, + iterationId: 'gid://gitlab/Iteration/66', + }); + }); + + it('adds iterationWildcardId to when current iteration selected', () => { + const boardConfig = { + iterationId: IterationIDs.CURRENT, + }; + + const result = formatIssueInput(issueInput, boardConfig); + + expect(result).toEqual({ + ...expected, + iterationWildcardId: 'CURRENT', + iterationCadenceId: undefined, + }); + }); + + it('includes iterationCadenceId and iterationId', () => { + gon.features = { + ...gon.features, + iterationCadences: true, + }; + + const boardConfig = { + iterationId: 66, + iterationCadenceId: 11, + }; + + const result = formatIssueInput(issueInput, boardConfig); + + expect(result).toEqual({ + ...expected, + iterationCadenceId: 'gid://gitlab/Iterations::Cadence/11', + iterationId: 'gid://gitlab/Iteration/66', + }); + + delete gon.features.iterationCadences; + }); +}); + describe('transformBoardConfig', () => { const boardConfig = { milestoneTitle: 'milestone', diff --git a/ee/spec/frontend/boards/stores/actions_spec.js b/ee/spec/frontend/boards/stores/actions_spec.js index bddea29f54326..e23edfeb12b13 100644 --- a/ee/spec/frontend/boards/stores/actions_spec.js +++ b/ee/spec/frontend/boards/stores/actions_spec.js @@ -2,7 +2,13 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import Vuex from 'vuex'; -import { BoardType, GroupByParamType, listsQuery, issuableTypes } from 'ee/boards/constants'; +import { + BoardType, + GroupByParamType, + listsQuery, + issuableTypes, + IterationIDs, +} from 'ee/boards/constants'; import epicCreateMutation from 'ee/boards/graphql/epic_create.mutation.graphql'; import actions, { gqlClient } from 'ee/boards/stores/actions'; import * as types from 'ee/boards/stores/mutation_types'; @@ -12,7 +18,9 @@ import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { mockMoveIssueParams, mockMoveData, mockMoveState } from 'jest/boards/mock_data'; import { formatListIssues } from '~/boards/boards_util'; +import { formatIssueInput } from 'ee_else_ce/boards/boards_util'; import listsIssuesQuery from '~/boards/graphql/lists_issues.query.graphql'; +import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; import * as typesCE from '~/boards/stores/mutation_types'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import * as commonUtils from '~/lib/utils/common_utils'; @@ -1390,3 +1398,145 @@ describe('setActiveEpicLabels', () => { ); }); }); + +describe('addListNewIssue', () => { + let state; + const iterationCadenceId = 'gid://gitlab/Iterations::Cadence/1'; + const baseState = { + boardType: 'group', + fullPath: 'gitlab-org/gitlab', + boardConfig: { + labelIds: [], + }, + }; + + const queryResponse = { + data: { + group: { + id: 'gid://gitlab/Group/1', + iterations: { + nodes: [ + { + id: 'gid://gitlab/Iteration/1', + iterationCadence: { + id: iterationCadenceId, + }, + }, + ], + }, + }, + }, + }; + + const fakeList = {}; + + beforeEach(() => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + }); + + describe('without cadenceId', () => { + describe('currentIteration selected in board config', () => { + beforeEach(() => { + state = { + ...baseState, + boardConfig: { + iterationId: IterationIDs.CURRENT, + }, + }; + }); + + it('adds iterationCadenceId from iteration', async () => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + errors: [], + }, + }, + }); + + await actions.addListNewIssue( + { dispatch: jest.fn(), commit: jest.fn(), state }, + { issueInput: mockIssue, list: fakeList }, + ); + + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: issueCreateMutation, + variables: { + input: formatIssueInput(mockIssue, { + ...state.boardConfig, + iterationCadenceId, + }), + }, + }); + }); + }); + + describe('currentIteration not in boardConfig', () => { + beforeEach(() => { + state = { + ...baseState, + boardConfig: { + iterationId: null, + }, + }; + }); + + it('does not add iterationCadenceId', async () => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + errors: [], + }, + }, + }); + + await actions.addListNewIssue( + { dispatch: jest.fn(), commit: jest.fn(), state }, + { issueInput: mockIssue, list: fakeList }, + ); + + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: issueCreateMutation, + variables: { + input: formatIssueInput(mockIssue, state.boardConfig), + }, + }); + }); + }); + }); + + describe('with iterationCadenceId', () => { + beforeEach(() => { + state = { + ...baseState, + boardConfig: { + iterationId: IterationIDs.CURRENT, + iterationCadenceId, + }, + }; + }); + + it('does not make query for cadence of current iteration', async () => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + errors: [], + }, + }, + }); + + await actions.addListNewIssue( + { dispatch: jest.fn(), commit: jest.fn(), state }, + { issueInput: mockIssue, list: fakeList }, + ); + + expect(gqlClient.query).not.toHaveBeenCalled(); + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: issueCreateMutation, + variables: { + input: formatIssueInput(mockIssue, state.boardConfig), + }, + }); + }); + }); +}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index f580469b072e5..08b4744ed6ec4 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -20,7 +20,7 @@ import { formatIssue, getMoveData, updateListPosition, -} from '~/boards/boards_util'; +} from 'ee_else_ce/boards/boards_util'; import { gqlClient } from '~/boards/graphql'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; -- GitLab