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 = '') => ...@@ -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
......
...@@ -20,6 +20,7 @@ module EE ...@@ -20,6 +20,7 @@ module EE
field :epics, # rubocop:disable Graphql/Descriptions field :epics, # rubocop:disable Graphql/Descriptions
::Types::EpicType.connection_type, ::Types::EpicType.connection_type,
null: true, null: true,
max_page_size: 2000,
resolver: ::Resolvers::EpicResolver resolver: ::Resolvers::EpicResolver
end end
end end
......
...@@ -14,6 +14,22 @@ module Resolvers ...@@ -14,6 +14,22 @@ module Resolvers
required: false, required: false,
description: 'Filter epics by state' 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, argument :start_date, Types::TimeType,
required: false, 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)' 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 ...@@ -5,6 +5,7 @@ module Types
graphql_name 'EpicState' graphql_name 'EpicState'
description 'State of a GitLab epic' description 'State of a GitLab epic'
value 'all'
value 'opened' value 'opened'
value 'closed' value 'closed'
end end
......
...@@ -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
......
...@@ -102,6 +102,80 @@ describe Resolvers::EpicResolver do ...@@ -102,6 +102,80 @@ describe Resolvers::EpicResolver do
end end
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 context 'with subgroups' do
let(:sub_group) { create(:group, parent: group) } let(:sub_group) { create(:group, parent: group) }
let(:iids) { [epic1, epic2].map(&:iid) } 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 ...@@ -6,6 +6,6 @@ describe GitlabSchema.types['EpicState'] do
it { expect(described_class.graphql_name).to eq('EpicState') } it { expect(described_class.graphql_name).to eq('EpicState') }
it 'exposes all the existing epic states' do 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
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