summaryrefslogtreecommitdiff
path: root/spec/frontend/analytics
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/analytics')
-rw-r--r--spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap28
-rw-r--r--spec/frontend/analytics/cycle_analytics/base_spec.js265
-rw-r--r--spec/frontend/analytics/cycle_analytics/filter_bar_spec.js229
-rw-r--r--spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js34
-rw-r--r--spec/frontend/analytics/cycle_analytics/mock_data.js261
-rw-r--r--spec/frontend/analytics/cycle_analytics/path_navigation_spec.js150
-rw-r--r--spec/frontend/analytics/cycle_analytics/stage_table_spec.js371
-rw-r--r--spec/frontend/analytics/cycle_analytics/store/actions_spec.js518
-rw-r--r--spec/frontend/analytics/cycle_analytics/store/getters_spec.js42
-rw-r--r--spec/frontend/analytics/cycle_analytics/store/mutations_spec.js132
-rw-r--r--spec/frontend/analytics/cycle_analytics/total_time_spec.js45
-rw-r--r--spec/frontend/analytics/cycle_analytics/utils_spec.js171
-rw-r--r--spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js91
-rw-r--r--spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js185
14 files changed, 2522 insertions, 0 deletions
diff --git a/spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap b/spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap
new file mode 100644
index 00000000000..92927ef16ec
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap
@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TotalTime with a blank object should render -- 1`] = `"<span> -- </span>"`;
+
+exports[`TotalTime with a valid time object with {"days": 3, "mins": 47, "seconds": 3} 1`] = `
+"<span>
+ 3 <span>days</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"hours": 7, "mins": 20, "seconds": 10} 1`] = `
+"<span>
+ 7 <span>hrs</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"hours": 23, "mins": 10} 1`] = `
+"<span>
+ 23 <span>hrs</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"mins": 47, "seconds": 3} 1`] = `
+"<span>
+ 47 <span>mins</span></span>"
+`;
+
+exports[`TotalTime with a valid time object with {"seconds": 35} 1`] = `
+"<span>
+ 35 <span>s</span></span>"
+`;
diff --git a/spec/frontend/analytics/cycle_analytics/base_spec.js b/spec/frontend/analytics/cycle_analytics/base_spec.js
new file mode 100644
index 00000000000..58588ff49ce
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/base_spec.js
@@ -0,0 +1,265 @@
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
+import BaseComponent from '~/analytics/cycle_analytics/components/base.vue';
+import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue';
+import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
+import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
+import { NOT_ENOUGH_DATA_ERROR } from '~/analytics/cycle_analytics/constants';
+import initState from '~/analytics/cycle_analytics/store/state';
+import {
+ transformedProjectStagePathData,
+ selectedStage,
+ issueEvents,
+ createdBefore,
+ createdAfter,
+ currentGroup,
+ stageCounts,
+ initialPaginationState as pagination,
+} from './mock_data';
+
+const selectedStageEvents = issueEvents.events;
+const noDataSvgPath = 'path/to/no/data';
+const noAccessSvgPath = 'path/to/no/access';
+const selectedStageCount = stageCounts[selectedStage.id];
+const fullPath = 'full/path/to/foo';
+
+Vue.use(Vuex);
+
+let wrapper;
+
+const { id: groupId, path: groupPath } = currentGroup;
+const defaultState = {
+ currentGroup,
+ createdBefore,
+ createdAfter,
+ stageCounts,
+ endpoints: { fullPath, groupId, groupPath },
+};
+
+function createStore({ initialState = {}, initialGetters = {} }) {
+ return new Vuex.Store({
+ state: {
+ ...initState(),
+ ...defaultState,
+ ...initialState,
+ },
+ getters: {
+ pathNavigationData: () => transformedProjectStagePathData,
+ filterParams: () => ({
+ created_after: createdAfter,
+ created_before: createdBefore,
+ }),
+ ...initialGetters,
+ },
+ });
+}
+
+function createComponent({ initialState, initialGetters } = {}) {
+ return extendedWrapper(
+ shallowMount(BaseComponent, {
+ store: createStore({ initialState, initialGetters }),
+ propsData: {
+ noDataSvgPath,
+ noAccessSvgPath,
+ },
+ stubs: {
+ StageTable,
+ },
+ }),
+ );
+}
+
+const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+const findPathNavigation = () => wrapper.findComponent(PathNavigation);
+const findFilters = () => wrapper.findComponent(ValueStreamFilters);
+const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics);
+const findStageTable = () => wrapper.findComponent(StageTable);
+const findStageEvents = () => findStageTable().props('stageEvents');
+const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
+const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
+
+const hasMetricsRequests = (reqs) => {
+ const foundReqs = findOverviewMetrics().props('requests');
+ expect(foundReqs.length).toEqual(reqs.length);
+ expect(foundReqs.map(({ name }) => name)).toEqual(reqs);
+};
+
+describe('Value stream analytics component', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents, pagination } });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders the path navigation component', () => {
+ expect(findPathNavigation().exists()).toBe(true);
+ });
+
+ it('receives the stages formatted for the path navigation', () => {
+ expect(findPathNavigation().props('stages')).toBe(transformedProjectStagePathData);
+ });
+
+ it('renders the overview metrics', () => {
+ expect(findOverviewMetrics().exists()).toBe(true);
+ });
+
+ it('passes requests prop to the metrics component', () => {
+ hasMetricsRequests(['recent activity']);
+ });
+
+ it('renders the stage table', () => {
+ expect(findStageTable().exists()).toBe(true);
+ });
+
+ it('passes the selected stage count to the stage table', () => {
+ expect(findStageTable().props('stageCount')).toBe(selectedStageCount);
+ });
+
+ it('renders the stage table events', () => {
+ expect(findStageEvents()).toEqual(selectedStageEvents);
+ });
+
+ it('renders the filters', () => {
+ expect(findFilters().exists()).toBe(true);
+ });
+
+ it('displays the date range selector and hides the project selector', () => {
+ expect(findFilters().props()).toMatchObject({
+ hasProjectFilter: false,
+ hasDateRangeFilter: true,
+ });
+ });
+
+ it('passes the paths to the filter bar', () => {
+ expect(findFilters().props()).toEqual({
+ groupId,
+ groupPath,
+ endDate: createdBefore,
+ hasDateRangeFilter: true,
+ hasProjectFilter: false,
+ selectedProjects: [],
+ startDate: createdAfter,
+ });
+ });
+
+ it('does not render the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders pagination', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ describe('with `cycleAnalyticsForGroups=true` license', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } });
+ });
+
+ it('passes requests prop to the metrics component', () => {
+ hasMetricsRequests(['time summary', 'recent activity']);
+ });
+ });
+
+ describe('isLoading = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { isLoading: true },
+ });
+ });
+
+ it('renders the path navigation component with prop `loading` set to true', () => {
+ expect(findPathNavigation().props('loading')).toBe(true);
+ });
+
+ it('does not render the stage table', () => {
+ expect(findStageTable().exists()).toBe(false);
+ });
+
+ it('renders the overview metrics', () => {
+ expect(findOverviewMetrics().exists()).toBe(true);
+ });
+
+ it('renders the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('isLoadingStage = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: { isLoadingStage: true },
+ });
+ });
+
+ it('renders the stage table with a loading icon', () => {
+ const tableWrapper = findStageTable();
+ expect(tableWrapper.exists()).toBe(true);
+ expect(tableWrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders the path navigation loading state', () => {
+ expect(findPathNavigation().props('loading')).toBe(true);
+ });
+ });
+
+ describe('isEmptyStage = true', () => {
+ const emptyStageParams = {
+ isEmptyStage: true,
+ selectedStage: { ...selectedStage, emptyStageText: 'This stage is empty' },
+ };
+ beforeEach(() => {
+ wrapper = createComponent({ initialState: emptyStageParams });
+ });
+
+ it('renders the empty stage with `Not enough data` message', () => {
+ expect(findEmptyStageTitle()).toBe(NOT_ENOUGH_DATA_ERROR);
+ });
+
+ describe('with a selectedStageError', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialState: {
+ ...emptyStageParams,
+ selectedStageError: 'There is too much data to calculate',
+ },
+ });
+ });
+
+ it('renders the empty stage with `There is too much data to calculate` message', () => {
+ expect(findEmptyStageTitle()).toBe('There is too much data to calculate');
+ });
+ });
+ });
+
+ describe('without a selected stage', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ initialGetters: { pathNavigationData: () => [] },
+ initialState: { selectedStage: null, isEmptyStage: true },
+ });
+ });
+
+ it('renders the stage table', () => {
+ expect(findStageTable().exists()).toBe(true);
+ });
+
+ it('does not render the path navigation', () => {
+ expect(findPathNavigation().exists()).toBe(false);
+ });
+
+ it('does not render the stage table events', () => {
+ expect(findStageEvents()).toHaveLength(0);
+ });
+
+ it('does not render the loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js b/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js
new file mode 100644
index 00000000000..2b26b202882
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js
@@ -0,0 +1,229 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import Vuex from 'vuex';
+import {
+ filterMilestones,
+ filterLabels,
+} from 'jest/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data';
+import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue';
+import storeConfig from '~/analytics/cycle_analytics/store';
+import * as commonUtils from '~/lib/utils/common_utils';
+import * as urlUtils from '~/lib/utils/url_utility';
+import {
+ TOKEN_TYPE_ASSIGNEE,
+ TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_LABEL,
+ TOKEN_TYPE_MILESTONE,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import * as utils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import initialFiltersState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+Vue.use(Vuex);
+
+const milestoneTokenType = TOKEN_TYPE_MILESTONE;
+const labelsTokenType = TOKEN_TYPE_LABEL;
+const authorTokenType = TOKEN_TYPE_AUTHOR;
+const assigneesTokenType = TOKEN_TYPE_ASSIGNEE;
+
+const initialFilterBarState = {
+ selectedMilestone: null,
+ selectedAuthor: null,
+ selectedAssigneeList: null,
+ selectedLabelList: null,
+};
+
+const defaultParams = {
+ milestone_title: null,
+ 'not[milestone_title]': null,
+ author_username: null,
+ 'not[author_username]': null,
+ assignee_username: null,
+ 'not[assignee_username]': null,
+ label_name: null,
+ 'not[label_name]': null,
+};
+
+async function shouldMergeUrlParams(wrapper, result) {
+ await nextTick();
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(result, window.location.href, {
+ spreadArrays: true,
+ });
+ expect(commonUtils.historyPushState).toHaveBeenCalled();
+}
+
+describe('Filter bar', () => {
+ let wrapper;
+ let store;
+ let mock;
+
+ let setFiltersMock;
+
+ const createStore = (initialState = {}) => {
+ setFiltersMock = jest.fn();
+
+ return new Vuex.Store({
+ modules: {
+ filters: {
+ namespaced: true,
+ state: {
+ ...initialFiltersState(),
+ ...initialState,
+ },
+ actions: {
+ setFilters: setFiltersMock,
+ },
+ },
+ },
+ });
+ };
+
+ const createComponent = (initialStore) => {
+ return shallowMount(FilterBar, {
+ store: initialStore,
+ propsData: {
+ groupPath: 'foo',
+ },
+ stubs: {
+ UrlSync,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ const selectedMilestone = [filterMilestones[0]];
+ const selectedLabelList = [filterLabels[0]];
+
+ const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBar);
+ const getSearchToken = (type) =>
+ findFilteredSearch()
+ .props('tokens')
+ .find((token) => token.type === type);
+
+ describe('default', () => {
+ beforeEach(() => {
+ store = createStore();
+ wrapper = createComponent(store);
+ });
+
+ it('renders FilteredSearchBar component', () => {
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+ });
+
+ describe('when the state has data', () => {
+ beforeEach(() => {
+ store = createStore({
+ milestones: { data: selectedMilestone },
+ labels: { data: selectedLabelList },
+ authors: { data: [] },
+ assignees: { data: [] },
+ });
+ wrapper = createComponent(store);
+ });
+
+ it('displays the milestone and label token', () => {
+ const tokens = findFilteredSearch().props('tokens');
+
+ expect(tokens).toHaveLength(4);
+ expect(tokens[0].type).toBe(milestoneTokenType);
+ expect(tokens[1].type).toBe(labelsTokenType);
+ expect(tokens[2].type).toBe(authorTokenType);
+ expect(tokens[3].type).toBe(assigneesTokenType);
+ });
+
+ it('provides the initial milestone token', () => {
+ const { initialMilestones: milestoneToken } = getSearchToken(milestoneTokenType);
+
+ expect(milestoneToken).toHaveLength(selectedMilestone.length);
+ });
+
+ it('provides the initial label token', () => {
+ const { initialLabels: labelToken } = getSearchToken(labelsTokenType);
+
+ expect(labelToken).toHaveLength(selectedLabelList.length);
+ });
+ });
+
+ describe('when the user interacts', () => {
+ beforeEach(() => {
+ store = createStore({
+ milestones: { data: filterMilestones },
+ labels: { data: filterLabels },
+ });
+ wrapper = createComponent(store);
+ jest.spyOn(utils, 'processFilters');
+ });
+
+ it('clicks on the search button, setFilters is dispatched', () => {
+ const filters = [
+ { type: TOKEN_TYPE_MILESTONE, value: { data: selectedMilestone[0].title, operator: '=' } },
+ { type: TOKEN_TYPE_LABEL, value: { data: selectedLabelList[0].title, operator: '=' } },
+ ];
+
+ findFilteredSearch().vm.$emit('onFilter', filters);
+
+ expect(utils.processFilters).toHaveBeenCalledWith(filters);
+
+ expect(setFiltersMock).toHaveBeenCalledWith(expect.anything(), {
+ selectedLabelList: [{ value: selectedLabelList[0].title, operator: '=' }],
+ selectedMilestone: { value: selectedMilestone[0].title, operator: '=' },
+ selectedAssigneeList: [],
+ selectedAuthor: null,
+ });
+ });
+ });
+
+ describe.each([
+ ['selectedMilestone', 'milestone_title', { value: '12.0', operator: '=' }, '12.0'],
+ ['selectedAuthor', 'author_username', { value: 'rootUser', operator: '=' }, 'rootUser'],
+ [
+ 'selectedLabelList',
+ 'label_name',
+ [
+ { value: 'Afternix', operator: '=' },
+ { value: 'Brouceforge', operator: '=' },
+ ],
+ ['Afternix', 'Brouceforge'],
+ ],
+ [
+ 'selectedAssigneeList',
+ 'assignee_username',
+ [
+ { value: 'rootUser', operator: '=' },
+ { value: 'secondaryUser', operator: '=' },
+ ],
+ ['rootUser', 'secondaryUser'],
+ ],
+ ])('with a %s updates the %s url parameter', (stateKey, paramKey, payload, result) => {
+ beforeEach(() => {
+ commonUtils.historyPushState = jest.fn();
+ urlUtils.mergeUrlParams = jest.fn();
+
+ mock = new MockAdapter(axios);
+ wrapper = createComponent(storeConfig);
+
+ wrapper.vm.$store.dispatch('filters/setFilters', {
+ ...initialFilterBarState,
+ [stateKey]: payload,
+ });
+ });
+ it(`sets the ${paramKey} url parameter`, () => {
+ return shouldMergeUrlParams(wrapper, {
+ ...defaultParams,
+ [paramKey]: result,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js b/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js
new file mode 100644
index 00000000000..9be92bb92bc
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+import Component from '~/analytics/cycle_analytics/components/formatted_stage_count.vue';
+
+describe('Formatted Stage Count', () => {
+ let wrapper = null;
+
+ const createComponent = (stageCount = null) => {
+ wrapper = shallowMount(Component, {
+ propsData: {
+ stageCount,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ stageCount | expectedOutput
+ ${null} | ${'-'}
+ ${1} | ${'1 item'}
+ ${10} | ${'10 items'}
+ ${1000} | ${'1,000 items'}
+ ${1001} | ${'1,000+ items'}
+ `('returns "$expectedOutput" for stageCount=$stageCount', ({ stageCount, expectedOutput }) => {
+ createComponent(stageCount);
+ expect(wrapper.text()).toContain(expectedOutput);
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/mock_data.js b/spec/frontend/analytics/cycle_analytics/mock_data.js
new file mode 100644
index 00000000000..f820f755400
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/mock_data.js
@@ -0,0 +1,261 @@
+import valueStreamAnalyticsStages from 'test_fixtures/projects/analytics/value_stream_analytics/stages.json';
+import issueStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/issue.json';
+import planStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/plan.json';
+import reviewStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/review.json';
+import codeStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/code.json';
+import testStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/test.json';
+import stagingStageFixtures from 'test_fixtures/projects/analytics/value_stream_analytics/events/staging.json';
+
+import { TEST_HOST } from 'helpers/test_constants';
+import {
+ DEFAULT_VALUE_STREAM,
+ PAGINATION_TYPE,
+ PAGINATION_SORT_DIRECTION_DESC,
+ PAGINATION_SORT_FIELD_END_EVENT,
+} from '~/analytics/cycle_analytics/constants';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { getDateInPast } from '~/lib/utils/datetime_utility';
+
+const DEFAULT_DAYS_IN_PAST = 30;
+export const createdBefore = new Date(2019, 0, 14);
+export const createdAfter = getDateInPast(createdBefore, DEFAULT_DAYS_IN_PAST);
+
+export const deepCamelCase = (obj) => convertObjectPropsToCamelCase(obj, { deep: true });
+
+export const getStageByTitle = (stages, title) =>
+ stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {};
+
+export const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging'];
+
+const stageFixtures = {
+ issue: issueStageFixtures,
+ plan: planStageFixtures,
+ review: reviewStageFixtures,
+ code: codeStageFixtures,
+ test: testStageFixtures,
+ staging: stagingStageFixtures,
+};
+
+export const summary = [
+ { value: '20', title: 'New Issues' },
+ { value: null, title: 'Commits' },
+ { value: null, title: 'Deploys' },
+ { value: null, title: 'Deployment Frequency', unit: '/day' },
+];
+
+export const issueStage = {
+ id: 'issue',
+ title: 'Issue',
+ name: 'issue',
+ legend: '',
+ description: 'Time before an issue gets scheduled',
+ value: null,
+};
+
+export const planStage = {
+ id: 'plan',
+ title: 'Plan',
+ name: 'plan',
+ legend: '',
+ description: 'Time before an issue starts implementation',
+ value: 75600,
+};
+
+export const codeStage = {
+ id: 'code',
+ title: 'Code',
+ name: 'code',
+ legend: '',
+ description: 'Time until first merge request',
+ value: 172800,
+};
+
+export const testStage = {
+ id: 'test',
+ title: 'Test',
+ name: 'test',
+ legend: '',
+ description: 'Total test time for all commits/merges',
+ value: 17550,
+};
+
+export const reviewStage = {
+ id: 'review',
+ title: 'Review',
+ name: 'review',
+ legend: '',
+ description: 'Time between merge request creation and merge/close',
+ value: null,
+};
+
+export const stagingStage = {
+ id: 'staging',
+ title: 'Staging',
+ name: 'staging',
+ legend: '',
+ description: 'From merge request merge until deploy to production',
+ value: 172800,
+};
+
+export const selectedStage = {
+ ...issueStage,
+ value: null,
+ active: false,
+ emptyStageText:
+ 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
+
+ slug: 'issue',
+};
+
+export const convertedData = {
+ summary: [
+ { value: '20', title: 'New Issues' },
+ { value: '-', title: 'Commits' },
+ { value: '-', title: 'Deploys' },
+ { value: '-', title: 'Deployment Frequency', unit: '/day' },
+ ],
+};
+
+export const rawIssueEvents = stageFixtures.issue;
+export const issueEvents = deepCamelCase(rawIssueEvents);
+export const reviewEvents = deepCamelCase(stageFixtures.review);
+
+export const pathNavIssueMetric = 172800;
+
+export const rawStageCounts = [
+ { id: 'issue', count: 6 },
+ { id: 'plan', count: 6 },
+ { id: 'code', count: 1 },
+ { id: 'test', count: 5 },
+ { id: 'review', count: 12 },
+ { id: 'staging', count: 3 },
+];
+
+export const stageCounts = {
+ code: 1,
+ issue: 6,
+ plan: 6,
+ review: 12,
+ staging: 3,
+ test: 5,
+};
+
+export const rawStageMedians = [
+ { id: 'issue', value: 172800 },
+ { id: 'plan', value: 86400 },
+ { id: 'review', value: 1036800 },
+ { id: 'code', value: 129600 },
+ { id: 'test', value: 259200 },
+ { id: 'staging', value: 388800 },
+];
+
+export const stageMedians = {
+ issue: 172800,
+ plan: 86400,
+ review: 1036800,
+ code: 129600,
+ test: 259200,
+ staging: 388800,
+};
+
+export const formattedStageMedians = {
+ issue: '2d',
+ plan: '1d',
+ review: '1w',
+ code: '1d',
+ test: '3d',
+ staging: '4d',
+};
+
+export const allowedStages = [issueStage, planStage, codeStage];
+
+export const transformedProjectStagePathData = [
+ {
+ metric: 172800,
+ selected: true,
+ stageCount: 6,
+ icon: null,
+ id: 'issue',
+ title: 'Issue',
+ name: 'issue',
+ legend: '',
+ description: 'Time before an issue gets scheduled',
+ value: null,
+ },
+ {
+ metric: 86400,
+ selected: false,
+ stageCount: 6,
+ icon: null,
+ id: 'plan',
+ title: 'Plan',
+ name: 'plan',
+ legend: '',
+ description: 'Time before an issue starts implementation',
+ value: 75600,
+ },
+ {
+ metric: 129600,
+ selected: false,
+ stageCount: 1,
+ icon: null,
+ id: 'code',
+ title: 'Code',
+ name: 'code',
+ legend: '',
+ description: 'Time until first merge request',
+ value: 172800,
+ },
+];
+
+export const selectedValueStream = DEFAULT_VALUE_STREAM;
+
+export const group = {
+ id: 1,
+ name: 'foo',
+ path: 'foo',
+ full_path: 'foo',
+ avatar_url: `${TEST_HOST}/images/home/nasa.svg`,
+};
+
+export const currentGroup = convertObjectPropsToCamelCase(group, { deep: true });
+
+export const selectedProjects = [
+ {
+ id: 'gid://gitlab/Project/1',
+ name: 'cool project',
+ pathWithNamespace: 'group/cool-project',
+ avatarUrl: null,
+ },
+ {
+ id: 'gid://gitlab/Project/2',
+ name: 'another cool project',
+ pathWithNamespace: 'group/another-cool-project',
+ avatarUrl: null,
+ },
+];
+
+export const rawValueStreamStages = valueStreamAnalyticsStages.stages;
+
+export const valueStreamStages = rawValueStreamStages.map((s) =>
+ convertObjectPropsToCamelCase(s, { deep: true }),
+);
+
+export const initialPaginationQuery = {
+ page: 15,
+ sort: PAGINATION_SORT_FIELD_END_EVENT,
+ direction: PAGINATION_SORT_DIRECTION_DESC,
+};
+
+export const initialPaginationState = {
+ ...initialPaginationQuery,
+ page: null,
+ hasNextPage: false,
+};
+
+export const basePaginationResult = {
+ pagination: PAGINATION_TYPE,
+ sort: PAGINATION_SORT_FIELD_END_EVENT,
+ direction: PAGINATION_SORT_DIRECTION_DESC,
+ page: null,
+};
diff --git a/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js b/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js
new file mode 100644
index 00000000000..107e62035c3
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js
@@ -0,0 +1,150 @@
+import { GlPath, GlSkeletonLoader } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import Component from '~/analytics/cycle_analytics/components/path_navigation.vue';
+import { transformedProjectStagePathData, selectedStage } from './mock_data';
+
+describe('Project PathNavigation', () => {
+ let wrapper = null;
+ let trackingSpy = null;
+
+ const createComponent = (props) => {
+ return extendedWrapper(
+ mount(Component, {
+ propsData: {
+ stages: transformedProjectStagePathData,
+ selectedStage,
+ loading: false,
+ ...props,
+ },
+ }),
+ );
+ };
+
+ const findPathNavigation = () => {
+ return wrapper.findByTestId('gl-path-nav');
+ };
+
+ const findPathNavigationItems = () => {
+ return findPathNavigation().findAll('li');
+ };
+
+ const findPathNavigationTitles = () => {
+ return findPathNavigation()
+ .findAll('li button')
+ .wrappers.map((w) => w.html());
+ };
+
+ const clickItemAt = (index) => {
+ findPathNavigationItems().at(index).find('button').trigger('click');
+ };
+
+ const pathItemContent = () => findPathNavigationItems().wrappers.map(extendedWrapper);
+ const firstPopover = () => wrapper.findAllByTestId('stage-item-popover').at(0);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('displays correctly', () => {
+ it('has the correct props', () => {
+ expect(wrapper.findComponent(GlPath).props('items')).toMatchObject(
+ transformedProjectStagePathData,
+ );
+ });
+
+ it('contains all the expected stages', () => {
+ const stageContent = findPathNavigationTitles();
+ transformedProjectStagePathData.forEach((stage, index) => {
+ expect(stageContent[index]).toContain(stage.title);
+ });
+ });
+
+ describe('loading', () => {
+ describe('is false', () => {
+ it('displays the gl-path component', () => {
+ expect(wrapper.findComponent(GlPath).exists()).toBe(true);
+ });
+
+ it('hides the gl-skeleton-loading component', () => {
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
+ });
+
+ it('renders each stage', () => {
+ const result = findPathNavigationTitles();
+ expect(result.length).toBe(transformedProjectStagePathData.length);
+ });
+
+ it('renders each stage with its median', () => {
+ const result = findPathNavigationTitles();
+ transformedProjectStagePathData.forEach(({ title, metric }, index) => {
+ expect(result[index]).toContain(title);
+ expect(result[index]).toContain(metric.toString());
+ });
+ });
+
+ describe('popovers', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stages: transformedProjectStagePathData });
+ });
+
+ it('renders popovers for all stages', () => {
+ pathItemContent().forEach((stage) => {
+ expect(stage.findByTestId('stage-item-popover').exists()).toBe(true);
+ });
+ });
+
+ it('shows the median stage time for the first stage item', () => {
+ expect(firstPopover().text()).toContain('Stage time (median)');
+ });
+ });
+ });
+
+ describe('is true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ loading: true });
+ });
+
+ it('hides the gl-path component', () => {
+ expect(wrapper.findComponent(GlPath).exists()).toBe(false);
+ });
+
+ it('displays the gl-skeleton-loading component', () => {
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('event handling', () => {
+ it('emits the selected event', () => {
+ expect(wrapper.emitted('selected')).toBeUndefined();
+
+ clickItemAt(0);
+ clickItemAt(1);
+ clickItemAt(2);
+
+ expect(wrapper.emitted().selected).toEqual([
+ [transformedProjectStagePathData[0]],
+ [transformedProjectStagePathData[1]],
+ [transformedProjectStagePathData[2]],
+ ]);
+ });
+
+ it('sends tracking information', () => {
+ clickItemAt(0);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_path_navigation', {
+ extra: { stage_id: selectedStage.slug },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/stage_table_spec.js b/spec/frontend/analytics/cycle_analytics/stage_table_spec.js
new file mode 100644
index 00000000000..cfccce7eae9
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/stage_table_spec.js
@@ -0,0 +1,371 @@
+import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
+import { PAGINATION_SORT_FIELD_DURATION } from '~/analytics/cycle_analytics/constants';
+import { issueEvents, issueStage, reviewStage, reviewEvents } from './mock_data';
+
+let wrapper = null;
+let trackingSpy = null;
+
+const noDataSvgPath = 'path/to/no/data';
+const emptyStateTitle = 'Too much data';
+const notEnoughDataError = "We don't have enough data to show this stage.";
+const issueEventItems = issueEvents.events;
+const reviewEventItems = reviewEvents.events;
+const [firstIssueEvent] = issueEventItems;
+const [firstReviewEvent] = reviewEventItems;
+const pagination = { page: 1, hasNextPage: true };
+
+const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event');
+const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
+const findTable = () => wrapper.findComponent(GlTable);
+const findTableHead = () => wrapper.find('thead');
+const findTableHeadColumns = () => findTableHead().findAll('th');
+const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title');
+const findStageEventLink = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-link');
+const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time');
+const findStageLastEvent = () => wrapper.findByTestId('vsa-stage-last-event');
+const findIcon = (name) => wrapper.findByTestId(`${name}-icon`);
+
+function createComponent(props = {}, shallow = false) {
+ const func = shallow ? shallowMount : mount;
+ return extendedWrapper(
+ func(StageTable, {
+ propsData: {
+ isLoading: false,
+ stageEvents: issueEventItems,
+ noDataSvgPath,
+ selectedStage: issueStage,
+ pagination,
+ ...props,
+ },
+ stubs: {
+ GlLoadingIcon,
+ GlEmptyState,
+ },
+ }),
+ );
+}
+
+describe('StageTable', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('is loaded with data', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('will render the correct events', () => {
+ const evs = findStageEvents();
+ expect(evs).toHaveLength(issueEventItems.length);
+
+ const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(titles[index]).toBe(ev.title);
+ });
+ });
+
+ it('will not display the default data message', () => {
+ expect(wrapper.html()).not.toContain(notEnoughDataError);
+ });
+ });
+
+ describe('with minimal stage data', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ currentStage: { title: 'New stage title' } });
+ });
+
+ it('will render the correct events', () => {
+ const evs = findStageEvents();
+ expect(evs).toHaveLength(issueEventItems.length);
+
+ const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(titles[index]).toBe(ev.title);
+ });
+ });
+
+ it('will not display the project name in the record link', () => {
+ const evs = findStageEvents();
+
+ const links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(links[index]).toBe(`#${ev.iid}`);
+ });
+ });
+ });
+
+ describe('default event', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ stageEvents: [{ ...firstIssueEvent }],
+ selectedStage: { ...issueStage, custom: false },
+ });
+ });
+
+ it('will render the event title', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-title').text()).toBe(firstIssueEvent.title);
+ });
+
+ it('will set the workflow title to "Issues"', () => {
+ expect(findTableHead().text()).toContain('Issues');
+ });
+
+ it('does not render the fork icon', () => {
+ expect(findIcon('fork').exists()).toBe(false);
+ });
+
+ it('does not render the branch icon', () => {
+ expect(findIcon('commit').exists()).toBe(false);
+ });
+
+ it('will render the total time', () => {
+ const createdAt = firstIssueEvent.createdAt.replace(' ago', '');
+ expect(findStageTime().text()).toBe(createdAt);
+ });
+
+ it('will render the end event', () => {
+ expect(findStageLastEvent().text()).toBe(firstIssueEvent.endEventTimestamp);
+ });
+
+ it('will render the author', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-author').text()).toContain(
+ firstIssueEvent.author.name,
+ );
+ });
+
+ it('will render the created at date', () => {
+ expect(wrapper.findByTestId('vsa-stage-event-date').text()).toContain(
+ firstIssueEvent.createdAt,
+ );
+ });
+ });
+
+ describe('merge request event', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ stageEvents: [{ ...firstReviewEvent }],
+ selectedStage: { ...reviewStage, custom: false },
+ });
+ });
+
+ it('will set the workflow title to "Merge requests"', () => {
+ expect(findTableHead().text()).toContain('Merge requests');
+ expect(findTableHead().text()).not.toContain('Issues');
+ });
+ });
+
+ describe('isLoading = true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ isLoading: true }, true);
+ });
+
+ it('will display the loading icon', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('will not display pagination', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('with no stageEvents', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stageEvents: [] });
+ });
+
+ it('will render the empty state', () => {
+ expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true);
+ });
+
+ it('will display the default no data message', () => {
+ expect(wrapper.html()).toContain(notEnoughDataError);
+ });
+
+ it('will not display the pagination component', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+
+ describe('emptyStateTitle set', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ stageEvents: [], emptyStateTitle });
+ });
+
+ it('will display the custom message', () => {
+ expect(wrapper.html()).not.toContain(notEnoughDataError);
+ expect(wrapper.html()).toContain(emptyStateTitle);
+ });
+ });
+
+ describe('includeProjectName set', () => {
+ const fakenamespace = 'some/fake/path';
+ beforeEach(() => {
+ wrapper = createComponent({ includeProjectName: true });
+ });
+
+ it('will display the project name in the record link', () => {
+ const evs = findStageEvents();
+
+ const links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
+ issueEventItems.forEach((ev, index) => {
+ expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`);
+ });
+ });
+
+ describe.each`
+ namespaceFullPath | hasFullPath
+ ${'fake'} | ${false}
+ ${fakenamespace} | ${true}
+ `('with a namespace', ({ namespaceFullPath, hasFullPath }) => {
+ let evs = null;
+ let links = null;
+
+ beforeEach(() => {
+ wrapper = createComponent({
+ includeProjectName: true,
+ stageEvents: issueEventItems.map((ie) => ({ ...ie, namespaceFullPath })),
+ });
+
+ evs = findStageEvents();
+ links = evs.wrappers.map((ev) => findStageEventLink(ev).text());
+ });
+
+ it(`with namespaceFullPath='${namespaceFullPath}' ${
+ hasFullPath ? 'will' : 'does not'
+ } include the namespace`, () => {
+ issueEventItems.forEach((ev, index) => {
+ if (hasFullPath) {
+ expect(links[index]).toBe(`${namespaceFullPath}/${ev.projectPath}#${ev.iid}`);
+ } else {
+ expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`);
+ }
+ });
+ });
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ wrapper.destroy();
+ });
+
+ it('will display the pagination component', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('clicking prev or next will emit an event', async () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+
+ findPagination().vm.$emit('input', 2);
+ await nextTick();
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([{ page: 2 }]);
+ });
+
+ it('clicking prev or next will send tracking information', () => {
+ findPagination().vm.$emit('input', 2);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { label: 'pagination' });
+ });
+
+ describe('with `hasNextPage=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ pagination: { page: 1, hasNextPage: false } });
+ });
+
+ it('will not display the pagination component', () => {
+ expect(findPagination().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Sorting', () => {
+ const triggerTableSort = (sortDesc = true) =>
+ findTable().vm.$emit('sort-changed', {
+ sortBy: PAGINATION_SORT_FIELD_DURATION,
+ sortDesc,
+ });
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ wrapper.destroy();
+ });
+
+ it('can sort the end event or duration', () => {
+ findTableHeadColumns()
+ .wrappers.slice(1)
+ .forEach((w) => {
+ expect(w.attributes('aria-sort')).toBe('none');
+ });
+ });
+
+ it('cannot be sorted by title', () => {
+ findTableHeadColumns()
+ .wrappers.slice(0, 1)
+ .forEach((w) => {
+ expect(w.attributes('aria-sort')).toBeUndefined();
+ });
+ });
+
+ it('clicking a table column will send tracking information', () => {
+ triggerTableSort();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'sort_duration_desc',
+ });
+ });
+
+ it('clicking a table column will update the sort field', () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+ triggerTableSort();
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
+ {
+ direction: 'desc',
+ sort: 'duration',
+ },
+ ]);
+ });
+
+ it('with sortDesc=false will toggle the direction field', () => {
+ expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
+ triggerTableSort(false);
+
+ expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
+ {
+ direction: 'asc',
+ sort: 'duration',
+ },
+ ]);
+ });
+
+ describe('with sortable=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ sortable: false });
+ });
+
+ it('cannot sort the table', () => {
+ findTableHeadColumns().wrappers.forEach((w) => {
+ expect(w.attributes('aria-sort')).toBeUndefined();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/store/actions_spec.js b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
new file mode 100644
index 00000000000..f87807804c9
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js
@@ -0,0 +1,518 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import * as actions from '~/analytics/cycle_analytics/store/actions';
+import * as getters from '~/analytics/cycle_analytics/store/getters';
+import httpStatusCodes from '~/lib/utils/http_status';
+import {
+ allowedStages,
+ selectedStage,
+ selectedValueStream,
+ currentGroup,
+ createdAfter,
+ createdBefore,
+ initialPaginationState,
+ reviewEvents,
+} from '../mock_data';
+
+const { id: groupId, path: groupPath } = currentGroup;
+const mockMilestonesPath = 'mock-milestones.json';
+const mockLabelsPath = 'mock-labels.json';
+const mockRequestPath = 'some/cool/path';
+const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams';
+const mockEndpoints = {
+ fullPath: mockFullPath,
+ requestPath: mockRequestPath,
+ labelsPath: mockLabelsPath,
+ milestonesPath: mockMilestonesPath,
+ groupId,
+ groupPath,
+};
+const mockSetDateActionCommit = {
+ payload: { createdAfter, createdBefore },
+ type: 'SET_DATE_RANGE',
+};
+
+const defaultState = {
+ ...getters,
+ selectedValueStream,
+ createdAfter,
+ createdBefore,
+ pagination: initialPaginationState,
+};
+
+describe('Project Value Stream Analytics actions', () => {
+ let state;
+ let mock;
+
+ beforeEach(() => {
+ state = { ...defaultState };
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ state = {};
+ });
+
+ const mutationTypes = (arr) => arr.map(({ type }) => type);
+
+ describe.each`
+ action | payload | expectedActions | expectedMutations
+ ${'setDateRange'} | ${{ createdAfter, createdBefore }} | ${[{ type: 'refetchStageData' }]} | ${[mockSetDateActionCommit]}
+ ${'setFilters'} | ${[]} | ${[{ type: 'refetchStageData' }]} | ${[]}
+ ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'refetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
+ ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
+ `('$action', ({ action, payload, expectedActions, expectedMutations }) => {
+ const types = mutationTypes(expectedMutations);
+ it(`will dispatch ${expectedActions} and commit ${types}`, () =>
+ testAction({
+ action: actions[action],
+ state,
+ payload,
+ expectedMutations,
+ expectedActions,
+ }));
+ });
+
+ describe('initializeVsa', () => {
+ const selectedAuthor = 'Author';
+ const selectedMilestone = 'Milestone 1';
+ const selectedAssigneeList = ['Assignee 1', 'Assignee 2'];
+ const selectedLabelList = ['Label 1', 'Label 2'];
+ const payload = {
+ endpoints: mockEndpoints,
+ selectedAuthor,
+ selectedMilestone,
+ selectedAssigneeList,
+ selectedLabelList,
+ selectedStage,
+ };
+ const mockFilterEndpoints = {
+ groupEndpoint: 'foo',
+ labelsEndpoint: mockLabelsPath,
+ milestonesEndpoint: mockMilestonesPath,
+ projectEndpoint: '/namespace/-/analytics/value_stream_analytics/value_streams',
+ };
+
+ it('will dispatch fetchValueStreams actions and commit SET_LOADING and INITIALIZE_VSA', () => {
+ return testAction({
+ action: actions.initializeVsa,
+ state: {},
+ payload,
+ expectedMutations: [
+ { type: 'INITIALIZE_VSA', payload },
+ { type: 'SET_LOADING', payload: true },
+ { type: 'SET_LOADING', payload: false },
+ ],
+ expectedActions: [
+ { type: 'filters/setEndpoints', payload: mockFilterEndpoints },
+ {
+ type: 'filters/initialize',
+ payload: { selectedAuthor, selectedMilestone, selectedAssigneeList, selectedLabelList },
+ },
+ { type: 'fetchValueStreams' },
+ { type: 'setInitialStage', payload: selectedStage },
+ ],
+ });
+ });
+ });
+
+ describe('setInitialStage', () => {
+ beforeEach(() => {
+ state = { ...state, stages: allowedStages };
+ });
+
+ describe('with a selected stage', () => {
+ it('will commit `SET_SELECTED_STAGE` and fetchValueStreamStageData actions', () => {
+ const fakeStage = { ...selectedStage, id: 'fake', name: 'fake-stae' };
+ return testAction({
+ action: actions.setInitialStage,
+ state,
+ payload: fakeStage,
+ expectedMutations: [
+ {
+ type: 'SET_SELECTED_STAGE',
+ payload: fakeStage,
+ },
+ ],
+ expectedActions: [{ type: 'fetchValueStreamStageData' }],
+ });
+ });
+ });
+
+ describe('without a selected stage', () => {
+ it('will select the first stage from the value stream', () => {
+ const [firstStage] = allowedStages;
+ testAction({
+ action: actions.setInitialStage,
+ state,
+ payload: null,
+ expectedMutations: [{ type: 'SET_SELECTED_STAGE', payload: firstStage }],
+ expectedActions: [{ type: 'fetchValueStreamStageData' }],
+ });
+ });
+ });
+
+ describe('with no value stream stages available', () => {
+ it('will return SET_NO_ACCESS_ERROR', () => {
+ state = { ...state, stages: [] };
+ testAction({
+ action: actions.setInitialStage,
+ state,
+ payload: null,
+ expectedMutations: [{ type: 'SET_NO_ACCESS_ERROR' }],
+ expectedActions: [],
+ });
+ });
+ });
+ });
+
+ describe('updateStageTablePagination', () => {
+ beforeEach(() => {
+ state = { ...state, selectedStage };
+ });
+
+ it(`will dispatch the "fetchStageData" action and commit the 'SET_PAGINATION' mutation`, () => {
+ return testAction({
+ action: actions.updateStageTablePagination,
+ state,
+ expectedMutations: [{ type: 'SET_PAGINATION' }],
+ expectedActions: [{ type: 'fetchStageData', payload: selectedStage.id }],
+ });
+ });
+ });
+
+ describe('fetchStageData', () => {
+ const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/;
+ const headers = {
+ 'X-Next-Page': 2,
+ 'X-Page': 1,
+ };
+
+ beforeEach(() => {
+ state = {
+ ...defaultState,
+ endpoints: mockEndpoints,
+ selectedStage,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockStagePath).reply(httpStatusCodes.OK, reviewEvents, headers);
+ });
+
+ it(`commits the 'RECEIVE_STAGE_DATA_SUCCESS' mutation`, () =>
+ testAction({
+ action: actions.fetchStageData,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_DATA' },
+ { type: 'RECEIVE_STAGE_DATA_SUCCESS', payload: reviewEvents },
+ { type: 'SET_PAGINATION', payload: { hasNextPage: true, page: 1 } },
+ ],
+ expectedActions: [],
+ }));
+
+ describe('with a successful request, but an error in the payload', () => {
+ const tooMuchDataError = 'Too much data';
+
+ beforeEach(() => {
+ state = {
+ ...defaultState,
+ endpoints: mockEndpoints,
+ selectedStage,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockStagePath).reply(httpStatusCodes.OK, { error: tooMuchDataError });
+ });
+
+ it(`commits the 'RECEIVE_STAGE_DATA_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchStageData,
+ state,
+ payload: { error: tooMuchDataError },
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_DATA' },
+ { type: 'RECEIVE_STAGE_DATA_ERROR', payload: tooMuchDataError },
+ ],
+ expectedActions: [],
+ }));
+ });
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ state = {
+ ...defaultState,
+ endpoints: mockEndpoints,
+ selectedStage,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockStagePath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_STAGE_DATA_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchStageData,
+ state,
+ payload: {},
+ expectedMutations: [{ type: 'REQUEST_STAGE_DATA' }, { type: 'RECEIVE_STAGE_DATA_ERROR' }],
+ expectedActions: [],
+ }));
+ });
+ });
+
+ describe('fetchValueStreams', () => {
+ const mockValueStreamPath = /\/analytics\/value_stream_analytics\/value_streams/;
+
+ beforeEach(() => {
+ state = {
+ endpoints: mockEndpoints,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
+ });
+
+ it(`commits the 'REQUEST_VALUE_STREAMS' mutation`, () =>
+ testAction({
+ action: actions.fetchValueStreams,
+ state,
+ payload: {},
+ expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }],
+ expectedActions: [{ type: 'receiveValueStreamsSuccess' }],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_VALUE_STREAMS_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchValueStreams,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_VALUE_STREAMS' },
+ { type: 'RECEIVE_VALUE_STREAMS_ERROR', payload: httpStatusCodes.BAD_REQUEST },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
+
+ describe('receiveValueStreamsSuccess', () => {
+ const mockValueStream = {
+ id: 'mockDefault',
+ name: 'mock default',
+ };
+ const mockValueStreams = [mockValueStream, selectedValueStream];
+ it('with data, will set the first value stream', () => {
+ testAction({
+ action: actions.receiveValueStreamsSuccess,
+ state,
+ payload: mockValueStreams,
+ expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: mockValueStreams }],
+ expectedActions: [{ type: 'setSelectedValueStream', payload: mockValueStream }],
+ });
+ });
+
+ it('without data, will set the default value stream', () => {
+ testAction({
+ action: actions.receiveValueStreamsSuccess,
+ state,
+ payload: [],
+ expectedMutations: [{ type: 'RECEIVE_VALUE_STREAMS_SUCCESS', payload: [] }],
+ expectedActions: [{ type: 'setSelectedValueStream', payload: selectedValueStream }],
+ });
+ });
+ });
+
+ describe('fetchValueStreamStages', () => {
+ const mockValueStreamPath = /\/analytics\/value_stream_analytics\/value_streams/;
+
+ beforeEach(() => {
+ state = {
+ endpoints: mockEndpoints,
+ selectedValueStream,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
+ });
+
+ it(`commits the 'REQUEST_VALUE_STREAM_STAGES' and 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS' mutations`, () =>
+ testAction({
+ action: actions.fetchValueStreamStages,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_VALUE_STREAM_STAGES' },
+ { type: 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS' },
+ ],
+ expectedActions: [],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchValueStreamStages,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_VALUE_STREAM_STAGES' },
+ { type: 'RECEIVE_VALUE_STREAM_STAGES_ERROR', payload: httpStatusCodes.BAD_REQUEST },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
+
+ describe('fetchStageMedians', () => {
+ const mockValueStreamPath = /median/;
+
+ const stageMediansPayload = [
+ { id: 'issue', value: null },
+ { id: 'plan', value: null },
+ { id: 'code', value: null },
+ ];
+
+ const stageMedianError = new Error(
+ `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`,
+ );
+
+ beforeEach(() => {
+ state = {
+ fullPath: mockFullPath,
+ selectedValueStream,
+ stages: allowedStages,
+ };
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK);
+ });
+
+ it(`commits the 'REQUEST_STAGE_MEDIANS' and 'RECEIVE_STAGE_MEDIANS_SUCCESS' mutations`, () =>
+ testAction({
+ action: actions.fetchStageMedians,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_MEDIANS' },
+ { type: 'RECEIVE_STAGE_MEDIANS_SUCCESS', payload: stageMediansPayload },
+ ],
+ expectedActions: [],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchStageMedians,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_MEDIANS' },
+ { type: 'RECEIVE_STAGE_MEDIANS_ERROR', payload: stageMedianError },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
+
+ describe('fetchStageCountValues', () => {
+ const mockValueStreamPath = /count/;
+ const stageCountsPayload = [
+ { id: 'issue', count: 1 },
+ { id: 'plan', count: 2 },
+ { id: 'code', count: 3 },
+ ];
+
+ const stageCountError = new Error(
+ `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`,
+ );
+
+ beforeEach(() => {
+ state = {
+ fullPath: mockFullPath,
+ selectedValueStream,
+ stages: allowedStages,
+ };
+ mock = new MockAdapter(axios);
+ mock
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 1 })
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 2 })
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 3 });
+ });
+
+ it(`commits the 'REQUEST_STAGE_COUNTS' and 'RECEIVE_STAGE_COUNTS_SUCCESS' mutations`, () =>
+ testAction({
+ action: actions.fetchStageCountValues,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_COUNTS' },
+ { type: 'RECEIVE_STAGE_COUNTS_SUCCESS', payload: stageCountsPayload },
+ ],
+ expectedActions: [],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_STAGE_COUNTS_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchStageCountValues,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_COUNTS' },
+ { type: 'RECEIVE_STAGE_COUNTS_ERROR', payload: stageCountError },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
+
+ describe('refetchStageData', () => {
+ it('will commit SET_LOADING and dispatch fetchValueStreamStageData actions', () =>
+ testAction({
+ action: actions.refetchStageData,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'SET_LOADING', payload: true },
+ { type: 'SET_LOADING', payload: false },
+ ],
+ expectedActions: [{ type: 'fetchValueStreamStageData' }],
+ }));
+ });
+
+ describe('fetchValueStreamStageData', () => {
+ it('will dispatch the fetchStageData, fetchStageMedians and fetchStageCountValues actions', () =>
+ testAction({
+ action: actions.fetchValueStreamStageData,
+ state,
+ payload: {},
+ expectedMutations: [],
+ expectedActions: [
+ { type: 'fetchStageData' },
+ { type: 'fetchStageMedians' },
+ { type: 'fetchStageCountValues' },
+ ],
+ }));
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/store/getters_spec.js b/spec/frontend/analytics/cycle_analytics/store/getters_spec.js
new file mode 100644
index 00000000000..8ad1e1b27de
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/store/getters_spec.js
@@ -0,0 +1,42 @@
+import * as getters from '~/analytics/cycle_analytics/store/getters';
+
+import {
+ allowedStages,
+ stageMedians,
+ transformedProjectStagePathData,
+ selectedStage,
+ stageCounts,
+ basePaginationResult,
+ initialPaginationState,
+} from '../mock_data';
+
+describe('Value stream analytics getters', () => {
+ let state = {};
+
+ describe('pathNavigationData', () => {
+ it('returns the transformed data', () => {
+ state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts };
+ expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData);
+ });
+ });
+
+ describe('paginationParams', () => {
+ beforeEach(() => {
+ state = { pagination: initialPaginationState };
+ });
+
+ it('returns the `pagination` type', () => {
+ expect(getters.paginationParams(state)).toEqual(basePaginationResult);
+ });
+
+ it('returns the `sort` type', () => {
+ expect(getters.paginationParams(state)).toEqual(basePaginationResult);
+ });
+
+ it('with page=10, sets the `page` property', () => {
+ const page = 10;
+ state = { pagination: { ...initialPaginationState, page } };
+ expect(getters.paginationParams(state)).toEqual({ ...basePaginationResult, page });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js
new file mode 100644
index 00000000000..567fac81e1f
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js
@@ -0,0 +1,132 @@
+import { useFakeDate } from 'helpers/fake_date';
+import * as types from '~/analytics/cycle_analytics/store/mutation_types';
+import mutations from '~/analytics/cycle_analytics/store/mutations';
+import {
+ PAGINATION_SORT_FIELD_END_EVENT,
+ PAGINATION_SORT_DIRECTION_DESC,
+} from '~/analytics/cycle_analytics/constants';
+import {
+ selectedStage,
+ rawIssueEvents,
+ issueEvents,
+ selectedValueStream,
+ rawValueStreamStages,
+ valueStreamStages,
+ rawStageMedians,
+ formattedStageMedians,
+ rawStageCounts,
+ stageCounts,
+ initialPaginationState as pagination,
+} from '../mock_data';
+
+let state;
+const rawEvents = rawIssueEvents.events;
+const convertedEvents = issueEvents.events;
+const mockRequestPath = 'fake/request/path';
+const mockCreatedAfter = '2020-06-18';
+const mockCreatedBefore = '2020-07-18';
+
+describe('Project Value Stream Analytics mutations', () => {
+ useFakeDate(2020, 6, 18);
+
+ beforeEach(() => {
+ state = { pagination };
+ });
+
+ afterEach(() => {
+ state = null;
+ });
+
+ it.each`
+ mutation | stateKey | value
+ ${types.REQUEST_VALUE_STREAMS} | ${'valueStreams'} | ${[]}
+ ${types.RECEIVE_VALUE_STREAMS_ERROR} | ${'valueStreams'} | ${[]}
+ ${types.REQUEST_VALUE_STREAM_STAGES} | ${'stages'} | ${[]}
+ ${types.RECEIVE_VALUE_STREAM_STAGES_ERROR} | ${'stages'} | ${[]}
+ ${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
+ ${types.REQUEST_STAGE_DATA} | ${'isEmptyStage'} | ${false}
+ ${types.REQUEST_STAGE_DATA} | ${'selectedStageEvents'} | ${[]}
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${'isLoadingStage'} | ${false}
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${'selectedStageEvents'} | ${[]}
+ ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
+ ${types.RECEIVE_STAGE_DATA_ERROR} | ${'selectedStageEvents'} | ${[]}
+ ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
+ ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
+ ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
+ ${types.REQUEST_STAGE_COUNTS} | ${'stageCounts'} | ${{}}
+ ${types.RECEIVE_STAGE_COUNTS_ERROR} | ${'stageCounts'} | ${{}}
+ ${types.SET_NO_ACCESS_ERROR} | ${'hasNoAccessError'} | ${true}
+ `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => {
+ mutations[mutation](state);
+
+ expect(state).toMatchObject({ [stateKey]: value });
+ });
+
+ const mockSetDatePayload = { createdAfter: mockCreatedAfter, createdBefore: mockCreatedBefore };
+ const mockInitialPayload = {
+ endpoints: { requestPath: mockRequestPath },
+ currentGroup: { title: 'cool-group' },
+ id: 1337,
+ ...mockSetDatePayload,
+ };
+ const mockInitializedObj = {
+ endpoints: { requestPath: mockRequestPath },
+ ...mockSetDatePayload,
+ };
+
+ it.each`
+ mutation | stateKey | value
+ ${types.INITIALIZE_VSA} | ${'endpoints'} | ${{ requestPath: mockRequestPath }}
+ ${types.INITIALIZE_VSA} | ${'createdAfter'} | ${mockCreatedAfter}
+ ${types.INITIALIZE_VSA} | ${'createdBefore'} | ${mockCreatedBefore}
+ `('$mutation will set $stateKey', ({ mutation, stateKey, value }) => {
+ mutations[mutation](state, { ...mockInitialPayload });
+
+ expect(state).toMatchObject({ ...mockInitializedObj, [stateKey]: value });
+ });
+
+ it.each`
+ mutation | payload | stateKey | value
+ ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter}
+ ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore}
+ ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
+ ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
+ ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
+ ${types.SET_PAGINATION} | ${pagination} | ${'pagination'} | ${{ ...pagination, sort: PAGINATION_SORT_FIELD_END_EVENT, direction: PAGINATION_SORT_DIRECTION_DESC }}
+ ${types.SET_PAGINATION} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} | ${'pagination'} | ${{ ...pagination, sort: 'duration', direction: 'asc' }}
+ ${types.SET_SELECTED_STAGE} | ${selectedStage} | ${'selectedStage'} | ${selectedStage}
+ ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
+ ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
+ ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
+ ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
+ `(
+ '$mutation with $payload will set $stateKey to $value',
+ ({ mutation, payload, stateKey, value }) => {
+ mutations[mutation](state, payload);
+
+ expect(state).toMatchObject({ [stateKey]: value });
+ },
+ );
+
+ describe('with a stage selected', () => {
+ beforeEach(() => {
+ state = {
+ selectedStage,
+ };
+ });
+
+ it.each`
+ mutation | payload | stateKey | value
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${[]} | ${'isEmptyStage'} | ${true}
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'selectedStageEvents'} | ${convertedEvents}
+ ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${rawEvents} | ${'isEmptyStage'} | ${false}
+ `(
+ '$mutation with $payload will set $stateKey to $value',
+ ({ mutation, payload, stateKey, value }) => {
+ mutations[mutation](state, payload);
+
+ expect(state).toMatchObject({ [stateKey]: value });
+ },
+ );
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/total_time_spec.js b/spec/frontend/analytics/cycle_analytics/total_time_spec.js
new file mode 100644
index 00000000000..47ee7aad8c4
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/total_time_spec.js
@@ -0,0 +1,45 @@
+import { mount } from '@vue/test-utils';
+import TotalTime from '~/analytics/cycle_analytics/components/total_time.vue';
+
+describe('TotalTime', () => {
+ let wrapper = null;
+
+ const createComponent = (propsData) => {
+ return mount(TotalTime, {
+ propsData,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with a valid time object', () => {
+ it.each`
+ time
+ ${{ seconds: 35 }}
+ ${{ mins: 47, seconds: 3 }}
+ ${{ days: 3, mins: 47, seconds: 3 }}
+ ${{ hours: 23, mins: 10 }}
+ ${{ hours: 7, mins: 20, seconds: 10 }}
+ `('with $time', ({ time }) => {
+ wrapper = createComponent({
+ time,
+ });
+
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+
+ describe('with a blank object', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ time: {},
+ });
+ });
+
+ it('should render --', () => {
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/utils_spec.js b/spec/frontend/analytics/cycle_analytics/utils_spec.js
new file mode 100644
index 00000000000..fe412bf7498
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/utils_spec.js
@@ -0,0 +1,171 @@
+import {
+ transformStagesForPathNavigation,
+ medianTimeToParsedSeconds,
+ formatMedianValues,
+ filterStagesByHiddenStatus,
+ buildCycleAnalyticsInitialData,
+} from '~/analytics/cycle_analytics/utils';
+import {
+ selectedStage,
+ allowedStages,
+ stageMedians,
+ pathNavIssueMetric,
+ rawStageMedians,
+} from './mock_data';
+
+describe('Value stream analytics utils', () => {
+ describe('transformStagesForPathNavigation', () => {
+ const stages = allowedStages;
+ const response = transformStagesForPathNavigation({
+ stages,
+ medians: stageMedians,
+ selectedStage,
+ });
+
+ describe('transforms the data as expected', () => {
+ it('returns an array of stages', () => {
+ expect(Array.isArray(response)).toBe(true);
+ expect(response.length).toBe(stages.length);
+ });
+
+ it('selects the correct stage', () => {
+ const selected = response.filter((stage) => stage.selected === true)[0];
+
+ expect(selected.title).toBe(selectedStage.title);
+ });
+
+ it('includes the correct metric for the associated stage', () => {
+ const issue = response.filter((stage) => stage.name === 'issue')[0];
+
+ expect(issue.metric).toBe(pathNavIssueMetric);
+ });
+ });
+ });
+
+ describe('medianTimeToParsedSeconds', () => {
+ it.each`
+ value | result
+ ${1036800} | ${'1w'}
+ ${259200} | ${'3d'}
+ ${172800} | ${'2d'}
+ ${86400} | ${'1d'}
+ ${1000} | ${'16m'}
+ ${61} | ${'1m'}
+ ${59} | ${'<1m'}
+ ${0} | ${'-'}
+ `('will correctly parse $value seconds into $result', ({ value, result }) => {
+ expect(medianTimeToParsedSeconds(value)).toBe(result);
+ });
+ });
+
+ describe('formatMedianValues', () => {
+ const calculatedMedians = formatMedianValues(rawStageMedians);
+
+ it('returns an object with each stage and their median formatted for display', () => {
+ rawStageMedians.forEach(({ id, value }) => {
+ expect(calculatedMedians).toMatchObject({ [id]: medianTimeToParsedSeconds(value) });
+ });
+ });
+ });
+
+ describe('filterStagesByHiddenStatus', () => {
+ const hiddenStages = [{ title: 'three', hidden: true }];
+ const visibleStages = [
+ { title: 'one', hidden: false },
+ { title: 'two', hidden: false },
+ ];
+ const mockStages = [...visibleStages, ...hiddenStages];
+
+ it.each`
+ isHidden | result
+ ${false} | ${visibleStages}
+ ${undefined} | ${hiddenStages}
+ ${true} | ${hiddenStages}
+ `('with isHidden=$isHidden returns matching stages', ({ isHidden, result }) => {
+ expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result);
+ });
+ });
+
+ describe('buildCycleAnalyticsInitialData', () => {
+ let res = null;
+ const projectId = '5';
+ const createdAfter = '2021-09-01';
+ const createdBefore = '2021-11-06';
+ const groupId = '146';
+ const groupPath = 'fake-group';
+ const fullPath = 'fake-group/fake-project';
+ const labelsPath = '/fake-group/fake-project/-/labels.json';
+ const milestonesPath = '/fake-group/fake-project/-/milestones.json';
+ const requestPath = '/fake-group/fake-project/-/value_stream_analytics';
+
+ const rawData = {
+ projectId,
+ createdBefore,
+ createdAfter,
+ fullPath,
+ requestPath,
+ labelsPath,
+ milestonesPath,
+ groupId,
+ groupPath,
+ };
+
+ describe('with minimal data', () => {
+ beforeEach(() => {
+ res = buildCycleAnalyticsInitialData(rawData);
+ });
+
+ it('sets the projectId', () => {
+ expect(res.projectId).toBe(parseInt(projectId, 10));
+ });
+
+ it('sets the date range', () => {
+ expect(res.createdBefore).toEqual(new Date(createdBefore));
+ expect(res.createdAfter).toEqual(new Date(createdAfter));
+ });
+
+ it('sets the endpoints', () => {
+ const { endpoints } = res;
+ expect(endpoints.fullPath).toBe(fullPath);
+ expect(endpoints.requestPath).toBe(requestPath);
+ expect(endpoints.labelsPath).toBe(labelsPath);
+ expect(endpoints.milestonesPath).toBe(milestonesPath);
+ expect(endpoints.groupId).toBe(parseInt(groupId, 10));
+ expect(endpoints.groupPath).toBe(groupPath);
+ });
+
+ it('returns null when there is no stage', () => {
+ expect(res.selectedStage).toBeNull();
+ });
+
+ it('returns false for missing features', () => {
+ expect(res.features.cycleAnalyticsForGroups).toBe(false);
+ });
+ });
+
+ describe('with a stage set', () => {
+ const jsonStage = '{"id":"fakeStage","title":"fakeStage"}';
+
+ it('parses the selectedStage data', () => {
+ res = buildCycleAnalyticsInitialData({ ...rawData, stage: jsonStage });
+
+ const { selectedStage: stage } = res;
+
+ expect(stage.id).toBe('fakeStage');
+ expect(stage.title).toBe('fakeStage');
+ });
+ });
+
+ describe('with features set', () => {
+ const fakeFeatures = { cycleAnalyticsForGroups: true };
+
+ it('sets the feature flags', () => {
+ res = buildCycleAnalyticsInitialData({
+ ...rawData,
+ gon: { licensed_features: fakeFeatures },
+ });
+ expect(res.features).toEqual(fakeFeatures);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js
new file mode 100644
index 00000000000..4f333e95d89
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js
@@ -0,0 +1,91 @@
+import { shallowMount } from '@vue/test-utils';
+import Daterange from '~/analytics/shared/components/daterange.vue';
+import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
+import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue';
+import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
+import {
+ createdAfter as startDate,
+ createdBefore as endDate,
+ currentGroup,
+ selectedProjects,
+} from './mock_data';
+
+function createComponent(props = {}) {
+ return shallowMount(ValueStreamFilters, {
+ propsData: {
+ selectedProjects,
+ groupId: currentGroup.id,
+ groupPath: currentGroup.fullPath,
+ startDate,
+ endDate,
+ ...props,
+ },
+ });
+}
+
+describe('ValueStreamFilters', () => {
+ let wrapper;
+
+ const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter);
+ const findDateRangePicker = () => wrapper.findComponent(Daterange);
+ const findFilterBar = () => wrapper.findComponent(FilterBar);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('will render the filter bar', () => {
+ expect(findFilterBar().exists()).toBe(true);
+ });
+
+ it('will render the projects dropdown', () => {
+ expect(findProjectsDropdown().exists()).toBe(true);
+ expect(wrapper.findComponent(ProjectsDropdownFilter).props()).toEqual(
+ expect.objectContaining({
+ queryParams: wrapper.vm.projectsQueryParams,
+ multiSelect: wrapper.vm.$options.multiProjectSelect,
+ }),
+ );
+ });
+
+ it('will render the date range picker', () => {
+ expect(findDateRangePicker().exists()).toBe(true);
+ });
+
+ it('will emit `selectProject` when a project is selected', () => {
+ findProjectsDropdown().vm.$emit('selected');
+
+ expect(wrapper.emitted('selectProject')).not.toBeUndefined();
+ });
+
+ it('will emit `setDateRange` when the date range changes', () => {
+ findDateRangePicker().vm.$emit('change');
+
+ expect(wrapper.emitted('setDateRange')).not.toBeUndefined();
+ });
+
+ describe('hasDateRangeFilter = false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ hasDateRangeFilter: false });
+ });
+
+ it('will not render the date range picker', () => {
+ expect(findDateRangePicker().exists()).toBe(false);
+ });
+ });
+
+ describe('hasProjectFilter = false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ hasProjectFilter: false });
+ });
+
+ it('will not render the project dropdown', () => {
+ expect(findProjectsDropdown().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js
new file mode 100644
index 00000000000..948dc5c9be2
--- /dev/null
+++ b/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js
@@ -0,0 +1,185 @@
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
+import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
+import { VSA_METRICS_GROUPS, METRICS_POPOVER_CONTENT } from '~/analytics/shared/constants';
+import { prepareTimeMetricsData } from '~/analytics/shared/utils';
+import MetricTile from '~/analytics/shared/components/metric_tile.vue';
+import { createAlert } from '~/flash';
+import { group } from './mock_data';
+
+jest.mock('~/flash');
+
+describe('ValueStreamMetrics', () => {
+ let wrapper;
+ let mockGetValueStreamSummaryMetrics;
+ let mockFilterFn;
+
+ const { full_path: requestPath } = group;
+ const fakeReqName = 'Mock metrics';
+ const metricsRequestFactory = () => ({
+ request: mockGetValueStreamSummaryMetrics,
+ endpoint: METRIC_TYPE_SUMMARY,
+ name: fakeReqName,
+ });
+
+ const createComponent = (props = {}) => {
+ return shallowMountExtended(ValueStreamMetrics, {
+ propsData: {
+ requestPath,
+ requestParams: {},
+ requests: [metricsRequestFactory()],
+ ...props,
+ },
+ });
+ };
+
+ const findMetrics = () => wrapper.findAllComponents(MetricTile);
+ const findMetricsGroups = () => wrapper.findAllByTestId('vsa-metrics-group');
+
+ const expectToHaveRequest = (fields) => {
+ expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
+ endpoint: METRIC_TYPE_SUMMARY,
+ requestPath,
+ ...fields,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with successful requests', () => {
+ beforeEach(() => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
+ });
+
+ it('will display a loader with pending requests', async () => {
+ wrapper = createComponent();
+ await nextTick();
+
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
+ });
+
+ describe('with data loaded', () => {
+ beforeEach(async () => {
+ wrapper = createComponent();
+ await waitForPromises();
+ });
+
+ it('fetches data from the value stream analytics endpoint', () => {
+ expectToHaveRequest({ params: {} });
+ });
+
+ describe.each`
+ index | identifier | value | label
+ ${0} | ${metricsData[0].identifier} | ${metricsData[0].value} | ${metricsData[0].title}
+ ${1} | ${metricsData[1].identifier} | ${metricsData[1].value} | ${metricsData[1].title}
+ ${2} | ${metricsData[2].identifier} | ${metricsData[2].value} | ${metricsData[2].title}
+ ${3} | ${metricsData[3].identifier} | ${metricsData[3].value} | ${metricsData[3].title}
+ `('metric tiles', ({ identifier, index, value, label }) => {
+ it(`renders a metric tile component for "${label}"`, () => {
+ const metric = findMetrics().at(index);
+ expect(metric.props('metric')).toMatchObject({ identifier, value, label });
+ expect(metric.isVisible()).toBe(true);
+ });
+ });
+
+ it('will not display a loading icon', () => {
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
+ });
+
+ describe('filterFn', () => {
+ const transferredMetricsData = prepareTimeMetricsData(metricsData, METRICS_POPOVER_CONTENT);
+
+ it('with a filter function, will call the function with the metrics data', async () => {
+ const filteredData = [
+ { identifier: 'issues', value: '3', title: 'New Issues', description: 'foo' },
+ ];
+ mockFilterFn = jest.fn(() => filteredData);
+
+ wrapper = createComponent({
+ filterFn: mockFilterFn,
+ });
+
+ await waitForPromises();
+
+ expect(mockFilterFn).toHaveBeenCalledWith(transferredMetricsData);
+ expect(wrapper.vm.metrics).toEqual(filteredData);
+ });
+
+ it('without a filter function, it will only update the metrics', async () => {
+ wrapper = createComponent();
+
+ await waitForPromises();
+
+ expect(mockFilterFn).not.toHaveBeenCalled();
+ expect(wrapper.vm.metrics).toEqual(transferredMetricsData);
+ });
+ });
+
+ describe('with additional params', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ requestParams: {
+ 'project_ids[]': [1],
+ created_after: '2020-01-01',
+ created_before: '2020-02-01',
+ },
+ });
+
+ await waitForPromises();
+ });
+
+ it('fetches data for the `getValueStreamSummaryMetrics` request', () => {
+ expectToHaveRequest({
+ params: {
+ 'project_ids[]': [1],
+ created_after: '2020-01-01',
+ created_before: '2020-02-01',
+ },
+ });
+ });
+ });
+
+ describe('groupBy', () => {
+ beforeEach(async () => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
+ wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS });
+ await waitForPromises();
+ });
+
+ it('renders the metrics as separate groups', () => {
+ const groups = findMetricsGroups();
+ expect(groups).toHaveLength(VSA_METRICS_GROUPS.length);
+ });
+
+ it('renders titles for each group', () => {
+ const groups = findMetricsGroups();
+ groups.wrappers.forEach((g, index) => {
+ const { title } = VSA_METRICS_GROUPS[index];
+ expect(g.html()).toContain(title);
+ });
+ });
+ });
+ });
+ });
+
+ describe('with a request failing', () => {
+ beforeEach(async () => {
+ mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
+ wrapper = createComponent();
+
+ await waitForPromises();
+ });
+
+ it('should render an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
+ });
+ });
+ });
+});