diff options
Diffstat (limited to 'spec/frontend/analytics')
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.`, + }); + }); + }); +}); |