Commit 12e1ac89 authored by Kushal Pandya's avatar Kushal Pandya Committed by Felipe Artur

Add support for fetch Roadmap epics via GraphQL

Fetches epics in Roadmap via Graph query including
support for filters.
parent 080efbba
...@@ -177,6 +177,15 @@ export const urlParamsToArray = (path = '') => ...@@ -177,6 +177,15 @@ export const urlParamsToArray = (path = '') =>
export const getUrlParamsArray = () => urlParamsToArray(window.location.search); 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 = '') => export const urlParamsToObject = (path = '') =>
splitPath(path).reduce((dataParam, filterParam) => { splitPath(path).reduce((dataParam, filterParam) => {
if (filterParam === '') { if (filterParam === '') {
...@@ -185,6 +194,7 @@ export const urlParamsToObject = (path = '') => ...@@ -185,6 +194,7 @@ export const urlParamsToObject = (path = '') =>
const data = dataParam; const data = dataParam;
let [key, value] = filterParam.split('='); let [key, value] = filterParam.split('=');
key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
const isArray = key.includes('[]'); const isArray = key.includes('[]');
key = key.replace('[]', ''); key = key.replace('[]', '');
value = decodeURIComponent(value.replace(/\+/g, ' ')); value = decodeURIComponent(value.replace(/\+/g, ' '));
......
...@@ -32,8 +32,16 @@ export default { ...@@ -32,8 +32,16 @@ export default {
}, },
}, },
data() { data() {
const roadmapGraphQL = gon.features && gon.features.roadmapGraphql;
return { return {
handleResizeThrottled: {}, 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: { computed: {
...@@ -78,7 +86,7 @@ export default { ...@@ -78,7 +86,7 @@ export default {
}, },
}, },
mounted() { mounted() {
this.fetchEpics(); this.fetchEpicsFn();
this.handleResizeThrottled = _.throttle(this.handleResize, 600); this.handleResizeThrottled = _.throttle(this.handleResize, 600);
window.addEventListener('resize', this.handleResizeThrottled, false); window.addEventListener('resize', this.handleResizeThrottled, false);
}, },
...@@ -89,7 +97,9 @@ export default { ...@@ -89,7 +97,9 @@ export default {
...mapActions([ ...mapActions([
'setWindowResizeInProgress', 'setWindowResizeInProgress',
'fetchEpics', 'fetchEpics',
'fetchEpicsGQL',
'fetchEpicsForTimeframe', 'fetchEpicsForTimeframe',
'fetchEpicsForTimeframeGQL',
'extendTimeframe', 'extendTimeframe',
'refreshEpicDates', 'refreshEpicDates',
]), ]),
...@@ -146,7 +156,7 @@ export default { ...@@ -146,7 +156,7 @@ export default {
this.refreshEpicDates(); this.refreshEpicDates();
this.$nextTick(() => { this.$nextTick(() => {
this.fetchEpicsForTimeframe({ this.fetchEpicsForTimeframeFn({
timeframe: this.extendedTimeframe, timeframe: this.extendedTimeframe,
}) })
.then(() => { .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'; ...@@ -3,12 +3,16 @@ import { mapActions } from 'vuex';
import Translate from '~/vue_shared/translate'; 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 { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { PRESET_TYPES, EPIC_DETAILS_CELL_WIDTH } from './constants'; 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'; import createStore from './store';
...@@ -48,6 +52,11 @@ export default () => { ...@@ -48,6 +52,11 @@ export default () => {
? dataset.presetType ? dataset.presetType
: PRESET_TYPES.MONTHS; : PRESET_TYPES.MONTHS;
const filterQueryString = window.location.search.substring(1); 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( const timeframe = getTimeframeForPreset(
presetType, presetType,
window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH, window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH,
...@@ -66,12 +75,15 @@ export default () => { ...@@ -66,12 +75,15 @@ export default () => {
defaultInnerHeight: Number(dataset.innerHeight), defaultInnerHeight: Number(dataset.innerHeight),
isChildEpics: parseBoolean(dataset.childEpics), isChildEpics: parseBoolean(dataset.childEpics),
currentGroupId: parseInt(dataset.groupId, 0), currentGroupId: parseInt(dataset.groupId, 0),
basePath: dataset.epicsPath,
fullPath: dataset.fullPath,
epicIid: dataset.iid,
newEpicEndpoint: dataset.newEpicEndpoint, newEpicEndpoint: dataset.newEpicEndpoint,
epicsState: dataset.epicsState, epicsState: dataset.epicsState,
basePath: dataset.epicsPath,
sortedBy: dataset.sortedBy, sortedBy: dataset.sortedBy,
filterQueryString, filterQueryString,
initialEpicsPath, initialEpicsPath,
filterParams,
presetType, presetType,
timeframe, timeframe,
}; };
...@@ -79,11 +91,15 @@ export default () => { ...@@ -79,11 +91,15 @@ export default () => {
created() { created() {
this.setInitialData({ this.setInitialData({
currentGroupId: this.currentGroupId, currentGroupId: this.currentGroupId,
fullPath: this.fullPath,
epicIid: this.epicIid,
sortedBy: this.sortedBy, sortedBy: this.sortedBy,
presetType: this.presetType, presetType: this.presetType,
epicsState: this.epicsState,
timeframe: this.timeframe, timeframe: this.timeframe,
basePath: this.basePath, basePath: this.basePath,
filterQueryString: this.filterQueryString, filterQueryString: this.filterQueryString,
filterParams: this.filterParams,
initialEpicsPath: this.initialEpicsPath, initialEpicsPath: this.initialEpicsPath,
defaultInnerHeight: this.defaultInnerHeight, defaultInnerHeight: this.defaultInnerHeight,
isChildEpics: this.isChildEpics, isChildEpics: this.isChildEpics,
......
...@@ -3,9 +3,17 @@ import { s__ } from '~/locale'; ...@@ -3,9 +3,17 @@ import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import * as epicUtils from '../utils/epic_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 { EXTEND_AS } from '../constants';
import groupEpics from '../queries/groupEpics.query.graphql';
import epicChildEpics from '../queries/epicChildEpics.query.graphql';
import * as types from './mutation_types'; import * as types from './mutation_types';
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); 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 ...@@ -13,6 +21,54 @@ export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DAT
export const setWindowResizeInProgress = ({ commit }, inProgress) => export const setWindowResizeInProgress = ({ commit }, inProgress) =>
commit(types.SET_WINDOW_RESIZE_IN_PROGRESS, 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 requestEpics = ({ commit }) => commit(types.REQUEST_EPICS);
export const requestEpicsForTimeframe = ({ commit }) => commit(types.REQUEST_EPICS_FOR_TIMEFRAME); export const requestEpicsForTimeframe = ({ commit }) => commit(types.REQUEST_EPICS_FOR_TIMEFRAME);
export const receiveEpicsSuccess = ( export const receiveEpicsSuccess = (
...@@ -60,9 +116,17 @@ export const fetchEpics = ({ state, dispatch }) => { ...@@ -60,9 +116,17 @@ export const fetchEpics = ({ state, dispatch }) => {
.then(({ data }) => { .then(({ data }) => {
dispatch('receiveEpicsSuccess', { rawEpics: data }); dispatch('receiveEpicsSuccess', { rawEpics: data });
}) })
.catch(() => { .catch(() => dispatch('receiveEpicsFailure'));
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 }) => { export const fetchEpicsForTimeframe = ({ state, dispatch }, { timeframe }) => {
...@@ -90,6 +154,20 @@ 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 }) => { export const extendTimeframe = ({ commit, state, getters }, { extendAs }) => {
const isExtendTypePrepend = extendAs === EXTEND_AS.PREPEND; const isExtendTypePrepend = extendAs === EXTEND_AS.PREPEND;
......
...@@ -4,11 +4,14 @@ export default () => ({ ...@@ -4,11 +4,14 @@ export default () => ({
epicsState: '', epicsState: '',
filterQueryString: '', filterQueryString: '',
initialEpicsPath: '', initialEpicsPath: '',
filterParams: null,
// Data // Data
epicIid: '',
epics: [], epics: [],
epicIds: [], epicIds: [],
currentGroupId: -1, currentGroupId: -1,
fullPath: '',
timeframe: [], timeframe: [],
extendedTimeframe: [], extendedTimeframe: [],
presetType: '', presetType: '',
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { newDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { newDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import createGqClient from '~/lib/graphql';
export const gqClient = createGqClient();
/** /**
* Updates provided `epic` object with necessary props * Updates provided `epic` object with necessary props
...@@ -71,10 +74,12 @@ export const processEpicDates = (epic, timeframeStartDate, timeframeEndDate) => ...@@ -71,10 +74,12 @@ export const processEpicDates = (epic, timeframeStartDate, timeframeEndDate) =>
*/ */
export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate) => { export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate) => {
const epicItem = convertObjectPropsToCamelCase(rawEpic); 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 // If startDate is present
const startDate = parsePikadayDate(rawEpic.start_date); const startDate = parsePikadayDate(rawStartDate);
epicItem.startDate = startDate; epicItem.startDate = startDate;
epicItem.originalStartDate = startDate; epicItem.originalStartDate = startDate;
} else { } else {
...@@ -82,9 +87,9 @@ export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate) ...@@ -82,9 +87,9 @@ export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate)
epicItem.startDateUndefined = true; epicItem.startDateUndefined = true;
} }
if (rawEpic.end_date) { if (rawEndDate) {
// If endDate is present // If endDate is present
const endDate = parsePikadayDate(rawEpic.end_date); const endDate = parsePikadayDate(rawEndDate);
epicItem.endDate = endDate; epicItem.endDate = endDate;
epicItem.originalEndDate = endDate; epicItem.originalEndDate = endDate;
} else { } else {
...@@ -96,3 +101,19 @@ export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate) ...@@ -96,3 +101,19 @@ export const formatEpicDetails = (rawEpic, timeframeStartDate, timeframeEndDate)
return epicItem; 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 = ( ...@@ -366,6 +366,46 @@ export const getTimeframeForPreset = (
return timeframe; 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 = ({ export const getEpicsPathForPreset = ({
basePath = '', basePath = '',
filterQueryString = '', filterQueryString = '',
...@@ -373,37 +413,19 @@ export const getEpicsPathForPreset = ({ ...@@ -373,37 +413,19 @@ export const getEpicsPathForPreset = ({
timeframe = [], timeframe = [],
epicsState = 'all', epicsState = 'all',
}) => { }) => {
let start;
let end;
let epicsPath = basePath; let epicsPath = basePath;
if (!basePath || !timeframe.length) { if (!basePath || !timeframe.length) {
return null; return null;
} }
const firstTimeframe = timeframe[0]; const range = getEpicsTimeframeRange({
const lastTimeframe = timeframe[timeframe.length - 1]; presetType,
timeframe,
// 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);
}
epicsPath += epicsPath.indexOf('?') === -1 ? '?' : '&'; epicsPath += epicsPath.indexOf('?') === -1 ? '?' : '&';
epicsPath += `state=${epicsState}&start_date=${range.startDate}&end_date=${range.dueDate}`;
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}`;
if (filterQueryString) { if (filterQueryString) {
epicsPath += `&${filterQueryString}`; epicsPath += `&${filterQueryString}`;
......
...@@ -17,6 +17,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -17,6 +17,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:epic_trees, @group) push_frontend_feature_flag(:epic_trees, @group)
push_frontend_feature_flag(:roadmap_graphql, @group)
end end
def index def index
......
...@@ -8,6 +8,9 @@ module Groups ...@@ -8,6 +8,9 @@ module Groups
before_action :check_epics_available! before_action :check_epics_available!
before_action :group before_action :group
before_action :persist_roadmap_layout, only: [:show] before_action :persist_roadmap_layout, only: [:show]
before_action do
push_frontend_feature_flag(:roadmap_graphql, @group)
end
# show roadmap for a group # show roadmap for a group
def show def show
......
...@@ -39,6 +39,8 @@ ...@@ -39,6 +39,8 @@
%section.col-md-12 %section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json), #js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id, group_id: @group.id,
iid: @epic.iid,
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false', has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group), new_epic_endpoint: group_epics_path(@group),
...@@ -88,6 +90,8 @@ ...@@ -88,6 +90,8 @@
%section.col-md-12 %section.col-md-12
#js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json), #js-roadmap{ data: { epics_path: group_epics_path(@group, parent_id: @epic.id, format: :json),
group_id: @group.id, group_id: @group.id,
iid: @epic.iid,
full_path: @group.full_path,
empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), empty_state_illustration: image_path('illustrations/epics/roadmap.svg'),
has_filters_applied: 'false', has_filters_applied: 'false',
new_epic_endpoint: group_epics_path(@group), new_epic_endpoint: group_epics_path(@group),
......
...@@ -9,6 +9,6 @@ ...@@ -9,6 +9,6 @@
- if @epics_count != 0 - if @epics_count != 0
= render 'shared/epic/search_bar', type: :epics, show_roadmap_presets: true, hide_extra_sort_options: true = 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 - else
= render 'shared/empty_states/roadmap' = render 'shared/empty_states/roadmap'
...@@ -19,6 +19,7 @@ describe 'Epic show', :js do ...@@ -19,6 +19,7 @@ describe 'Epic show', :js do
end end
let(:epic) { create(:epic, group: group, title: epic_title, description: markdown, author: user) } 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_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) } 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 ...@@ -85,6 +86,7 @@ describe 'Epic show', :js do
expect(page).to have_selector('.roadmap-container .roadmap-shell') expect(page).to have_selector('.roadmap-container .roadmap-shell')
page.within('.roadmap-shell .epics-list-section') do 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(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') expect(find('.epics-list-item:nth-child(2) .epic-title a')).to have_content('Child epic A')
end end
......
...@@ -62,6 +62,46 @@ describe('Roadmap AppComponent', () => { ...@@ -62,6 +62,46 @@ describe('Roadmap AppComponent', () => {
it('returns default data props', () => { it('returns default data props', () => {
expect(vm.handleResizeThrottled).toBeDefined(); 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', () => { describe('computed', () => {
...@@ -192,7 +232,7 @@ describe('Roadmap AppComponent', () => { ...@@ -192,7 +232,7 @@ describe('Roadmap AppComponent', () => {
it('calls `fetchEpicsForTimeframe` with extended timeframe array', done => { it('calls `fetchEpicsForTimeframe` with extended timeframe array', done => {
spyOn(vm, 'extendTimeframe').and.stub(); spyOn(vm, 'extendTimeframe').and.stub();
spyOn(vm, 'refreshEpicDates').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; const extendType = EXTEND_AS.PREPEND;
...@@ -200,7 +240,7 @@ describe('Roadmap AppComponent', () => { ...@@ -200,7 +240,7 @@ describe('Roadmap AppComponent', () => {
vm.$nextTick() vm.$nextTick()
.then(() => { .then(() => {
expect(vm.fetchEpicsForTimeframe).toHaveBeenCalledWith( expect(vm.fetchEpicsForTimeframeFn).toHaveBeenCalledWith(
jasmine.objectContaining({ jasmine.objectContaining({
timeframe: vm.extendedTimeframe, timeframe: vm.extendedTimeframe,
}), }),
......
...@@ -305,3 +305,58 @@ export const mockUnsortedEpics = [ ...@@ -305,3 +305,58 @@ export const mockUnsortedEpics = [
endDate: new Date(2015, 7, 15), 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'; ...@@ -5,8 +5,10 @@ import * as types from 'ee/roadmap/store/mutation_types';
import defaultState from 'ee/roadmap/store/state'; import defaultState from 'ee/roadmap/store/state';
import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils'; 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 { 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 axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
...@@ -22,6 +24,8 @@ import { ...@@ -22,6 +24,8 @@ import {
mockRawEpic, mockRawEpic,
mockFormattedEpic, mockFormattedEpic,
mockSortedBy, mockSortedBy,
mockGroupEpicsQueryResponse,
mockEpicChildEpicsQueryResponse,
} from '../mock_data'; } from '../mock_data';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate); const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
...@@ -76,6 +80,73 @@ describe('Roadmap Vuex Actions', () => { ...@@ -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', () => { describe('requestEpics', () => {
it('Should set `epicsFetchInProgress` to true', done => { it('Should set `epicsFetchInProgress` to true', done => {
testAction(actions.requestEpics, {}, state, [{ type: 'REQUEST_EPICS' }], [], done); testAction(actions.requestEpics, {}, state, [{ type: 'REQUEST_EPICS' }], [], done);
...@@ -315,7 +386,7 @@ describe('Roadmap Vuex Actions', () => { ...@@ -315,7 +386,7 @@ describe('Roadmap Vuex Actions', () => {
describe('refreshEpicDates', () => { describe('refreshEpicDates', () => {
it('Should update epics after refreshing epic dates to match with updated timeframe', done => { it('Should update epics after refreshing epic dates to match with updated timeframe', done => {
const epics = rawEpics.map(epic => const epics = rawEpics.map(epic =>
formatEpicDetails(epic, state.timeframeStartDate, state.timeframeEndDate), epicUtils.formatEpicDetails(epic, state.timeframeStartDate, state.timeframeEndDate),
); );
testAction( testAction(
......
...@@ -2,7 +2,7 @@ import * as epicUtils from 'ee/roadmap/utils/epic_utils'; ...@@ -2,7 +2,7 @@ import * as epicUtils from 'ee/roadmap/utils/epic_utils';
import { parsePikadayDate } from '~/lib/utils/datetime_utility'; import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import { rawEpics } from '../mock_data'; import { rawEpics, mockGroupEpicsQueryResponse } from '../mock_data';
describe('processEpicDates', () => { describe('processEpicDates', () => {
const timeframeStartDate = new Date(2017, 0, 1); const timeframeStartDate = new Date(2017, 0, 1);
...@@ -89,3 +89,19 @@ describe('formatEpicDetails', () => { ...@@ -89,3 +89,19 @@ describe('formatEpicDetails', () => {
expect(epic.endDateUndefined).toBe(true); 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 { ...@@ -6,9 +6,10 @@ import {
getTimeframeForWeeksView, getTimeframeForWeeksView,
extendTimeframeForWeeksView, extendTimeframeForWeeksView,
extendTimeframeForAvailableWidth, extendTimeframeForAvailableWidth,
getEpicsTimeframeRange,
getEpicsPathForPreset, getEpicsPathForPreset,
sortEpics,
assignDates, assignDates,
sortEpics,
} from 'ee/roadmap/utils/roadmap_utils'; } from 'ee/roadmap/utils/roadmap_utils';
import { PRESET_TYPES } from 'ee/roadmap/constants'; import { PRESET_TYPES } from 'ee/roadmap/constants';
...@@ -297,6 +298,53 @@ describe('extendTimeframeForAvailableWidth', () => { ...@@ -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', () => { describe('getEpicsPathForPreset', () => {
const basePath = '/groups/gitlab-org/-/epics.json'; const basePath = '/groups/gitlab-org/-/epics.json';
const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug'; 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