diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js index fd9b0160b0df48699dbab712bb564d403e9c1d0b..11786f6c365a22fd9a0b59f05ed133008675b371 100644 --- a/app/assets/javascripts/api/analytics_api.js +++ b/app/assets/javascripts/api/analytics_api.js @@ -1,33 +1,32 @@ import axios from '~/lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; -const GROUP_VSA_PATH_BASE = - '/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id'; -const PROJECT_VSA_PATH_BASE = '/:project_path/-/analytics/value_stream_analytics/value_streams'; +const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams'; const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; +const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`; -const buildProjectValueStreamPath = (projectPath, valueStreamId = null) => { +const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => { if (valueStreamId) { return buildApiUrl(PROJECT_VSA_STAGES_PATH) - .replace(':project_path', projectPath) + .replace(':request_path', requestPath) .replace(':value_stream_id', valueStreamId); } - return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':project_path', projectPath); + return buildApiUrl(PROJECT_VSA_PATH_BASE).replace(':request_path', requestPath); }; -const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) => - buildApiUrl(GROUP_VSA_PATH_BASE) - .replace(':id', groupId) +const buildValueStreamStageDataPath = ({ requestPath, valueStreamId = null, stageId = null }) => + buildApiUrl(PROJECT_VSA_STAGE_DATA_PATH) + .replace(':request_path', requestPath) .replace(':value_stream_id', valueStreamId) .replace(':stage_id', stageId); -export const getProjectValueStreams = (projectPath) => { - const url = buildProjectValueStreamPath(projectPath); +export const getProjectValueStreams = (requestPath) => { + const url = buildProjectValueStreamPath(requestPath); return axios.get(url); }; -export const getProjectValueStreamStages = (projectPath, valueStreamId) => { - const url = buildProjectValueStreamPath(projectPath, valueStreamId); +export const getProjectValueStreamStages = (requestPath, valueStreamId) => { + const url = buildProjectValueStreamPath(requestPath, valueStreamId); return axios.get(url); }; @@ -45,7 +44,15 @@ export const getProjectValueStreamMetrics = (requestPath, params) => * When used for project level VSA, requests should include the `project_id` in the params object */ -export const getValueStreamStageMedian = ({ groupId, valueStreamId, stageId }, params = {}) => { - const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId }); +export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => { + const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); return axios.get(`${stageBase}/median`, { params }); }; + +export const getValueStreamStageRecords = ( + { requestPath, valueStreamId, stageId }, + params = {}, +) => { + const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId }); + return axios.get(`${stageBase}/records`, { params }); +}; diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index e637bd0d8194347995231b7a54dceca28801c1e4..0dc221abb613fd7497b863596b0cc0ed222d1e9b 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -42,7 +42,7 @@ export default { 'selectedStageError', 'stages', 'summary', - 'startDate', + 'daysInPast', 'permissions', ]), ...mapGetters(['pathNavigationData']), @@ -51,13 +51,15 @@ export default { return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; }, displayNotEnoughData() { - return this.selectedStageReady && this.isEmptyStage; + return !this.isLoadingStage && this.isEmptyStage; }, displayNoAccess() { - return this.selectedStageReady && !this.isUserAllowed(this.selectedStage.id); + return ( + !this.isLoadingStage && this.selectedStage?.id && !this.isUserAllowed(this.selectedStage.id) + ); }, - selectedStageReady() { - return !this.isLoadingStage && this.selectedStage; + displayPathNavigation() { + return this.isLoading || (this.selectedStage && this.pathNavigationData.length); }, emptyStageTitle() { if (this.displayNoAccess) { @@ -83,8 +85,8 @@ export default { 'setSelectedStage', 'setDateRange', ]), - handleDateSelect(startDate) { - this.setDateRange({ startDate }); + handleDateSelect(daysInPast) { + this.setDateRange(daysInPast); }, onSelectStage(stage) { this.setSelectedStage(stage); @@ -101,15 +103,18 @@ export default { dayRangeOptions: [7, 30, 90], i18n: { dropdownText: __('Last %{days} days'), + pageTitle: __('Value Stream Analytics'), + recentActivity: __('Recent Project Activity'), }, }; </script> <template> <div class="cycle-analytics"> + <h3>{{ $options.i18n.pageTitle }}</h3> <path-navigation - v-if="selectedStageReady" + v-if="displayPathNavigation" class="js-path-navigation gl-w-full gl-pb-2" - :loading="isLoading" + :loading="isLoading || isLoadingStage" :stages="pathNavigationData" :selected-stage="selectedStage" :with-stage-counts="false" @@ -135,7 +140,7 @@ export default { <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> <span class="dropdown-label"> <gl-sprintf :message="$options.i18n.dropdownText"> - <template #days>{{ startDate }}</template> + <template #days>{{ daysInPast }}</template> </gl-sprintf> <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" /> </span> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue index 2e225d90f9c77380a08aa5d1284584810bc8b5ef..7b31e8d902d679d114f43091833812bac446c503 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue @@ -52,7 +52,7 @@ export default { selectedStage: { type: Object, required: false, - default: () => ({ custom: false }), + default: () => ({}), }, isLoading: { type: Boolean, @@ -102,7 +102,7 @@ export default { }, computed: { isEmptyStage() { - return !this.stageEvents.length; + return !this.selectedStage || !this.stageEvents.length; }, emptyStateTitleText() { return this.emptyStateTitle || NOT_ENOUGH_DATA_ERROR; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 615f96c3860901b3f6a00ef28f35ab6e0bb6b9eb..cce2edb24472054c4619d2d3e5997848a1e5a7c0 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -20,11 +20,9 @@ export default () => { store.dispatch('initializeVsa', { projectId: parseInt(projectId, 10), groupPath, - requestPath, - fullPath, - features: { - cycleAnalyticsForGroups: - (groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false, + endpoints: { + requestPath, + fullPath, }, }); diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index 5a7dbbd28bb9a0c3916fed83b84b642a503141c6..fd606109151738656cb2b9df4bcd8ac9fbadcee3 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -1,29 +1,28 @@ import { getProjectValueStreamStages, getProjectValueStreams, - getProjectValueStreamStageData, getProjectValueStreamMetrics, getValueStreamStageMedian, + getValueStreamStageRecords, } from '~/api/analytics_api'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { - DEFAULT_DAYS_TO_DISPLAY, - DEFAULT_VALUE_STREAM, - I18N_VSA_ERROR_STAGE_MEDIAN, -} from '../constants'; +import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants'; import * as types from './mutation_types'; export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => { commit(types.SET_SELECTED_VALUE_STREAM, valueStream); - return dispatch('fetchValueStreamStages'); + return Promise.all([dispatch('fetchValueStreamStages'), dispatch('fetchCycleAnalyticsData')]); }; export const fetchValueStreamStages = ({ commit, state }) => { - const { fullPath, selectedValueStream } = state; + const { + endpoints: { fullPath }, + selectedValueStream: { id }, + } = state; commit(types.REQUEST_VALUE_STREAM_STAGES); - return getProjectValueStreamStages(fullPath, selectedValueStream.id) + return getProjectValueStreamStages(fullPath, id) .then(({ data }) => commit(types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS, data)) .catch(({ response: { status } }) => { commit(types.RECEIVE_VALUE_STREAM_STAGES_ERROR, status); @@ -41,16 +40,11 @@ export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { export const fetchValueStreams = ({ commit, dispatch, state }) => { const { - fullPath, - features: { cycleAnalyticsForGroups }, + endpoints: { fullPath }, } = state; commit(types.REQUEST_VALUE_STREAMS); - const stageRequests = ['setSelectedStage']; - if (cycleAnalyticsForGroups) { - stageRequests.push('fetchStageMedians'); - } - + const stageRequests = ['setSelectedStage', 'fetchStageMedians']; return getProjectValueStreams(fullPath) .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) .then(() => Promise.all(stageRequests.map((r) => dispatch(r)))) @@ -58,9 +52,10 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => { commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); }); }; - export const fetchCycleAnalyticsData = ({ - state: { requestPath }, + state: { + endpoints: { requestPath }, + }, getters: { legacyFilterParams }, commit, }) => { @@ -76,18 +71,10 @@ export const fetchCycleAnalyticsData = ({ }); }; -export const fetchStageData = ({ - state: { requestPath, selectedStage }, - getters: { legacyFilterParams }, - commit, -}) => { +export const fetchStageData = ({ getters: { requestParams, filterParams }, commit }) => { commit(types.REQUEST_STAGE_DATA); - return getProjectValueStreamStageData({ - requestPath, - stageId: selectedStage.id, - params: legacyFilterParams, - }) + return getValueStreamStageRecords(requestParams, filterParams) .then(({ data }) => { // when there's a query timeout, the request succeeds but the error is encoded in the response data if (data?.error) { @@ -134,22 +121,32 @@ export const setSelectedStage = ({ dispatch, commit, state: { stages } }, select return dispatch('fetchStageData'); }; -const refetchData = (dispatch, commit) => { - commit(types.SET_LOADING, true); +export const setLoading = ({ commit }, value) => commit(types.SET_LOADING, value); + +const refetchStageData = (dispatch) => { return Promise.resolve() - .then(() => dispatch('fetchValueStreams')) - .then(() => dispatch('fetchCycleAnalyticsData')) - .finally(() => commit(types.SET_LOADING, false)); + .then(() => dispatch('setLoading', true)) + .then(() => + Promise.all([ + dispatch('fetchCycleAnalyticsData'), + dispatch('fetchStageData'), + dispatch('fetchStageMedians'), + ]), + ) + .finally(() => dispatch('setLoading', false)); }; -export const setFilters = ({ dispatch, commit }) => refetchData(dispatch, commit); +export const setFilters = ({ dispatch }) => refetchStageData(dispatch); -export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => { - commit(types.SET_DATE_RANGE, { startDate }); - return refetchData(dispatch, commit); +export const setDateRange = ({ dispatch, commit }, daysInPast) => { + commit(types.SET_DATE_RANGE, daysInPast); + return refetchStageData(dispatch); }; export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { commit(types.INITIALIZE_VSA, initialData); - return refetchData(dispatch, commit); + + return dispatch('setLoading', true) + .then(() => dispatch('fetchValueStreams')) + .finally(() => dispatch('setLoading', false)); }; diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js index 66971ea8a2ea0fe4a72472e7b95d5603cc30d6aa..9faccabcaad4b769e6ac23886e4a70b219817f6b 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -13,11 +13,11 @@ export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage export const requestParams = (state) => { const { - selectedStage: { id: stageId = null }, - groupPath: groupId, + endpoints: { fullPath }, selectedValueStream: { id: valueStreamId }, + selectedStage: { id: stageId = null }, } = state; - return { valueStreamId, groupId, stageId }; + return { requestPath: fullPath, valueStreamId, stageId }; }; const dateRangeParams = ({ createdAfter, createdBefore }) => ({ @@ -25,15 +25,14 @@ const dateRangeParams = ({ createdAfter, createdBefore }) => ({ created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null, }); -export const legacyFilterParams = ({ startDate }) => { +export const legacyFilterParams = ({ daysInPast }) => { return { - 'cycle_analytics[start_date]': startDate, + 'cycle_analytics[start_date]': daysInPast, }; }; -export const filterParams = ({ id, ...rest }) => { +export const filterParams = (state) => { return { - project_ids: [id], - ...dateRangeParams(rest), + ...dateRangeParams(state), }; }; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 50157cc3618d9e00eaa9f91b91b489b9d342d49f..65035c0ebb8cf4ce758d5b52439c86520bf7e19d 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -4,15 +4,11 @@ import { decorateData, formatMedianValues, calculateFormattedDayInPast } from '. import * as types from './mutation_types'; export default { - [types.INITIALIZE_VSA](state, { requestPath, fullPath, groupPath, projectId, features }) { - state.requestPath = requestPath; - state.fullPath = fullPath; - state.groupPath = groupPath; - state.id = projectId; + [types.INITIALIZE_VSA](state, { endpoints }) { + state.endpoints = endpoints; const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY); state.createdBefore = now; state.createdAfter = past; - state.features = features; }, [types.SET_LOADING](state, loadingState) { state.isLoading = loadingState; @@ -23,9 +19,9 @@ export default { [types.SET_SELECTED_STAGE](state, stage) { state.selectedStage = stage; }, - [types.SET_DATE_RANGE](state, { startDate }) { - state.startDate = startDate; - const { now, past } = calculateFormattedDayInPast(startDate); + [types.SET_DATE_RANGE](state, daysInPast) { + state.daysInPast = daysInPast; + const { now, past } = calculateFormattedDayInPast(daysInPast); state.createdBefore = now; state.createdAfter = past; }, @@ -50,25 +46,16 @@ export default { [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { state.isLoading = true; state.hasError = false; - if (!state.features.cycleAnalyticsForGroups) { - state.medians = {}; - } }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { - const { summary, medians } = decorateData(data); - if (!state.features.cycleAnalyticsForGroups) { - state.medians = formatMedianValues(medians); - } - state.permissions = data.permissions; + const { summary } = decorateData(data); + state.permissions = data?.permissions || {}; state.summary = summary; state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { state.isLoading = false; state.hasError = true; - if (!state.features.cycleAnalyticsForGroups) { - state.medians = {}; - } }, [types.REQUEST_STAGE_DATA](state) { state.isLoadingStage = true; @@ -76,7 +63,7 @@ export default { state.selectedStageEvents = []; state.hasError = false; }, - [types.RECEIVE_STAGE_DATA_SUCCESS](state, { events = [] }) { + [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) { state.isLoadingStage = false; state.isEmptyStage = !events.length; state.selectedStageEvents = events.map((ev) => diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 4d61077fb99fc6934b4bdeb498de7263693fc882..562b5d0a743a211ea3f32c7035e94c2a31eab3b0 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -1,11 +1,9 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; export default () => ({ - features: {}, id: null, - requestPath: '', - fullPath: '', - startDate: DEFAULT_DAYS_TO_DISPLAY, + endpoints: {}, + daysInPast: DEFAULT_DAYS_TO_DISPLAY, createdAfter: null, createdBefore: null, stages: [], @@ -23,5 +21,4 @@ export default () => ({ isLoadingStage: false, isEmptyStage: false, permissions: {}, - parentPath: null, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 1f72291e97b8fb8de4bb2c3e209832928646a4a5..c941799a2ed3ae0f0a35ddd92a5480acd9228fbd 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -8,13 +8,11 @@ import { parseSeconds } from '~/lib/utils/datetime_utility'; import { s__, sprintf } from '../locale'; const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); -const mapToMedians = ({ name: id, value }) => ({ id, value }); export const decorateData = (data = {}) => { - const { stats: stages, summary } = data; + const { summary } = data; return { summary: summary?.map((item) => mapToSummary(item)) || [], - medians: stages?.map((item) => mapToMedians(item)) || [], }; }; diff --git a/ee/app/assets/javascripts/analytics/cycle_analytics/store/actions/stages.js b/ee/app/assets/javascripts/analytics/cycle_analytics/store/actions/stages.js index 32a764b4a6193a938c78daf7167d1330177af7aa..348e4ecd8b9482c128f16386f9ea32585e01fddc 100644 --- a/ee/app/assets/javascripts/analytics/cycle_analytics/store/actions/stages.js +++ b/ee/app/assets/javascripts/analytics/cycle_analytics/store/actions/stages.js @@ -1,5 +1,5 @@ import Api from 'ee/api'; -import { getValueStreamStageMedian } from '~/api/analytics_api'; +import { getGroupValueStreamStageMedian } from 'ee/api/analytics_api'; import { I18N_VSA_ERROR_STAGES, I18N_VSA_ERROR_STAGE_MEDIAN, @@ -59,7 +59,7 @@ export const receiveStageMedianValuesError = ({ commit }, error) => { }; const fetchStageMedian = ({ groupId, valueStreamId, stageId, params }) => - getValueStreamStageMedian({ groupId, valueStreamId, stageId }, params).then(({ data }) => { + getGroupValueStreamStageMedian({ groupId, valueStreamId, stageId }, params).then(({ data }) => { return { id: stageId, ...(data?.error diff --git a/ee/app/assets/javascripts/api/analytics_api.js b/ee/app/assets/javascripts/api/analytics_api.js new file mode 100644 index 0000000000000000000000000000000000000000..60aed799e914f15a6f02f4e0819e25d4c353b56c --- /dev/null +++ b/ee/app/assets/javascripts/api/analytics_api.js @@ -0,0 +1,19 @@ +import { buildApiUrl } from '~/api/api_utils'; +import axios from '~/lib/utils/axios_utils'; + +const GROUP_VSA_PATH_BASE = + '/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id'; + +const buildGroupValueStreamPath = ({ groupId, valueStreamId = null, stageId = null }) => + buildApiUrl(GROUP_VSA_PATH_BASE) + .replace(':id', groupId) + .replace(':value_stream_id', valueStreamId) + .replace(':stage_id', stageId); + +export const getGroupValueStreamStageMedian = ( + { groupId, valueStreamId, stageId }, + params = {}, +) => { + const stageBase = buildGroupValueStreamPath({ groupId, valueStreamId, stageId }); + return axios.get(`${stageBase}/median`, { params }); +}; diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 418247c88aa516e9b25bace27eabf3ec5d4bfd92..704adfa568dcce581894903c016251d95db554d3 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -46,9 +46,9 @@ @build = create_cycle(user, project, issue, mr, milestone, pipeline) deploy_master(user, project) - issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.day) + issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.hour) merge_request = issue.merge_requests_closing_issues.first.merge_request - merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.day) + merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.hour) merge_request.metrics.update!( latest_build_started_at: 4.hours.ago, latest_build_finished_at: 3.hours.ago, diff --git a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap b/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap deleted file mode 100644 index 771625a3e51ad40c2288603ac843b384274bc851..0000000000000000000000000000000000000000 --- a/spec/frontend/cycle_analytics/__snapshots__/base_spec.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`; diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index 5b01741c9e436ed81eadf42c48264b2058ce5784..c2c6b2a5d06caf42cbc930566628600b07fc9c53 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -8,7 +8,15 @@ import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue'; import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants'; import initState from '~/cycle_analytics/store/state'; -import { selectedStage, issueEvents } from './mock_data'; +import { + permissions, + transformedProjectStagePathData, + selectedStage, + issueEvents, + createdBefore, + createdAfter, + currentGroup, +} from './mock_data'; const selectedStageEvents = issueEvents.events; const noDataSvgPath = 'path/to/no/data'; @@ -18,25 +26,31 @@ Vue.use(Vuex); let wrapper; -function createStore({ initialState = {} }) { +const defaultState = { + permissions, + currentGroup, + createdBefore, + createdAfter, +}; + +function createStore({ initialState = {}, initialGetters = {} }) { return new Vuex.Store({ state: { ...initState(), - permissions: { - [selectedStage.id]: true, - }, + ...defaultState, ...initialState, }, getters: { - pathNavigationData: () => [], + pathNavigationData: () => transformedProjectStagePathData, + ...initialGetters, }, }); } -function createComponent({ initialState } = {}) { +function createComponent({ initialState, initialGetters } = {}) { return extendedWrapper( shallowMount(BaseComponent, { - store: createStore({ initialState }), + store: createStore({ initialState, initialGetters }), propsData: { noDataSvgPath, noAccessSvgPath, @@ -57,16 +71,7 @@ const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('tit describe('Value stream analytics component', () => { beforeEach(() => { - wrapper = createComponent({ - initialState: { - isLoading: false, - isLoadingStage: false, - isEmptyStage: false, - selectedStageEvents, - selectedStage, - selectedStageError: '', - }, - }); + wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } }); }); afterEach(() => { @@ -102,7 +107,7 @@ describe('Value stream analytics component', () => { }); it('renders the path navigation component with prop `loading` set to true', () => { - expect(findPathNavigation().html()).toMatchSnapshot(); + expect(findPathNavigation().props('loading')).toBe(true); }); it('does not render the overview metrics', () => { @@ -130,13 +135,19 @@ describe('Value stream analytics component', () => { expect(tableWrapper.exists()).toBe(true); expect(tableWrapper.find(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: { selectedStage, isEmptyStage: true }, - }); + wrapper = createComponent({ initialState: emptyStageParams }); }); it('renders the empty stage with `Not enough data` message', () => { @@ -147,8 +158,7 @@ describe('Value stream analytics component', () => { beforeEach(() => { wrapper = createComponent({ initialState: { - selectedStage, - isEmptyStage: true, + ...emptyStageParams, selectedStageError: 'There is too much data to calculate', }, }); @@ -164,7 +174,9 @@ describe('Value stream analytics component', () => { beforeEach(() => { wrapper = createComponent({ initialState: { + selectedStage, permissions: { + ...permissions, [selectedStage.id]: false, }, }, @@ -179,6 +191,7 @@ describe('Value stream analytics component', () => { describe('without a selected stage', () => { beforeEach(() => { wrapper = createComponent({ + initialGetters: { pathNavigationData: () => [] }, initialState: { selectedStage: null, isEmptyStage: true }, }); }); @@ -187,7 +200,7 @@ describe('Value stream analytics component', () => { expect(findStageTable().exists()).toBe(true); }); - it('does not render the path navigation component', () => { + it('does not render the path navigation', () => { expect(findPathNavigation().exists()).toBe(false); }); diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 8a8dd374f8e6939370f158ad0892798a05d2a8de..28715aa87e846b7c361e4a386e6433f040806aeb 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -2,39 +2,23 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/cycle_analytics/store/actions'; +import * as getters from '~/cycle_analytics/store/getters'; import httpStatusCodes from '~/lib/utils/http_status'; import { allowedStages, selectedStage, selectedValueStream } from '../mock_data'; const mockRequestPath = 'some/cool/path'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; const mockStartDate = 30; -const mockRequestedDataActions = ['fetchValueStreams', 'fetchCycleAnalyticsData']; -const mockInitializeActionCommit = { - payload: { requestPath: mockRequestPath }, - type: 'INITIALIZE_VSA', -}; +const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath }; const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' }; -const mockRequestedDataMutations = [ - { - payload: true, - type: 'SET_LOADING', - }, - { - payload: false, - type: 'SET_LOADING', - }, -]; - -const features = { - cycleAnalyticsForGroups: true, -}; + +const defaultState = { ...getters, selectedValueStream }; describe('Project Value Stream Analytics actions', () => { let state; let mock; beforeEach(() => { - state = {}; mock = new MockAdapter(axios); }); @@ -45,28 +29,62 @@ describe('Project Value Stream Analytics actions', () => { const mutationTypes = (arr) => arr.map(({ type }) => type); + const mockFetchStageDataActions = [ + { type: 'setLoading', payload: true }, + { type: 'fetchCycleAnalyticsData' }, + { type: 'fetchStageData' }, + { type: 'fetchStageMedians' }, + { type: 'setLoading', payload: false }, + ]; + describe.each` - action | payload | expectedActions | expectedMutations - ${'initializeVsa'} | ${{ requestPath: mockRequestPath }} | ${mockRequestedDataActions} | ${[mockInitializeActionCommit, ...mockRequestedDataMutations]} - ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockRequestedDataActions} | ${[mockSetDateActionCommit, ...mockRequestedDataMutations]} - ${'setSelectedStage'} | ${{ selectedStage }} | ${['fetchStageData']} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} - ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${['fetchValueStreamStages']} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]} + action | payload | expectedActions | expectedMutations + ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]} + ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]} + ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]} + ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]} + ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ 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: expectedActions.map((a) => ({ type: a })), + expectedActions, })); }); + describe('initializeVsa', () => { + let mockDispatch; + let mockCommit; + const payload = { endpoints: mockEndpoints }; + + beforeEach(() => { + mockDispatch = jest.fn(() => Promise.resolve()); + mockCommit = jest.fn(); + }); + + it('will dispatch the setLoading and fetchValueStreams actions and commit INITIALIZE_VSA', async () => { + await actions.initializeVsa( + { + ...state, + dispatch: mockDispatch, + commit: mockCommit, + }, + payload, + ); + expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints }); + expect(mockDispatch).toHaveBeenCalledWith('setLoading', true); + expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams'); + expect(mockDispatch).toHaveBeenCalledWith('setLoading', false); + }); + }); + describe('fetchCycleAnalyticsData', () => { beforeEach(() => { - state = { requestPath: mockRequestPath }; + state = { endpoints: mockEndpoints }; mock = new MockAdapter(axios); mock.onGet(mockRequestPath).reply(httpStatusCodes.OK); }); @@ -85,7 +103,7 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { - state = { requestPath: mockRequestPath }; + state = { endpoints: mockEndpoints }; mock = new MockAdapter(axios); mock.onGet(mockRequestPath).reply(httpStatusCodes.BAD_REQUEST); }); @@ -105,11 +123,12 @@ describe('Project Value Stream Analytics actions', () => { }); describe('fetchStageData', () => { - const mockStagePath = `${mockRequestPath}/events/${selectedStage.name}`; + const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/; beforeEach(() => { state = { - requestPath: mockRequestPath, + ...defaultState, + endpoints: mockEndpoints, startDate: mockStartDate, selectedStage, }; @@ -131,7 +150,8 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { - requestPath: mockRequestPath, + ...defaultState, + endpoints: mockEndpoints, startDate: mockStartDate, selectedStage, }; @@ -155,7 +175,8 @@ describe('Project Value Stream Analytics actions', () => { describe('with a failing request', () => { beforeEach(() => { state = { - requestPath: mockRequestPath, + ...defaultState, + endpoints: mockEndpoints, startDate: mockStartDate, selectedStage, }; @@ -179,8 +200,7 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { - features, - fullPath: mockFullPath, + endpoints: mockEndpoints, }; mock = new MockAdapter(axios); mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); @@ -199,26 +219,6 @@ describe('Project Value Stream Analytics actions', () => { ], })); - describe('with cycleAnalyticsForGroups=false', () => { - beforeEach(() => { - state = { - features: { cycleAnalyticsForGroups: false }, - fullPath: mockFullPath, - }; - mock = new MockAdapter(axios); - mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); - }); - - it("does not dispatch the 'fetchStageMedians' request", () => - testAction({ - action: actions.fetchValueStreams, - state, - payload: {}, - expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], - expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], - })); - }); - describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); @@ -271,7 +271,7 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { - fullPath: mockFullPath, + endpoints: mockEndpoints, selectedValueStream, }; mock = new MockAdapter(axios); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index c2bc124d5ba46bbf33f645995f1cdbb677f2c612..dcbc2369983c8874825321c5293064c97c264c58 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -21,15 +21,12 @@ const convertedEvents = issueEvents.events; const mockRequestPath = 'fake/request/path'; const mockCreatedAfter = '2020-06-18'; const mockCreatedBefore = '2020-07-18'; -const features = { - cycleAnalyticsForGroups: true, -}; describe('Project Value Stream Analytics mutations', () => { useFakeDate(2020, 6, 18); beforeEach(() => { - state = { features }; + state = {}; }); afterEach(() => { @@ -61,25 +58,45 @@ describe('Project Value Stream Analytics mutations', () => { ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}} ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}} `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => { - mutations[mutation](state, {}); + mutations[mutation](state); expect(state).toMatchObject({ [stateKey]: value }); }); + const mockInitialPayload = { + endpoints: { requestPath: mockRequestPath }, + currentGroup: { title: 'cool-group' }, + id: 1337, + }; + const mockInitializedObj = { + endpoints: { requestPath: mockRequestPath }, + createdAfter: mockCreatedAfter, + createdBefore: mockCreatedBefore, + }; + it.each` - mutation | payload | stateKey | value - ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} - ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY} - ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter} - ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdBefore'} | ${mockCreatedBefore} - ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} - ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} - ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} + 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} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY} + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} + ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} + ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} + ${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} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { @@ -97,41 +114,10 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: [] }} | ${'isEmptyStage'} | ${true} - ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'selectedStageEvents'} | ${convertedEvents} - ${types.RECEIVE_STAGE_DATA_SUCCESS} | ${{ events: rawEvents }} | ${'isEmptyStage'} | ${false} - `( - '$mutation with $payload will set $stateKey to $value', - ({ mutation, payload, stateKey, value }) => { - mutations[mutation](state, payload); - - expect(state).toMatchObject({ [stateKey]: value }); - }, - ); - }); - - describe('with cycleAnalyticsForGroups=false', () => { - useFakeDate(2020, 6, 18); - - beforeEach(() => { - state = { features: { cycleAnalyticsForGroups: false } }; - }); - - const formattedMedians = { - code: '2d', - issue: '-', - plan: '21h', - review: '-', - staging: '2d', - test: '4h', - }; - - it.each` - mutation | payload | stateKey | value - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'medians'} | ${formattedMedians} - ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${{}} | ${'medians'} | ${{}} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${{}} | ${'medians'} | ${{}} + 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 }) => {