Commit 6069739e authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch '12887-use-graphql-for-group-roadmaps' into 'master'

Use GraphQL for Roadmap

See merge request gitlab-org/gitlab!17607
parents 82f0365d b0a6a841
......@@ -177,6 +177,15 @@ export const urlParamsToArray = (path = '') =>
export const getUrlParamsArray = () => urlParamsToArray(window.location.search);
/**
* Accepts encoding string which includes query params being
* sent to URL.
*
* @param {string} path Query param string
*
* @returns {object} Query params object containing key-value pairs
* with both key and values decoded into plain string.
*/
export const urlParamsToObject = (path = '') =>
splitPath(path).reduce((dataParam, filterParam) => {
if (filterParam === '') {
......@@ -185,6 +194,7 @@ export const urlParamsToObject = (path = '') =>
const data = dataParam;
let [key, value] = filterParam.split('=');
key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
const isArray = key.includes('[]');
key = key.replace('[]', '');
value = decodeURIComponent(value.replace(/\+/g, ' '));
......
......@@ -32,8 +32,16 @@ export default {
},
},
data() {
const roadmapGraphQL = gon.features && gon.features.roadmapGraphql;
return {
handleResizeThrottled: {},
// TODO
// Remove these method alias and call actual
// method once feature flag is removed.
fetchEpicsFn: roadmapGraphQL ? this.fetchEpicsGQL : this.fetchEpics,
fetchEpicsForTimeframeFn: roadmapGraphQL
? this.fetchEpicsForTimeframeGQL
: this.fetchEpicsForTimeframe,
};
},
computed: {
......@@ -78,7 +86,7 @@ export default {
},
},
mounted() {
this.fetchEpics();
this.fetchEpicsFn();
this.handleResizeThrottled = _.throttle(this.handleResize, 600);
window.addEventListener('resize', this.handleResizeThrottled, false);
},
......@@ -89,7 +97,9 @@ export default {
...mapActions([
'setWindowResizeInProgress',
'fetchEpics',
'fetchEpicsGQL',
'fetchEpicsForTimeframe',
'fetchEpicsForTimeframeGQL',
'extendTimeframe',
'refreshEpicDates',
]),
......@@ -146,7 +156,7 @@ export default {
this.refreshEpicDates();
this.$nextTick(() => {
this.fetchEpicsForTimeframe({
this.fetchEpicsForTimeframeFn({
timeframe: this.extendedTimeframe,
})
.then(() => {
......
query epicChildEpics(
$fullPath: ID!
$iid: ID!
$state: EpicState
$sort: EpicSort
$startDate: Time
$dueDate: Time
) {
group(fullPath: $fullPath) {
id
name
epic(iid: $iid) {
id
title
children(state: $state, sort: $sort, startDate: $startDate, endDate: $dueDate) {
edges {
node {
id
title
state
webUrl
startDate
dueDate
group {
name
fullName
}
}
}
}
}
}
}
query groupEpics(
$fullPath: ID!
$state: EpicState
$sort: EpicSort
$startDate: Time
$dueDate: Time
$labelName: [String!] = []
$authorUsername: String = ""
$search: String = ""
) {
group(fullPath: $fullPath) {
id
name
epics(
state: $state
sort: $sort
startDate: $startDate
endDate: $dueDate
labelName: $labelName
authorUsername: $authorUsername
search: $search
) {
edges {
node {
id
title
state
webUrl
startDate
dueDate
group {
name
fullName
}
}
}
}
}
}
......@@ -3,12 +3,16 @@ import { mapActions } from 'vuex';
import Translate from '~/vue_shared/translate';
import { parseBoolean } from '~/lib/utils/common_utils';
import {
parseBoolean,
urlParamsToObject,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { PRESET_TYPES, EPIC_DETAILS_CELL_WIDTH } from './constants';
import { getTimeframeForPreset, getEpicsPathForPreset } from './utils/roadmap_utils';
import { getEpicsPathForPreset, getTimeframeForPreset } from './utils/roadmap_utils';
import createStore from './store';
......@@ -48,6 +52,11 @@ export default () => {
? dataset.presetType
: PRESET_TYPES.MONTHS;
const filterQueryString = window.location.search.substring(1);
const filterParams = Object.assign(
convertObjectPropsToCamelCase(urlParamsToObject(filterQueryString), {
dropKeys: ['scope', 'utf8', 'state', 'sort', 'layout'], // These keys are unsupported/unnecessary
}),
);
const timeframe = getTimeframeForPreset(
presetType,
window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH,
......@@ -66,12 +75,15 @@ export default () => {
defaultInnerHeight: Number(dataset.innerHeight),
isChildEpics: parseBoolean(dataset.childEpics),
currentGroupId: parseInt(dataset.groupId, 0),
basePath: dataset.epicsPath,
fullPath: dataset.fullPath,
epicIid: dataset.iid,
newEpicEndpoint: dataset.newEpicEndpoint,
epicsState: dataset.epicsState,
basePath: dataset.epicsPath,
sortedBy: dataset.sortedBy,
filterQueryString,
initialEpicsPath,
filterParams,
presetType,
timeframe,
};
......@@ -79,11 +91,15 @@ export default () => {
created() {
this.setInitialData({
currentGroupId: this.currentGroupId,
fullPath: this.fullPath,
epicIid: this.epicIid,
sortedBy: this.sortedBy,
presetType: this.presetType,
epicsState: this.epicsState,
timeframe: this.timeframe,
basePath: this.basePath,
filterQueryString: this.filterQueryString,
filterParams: this.filterParams,
initialEpicsPath: this.initialEpicsPath,
defaultInnerHeight: this.defaultInnerHeight,
isChildEpics: this.isChildEpics,
......
......@@ -3,9 +3,17 @@ import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import * as epicUtils from '../utils/epic_utils';
import { getEpicsPathForPreset, sortEpics, extendTimeframeForPreset } from '../utils/roadmap_utils';
import {
getEpicsPathForPreset,
getEpicsTimeframeRange,
sortEpics,
extendTimeframeForPreset,
} from '../utils/roadmap_utils';
import { EXTEND_AS } from '../constants';
import groupEpics from '../queries/groupEpics.query.graphql';
import epicChildEpics from '../queries/epicChildEpics.query.graphql';
import * as types from './mutation_types';
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
......@@ -13,6 +21,54 @@ export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DAT
export const setWindowResizeInProgress = ({ commit }, inProgress) =>
commit(types.SET_WINDOW_RESIZE_IN_PROGRESS, inProgress);
export const fetchGroupEpics = (
{ epicIid, fullPath, epicsState, sortedBy, presetType, filterParams, timeframe },
defaultTimeframe,
) => {
let query;
let variables = {
fullPath,
state: epicsState,
sort: sortedBy,
...getEpicsTimeframeRange({
presetType,
timeframe: defaultTimeframe || timeframe,
}),
};
// When epicIid is present,
// Roadmap is being accessed from within an Epic,
// and then we don't need to pass `filterParams`.
if (epicIid) {
query = epicChildEpics;
variables.iid = epicIid;
} else {
query = groupEpics;
variables = {
...variables,
...filterParams,
};
}
return epicUtils.gqClient
.query({
query,
variables,
})
.then(({ data }) => {
const { group } = data;
let edges;
if (epicIid) {
edges = (group.epic && group.epic.children.edges) || [];
} else {
edges = (group.epics && group.epics.edges) || [];
}
return epicUtils.extractGroupEpics(edges);
});
};
export const requestEpics = ({ commit }) => commit(types.REQUEST_EPICS);
export const requestEpicsForTimeframe = ({ commit }) => commit(types.REQUEST_EPICS_FOR_TIMEFRAME);
export const receiveEpicsSuccess = (
......@@ -60,9 +116,17 @@ export const fetchEpics = ({ state, dispatch }) => {
.then(({ data }) => {
dispatch('receiveEpicsSuccess', { rawEpics: data });
})
.catch(() => {
dispatch('receiveEpicsFailure');
});
.catch(() => dispatch('receiveEpicsFailure'));
};
export const fetchEpicsGQL = ({ state, dispatch }) => {
dispatch('requestEpics');
fetchGroupEpics(state)
.then(rawEpics => {
dispatch('receiveEpicsSuccess', { rawEpics });
})
.catch(() => dispatch('receiveEpicsFailure'));
};
export const fetchEpicsForTimeframe = ({ state, dispatch }, { timeframe }) => {
......@@ -90,6 +154,20 @@ export const fetchEpicsForTimeframe = ({ state, dispatch }, { timeframe }) => {
});
};
export const fetchEpicsForTimeframeGQL = ({ state, dispatch }, { timeframe }) => {
dispatch('requestEpicsForTimeframe');
return fetchGroupEpics(state, timeframe)
.then(rawEpics => {
dispatch('receiveEpicsSuccess', {
rawEpics,
newEpic: true,
timeframeExtended: true,
});
})
.catch(() => dispatch('receiveEpicsFailure'));
};
export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => {
const isExtendTypePrepend = extendAs === EXTEND_AS.PREPEND;
......
......@@ -4,11 +4,14 @@ export default () => ({
epicsState: '',
filterQueryString: '',
initialEpicsPath: '',
filterParams: null,
// Data
epicIid: '',
epics: [],
epicIds: [],
currentGroupId: -1,
fullPath: '',
timeframe: [],
extendedTimeframe: [],
presetType: '',
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { newDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import createGqClient from '~/lib/graphql';
export const gqClient = createGqClient();
/**
* Updates provided `epic` object with necessary props
......@@ -71,10 +74,12 @@ export const processEpicDates = (epic, timeframeStartDate, timeframeEndDate) =>
*/
export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate) => {
const epicItem = convertObjectPropsToCamelCase(rawEpic);
const rawStartDate = rawEpic.start_date || rawEpic.startDate;
const rawEndDate = rawEpic.end_date || rawEpic.dueDate;
if (rawEpic.start_date) {
if (rawStartDate) {
// If startDate is present
const startDate = parsePikadayDate(rawEpic.start_date);
const startDate = parsePikadayDate(rawStartDate);
epicItem.startDate = startDate;
epicItem.originalStartDate = startDate;
} else {
......@@ -82,9 +87,9 @@ export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate)
epicItem.startDateUndefined = true;
}
if (rawEpic.end_date) {
if (rawEndDate) {
// If endDate is present
const endDate = parsePikadayDate(rawEpic.end_date);
const endDate = parsePikadayDate(rawEndDate);
epicItem.endDate = endDate;
epicItem.originalEndDate = endDate;
} else {
......@@ -96,3 +101,19 @@ export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate)
return epicItem;
};
/**
* Returns array of epics extracted from GraphQL response
* discarding the `edges`->`node` nesting
*
* @param {Object} group
*/
export const extractGroupEpics = edges =>
edges.map(({ node, epicNode = node }) => ({
...epicNode,
// We can get rid of below two lines
// by updating `epic_item_details.vue`
// once we move to GraphQL permanently.
groupName: epicNode.group.name,
groupFullName: epicNode.group.fullName,
}));
......@@ -366,6 +366,46 @@ export const getTimeframeForPreset = (
return timeframe;
};
/**
* Returns timeframe range in string based on provided config.
*
* @param {object} config
* @param {string} config.presetType String representing preset type
* @param {array} config.timeframe Array of dates representing timeframe
*
* @returns {object} Returns an object containing `startDate` & `dueDate` strings
* Computed using presetType and timeframe.
*/
export const getEpicsTimeframeRange = ({ presetType = '', timeframe = [] }) => {
let start;
let due;
const firstTimeframe = timeframe[0];
const lastTimeframe = timeframe[timeframe.length - 1];
// Compute start and end dates from timeframe
// based on provided presetType.
if (presetType === PRESET_TYPES.QUARTERS) {
[start] = firstTimeframe.range;
due = lastTimeframe.range[lastTimeframe.range.length - 1];
} else if (presetType === PRESET_TYPES.MONTHS) {
start = firstTimeframe;
due = lastTimeframe;
} else if (presetType === PRESET_TYPES.WEEKS) {
start = firstTimeframe;
due = newDate(lastTimeframe);
due.setDate(due.getDate() + 6);
}
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const dueDate = `${due.getFullYear()}-${due.getMonth() + 1}-${due.getDate()}`;
return {
startDate,
dueDate,
};
};
export const getEpicsPathForPreset = ({
basePath = '',
filterQueryString = '',
......@@ -373,37 +413,19 @@ export const getEpicsPathForPreset = ({
timeframe = [],
epicsState = 'all',
}) => {
let start;
let end;
let epicsPath = basePath;
if (!basePath || !timeframe.length) {
return null;
}
const firstTimeframe = timeframe[0];
const lastTimeframe = timeframe[timeframe.length - 1];
// Construct Epic API path to include
// `start_date` & `end_date` query params to get list of
// epics only for timeframe.
if (presetType === PRESET_TYPES.QUARTERS) {
[start] = firstTimeframe.range;
end = lastTimeframe.range[lastTimeframe.range.length - 1];
} else if (presetType === PRESET_TYPES.MONTHS) {
start = firstTimeframe;
end = lastTimeframe;
} else if (presetType === PRESET_TYPES.WEEKS) {
start = firstTimeframe;
end = newDate(lastTimeframe);
end.setDate(end.getDate() + 6);
}
const range = getEpicsTimeframeRange({
presetType,
timeframe,
});
epicsPath += epicsPath.indexOf('?') === -1 ? '?' : '&';
const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
epicsPath += `state=${epicsState}&start_date=${startDate}&end_date=${endDate}`;
epicsPath += `state=${epicsState}&start_date=${range.startDate}&end_date=${range.dueDate}`;
if (filterQueryString) {
epicsPath += `&${filterQueryString}`;
......
......@@ -17,6 +17,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:epic_trees, @group)
push_frontend_feature_flag(:roadmap_graphql, @group)
end
def index
......
......@@ -8,6 +8,9 @@ module Groups
before_action :check_epics_available!
before_action :group
before_action :persist_roadmap_layout, only: [:show]
before_action do
push_frontend_feature_flag(:roadmap_graphql, @group)
end
# show roadmap for a group
def show
......
......@@ -20,6 +20,7 @@ module EE
field :epics, # rubocop:disable Graphql/Descriptions
::Types::EpicType.connection_type,
null: true,
max_page_size: 2000,
resolver: ::Resolvers::EpicResolver
end
end
......
......@@ -14,6 +14,22 @@ module Resolvers
required: false,
description: 'Filter epics by state'
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Filter epics by title and description'
argument :sort, Types::EpicSortEnum,
required: false,
description: 'List epics by sort order'
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Filter epics by author'
argument :label_name, [GraphQL::STRING_TYPE],
required: false,
description: 'Filter epics by labels'
argument :start_date, Types::TimeType,
required: false,
description: 'List epics within a time frame where epics.start_date is between start_date and end_date parameters (end_date parameter must be present)'
......
# frozen_string_literal: true
module Types
class EpicSortEnum < BaseEnum
graphql_name 'EpicSort'
description 'Roadmap sort values'
value 'start_date_desc', 'Start date at descending order'
value 'start_date_asc', 'Start date at ascending order'
value 'end_date_desc', 'End date at descending order'
value 'end_date_asc', 'End date at ascending order'
end
end
......@@ -5,6 +5,7 @@ module Types
graphql_name 'EpicState'
description 'State of a GitLab epic'
value 'all'
value 'opened'
value 'closed'
end
......
......@@ -39,6 +39,8 @@
%section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id,
iid: @epic.iid,
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group),
......@@ -88,6 +90,8 @@
%section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id,
iid: @epic.iid,
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group),
......
......@@ -9,6 +9,6 @@
- if @epics_count != 0
= render 'shared/epic/search_bar', type: :epics, show_roadmap_presets: true, hide_extra_sort_options: true
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group), preset_type: roadmap_layout, epics_state: @epics_state, sorted_by: @sort } }
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, full_path: @group.full_path, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group), preset_type: roadmap_layout, epics_state: @epics_state, sorted_by: @sort } }
- else
= render 'shared/empty_states/roadmap'
......@@ -19,6 +19,7 @@ describe 'Epic show', :js do
end
let(:epic) { create(:epic, group: group, title: epic_title, description: markdown, author: user) }
let!(:not_child) { create(:epic, group: group, title: 'not child epic', description: markdown, author: user, start_date: 50.days.ago, end_date: 10.days.ago) }
let!(:child_epic_a) { create(:epic, group: group, title: 'Child epic A', description: markdown, parent: epic, start_date: 50.days.ago, end_date: 10.days.ago) }
let!(:child_epic_b) { create(:epic, group: group, title: 'Child epic B', description: markdown, parent: epic, start_date: 100.days.ago, end_date: 20.days.ago) }
......@@ -85,6 +86,7 @@ describe 'Epic show', :js do
expect(page).to have_selector('.roadmap-container .roadmap-shell')
page.within('.roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
expect(find('.epics-list-item:nth-child(1) .epic-title a')).to have_content('Child epic B')
expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A')
end
......
......@@ -102,6 +102,80 @@ describe Resolvers::EpicResolver do
end
end
context 'with search' do
let!(:epic1) { create(:epic, group: group, title: 'first created', description: 'description') }
let!(:epic2) { create(:epic, group: group, title: 'second created', description: 'text 1') }
let!(:epic3) { create(:epic, group: group, title: 'third', description: 'text 2') }
it 'filters epics by title' do
epics = resolve_epics(search: 'created')
expect(epics).to match_array([epic1, epic2])
end
it 'filters epics by description' do
epics = resolve_epics(search: 'text')
expect(epics).to match_array([epic2, epic3])
end
end
context 'with author_username' do
it 'filters epics by author' do
user = create(:user)
epic = create(:epic, group: group, author: user )
create(:epic, group: group)
epics = resolve_epics(author_username: user.username)
expect(epics).to match_array([epic])
end
end
context 'with label_name' do
it 'filters epics by labels' do
label_1 = create(:group_label, group: group)
label_2 = create(:group_label, group: group)
epic_1 = create(:labeled_epic, group: group, labels: [label_1, label_2])
create(:labeled_epic, group: group, labels: [label_1])
create(:labeled_epic, group: group)
epics = resolve_epics(label_name: [label_1.title, label_2.title])
expect(epics).to match_array([epic_1])
end
end
context 'with sort' do
let!(:epic1) { create(:epic, group: group, title: 'first created', description: 'description', start_date: 10.days.ago, end_date: 10.days.from_now) }
let!(:epic2) { create(:epic, group: group, title: 'second created', description: 'text 1', start_date: 20.days.ago, end_date: 20.days.from_now) }
let!(:epic3) { create(:epic, group: group, title: 'third', description: 'text 2', start_date: 30.days.ago, end_date: 30.days.from_now) }
it 'orders epics by start date in descending order' do
epics = resolve_epics(sort: 'start_date_desc')
expect(epics).to eq([epic1, epic2, epic3])
end
it 'orders epics by start date in ascending order' do
epics = resolve_epics(sort: 'start_date_asc')
expect(epics).to eq([epic3, epic2, epic1])
end
it 'orders epics by end date in descending order' do
epics = resolve_epics(sort: 'end_date_desc')
expect(epics).to eq([epic3, epic2, epic1])
end
it 'orders epics by end date in ascending order' do
epics = resolve_epics(sort: 'end_date_asc')
expect(epics).to eq([epic1, epic2, epic3])
end
end
context 'with subgroups' do
let(:sub_group) { create(:group, parent: group) }
let(:iids) { [epic1, epic2].map(&:iid) }
......
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['EpicSort'] do
it { expect(described_class.graphql_name).to eq('EpicSort') }
it 'exposes all the existing epic sort orders' do
expect(described_class.values.keys).to include(*%w[start_date_desc start_date_asc end_date_desc end_date_asc])
end
end
......@@ -6,6 +6,6 @@ describe GitlabSchema.types['EpicState'] do
it { expect(described_class.graphql_name).to eq('EpicState') }
it 'exposes all the existing epic states' do
expect(described_class.values.keys).to include(*%w[opened closed])
expect(described_class.values.keys).to include(*%w[all opened closed])
end
end
......@@ -62,6 +62,46 @@ describe('Roadmap AppComponent', () => {
it('returns default data props', () => {
expect(vm.handleResizeThrottled).toBeDefined();
});
describe('when `gon.feature.roadmapGraphql` is true', () => {
const originalGonFeatures = Object.assign({}, gon.features);
beforeAll(() => {
gon.features = { roadmapGraphql: true };
});
afterAll(() => {
gon.features = originalGonFeatures;
});
it('returns data prop containing `fetchEpicsFn` mapped to `fetchEpicsGQL`', () => {
expect(vm.fetchEpicsFn).toBe(vm.fetchEpicsGQL);
});
it('returns data prop containing `fetchEpicsForTimeframeFn` mapped to `fetchEpicsForTimeframeGQL`', () => {
expect(vm.fetchEpicsForTimeframeFn).toBe(vm.fetchEpicsForTimeframeGQL);
});
});
describe('when `gon.feature.roadmapGraphql` is false', () => {
const originalGonFeatures = Object.assign({}, gon.features);
beforeAll(() => {
gon.features = { roadmapGraphql: false };
});
afterAll(() => {
gon.features = originalGonFeatures;
});
it('returns data prop containing `fetchEpicsFn` mapped to `fetchEpics`', () => {
expect(vm.fetchEpicsFn).toBe(vm.fetchEpics);
});
it('returns data prop containing `fetchEpicsForTimeframeFn` mapped to `fetchEpicsForTimeframe`', () => {
expect(vm.fetchEpicsForTimeframeFn).toBe(vm.fetchEpicsForTimeframe);
});
});
});
describe('computed', () => {
......@@ -192,7 +232,7 @@ describe('Roadmap AppComponent', () => {
it('calls `fetchEpicsForTimeframe` with extended timeframe array', done => {
spyOn(vm, 'extendTimeframe').and.stub();
spyOn(vm, 'refreshEpicDates').and.stub();
spyOn(vm, 'fetchEpicsForTimeframe').and.callFake(() => new Promise(() => {}));
spyOn(vm, 'fetchEpicsForTimeframeFn').and.callFake(() => new Promise(() => {}));
const extendType = EXTEND_AS.PREPEND;
......@@ -200,7 +240,7 @@ describe('Roadmap AppComponent', () => {
vm.$nextTick()
.then(() => {
expect(vm.fetchEpicsForTimeframe).toHaveBeenCalledWith(
expect(vm.fetchEpicsForTimeframeFn).toHaveBeenCalledWith(
jasmine.objectContaining({
timeframe: vm.extendedTimeframe,
}),
......
......@@ -305,3 +305,58 @@ export const mockUnsortedEpics = [
endDate: new Date(2015, 7, 15),
},
];
export const mockGroupEpicsQueryResponse = {
data: {
group: {
id: 'gid://gitlab/Group/2',
name: 'Gitlab Org',
epics: {
edges: [
{
node: {
id: 'gid://gitlab/Epic/40',
title: 'Marketing epic',
startDate: '2017-12-25',
dueDate: '2018-03-09',
webUrl: '/groups/gitlab-org/marketing/-/epics/1',
group: {
name: 'Gitlab Org',
fullName: 'Gitlab Org',
},
},
},
{
node: {
id: 'gid://gitlab/Epic/41',
title: 'Another marketing',
startDate: '2017-12-26',
dueDate: '2018-03-10',
webUrl: '/groups/gitlab-org/marketing/-/epics/2',
group: {
name: 'Gitlab Org',
fullName: 'Gitlab Org',
},
},
},
],
},
},
},
};
export const mockEpicChildEpicsQueryResponse = {
data: {
group: {
id: 'gid://gitlab/Group/2',
name: 'Gitlab Org',
epic: {
id: 'gid://gitlab/Epic/1',
title: 'Error omnis quos consequatur',
children: {
edges: mockGroupEpicsQueryResponse.data.group.epics.edges,
},
},
},
},
};
......@@ -5,8 +5,10 @@ import * as types from 'ee/roadmap/store/mutation_types';
import defaultState from 'ee/roadmap/store/state';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
import { formatEpicDetails } from 'ee/roadmap/utils/epic_utils';
import * as epicUtils from 'ee/roadmap/utils/epic_utils';
import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
import groupEpics from 'ee/roadmap/queries/groupEpics.query.graphql';
import epicChildEpics from 'ee/roadmap/queries/epicChildEpics.query.graphql';
import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper';
......@@ -22,6 +24,8 @@ import {
mockRawEpic,
mockFormattedEpic,
mockSortedBy,
mockGroupEpicsQueryResponse,
mockEpicChildEpicsQueryResponse,
} from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
......@@ -76,6 +80,73 @@ describe('Roadmap Vuex Actions', () => {
});
});
describe('fetchGroupEpics', () => {
let mockState;
let expectedVariables;
beforeEach(() => {
mockState = {
fullPath: 'gitlab-org',
epicsState: 'all',
sortedBy: 'start_date_asc',
presetType: PRESET_TYPES.MONTHS,
filterParams: {},
timeframe: mockTimeframeMonths,
};
expectedVariables = {
fullPath: 'gitlab-org',
state: mockState.epicsState,
sort: mockState.sortedBy,
startDate: '2017-11-1',
dueDate: '2018-6-30',
};
});
it('should fetch Group Epics using GraphQL client when epicIid is not present in state', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(
Promise.resolve({
data: mockGroupEpicsQueryResponse.data,
}),
);
actions
.fetchGroupEpics(mockState)
.then(() => {
expect(epicUtils.gqClient.query).toHaveBeenCalledWith({
query: groupEpics,
variables: expectedVariables,
});
})
.then(done)
.catch(done.fail);
});
it('should fetch child Epics of an Epic using GraphQL client when epicIid is present in state', done => {
spyOn(epicUtils.gqClient, 'query').and.returnValue(
Promise.resolve({
data: mockEpicChildEpicsQueryResponse.data,
}),
);
mockState.epicIid = '1';
actions
.fetchGroupEpics(mockState)
.then(() => {
expect(epicUtils.gqClient.query).toHaveBeenCalledWith({
query: epicChildEpics,
variables: {
iid: '1',
...expectedVariables,
},
});
})
.then(done)
.catch(done.fail);
});
});
describe('requestEpics', () => {
it('Should set `epicsFetchInProgress` to true', done => {
testAction(actions.requestEpics, {}, state, [{ type: 'REQUEST_EPICS' }], [], done);
......@@ -315,7 +386,7 @@ describe('Roadmap Vuex Actions', () => {
describe('refreshEpicDates', () => {
it('Should update epics after refreshing epic dates to match with updated timeframe', done => {
const epics = rawEpics.map(epic =>
formatEpicDetails(epic, state.timeframeStartDate, state.timeframeEndDate),
epicUtils.formatEpicDetails(epic, state.timeframeStartDate, state.timeframeEndDate),
);
testAction(
......
......@@ -2,7 +2,7 @@ import * as epicUtils from 'ee/roadmap/utils/epic_utils';
import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import { rawEpics } from '../mock_data';
import { rawEpics, mockGroupEpicsQueryResponse } from '../mock_data';
describe('processEpicDates', () => {
const timeframeStartDate = new Date(2017, 0, 1);
......@@ -89,3 +89,19 @@ describe('formatEpicDetails', () => {
expect(epic.endDateUndefined).toBe(true);
});
});
describe('extractGroupEpics', () => {
it('returns array of epics with `edges->nodes` nesting removed', () => {
const { edges } = mockGroupEpicsQueryResponse.data.group.epics;
const extractedEpics = epicUtils.extractGroupEpics(edges);
expect(extractedEpics.length).toBe(edges.length);
expect(extractedEpics[0]).toEqual(
jasmine.objectContaining({
...edges[0].node,
groupName: edges[0].node.group.name,
groupFullName: edges[0].node.group.fullName,
}),
);
});
});
......@@ -6,9 +6,10 @@ import {
getTimeframeForWeeksView,
extendTimeframeForWeeksView,
extendTimeframeForAvailableWidth,
getEpicsTimeframeRange,
getEpicsPathForPreset,
sortEpics,
assignDates,
sortEpics,
} from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants';
......@@ -297,6 +298,53 @@ describe('extendTimeframeForAvailableWidth', () => {
});
});
describe('getEpicsTimeframeRange', () => {
it('returns object containing startDate and dueDate based on provided timeframe for Quarters', () => {
const timeframeQuarters = getTimeframeForQuartersView(new Date(2018, 0, 1));
const range = getEpicsTimeframeRange({
presetType: PRESET_TYPES.QUARTERS,
timeframe: timeframeQuarters,
});
expect(range).toEqual(
jasmine.objectContaining({
startDate: '2017-7-1',
dueDate: '2019-3-31',
}),
);
});
it('returns object containing startDate and dueDate based on provided timeframe for Months', () => {
const timeframeMonths = getTimeframeForMonthsView(new Date(2018, 0, 1));
const range = getEpicsTimeframeRange({
presetType: PRESET_TYPES.MONTHS,
timeframe: timeframeMonths,
});
expect(range).toEqual(
jasmine.objectContaining({
startDate: '2017-11-1',
dueDate: '2018-6-30',
}),
);
});
it('returns object containing startDate and dueDate based on provided timeframe for Weeks', () => {
const timeframeWeeks = getTimeframeForWeeksView(new Date(2018, 0, 1));
const range = getEpicsTimeframeRange({
presetType: PRESET_TYPES.WEEKS,
timeframe: timeframeWeeks,
});
expect(range).toEqual(
jasmine.objectContaining({
startDate: '2017-12-17',
dueDate: '2018-2-3',
}),
);
});
});
describe('getEpicsPathForPreset', () => {
const basePath = '/groups/gitlab-org/-/epics.json';
const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug';
......
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