Commit 8c1e6c17 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Kushal Pandya

Set initial date from backend params

Set group and proejct id

Persists the group the project id
params to the url, and also initializes the
cycle analytics app with them if present.
parent a8603723
<script>
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
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 ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import Scatterplot from '../../shared/components/scatterplot.vue';
......@@ -56,7 +55,7 @@ export default {
'isCreatingCustomStage',
'isEditingCustomStage',
'selectedGroup',
'selectedProjectIds',
'selectedProjects',
'selectedStage',
'stages',
'summary',
......@@ -77,6 +76,7 @@ export default {
'tasksByTypeChartData',
'durationChartMedianData',
'activeStages',
'selectedProjectIds',
]),
shouldRenderEmptyState() {
return !this.selectedGroup;
......@@ -121,7 +121,6 @@ export default {
},
},
mounted() {
this.initDateRange();
this.setFeatureFlags({
hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled,
hasDurationChartMedian: this.glFeatures.cycleAnalyticsScatterplotMedianEnabled,
......@@ -154,8 +153,7 @@ export default {
this.fetchCycleAnalyticsData();
},
onProjectsSelect(projects) {
const projectIds = projects.map(value => value.id);
this.setSelectedProjects(projectIds);
this.setSelectedProjects(projects);
this.fetchCycleAnalyticsData();
},
onStageSelect(stage) {
......@@ -169,11 +167,6 @@ export default {
onShowEditStageForm(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) {
this.createCustomStage(data);
},
......@@ -215,6 +208,7 @@ export default {
<groups-dropdown-filter
class="js-groups-dropdown-filter dropdown-select"
:query-params="$options.groupsQueryParams"
:default-group="selectedGroup"
@selected="onGroupSelect"
/>
<projects-dropdown-filter
......@@ -224,6 +218,7 @@ export default {
:group-id="selectedGroup.id"
:query-params="$options.projectsQueryParams"
:multi-select="$options.multiProjectSelect"
:default-projects="selectedProjects"
@selected="onProjectsSelect"
/>
<div
......
import Vue from 'vue';
import CycleAnalytics from './components/base.vue';
import createStore from './store';
import { buildCycleAnalyticsInitialData } from '../shared/utils';
export default () => {
const el = document.querySelector('#js-cycle-analytics-app');
const { emptyStateSvgPath, noDataSvgPath, noAccessSvgPath } = el.dataset;
const initialData = buildCycleAnalyticsInitialData(el.dataset);
const store = createStore();
store.dispatch('initializeCycleAnalytics', initialData);
return new Vue({
el,
name: 'CycleAnalyticsApp',
store: createStore(),
components: {
CycleAnalytics,
},
store,
render: createElement =>
createElement(CycleAnalytics, {
props: {
......
import dateFormat from 'dateformat';
import Api from 'ee/api';
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 { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants';
import { toYmd } from '../../shared/utils';
const removeError = () => {
const flashEl = document.querySelector('.flash-alert');
......@@ -29,17 +32,50 @@ const isStageNameExistsError = ({ status, errors }) => {
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) =>
commit(types.SET_FEATURE_FLAGS, featureFlags);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) =>
commit(types.SET_SELECTED_PROJECTS, projectIds);
export const setSelectedGroup = ({ commit, getters }, group) => {
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 setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => {
export const setDateRange = (
{ commit, dispatch, getters },
{ skipFetch = false, startDate, endDate },
) => {
commit(types.SET_DATE_RANGE, { startDate, endDate });
updateUrlParams(
{ getters },
{
created_after: toYmd(startDate),
created_before: toYmd(endDate),
},
);
if (skipFetch) return false;
......@@ -548,3 +584,17 @@ export const setTasksByTypeFilters = ({ dispatch, commit }, data) => {
commit(types.SET_TASKS_BY_TYPE_FILTERS, data);
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
export const currentGroupPath = ({ selectedGroup }) =>
selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null;
export const cycleAnalyticsRequestParams = ({
startDate = null,
endDate = null,
selectedProjectIds = [],
}) => ({
project_ids: selectedProjectIds,
export const selectedProjectIds = ({ selectedProjects }) =>
selectedProjects.length ? selectedProjects.map(({ id }) => id) : [];
export const cycleAnalyticsRequestParams = ({ startDate = null, endDate = null }, getters) => ({
project_ids: getters.selectedProjectIds,
created_after: startDate ? dateFormat(startDate, 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
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 INITIALIZE_CYCLE_ANALYTICS = 'INITIALIZE_CYCLE_ANALYTICS';
export const INITIALIZE_CYCLE_ANALYTICS_SUCCESS = 'INITIALIZE_CYCLE_ANALYTICS_SUCCESS';
......@@ -9,10 +9,10 @@ export default {
},
[types.SET_SELECTED_GROUP](state, group) {
state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true });
state.selectedProjectIds = [];
state.selectedProjects = [];
},
[types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds;
[types.SET_SELECTED_PROJECTS](state, projects) {
state.selectedProjects = projects;
},
[types.SET_SELECTED_STAGE](state, rawData) {
state.selectedStage = convertObjectPropsToCamelCase(rawData);
......@@ -236,4 +236,22 @@ export default {
}
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 () => ({
isEditingCustomStage: false,
selectedGroup: null,
selectedProjectIds: [],
selectedProjects: [],
selectedStage: null,
currentStageEvents: [],
......
......@@ -5,13 +5,9 @@ import FilterDropdowns from './components/filter_dropdowns.vue';
import DateRange from '../shared/components/daterange.vue';
import ProductivityAnalyticsApp from './components/app.vue';
import FilteredSearchProductivityAnalytics from './filtered_search_productivity_analytics';
import {
getLabelsEndpoint,
getMilestonesEndpoint,
buildGroupFromDataset,
buildProjectFromDataset,
} from './utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getLabelsEndpoint, getMilestonesEndpoint } from './utils';
import { buildGroupFromDataset, buildProjectFromDataset } from '../shared/utils';
export default () => {
const container = document.getElementById('js-productivity-analytics');
......
......@@ -186,45 +186,3 @@ export const getMedianLineData = (data, startDate, endDate, daysOffset) => {
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 {
:default-min-date="minDate"
:max-date-range="maxDateRange"
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"
end-picker-class="d-flex flex-column flex-lg-row align-items-lg-center"
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="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center"
/>
<div
v-if="maxDateRange"
......
import dateFormat from 'dateformat';
import { dateFormats } from './constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const toYmd = date => dateFormat(date, dateFormats.isoDate);
......@@ -8,3 +9,83 @@ export default {
};
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
end
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
def data_collector
......
......@@ -36,7 +36,7 @@ module Analytics
end
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
def allowed_params
......
......@@ -18,7 +18,7 @@ class Analytics::CycleAnalyticsController < Analytics::ApplicationController
before_action :build_request_params, only: :show
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
def allowed_params
......
---
title: Add deep links for cycle analytics
merge_request: 23493
author:
type: added
......@@ -18,16 +18,20 @@ module Gitlab
attr_accessor :group
attr_reader :current_user
validates :created_after, presence: true
validates :created_before, presence: true
validate :validate_created_before
validate :validate_date_range
def initialize(params = {})
def initialize(params = {}, current_user:)
params[:created_before] ||= Date.today.at_end_of_day
params[:created_after] ||= default_created_after(params[:created_before])
@current_user = current_user
super(params)
end
......@@ -38,9 +42,9 @@ module Gitlab
def to_data_attributes
{}.tap do |attrs|
attrs[:group] = group_data_attributes if group
attrs[:project_ids] = project_ids if project_ids.any?
attrs[:created_after] = created_after.iso8601
attrs[:created_before] = created_before.iso8601
attrs[:projects] = group_projects(project_ids) if group && project_ids.any?
end
end
......@@ -50,7 +54,30 @@ module Gitlab
{
id: group.id,
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
......
......@@ -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) }
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
stub_licensed_features(cycle_analytics_for_groups: true)
......@@ -34,11 +43,94 @@ describe 'Group Value Stream Analytics', :js do
visit analytics_cycle_analytics_path
end
it 'displays an empty state before a group is selected' do
element = page.find('.row.empty-state')
it_behaves_like "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')
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
context 'without valid query parameters set' do
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
context 'displays correct fields after group selection' do
......
......@@ -62,6 +62,12 @@ function createComponent({
...opts,
});
comp.vm.$store.dispatch('initializeCycleAnalytics', {
group: mockData.group,
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
});
if (withStageSelected) {
comp.vm.$store.dispatch('setSelectedGroup', {
...mockData.group,
......@@ -113,6 +119,11 @@ describe('Cycle Analytics component', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
wrapper.vm.$store.dispatch('initializeCycleAnalytics', {
createdAfter: mockData.startDate,
createdBefore: mockData.endDate,
});
});
afterEach(() => {
......@@ -120,27 +131,6 @@ describe('Cycle Analytics component', () => {
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('before a filter has been selected', () => {
it('displays an empty state', () => {
......
......@@ -202,3 +202,18 @@ export const transformedDurationMedianData = [
];
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 MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
......@@ -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 createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import { toYmd } from 'ee/analytics/shared/utils';
import {
group,
summaryData,
......@@ -46,7 +49,24 @@ describe('Cycle analytics actions', () => {
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(() => {
commonUtils.historyPushState = jest.fn();
urlUtils.setUrlParams = jest.fn();
state = {
startDate,
endDate,
......@@ -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', () => {
const payload = { startDate, endDate };
it('sets the dates as expected and dispatches fetchCycleAnalyticsData', done => {
testAction(
actions.setDateRange,
{ startDate, endDate },
payload,
state,
[{ type: types.SET_DATE_RANGE, payload: { startDate, endDate } }],
[{ type: 'fetchCycleAnalyticsData' }],
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', () => {
......@@ -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', () => {
const response = {
data: {
......@@ -1462,6 +1609,7 @@ describe('Cycle analytics actions', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('will flash an error message', () =>
actions
.receiveCreateCustomStageSuccess(
......
......@@ -7,10 +7,10 @@ import {
durationChartPlottableData,
durationChartPlottableMedianData,
allowedStages,
selectedProjects,
} from '../mock_data';
let state = null;
const selectedProjectIds = [5, 8, 11];
describe('Cycle analytics getters', () => {
describe('hasNoAccessError', () => {
......@@ -61,7 +61,7 @@ describe('Cycle analytics getters', () => {
},
startDate,
endDate,
selectedProjectIds,
selectedProjects,
};
});
......@@ -69,9 +69,13 @@ describe('Cycle analytics getters', () => {
param | value
${'created_after'} | ${'2018-12-15'}
${'created_before'} | ${'2019-01-14'}
${'project_ids'} | ${[5, 8, 11]}
${'project_ids'} | ${[1, 2]}
`('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 {
transformedDurationData,
transformedTasksByTypeData,
transformedDurationMedianData,
selectedProjects,
} from '../mock_data';
let state = null;
......@@ -80,6 +81,7 @@ describe('Cycle analytics mutations', () => {
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
${types.REQUEST_DURATION_MEDIAN_DATA} | ${'isLoadingDurationChartMedianData'} | ${true}
${types.INITIALIZE_CYCLE_ANALYTICS_SUCCESS} | ${'isLoading'} | ${false}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
......@@ -89,8 +91,8 @@ describe('Cycle analytics mutations', () => {
it.each`
mutation | payload | expectedState
${types.SET_FEATURE_FLAGS} | ${{ hasDurationChart: true }} | ${{ featureFlags: { hasDurationChart: true } }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjects: [] }}
${types.SET_SELECTED_PROJECTS} | ${selectedProjects} | ${{ selectedProjects }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${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 }}
......@@ -317,4 +319,30 @@ describe('Cycle analytics mutations', () => {
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 {
getLabelsEndpoint,
getMilestonesEndpoint,
getDefaultStartDate,
buildGroupFromDataset,
buildProjectFromDataset,
initDateArray,
transformScatterData,
getScatterPlotData,
......@@ -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', () => {
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');
......
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 @@
require 'spec_helper'
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) }
subject { described_class.new(params, current_user: user) }
before do
root_group.add_owner(user)
end
describe 'validations' do
it 'is valid' do
......@@ -56,16 +77,31 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do
end
describe 'optional `project_ids`' do
it { expect(subject.project_ids).to eq([]) }
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
context 'with a valid group' do
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
context 'without a valid group' do
before do
params[:project_ids] = project_ids
params[:group] = nil
end
it { expect(subject.project_ids).to eq(project_ids) }
it { expect(subject.to_data_attributes[:projects]).to eq(nil) }
end
end
context 'when `project_ids` is not an array' do
......@@ -83,11 +119,25 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do
it { expect(subject.project_ids).to eq([]) }
end
context 'when `project_ids` is empty' do
before do
params[:project_ids] = []
end
describe 'optional `group_id`' do
it { expect(subject.group).to be_nil }
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
describe 'optional `group_id`' do
context 'when `group_id` is not empty' do
let(:group_id) { 'ca-test-group' }
......@@ -105,5 +155,13 @@ describe Gitlab::Analytics::CycleAnalytics::RequestParams do
it { expect(subject.group).to eq(nil) }
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
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