Commit 692e578c authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '32426-fe-deep-links-for-cycle-analytics' into 'master'

FE deep links for cycle analytics

Closes #202103 and #32426

See merge request gitlab-org/gitlab!23493
parents fa698025 8c1e6c17
<script> <script>
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE, DEFAULT_DAYS_IN_PAST } from '../constants'; import { PROJECTS_PER_PAGE } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue'; import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import Scatterplot from '../../shared/components/scatterplot.vue'; import Scatterplot from '../../shared/components/scatterplot.vue';
...@@ -56,7 +55,7 @@ export default { ...@@ -56,7 +55,7 @@ export default {
'isCreatingCustomStage', 'isCreatingCustomStage',
'isEditingCustomStage', 'isEditingCustomStage',
'selectedGroup', 'selectedGroup',
'selectedProjectIds', 'selectedProjects',
'selectedStage', 'selectedStage',
'stages', 'stages',
'summary', 'summary',
...@@ -77,6 +76,7 @@ export default { ...@@ -77,6 +76,7 @@ export default {
'tasksByTypeChartData', 'tasksByTypeChartData',
'durationChartMedianData', 'durationChartMedianData',
'activeStages', 'activeStages',
'selectedProjectIds',
]), ]),
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.selectedGroup; return !this.selectedGroup;
...@@ -121,7 +121,6 @@ export default { ...@@ -121,7 +121,6 @@ export default {
}, },
}, },
mounted() { mounted() {
this.initDateRange();
this.setFeatureFlags({ this.setFeatureFlags({
hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled, hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled,
hasDurationChartMedian: this.glFeatures.cycleAnalyticsScatterplotMedianEnabled, hasDurationChartMedian: this.glFeatures.cycleAnalyticsScatterplotMedianEnabled,
...@@ -154,8 +153,7 @@ export default { ...@@ -154,8 +153,7 @@ export default {
this.fetchCycleAnalyticsData(); this.fetchCycleAnalyticsData();
}, },
onProjectsSelect(projects) { onProjectsSelect(projects) {
const projectIds = projects.map(value => value.id); this.setSelectedProjects(projects);
this.setSelectedProjects(projectIds);
this.fetchCycleAnalyticsData(); this.fetchCycleAnalyticsData();
}, },
onStageSelect(stage) { onStageSelect(stage) {
...@@ -169,11 +167,6 @@ export default { ...@@ -169,11 +167,6 @@ export default {
onShowEditStageForm(initData = {}) { onShowEditStageForm(initData = {}) {
this.showEditCustomStageForm(initData); this.showEditCustomStageForm(initData);
}, },
initDateRange() {
const endDate = new Date(Date.now());
const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST);
this.setDateRange({ skipFetch: true, startDate, endDate });
},
onCreateCustomStage(data) { onCreateCustomStage(data) {
this.createCustomStage(data); this.createCustomStage(data);
}, },
...@@ -215,6 +208,7 @@ export default { ...@@ -215,6 +208,7 @@ export default {
<groups-dropdown-filter <groups-dropdown-filter
class="js-groups-dropdown-filter dropdown-select" class="js-groups-dropdown-filter dropdown-select"
:query-params="$options.groupsQueryParams" :query-params="$options.groupsQueryParams"
:default-group="selectedGroup"
@selected="onGroupSelect" @selected="onGroupSelect"
/> />
<projects-dropdown-filter <projects-dropdown-filter
...@@ -224,6 +218,7 @@ export default { ...@@ -224,6 +218,7 @@ export default {
:group-id="selectedGroup.id" :group-id="selectedGroup.id"
:query-params="$options.projectsQueryParams" :query-params="$options.projectsQueryParams"
:multi-select="$options.multiProjectSelect" :multi-select="$options.multiProjectSelect"
:default-projects="selectedProjects"
@selected="onProjectsSelect" @selected="onProjectsSelect"
/> />
<div <div
......
import Vue from 'vue'; import Vue from 'vue';
import CycleAnalytics from './components/base.vue'; import CycleAnalytics from './components/base.vue';
import createStore from './store'; import createStore from './store';
import { buildCycleAnalyticsInitialData } from '../shared/utils';
export default () => { export default () => {
const el = document.querySelector('#js-cycle-analytics-app'); const el = document.querySelector('#js-cycle-analytics-app');
const { emptyStateSvgPath, noDataSvgPath, noAccessSvgPath } = el.dataset; const { emptyStateSvgPath, noDataSvgPath, noAccessSvgPath } = el.dataset;
const initialData = buildCycleAnalyticsInitialData(el.dataset);
const store = createStore();
store.dispatch('initializeCycleAnalytics', initialData);
return new Vue({ return new Vue({
el, el,
name: 'CycleAnalyticsApp', name: 'CycleAnalyticsApp',
store: createStore(), store,
components: {
CycleAnalytics,
},
render: createElement => render: createElement =>
createElement(CycleAnalytics, { createElement(CycleAnalytics, {
props: { props: {
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import Api from 'ee/api'; import Api from 'ee/api';
import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility'; import { getDayDifference, getDateInPast } from '~/lib/utils/datetime_utility';
import { historyPushState } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
import createFlash, { hideFlash } from '~/flash'; import createFlash, { hideFlash } from '~/flash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants'; import { dateFormats } from '../../shared/constants';
import { toYmd } from '../../shared/utils';
const removeError = () => { const removeError = () => {
const flashEl = document.querySelector('.flash-alert'); const flashEl = document.querySelector('.flash-alert');
...@@ -29,17 +32,50 @@ const isStageNameExistsError = ({ status, errors }) => { ...@@ -29,17 +32,50 @@ const isStageNameExistsError = ({ status, errors }) => {
return false; return false;
}; };
const updateUrlParams = (
{ getters: { currentGroupPath, selectedProjectIds } },
additionalParams = {},
) => {
historyPushState(
setUrlParams(
{
group_id: currentGroupPath,
'project_ids[]': selectedProjectIds,
...additionalParams,
},
window.location.href,
true,
),
);
};
export const setFeatureFlags = ({ commit }, featureFlags) => export const setFeatureFlags = ({ commit }, featureFlags) =>
commit(types.SET_FEATURE_FLAGS, featureFlags); commit(types.SET_FEATURE_FLAGS, featureFlags);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) => export const setSelectedGroup = ({ commit, getters }, group) => {
commit(types.SET_SELECTED_PROJECTS, projectIds); commit(types.SET_SELECTED_GROUP, group);
updateUrlParams({ getters });
};
export const setSelectedProjects = ({ commit, getters }, projects) => {
commit(types.SET_SELECTED_PROJECTS, projects);
updateUrlParams({ getters });
};
export const setSelectedStage = ({ commit }, stage) => commit(types.SET_SELECTED_STAGE, stage); export const setSelectedStage = ({ commit }, stage) => commit(types.SET_SELECTED_STAGE, stage);
export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => { export const setDateRange = (
{ commit, dispatch, getters },
{ skipFetch = false, startDate, endDate },
) => {
commit(types.SET_DATE_RANGE, { startDate, endDate }); commit(types.SET_DATE_RANGE, { startDate, endDate });
updateUrlParams(
{ getters },
{
created_after: toYmd(startDate),
created_before: toYmd(endDate),
},
);
if (skipFetch) return false; if (skipFetch) return false;
...@@ -548,3 +584,17 @@ export const setTasksByTypeFilters = ({ dispatch, commit }, data) => { ...@@ -548,3 +584,17 @@ export const setTasksByTypeFilters = ({ dispatch, commit }, data) => {
commit(types.SET_TASKS_BY_TYPE_FILTERS, data); commit(types.SET_TASKS_BY_TYPE_FILTERS, data);
dispatch('fetchTasksByTypeData'); dispatch('fetchTasksByTypeData');
}; };
export const initializeCycleAnalyticsSuccess = ({ commit }) =>
commit(types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS);
export const initializeCycleAnalytics = ({ dispatch, commit }, initialData = {}) => {
commit(types.INITIALIZE_CYCLE_ANALYTICS, initialData);
if (initialData?.group?.fullPath) {
return dispatch('fetchCycleAnalyticsData').then(() =>
dispatch('initializeCycleAnalyticsSuccess'),
);
}
return dispatch('initializeCycleAnalyticsSuccess');
};
...@@ -8,12 +8,11 @@ export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDE ...@@ -8,12 +8,11 @@ export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDE
export const currentGroupPath = ({ selectedGroup }) => export const currentGroupPath = ({ selectedGroup }) =>
selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null; selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null;
export const cycleAnalyticsRequestParams = ({ export const selectedProjectIds = ({ selectedProjects }) =>
startDate = null, selectedProjects.length ? selectedProjects.map(({ id }) => id) : [];
endDate = null,
selectedProjectIds = [], export const cycleAnalyticsRequestParams = ({ startDate = null, endDate = null }, getters) => ({
}) => ({ project_ids: getters.selectedProjectIds,
project_ids: selectedProjectIds,
created_after: startDate ? dateFormat(startDate, dateFormats.isoDate) : null, created_after: startDate ? dateFormat(startDate, dateFormats.isoDate) : null,
created_before: endDate ? dateFormat(endDate, dateFormats.isoDate) : null, created_before: endDate ? dateFormat(endDate, dateFormats.isoDate) : null,
}); });
......
...@@ -63,3 +63,6 @@ export const RECEIVE_DURATION_MEDIAN_DATA_SUCCESS = 'RECEIVE_DURATION_MEDIAN_DAT ...@@ -63,3 +63,6 @@ export const RECEIVE_DURATION_MEDIAN_DATA_SUCCESS = 'RECEIVE_DURATION_MEDIAN_DAT
export const RECEIVE_DURATION_MEDIAN_DATA_ERROR = 'RECEIVE_DURATION_MEDIAN_DATA_ERROR'; export const RECEIVE_DURATION_MEDIAN_DATA_ERROR = 'RECEIVE_DURATION_MEDIAN_DATA_ERROR';
export const SET_TASKS_BY_TYPE_FILTERS = 'SET_TASKS_BY_TYPE_FILTERS'; export const SET_TASKS_BY_TYPE_FILTERS = 'SET_TASKS_BY_TYPE_FILTERS';
export const INITIALIZE_CYCLE_ANALYTICS = 'INITIALIZE_CYCLE_ANALYTICS';
export const INITIALIZE_CYCLE_ANALYTICS_SUCCESS = 'INITIALIZE_CYCLE_ANALYTICS_SUCCESS';
...@@ -9,10 +9,10 @@ export default { ...@@ -9,10 +9,10 @@ export default {
}, },
[types.SET_SELECTED_GROUP](state, group) { [types.SET_SELECTED_GROUP](state, group) {
state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true }); state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true });
state.selectedProjectIds = []; state.selectedProjects = [];
}, },
[types.SET_SELECTED_PROJECTS](state, projectIds) { [types.SET_SELECTED_PROJECTS](state, projects) {
state.selectedProjectIds = projectIds; state.selectedProjects = projects;
}, },
[types.SET_SELECTED_STAGE](state, rawData) { [types.SET_SELECTED_STAGE](state, rawData) {
state.selectedStage = convertObjectPropsToCamelCase(rawData); state.selectedStage = convertObjectPropsToCamelCase(rawData);
...@@ -236,4 +236,22 @@ export default { ...@@ -236,4 +236,22 @@ export default {
} }
state.tasksByType = { ...tasksByTypeRest, labelIds, ...updatedFilter }; state.tasksByType = { ...tasksByTypeRest, labelIds, ...updatedFilter };
}, },
[types.INITIALIZE_CYCLE_ANALYTICS](
state,
{
group: selectedGroup = null,
createdAfter: startDate = null,
createdBefore: endDate = null,
selectedProjects = [],
} = {},
) {
state.isLoading = true;
state.selectedGroup = selectedGroup;
state.selectedProjects = selectedProjects;
state.startDate = startDate;
state.endDate = endDate;
},
[types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS](state) {
state.isLoading = false;
},
}; };
...@@ -20,7 +20,7 @@ export default () => ({ ...@@ -20,7 +20,7 @@ export default () => ({
isEditingCustomStage: false, isEditingCustomStage: false,
selectedGroup: null, selectedGroup: null,
selectedProjectIds: [], selectedProjects: [],
selectedStage: null, selectedStage: null,
currentStageEvents: [], currentStageEvents: [],
......
...@@ -5,13 +5,9 @@ import FilterDropdowns from './components/filter_dropdowns.vue'; ...@@ -5,13 +5,9 @@ import FilterDropdowns from './components/filter_dropdowns.vue';
import DateRange from '../shared/components/daterange.vue'; import DateRange from '../shared/components/daterange.vue';
import ProductivityAnalyticsApp from './components/app.vue'; import ProductivityAnalyticsApp from './components/app.vue';
import FilteredSearchProductivityAnalytics from './filtered_search_productivity_analytics'; import FilteredSearchProductivityAnalytics from './filtered_search_productivity_analytics';
import {
getLabelsEndpoint,
getMilestonesEndpoint,
buildGroupFromDataset,
buildProjectFromDataset,
} from './utils';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { getLabelsEndpoint, getMilestonesEndpoint } from './utils';
import { buildGroupFromDataset, buildProjectFromDataset } from '../shared/utils';
export default () => { export default () => {
const container = document.getElementById('js-productivity-analytics'); const container = document.getElementById('js-productivity-analytics');
......
...@@ -186,45 +186,3 @@ export const getMedianLineData = (data, startDate, endDate, daysOffset) => { ...@@ -186,45 +186,3 @@ export const getMedianLineData = (data, startDate, endDate, daysOffset) => {
return result; return result;
}; };
/**
* Creates a group object from a dataset. Returns null if no groupId is present.
*
* @param {Object} dataset - The container's dataset
* @returns {Object} - A group object
*/
export const buildGroupFromDataset = dataset => {
const { groupId, groupName, groupFullPath, groupAvatarUrl } = dataset;
if (groupId) {
return {
id: Number(groupId),
name: groupName,
full_path: groupFullPath,
avatar_url: groupAvatarUrl,
};
}
return null;
};
/**
* Creates a project object from a dataset. Returns null if no projectId is present.
*
* @param {Object} dataset - The container's dataset
* @returns {Object} - A project object
*/
export const buildProjectFromDataset = dataset => {
const { projectId, projectName, projectPathWithNamespace, projectAvatarUrl } = dataset;
if (projectId) {
return {
id: Number(projectId),
name: projectName,
path_with_namespace: projectPathWithNamespace,
avatar_url: projectAvatarUrl,
};
}
return null;
};
...@@ -74,8 +74,8 @@ export default { ...@@ -74,8 +74,8 @@ export default {
:default-min-date="minDate" :default-min-date="minDate"
:max-date-range="maxDateRange" :max-date-range="maxDateRange"
theme="animate-picker" theme="animate-picker"
start-picker-class="d-flex flex-column flex-lg-row align-items-lg-center mr-lg-2 mb-2 mb-md-0" start-picker-class="js-daterange-picker-from d-flex flex-column flex-lg-row align-items-lg-center mr-lg-2 mb-2 mb-md-0"
end-picker-class="d-flex flex-column flex-lg-row align-items-lg-center" end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center"
/> />
<div <div
v-if="maxDateRange" v-if="maxDateRange"
......
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { dateFormats } from './constants'; import { dateFormats } from './constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const toYmd = date => dateFormat(date, dateFormats.isoDate); export const toYmd = date => dateFormat(date, dateFormats.isoDate);
...@@ -8,3 +9,83 @@ export default { ...@@ -8,3 +9,83 @@ export default {
}; };
export const formattedDate = d => dateFormat(d, dateFormats.defaultDate); export const formattedDate = d => dateFormat(d, dateFormats.defaultDate);
/**
* Creates a group object from a dataset. Returns null if no groupId is present.
*
* @param {Object} dataset - The container's dataset
* @returns {Object} - A group object
*/
export const buildGroupFromDataset = dataset => {
const { groupId, groupName, groupFullPath, groupAvatarUrl } = dataset;
if (groupId) {
return {
id: Number(groupId),
name: groupName,
full_path: groupFullPath,
avatar_url: groupAvatarUrl,
};
}
return null;
};
/**
* Creates a project object from a dataset. Returns null if no projectId is present.
*
* @param {Object} dataset - The container's dataset
* @returns {Object} - A project object
*/
export const buildProjectFromDataset = dataset => {
const { projectId, projectName, projectPathWithNamespace, projectAvatarUrl } = dataset;
if (projectId) {
return {
id: Number(projectId),
name: projectName,
path_with_namespace: projectPathWithNamespace,
avatar_url: projectAvatarUrl,
};
}
return null;
};
/**
* Creates an array of project objects from a json string. Returns null if no projects are present.
*
* @param {String} data - JSON encoded array of projects
* @returns {Array} - An array of project objects
*/
const buildProjectsFromJSON = (projects = []) => {
if (!projects.length) return [];
return JSON.parse(projects);
};
/**
* Builds the initial data object for cycle analytics with data loaded from the backend
*
* @param {Object} dataset - dataset object paseed to the frontend via data-* properties
* @returns {Object} - The initial data to load the app with
*/
export const buildCycleAnalyticsInitialData = ({
groupId = null,
createdBefore = null,
createdAfter = null,
projects = null,
groupName = null,
groupFullPath = null,
groupAvatarUrl = null,
} = {}) => ({
group: groupId
? convertObjectPropsToCamelCase(
buildGroupFromDataset({ groupId, groupName, groupFullPath, groupAvatarUrl }),
)
: null,
createdBefore: createdBefore ? new Date(createdBefore) : null,
createdAfter: createdAfter ? new Date(createdAfter) : null,
selectedProjects: projects
? buildProjectsFromJSON(projects).map(convertObjectPropsToCamelCase)
: [],
});
...@@ -68,7 +68,7 @@ module Analytics ...@@ -68,7 +68,7 @@ module Analytics
end end
def request_params def request_params
@request_params ||= Gitlab::Analytics::CycleAnalytics::RequestParams.new(data_collector_params) @request_params ||= Gitlab::Analytics::CycleAnalytics::RequestParams.new(data_collector_params, current_user: current_user)
end end
def data_collector def data_collector
......
...@@ -36,7 +36,7 @@ module Analytics ...@@ -36,7 +36,7 @@ module Analytics
end end
def request_params def request_params
@request_params ||= Gitlab::Analytics::CycleAnalytics::RequestParams.new(allowed_params) @request_params ||= Gitlab::Analytics::CycleAnalytics::RequestParams.new(allowed_params, current_user: current_user)
end end
def allowed_params def allowed_params
......
...@@ -18,7 +18,7 @@ class Analytics::CycleAnalyticsController < Analytics::ApplicationController ...@@ -18,7 +18,7 @@ class Analytics::CycleAnalyticsController < Analytics::ApplicationController
before_action :build_request_params, only: :show before_action :build_request_params, only: :show
def build_request_params def build_request_params
@request_params ||= Gitlab::Analytics::CycleAnalytics::RequestParams.new(allowed_params.merge(group: @group)) @request_params ||= Gitlab::Analytics::CycleAnalytics::RequestParams.new(allowed_params.merge(group: @group), current_user: current_user)
end end
def allowed_params def allowed_params
......
---
title: Add deep links for cycle analytics
merge_request: 23493
author:
type: added
...@@ -18,16 +18,20 @@ module Gitlab ...@@ -18,16 +18,20 @@ module Gitlab
attr_accessor :group attr_accessor :group
attr_reader :current_user
validates :created_after, presence: true validates :created_after, presence: true
validates :created_before, presence: true validates :created_before, presence: true
validate :validate_created_before validate :validate_created_before
validate :validate_date_range validate :validate_date_range
def initialize(params = {}) def initialize(params = {}, current_user:)
params[:created_before] ||= Date.today.at_end_of_day params[:created_before] ||= Date.today.at_end_of_day
params[:created_after] ||= default_created_after(params[:created_before]) params[:created_after] ||= default_created_after(params[:created_before])
@current_user = current_user
super(params) super(params)
end end
...@@ -38,9 +42,9 @@ module Gitlab ...@@ -38,9 +42,9 @@ module Gitlab
def to_data_attributes def to_data_attributes
{}.tap do |attrs| {}.tap do |attrs|
attrs[:group] = group_data_attributes if group attrs[:group] = group_data_attributes if group
attrs[:project_ids] = project_ids if project_ids.any?
attrs[:created_after] = created_after.iso8601 attrs[:created_after] = created_after.iso8601
attrs[:created_before] = created_before.iso8601 attrs[:created_before] = created_before.iso8601
attrs[:projects] = group_projects(project_ids) if group && project_ids.any?
end end
end end
...@@ -50,7 +54,30 @@ module Gitlab ...@@ -50,7 +54,30 @@ module Gitlab
{ {
id: group.id, id: group.id,
name: group.name, name: group.name,
full_path: group.full_path full_path: group.full_path,
avatar_url: group.avatar_url
}
end
def group_projects(project_ids)
GroupProjectsFinder.new(
group: group,
current_user: current_user,
options: { include_subgroups: true },
project_ids_relation: project_ids
)
.execute
.with_route
.map { |project| project_data_attributes(project) }
.to_json
end
def project_data_attributes(project)
{
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url
} }
end end
......
...@@ -20,6 +20,15 @@ describe 'Group Value Stream Analytics', :js do ...@@ -20,6 +20,15 @@ describe 'Group Value Stream Analytics', :js do
let!("issue_#{i}".to_sym) { create(:issue, title: "New Issue #{i}", project: project, created_at: 2.days.ago) } let!("issue_#{i}".to_sym) { create(:issue, title: "New Issue #{i}", project: project, created_at: 2.days.ago) }
end end
shared_examples 'empty state' do
it 'displays an empty state before a group is selected' do
element = page.find('.row.empty-state')
expect(element).to have_content(_("Value Stream Analytics can help you determine your team’s velocity"))
expect(element.find('.svg-content img')['src']).to have_content('illustrations/analytics/cycle-analytics-empty-chart')
end
end
before do before do
stub_licensed_features(cycle_analytics_for_groups: true) stub_licensed_features(cycle_analytics_for_groups: true)
...@@ -34,11 +43,94 @@ describe 'Group Value Stream Analytics', :js do ...@@ -34,11 +43,94 @@ describe 'Group Value Stream Analytics', :js do
visit analytics_cycle_analytics_path visit analytics_cycle_analytics_path
end end
it 'displays an empty state before a group is selected' do it_behaves_like "empty state"
element = page.find('.row.empty-state')
context 'deep linked url parameters' do
group_dropdown = '.js-groups-dropdown-filter'
projects_dropdown = '.js-projects-dropdown-filter'
before do
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_owner(user)
sign_in(user)
end
shared_examples "group dropdown set" do
it "has the group dropdown prepopulated" do
element = page.find(group_dropdown)
expect(element).to have_content group.name
end
end
expect(element).to have_content("Value Stream Analytics can help you determine your team’s velocity") context 'without valid query parameters set' do
expect(element.find('.svg-content img')['src']).to have_content('illustrations/analytics/cycle-analytics-empty-chart') context 'with no group_id set' do
before do
visit analytics_cycle_analytics_path
end
it_behaves_like "empty state"
end
context 'with created_after date > created_before date' do
before do
visit "#{analytics_cycle_analytics_path}?created_after=2019-12-31&created_before=2019-11-01"
end
it_behaves_like "empty state"
end
context 'with fake parameters' do
before do
visit "#{analytics_cycle_analytics_path}?beans=not-cool"
end
it_behaves_like "empty state"
end
end
context 'with valid query parameters set' do
context 'with group_id set' do
before do
visit "#{analytics_cycle_analytics_path}?group_id=#{group.full_path}"
end
it_behaves_like "group dropdown set"
end
context 'with project_ids set' do
before do
visit "#{analytics_cycle_analytics_path}?group_id=#{group.full_path}&project_ids[]=#{project.id}"
end
it "has the projects dropdown prepopulated" do
element = page.find(projects_dropdown)
expect(element).to have_content project.name
end
it_behaves_like "group dropdown set"
end
context 'with created_before and created_after set' do
date_range = '.js-daterange-picker'
before do
visit "#{analytics_cycle_analytics_path}?group_id=#{group.full_path}&created_before=2019-12-31&created_after=2019-11-01"
end
it "has the date range prepopulated" do
element = page.find(date_range)
expect(element.find('.js-daterange-picker-from input').value).to eq "2019-11-01"
expect(element.find('.js-daterange-picker-to input').value).to eq "2019-12-31"
end
it_behaves_like "group dropdown set"
end
end
end end
context 'displays correct fields after group selection' do context 'displays correct fields after group selection' do
......
...@@ -62,6 +62,12 @@ function createComponent({ ...@@ -62,6 +62,12 @@ function createComponent({
...opts, ...opts,
}); });
comp.vm.$store.dispatch('initializeCycleAnalytics', {
group: mockData.group,
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
});
if (withStageSelected) { if (withStageSelected) {
comp.vm.$store.dispatch('setSelectedGroup', { comp.vm.$store.dispatch('setSelectedGroup', {
...mockData.group, ...mockData.group,
...@@ -113,6 +119,11 @@ describe('Cycle Analytics component', () => { ...@@ -113,6 +119,11 @@ describe('Cycle Analytics component', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
wrapper = createComponent(); wrapper = createComponent();
wrapper.vm.$store.dispatch('initializeCycleAnalytics', {
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
});
}); });
afterEach(() => { afterEach(() => {
...@@ -120,27 +131,6 @@ describe('Cycle Analytics component', () => { ...@@ -120,27 +131,6 @@ describe('Cycle Analytics component', () => {
mock.restore(); mock.restore();
}); });
describe('mounted', () => {
const actionSpies = {
setDateRange: jest.fn(),
};
beforeEach(() => {
jest.spyOn(global.Date, 'now').mockImplementation(() => new Date(mockData.endDate));
wrapper = createComponent({ opts: { methods: actionSpies } });
});
describe('initDateRange', () => {
it('dispatches setDateRange with skipFetch=true', () => {
expect(actionSpies.setDateRange).toHaveBeenCalledWith({
skipFetch: true,
startDate: mockData.startDate,
endDate: mockData.endDate,
});
});
});
});
describe('displays the components as required', () => { describe('displays the components as required', () => {
describe('before a filter has been selected', () => { describe('before a filter has been selected', () => {
it('displays an empty state', () => { it('displays an empty state', () => {
......
...@@ -202,3 +202,18 @@ export const transformedDurationMedianData = [ ...@@ -202,3 +202,18 @@ export const transformedDurationMedianData = [
]; ];
export const durationChartPlottableMedianData = [['2018-12-31', 29], ['2019-01-01', 100]]; export const durationChartPlottableMedianData = [['2018-12-31', 29], ['2019-01-01', 100]];
export const selectedProjects = [
{
id: 1,
name: 'cool project',
pathWithNamespace: 'group/cool-project',
avatarUrl: null,
},
{
id: 2,
name: 'another cool project',
pathWithNamespace: 'group/another-cool-project',
avatarUrl: null,
},
];
import * as commonUtils from '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import axios from 'axios'; import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
...@@ -7,6 +9,7 @@ import * as types from 'ee/analytics/cycle_analytics/store/mutation_types'; ...@@ -7,6 +9,7 @@ import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { TASKS_BY_TYPE_FILTERS } from 'ee/analytics/cycle_analytics/constants'; import { TASKS_BY_TYPE_FILTERS } from 'ee/analytics/cycle_analytics/constants';
import createFlash from '~/flash'; import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { toYmd } from 'ee/analytics/shared/utils';
import { import {
group, group,
summaryData, summaryData,
...@@ -46,7 +49,24 @@ describe('Cycle analytics actions', () => { ...@@ -46,7 +49,24 @@ describe('Cycle analytics actions', () => {
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg); expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
} }
function shouldSetUrlParams({ action, payload, result }) {
const store = {
state,
getters,
commit: jest.fn(),
dispatch: jest.fn(() => Promise.resolve()),
};
return actions[action](store, payload).then(() => {
expect(urlUtils.setUrlParams).toHaveBeenCalledWith(result, window.location.href, true);
expect(commonUtils.historyPushState).toHaveBeenCalled();
});
}
beforeEach(() => { beforeEach(() => {
commonUtils.historyPushState = jest.fn();
urlUtils.setUrlParams = jest.fn();
state = { state = {
startDate, startDate,
endDate, endDate,
...@@ -86,17 +106,83 @@ describe('Cycle analytics actions', () => { ...@@ -86,17 +106,83 @@ describe('Cycle analytics actions', () => {
); );
}); });
describe('setSelectedGroup', () => {
const payload = { full_path: 'someNewGroup' };
it('calls setUrlParams with the group params', () => {
actions.setSelectedGroup(
{
state,
getters: {
currentGroupPath: 'someNewGroup',
selectedProjectIds: [],
},
commit: jest.fn(),
},
payload,
);
expect(urlUtils.setUrlParams).toHaveBeenCalledWith(
{
group_id: 'someNewGroup',
'project_ids[]': [],
},
window.location.href,
true,
);
expect(commonUtils.historyPushState).toHaveBeenCalled();
});
});
describe('setSelectedProjects', () => {
const payload = [1, 2];
it('calls setUrlParams with the date params', () => {
actions.setSelectedProjects(
{
state,
getters: {
currentGroupPath: 'test-group',
selectedProjectIds: payload,
},
commit: jest.fn(),
},
payload,
);
expect(urlUtils.setUrlParams).toHaveBeenCalledWith(
{ 'project_ids[]': payload, group_id: 'test-group' },
window.location.href,
true,
);
expect(commonUtils.historyPushState).toHaveBeenCalled();
});
});
describe('setDateRange', () => { describe('setDateRange', () => {
const payload = { startDate, endDate };
it('sets the dates as expected and dispatches fetchCycleAnalyticsData', done => { it('sets the dates as expected and dispatches fetchCycleAnalyticsData', done => {
testAction( testAction(
actions.setDateRange, actions.setDateRange,
{ startDate, endDate }, payload,
state, state,
[{ type: types.SET_DATE_RANGE, payload: { startDate, endDate } }], [{ type: types.SET_DATE_RANGE, payload: { startDate, endDate } }],
[{ type: 'fetchCycleAnalyticsData' }], [{ type: 'fetchCycleAnalyticsData' }],
done, done,
); );
}); });
it('calls setUrlParams with the date params', () => {
shouldSetUrlParams({
action: 'setDateRange',
payload,
result: {
group_id: getters.currentGroupPath,
'project_ids[]': getters.selectedProjectIds,
created_after: toYmd(payload.startDate),
created_before: toYmd(payload.endDate),
},
});
});
}); });
describe('fetchStageData', () => { describe('fetchStageData', () => {
...@@ -1442,6 +1528,67 @@ describe('Cycle analytics actions', () => { ...@@ -1442,6 +1528,67 @@ describe('Cycle analytics actions', () => {
}); });
}); });
describe('initializeCycleAnalytics', () => {
let mockDispatch;
let mockCommit;
let store;
const initialData = {
group: selectedGroup,
projectIds: [1, 2],
};
beforeEach(() => {
commonUtils.historyPushState = jest.fn();
urlUtils.setUrlParams = jest.fn();
mockDispatch = jest.fn(() => Promise.resolve());
mockCommit = jest.fn();
store = {
state,
getters,
commit: mockCommit,
dispatch: mockDispatch,
};
});
describe('with no initialData', () => {
it('commits "INITIALIZE_CYCLE_ANALYTICS"', () =>
actions.initializeCycleAnalytics(store).then(() => {
expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_CYCLE_ANALYTICS', {});
}));
it('dispatches "initializeCycleAnalyticsSuccess"', () =>
actions.initializeCycleAnalytics(store).then(() => {
expect(mockDispatch).not.toHaveBeenCalledWith('fetchCycleAnalyticsData');
expect(mockDispatch).toHaveBeenCalledWith('initializeCycleAnalyticsSuccess');
}));
});
describe('with initialData', () => {
it('dispatches "fetchCycleAnalyticsData" and "initializeCycleAnalyticsSuccess"', () =>
actions.initializeCycleAnalytics(store, initialData).then(() => {
expect(mockDispatch).toHaveBeenCalledWith('fetchCycleAnalyticsData');
expect(mockDispatch).toHaveBeenCalledWith('initializeCycleAnalyticsSuccess');
}));
it('commits "INITIALIZE_CYCLE_ANALYTICS"', () =>
actions.initializeCycleAnalytics(store, initialData).then(() => {
expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_CYCLE_ANALYTICS', initialData);
}));
});
});
describe('initializeCycleAnalyticsSuccess', () => {
it(`commits the ${types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS} mutation`, () =>
testAction(
actions.initializeCycleAnalyticsSuccess,
null,
state,
[{ type: types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS }],
[],
));
});
describe('receiveCreateCustomStageSuccess', () => { describe('receiveCreateCustomStageSuccess', () => {
const response = { const response = {
data: { data: {
...@@ -1462,6 +1609,7 @@ describe('Cycle analytics actions', () => { ...@@ -1462,6 +1609,7 @@ describe('Cycle analytics actions', () => {
beforeEach(() => { beforeEach(() => {
setFixtures('<div class="flash-container"></div>'); setFixtures('<div class="flash-container"></div>');
}); });
it('will flash an error message', () => it('will flash an error message', () =>
actions actions
.receiveCreateCustomStageSuccess( .receiveCreateCustomStageSuccess(
......
...@@ -7,10 +7,10 @@ import { ...@@ -7,10 +7,10 @@ import {
durationChartPlottableData, durationChartPlottableData,
durationChartPlottableMedianData, durationChartPlottableMedianData,
allowedStages, allowedStages,
selectedProjects,
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
const selectedProjectIds = [5, 8, 11];
describe('Cycle analytics getters', () => { describe('Cycle analytics getters', () => {
describe('hasNoAccessError', () => { describe('hasNoAccessError', () => {
...@@ -61,7 +61,7 @@ describe('Cycle analytics getters', () => { ...@@ -61,7 +61,7 @@ describe('Cycle analytics getters', () => {
}, },
startDate, startDate,
endDate, endDate,
selectedProjectIds, selectedProjects,
}; };
}); });
...@@ -69,9 +69,13 @@ describe('Cycle analytics getters', () => { ...@@ -69,9 +69,13 @@ describe('Cycle analytics getters', () => {
param | value param | value
${'created_after'} | ${'2018-12-15'} ${'created_after'} | ${'2018-12-15'}
${'created_before'} | ${'2019-01-14'} ${'created_before'} | ${'2019-01-14'}
${'project_ids'} | ${[5, 8, 11]} ${'project_ids'} | ${[1, 2]}
`('should return the $param with value $value', ({ param, value }) => { `('should return the $param with value $value', ({ param, value }) => {
expect(getters.cycleAnalyticsRequestParams(state)).toMatchObject({ [param]: value }); expect(
getters.cycleAnalyticsRequestParams(state, { selectedProjectIds: [1, 2] }),
).toMatchObject({
[param]: value,
});
}); });
}); });
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
transformedDurationData, transformedDurationData,
transformedTasksByTypeData, transformedTasksByTypeData,
transformedDurationMedianData, transformedDurationMedianData,
selectedProjects,
} from '../mock_data'; } from '../mock_data';
let state = null; let state = null;
...@@ -80,6 +81,7 @@ describe('Cycle analytics mutations', () => { ...@@ -80,6 +81,7 @@ describe('Cycle analytics mutations', () => {
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}} ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}} ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
${types.REQUEST_DURATION_MEDIAN_DATA} | ${'isLoadingDurationChartMedianData'} | ${true} ${types.REQUEST_DURATION_MEDIAN_DATA} | ${'isLoadingDurationChartMedianData'} | ${true}
${types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS} | ${'isLoading'} | ${false}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => { `('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state); mutations[mutation](state);
...@@ -89,8 +91,8 @@ describe('Cycle analytics mutations', () => { ...@@ -89,8 +91,8 @@ describe('Cycle analytics mutations', () => {
it.each` it.each`
mutation | payload | expectedState mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }} ${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }} ${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjects: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }} ${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }} ${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }} ${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${{ updatedDurationStageData: transformedDurationData, updatedDurationStageMedianData: transformedDurationMedianData }} | ${{ durationData: transformedDurationData, durationMedianData: transformedDurationMedianData }} ${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${{ updatedDurationStageData: transformedDurationData, updatedDurationStageMedianData: transformedDurationMedianData }} | ${{ durationData: transformedDurationData, durationMedianData: transformedDurationMedianData }}
...@@ -317,4 +319,30 @@ describe('Cycle analytics mutations', () => { ...@@ -317,4 +319,30 @@ describe('Cycle analytics mutations', () => {
expect(state.tasksByType).toEqual({ labelIds: [10, 30, 20] }); expect(state.tasksByType).toEqual({ labelIds: [10, 30, 20] });
}); });
}); });
describe(`${types.INITIALIZE_CYCLE_ANALYTICS}`, () => {
const initialData = {
group: { fullPath: 'cool-group' },
selectedProjects,
createdAfter: '2019-12-31',
createdBefore: '2020-01-01',
};
it.each`
stateKey | expectedState
${'isLoading'} | ${true}
${'selectedGroup'} | ${initialData.group}
${'selectedProjects'} | ${initialData.selectedProjects}
${'startDate'} | ${initialData.createdAfter}
${'endDate'} | ${initialData.createdBefore}
`(
'$mutation with payload $payload will update state with $expectedState',
({ stateKey, expectedState }) => {
state = {};
mutations[types.INITIALIZE_CYCLE_ANALYTICS](state, initialData);
expect(state[stateKey]).toEqual(expectedState);
},
);
});
}); });
...@@ -2,8 +2,6 @@ import { ...@@ -2,8 +2,6 @@ import {
getLabelsEndpoint, getLabelsEndpoint,
getMilestonesEndpoint, getMilestonesEndpoint,
getDefaultStartDate, getDefaultStartDate,
buildGroupFromDataset,
buildProjectFromDataset,
initDateArray, initDateArray,
transformScatterData, transformScatterData,
getScatterPlotData, getScatterPlotData,
...@@ -64,51 +62,6 @@ describe('Productivity Analytics utils', () => { ...@@ -64,51 +62,6 @@ describe('Productivity Analytics utils', () => {
}); });
}); });
describe('buildGroupFromDataset', () => {
it('returns null if groupId is missing', () => {
const dataset = { foo: 'bar' };
expect(buildGroupFromDataset(dataset)).toBeNull();
});
it('returns a group object when the groupId is given', () => {
const dataset = {
groupId: '1',
groupName: 'My Group',
groupFullPath: 'my-group',
groupAvatarUrl: 'foo/bar',
};
expect(buildGroupFromDataset(dataset)).toEqual({
id: 1,
name: 'My Group',
full_path: 'my-group',
avatar_url: 'foo/bar',
});
});
});
describe('buildProjectFromDataset', () => {
it('returns null if projectId is missing', () => {
const dataset = { foo: 'bar' };
expect(buildProjectFromDataset(dataset)).toBeNull();
});
it('returns a project object when the projectId is given', () => {
const dataset = {
projectId: '1',
projectName: 'My Project',
projectPathWithNamespace: 'my-group/my-project',
};
expect(buildProjectFromDataset(dataset)).toEqual({
id: 1,
name: 'My Project',
path_with_namespace: 'my-group/my-project',
avatar_url: undefined,
});
});
});
describe('initDateArray', () => { describe('initDateArray', () => {
it('creates a two-dimensional array with 3 empty arrays for startDate=2019-09-01 and endDate=2019-09-03', () => { it('creates a two-dimensional array with 3 empty arrays for startDate=2019-09-01 and endDate=2019-09-03', () => {
const startDate = new Date('2019-09-01'); const startDate = new Date('2019-09-01');
......
import {
buildGroupFromDataset,
buildProjectFromDataset,
buildCycleAnalyticsInitialData,
} from 'ee/analytics/shared/utils';
const groupDataset = {
groupId: '1',
groupName: 'My Group',
groupFullPath: 'my-group',
groupAvatarUrl: 'foo/bar',
};
const projectDataset = {
projectId: '1',
projectName: 'My Project',
projectPathWithNamespace: 'my-group/my-project',
};
const rawProjects = JSON.stringify([
{
project_id: '1',
project_name: 'My Project',
project_path_with_namespace: 'my-group/my-project',
},
]);
describe('buildGroupFromDataset', () => {
it('returns null if groupId is missing', () => {
expect(buildGroupFromDataset({ foo: 'bar' })).toBeNull();
});
it('returns a group object when the groupId is given', () => {
expect(buildGroupFromDataset(groupDataset)).toEqual({
id: 1,
name: 'My Group',
full_path: 'my-group',
avatar_url: 'foo/bar',
});
});
});
describe('buildProjectFromDataset', () => {
it('returns null if projectId is missing', () => {
expect(buildProjectFromDataset({ foo: 'bar' })).toBeNull();
});
it('returns a project object when the projectId is given', () => {
expect(buildProjectFromDataset(projectDataset)).toEqual({
id: 1,
name: 'My Project',
path_with_namespace: 'my-group/my-project',
avatar_url: undefined,
});
});
});
describe('buildCycleAnalyticsInitialData', () => {
it.each`
field | value
${'group'} | ${null}
${'createdBefore'} | ${null}
${'createdAfter'} | ${null}
${'selectedProjects'} | ${[]}
`('will set a default value for "$field" if is not present', ({ field, value }) => {
expect(buildCycleAnalyticsInitialData()).toMatchObject({
[field]: value,
});
});
describe('group', () => {
it("will be set given a valid 'groupId' and all group parameters", () => {
expect(buildCycleAnalyticsInitialData(groupDataset)).toMatchObject({
group: { avatarUrl: 'foo/bar', fullPath: 'my-group', id: 1, name: 'My Group' },
});
});
it.each`
field | value
${'avatarUrl'} | ${null}
${'fullPath'} | ${null}
${'name'} | ${null}
`("will be $value if the '$field' field is not present", ({ field, value }) => {
expect(buildCycleAnalyticsInitialData({ groupId: groupDataset.groupId })).toMatchObject({
group: { id: 1, [field]: value },
});
});
});
describe('selectedProjects', () => {
it('will be set given an array of projects', () => {
expect(buildCycleAnalyticsInitialData({ projects: rawProjects })).toMatchObject({
selectedProjects: [
{
projectId: '1',
projectName: 'My Project',
projectPathWithNamespace: 'my-group/my-project',
},
],
});
});
it.each`
field | value
${'selectedProjects'} | ${null}
${'selectedProjects'} | ${[]}
${'selectedProjects'} | ${''}
`('will be an empty array if given a value of `$value`', ({ value, field }) => {
expect(buildCycleAnalyticsInitialData({ projects: value })).toMatchObject({
[field]: [],
});
});
});
describe.each`
field | value
${'createdBefore'} | ${'2019-12-31'}
${'createdAfter'} | ${'2019-10-31'}
`('$field', ({ field, value }) => {
it('given a valid date, will return a date object', () => {
expect(buildCycleAnalyticsInitialData({ [field]: value })).toMatchObject({
[field]: new Date(value),
});
});
it('will return null if omitted', () => {
expect(buildCycleAnalyticsInitialData()).toMatchObject({ [field]: null });
});
});
});
...@@ -3,9 +3,30 @@ ...@@ -3,9 +3,30 @@
require 'spec_helper' require 'spec_helper'
describe Gitlab::Analytics::CycleAnalytics::RequestParams do describe Gitlab::Analytics::CycleAnalytics::RequestParams do
let(:params) { { created_after: '2019-01-01', created_before: '2019-03-01' } } let_it_be(:user) { create(:user) }
let_it_be(:root_group) { create(:group) }
let_it_be(:sub_group) { create(:group, parent: root_group) }
let_it_be(:sub_group_project) { create(:project, id: 1, group: sub_group) }
let_it_be(:root_group_projects) do
[
create(:project, id: 2, group: root_group),
create(:project, id: 3, group: root_group)
]
end
let(:project_ids) { root_group_projects.collect(&:id) }
let(:params) do
{ created_after: '2019-01-01',
created_before: '2019-03-01',
project_ids: [2, 3],
group: root_group }
end
subject { described_class.new(params, current_user: user) }
subject { described_class.new(params) } before do
root_group.add_owner(user)
end
describe 'validations' do describe 'validations' do
it 'is valid' do it 'is valid' do
...@@ -56,16 +77,31 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do ...@@ -56,16 +77,31 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do
end end
describe 'optional `project_ids`' do describe 'optional `project_ids`' do
it { expect(subject.project_ids).to eq([]) }
context 'when `project_ids` is not empty' do context 'when `project_ids` is not empty' do
let(:project_ids) { [1, 2, 3] } def json_project(project)
{ id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url }.to_json
end
before do context 'with a valid group' do
params[:project_ids] = project_ids it { expect(subject.project_ids).to eq(project_ids) }
it 'contains every project of the group' do
root_group_projects.each do |project|
expect(subject.to_data_attributes[:projects]).to include(json_project(project))
end
end
end end
it { expect(subject.project_ids).to eq(project_ids) } context 'without a valid group' do
before do
params[:group] = nil
end
it { expect(subject.to_data_attributes[:projects]).to eq(nil) }
end
end end
context 'when `project_ids` is not an array' do context 'when `project_ids` is not an array' do
...@@ -83,11 +119,25 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do ...@@ -83,11 +119,25 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do
it { expect(subject.project_ids).to eq([]) } it { expect(subject.project_ids).to eq([]) }
end end
context 'when `project_ids` is empty' do
before do
params[:project_ids] = []
end
it { expect(subject.project_ids).to eq([]) }
end
context 'is a subgroup project' do
before do
params[:project_ids] = sub_group_project.id
end
it { expect(subject.project_ids).to eq([sub_group_project.id]) }
end
end end
describe 'optional `group_id`' do describe 'optional `group_id`' do
it { expect(subject.group).to be_nil }
context 'when `group_id` is not empty' do context 'when `group_id` is not empty' do
let(:group_id) { 'ca-test-group' } let(:group_id) { 'ca-test-group' }
...@@ -105,5 +155,13 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do ...@@ -105,5 +155,13 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do
it { expect(subject.group).to eq(nil) } it { expect(subject.group).to eq(nil) }
end end
context 'when `group_id` is a subgroup' do
before do
params[:group] = sub_group.id
end
it { expect(subject.group).to eq(sub_group.id) }
end
end end
end end
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