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>
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"
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")
expect(element.find('.svg-content img')['src']).to have_content('illustrations/analytics/cycle-analytics-empty-chart')
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, current_user: user) }
subject { described_class.new(params) }
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
before do
params[:project_ids] = project_ids
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
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
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
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
it { expect(subject.group).to be_nil }
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