diff options
Diffstat (limited to 'app/assets/javascripts/cycle_analytics')
11 files changed, 422 insertions, 145 deletions
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 11a263015e4..8492f0b73e1 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -1,7 +1,8 @@ <script> import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import Cookies from 'js-cookie'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import { __ } from '~/locale'; import banner from './banner.vue'; import stageCodeComponent from './stage_code_component.vue'; @@ -29,6 +30,7 @@ export default { 'stage-staging-component': stageStagingComponent, 'stage-production-component': stageComponent, 'stage-nav-item': stageNavItem, + PathNavigation, }, props: { noDataSvgPath: { @@ -52,21 +54,33 @@ export default { 'isEmptyStage', 'selectedStage', 'selectedStageEvents', + 'selectedStageError', 'stages', 'summary', 'startDate', + 'permissions', ]), + ...mapGetters(['pathNavigationData']), displayStageEvents() { const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; }, displayNotEnoughData() { - const { selectedStage, isEmptyStage, isLoadingStage } = this; - return selectedStage && isEmptyStage && !isLoadingStage; + return this.selectedStageReady && this.isEmptyStage; }, displayNoAccess() { - const { selectedStage } = this; - return selectedStage && !selectedStage.isUserAllowed; + return this.selectedStageReady && !this.isUserAllowed(this.selectedStage.id); + }, + selectedStageReady() { + return !this.isLoadingStage && this.selectedStage; + }, + emptyStageTitle() { + return this.selectedStageError + ? this.selectedStageError + : __("We don't have enough data to show this stage."); + }, + emptyStageText() { + return !this.selectedStageError ? this.selectedStage.emptyStageText : ''; }, }, methods: { @@ -78,25 +92,18 @@ export default { ]), handleDateSelect(startDate) { this.setDateRange({ startDate }); - this.fetchCycleAnalyticsData(); }, - isActiveStage(stage) { - return stage.slug === this.selectedStage.slug; - }, - selectStage(stage) { - if (this.selectedStage === stage) return; - + onSelectStage(stage) { this.setSelectedStage(stage); - if (!stage.isUserAllowed) { - return; - } - - this.fetchStageData(); }, dismissOverviewDialog() { this.isOverviewDialogDismissed = true; Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 }); }, + isUserAllowed(id) { + const { permissions } = this; + return Boolean(permissions?.[id]); + }, }, dayRangeOptions: [7, 30, 90], i18n: { @@ -106,9 +113,23 @@ export default { </script> <template> <div class="cycle-analytics"> + <path-navigation + v-if="selectedStageReady" + class="js-path-navigation gl-w-full gl-pb-2" + :loading="isLoading" + :stages="pathNavigationData" + :selected-stage="selectedStage" + :with-stage-counts="false" + @selected="onSelectStage" + /> <gl-loading-icon v-if="isLoading" size="lg" /> <div v-else class="wrapper"> - <div class="card"> + <!-- + We wont have access to the stage counts until we move to a default value stream + For now we can use the `withStageCounts` flag to ensure we don't display empty stage counts + Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/326705 + --> + <div class="card" data-testid="vsa-stage-overview-metrics"> <div class="card-header">{{ __('Recent Project Activity') }}</div> <div class="d-flex justify-content-between"> <div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center"> @@ -139,40 +160,12 @@ export default { </div> </div> </div> - <div class="stage-panel-container"> - <div class="card stage-panel"> + <div class="stage-panel-container" data-testid="vsa-stage-table"> + <div class="card stage-panel gl-px-5"> <div class="card-header border-bottom-0"> <nav class="col-headers"> - <ul> - <li class="stage-header pl-5"> - <span class="stage-name font-weight-bold">{{ - s__('ProjectLifecycle|Stage') - }}</span> - <span - class="has-tooltip" - data-placement="top" - :title="__('The phase of the development lifecycle.')" - aria-hidden="true" - > - <gl-icon name="question-o" class="gl-text-gray-500" /> - </span> - </li> - <li class="median-header"> - <span class="stage-name font-weight-bold">{{ __('Median') }}</span> - <span - class="has-tooltip" - data-placement="top" - :title=" - __( - 'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.', - ) - " - aria-hidden="true" - > - <gl-icon name="question-o" class="gl-text-gray-500" /> - </span> - </li> - <li class="event-header pl-3"> + <ul class="gl-display-flex gl-justify-content-space-between gl-list-style-none"> + <li> <span v-if="selectedStage" class="stage-name font-weight-bold">{{ selectedStage.legend ? __(selectedStage.legend) : __('Related Issues') }}</span> @@ -187,7 +180,7 @@ export default { <gl-icon name="question-o" class="gl-text-gray-500" /> </span> </li> - <li class="total-time-header pr-5 text-right"> + <li> <span class="stage-name font-weight-bold">{{ __('Time') }}</span> <span class="has-tooltip" @@ -201,45 +194,31 @@ export default { </ul> </nav> </div> - <div class="stage-panel-body"> - <nav class="stage-nav"> - <ul> - <stage-nav-item - v-for="stage in stages" - :key="stage.title" - :title="stage.title" - :is-user-allowed="stage.isUserAllowed" - :value="stage.value" - :is-active="isActiveStage(stage)" - @select="selectStage(stage)" - /> - </ul> - </nav> - <section class="stage-events overflow-auto"> - <gl-loading-icon v-show="isLoadingStage" size="lg" /> - <template v-if="displayNoAccess"> + <section class="stage-events gl-overflow-auto gl-w-full"> + <gl-loading-icon v-if="isLoadingStage" size="lg" /> + <template v-else> <gl-empty-state + v-if="displayNoAccess" class="js-empty-state" :title="__('You need permission.')" :svg-path="noAccessSvgPath" :description="__('Want to see the data? Please ask an administrator for access.')" /> - </template> - <template v-else> - <template v-if="displayNotEnoughData"> + <template v-else> <gl-empty-state + v-if="displayNotEnoughData" class="js-empty-state" - :description="selectedStage.emptyStageText" + :description="emptyStageText" :svg-path="noDataSvgPath" - :title="__('We don\'t have enough data to show this stage.')" + :title="emptyStageTitle" /> - </template> - <template v-if="displayStageEvents"> <component :is="selectedStage.component" + v-if="displayStageEvents" :stage="selectedStage" :items="selectedStageEvents" + data-testid="stage-table-events" /> </template> </template> diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue new file mode 100644 index 00000000000..c1e33f73b13 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue @@ -0,0 +1,127 @@ +<script> +import { + GlPath, + GlPopover, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { OVERVIEW_STAGE_ID } from '../constants'; + +export default { + name: 'PathNavigation', + components: { + GlPath, + GlSkeletonLoading, + GlPopover, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + stages: { + type: Array, + required: true, + }, + selectedStage: { + type: Object, + required: false, + default: () => {}, + }, + withStageCounts: { + type: Boolean, + required: false, + default: true, + }, + }, + methods: { + showPopover({ id }) { + return id && id !== OVERVIEW_STAGE_ID; + }, + hasStageCount({ stageCount = null }) { + return stageCount !== null; + }, + onSelectStage($event) { + this.$emit('selected', $event); + this.track('click_path_navigation', { + extra: { + stage_id: $event.id, + }, + }); + }, + }, + popoverOptions: { + triggers: 'hover', + placement: 'bottom', + }, +}; +</script> +<template> + <gl-skeleton-loading v-if="loading" :lines="2" class="h-auto pt-2 pb-1" /> + <gl-path v-else :key="selectedStage.id" :items="stages" @selected="onSelectStage"> + <template #default="{ pathItem, pathId }"> + <gl-popover + v-if="showPopover(pathItem)" + v-bind="$options.popoverOptions" + :target="pathId" + :css-classes="['stage-item-popover']" + data-testid="stage-item-popover" + > + <template #title>{{ pathItem.title }}</template> + <div class="gl-px-4"> + <div class="gl-display-flex gl-justify-content-space-between"> + <div class="gl-pr-4 gl-pb-4"> + {{ s__('ValueStreamEvent|Stage time (median)') }} + </div> + <div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div> + </div> + </div> + <div v-if="withStageCounts" class="gl-px-4"> + <div class="gl-display-flex gl-justify-content-space-between"> + <div class="gl-pr-4 gl-pb-4"> + {{ s__('ValueStreamEvent|Items in stage') }} + </div> + <div class="gl-pb-4 gl-font-weight-bold"> + <template v-if="hasStageCount(pathItem)">{{ + n__('%d item', '%d items', pathItem.stageCount) + }}</template> + <template v-else>-</template> + </div> + </div> + </div> + <div class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"> + <div + v-if="pathItem.startEventHtmlDescription" + class="gl-display-flex gl-flex-direction-row" + > + <div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label"> + {{ s__('ValueStreamEvent|Start') }} + </div> + <div + v-safe-html="pathItem.startEventHtmlDescription" + class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description" + ></div> + </div> + <div + v-if="pathItem.endEventHtmlDescription" + class="gl-display-flex gl-flex-direction-row" + > + <div class="gl-display-flex gl-flex-direction-column gl-pr-4 metric-label"> + {{ s__('ValueStreamEvent|Stop') }} + </div> + <div + v-safe-html="pathItem.endEventHtmlDescription" + class="gl-display-flex gl-flex-direction-column stage-event-description" + ></div> + </div> + </div> + </gl-popover> + </template> + </gl-path> +</template> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js index d79de207afe..96c89049e90 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -1 +1,8 @@ export const DEFAULT_DAYS_TO_DISPLAY = 30; +export const OVERVIEW_STAGE_ID = 'overview'; + +export const DEFAULT_VALUE_STREAM = { + id: 'default', + slug: 'default', + name: 'default', +}; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 00192cc61f8..57cb220d9c9 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -8,10 +8,11 @@ Vue.use(Translate); export default () => { const store = createStore(); const el = document.querySelector('#js-cycle-analytics'); - const { noAccessSvgPath, noDataSvgPath, requestPath } = el.dataset; + const { noAccessSvgPath, noDataSvgPath, requestPath, fullPath } = el.dataset; store.dispatch('initializeVsa', { requestPath, + fullPath, }); // eslint-disable-next-line no-new @@ -24,6 +25,7 @@ export default () => { props: { noDataSvgPath, noAccessSvgPath, + fullPath, }, }), }); diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js index fe3c6d6b3ba..faf1c37d86a 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -1,27 +1,60 @@ +import { + getProjectValueStreamStages, + getProjectValueStreams, + getProjectValueStreamStageData, + getProjectValueStreamMetrics, +} from '~/api/analytics_api'; import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; +import { DEFAULT_DAYS_TO_DISPLAY, DEFAULT_VALUE_STREAM } from '../constants'; import * as types from './mutation_types'; -export const fetchCycleAnalyticsData = ({ - state: { requestPath, startDate }, - dispatch, - commit, -}) => { +export const setSelectedValueStream = ({ commit, dispatch }, valueStream) => { + commit(types.SET_SELECTED_VALUE_STREAM, valueStream); + return dispatch('fetchValueStreamStages'); +}; + +export const fetchValueStreamStages = ({ commit, state }) => { + const { fullPath, selectedValueStream } = state; + commit(types.REQUEST_VALUE_STREAM_STAGES); + + return getProjectValueStreamStages(fullPath, selectedValueStream.id) + .then(({ data }) => commit(types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS, data)) + .catch(({ response: { status } }) => { + commit(types.RECEIVE_VALUE_STREAM_STAGES_ERROR, status); + }); +}; + +export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { + commit(types.RECEIVE_VALUE_STREAMS_SUCCESS, data); + if (data.length) { + const [firstStream] = data; + return dispatch('setSelectedValueStream', firstStream); + } + return dispatch('setSelectedValueStream', DEFAULT_VALUE_STREAM); +}; + +export const fetchValueStreams = ({ commit, dispatch, state }) => { + const { fullPath } = state; + commit(types.REQUEST_VALUE_STREAMS); + + return getProjectValueStreams(fullPath) + .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) + .then(() => dispatch('setSelectedStage')) + .catch(({ response: { status } }) => { + commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); + }); +}; + +export const fetchCycleAnalyticsData = ({ state: { requestPath, startDate }, commit }) => { commit(types.REQUEST_CYCLE_ANALYTICS_DATA); - return axios - .get(requestPath, { - params: { 'cycle_analytics[start_date]': startDate }, - }) + return getProjectValueStreamMetrics(requestPath, { 'cycle_analytics[start_date]': startDate }) .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data)) - .then(() => dispatch('setSelectedStage')) - .then(() => dispatch('fetchStageData')) .catch(() => { commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR); createFlash({ - message: __('There was an error while fetching value stream analytics data.'), + message: __('There was an error while fetching value stream summary data.'), }); }); }; @@ -29,23 +62,42 @@ export const fetchCycleAnalyticsData = ({ export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => { commit(types.REQUEST_STAGE_DATA); - return axios - .get(`${requestPath}/events/${selectedStage.name}.json`, { - params: { 'cycle_analytics[start_date]': startDate }, + return getProjectValueStreamStageData({ + requestPath, + stageId: selectedStage.id, + params: { 'cycle_analytics[start_date]': startDate }, + }) + .then(({ data }) => { + // when there's a query timeout, the request succeeds but the error is encoded in the response data + if (data?.error) { + commit(types.RECEIVE_STAGE_DATA_ERROR, data.error); + } else { + commit(types.RECEIVE_STAGE_DATA_SUCCESS, data); + } }) - .then(({ data }) => commit(types.RECEIVE_STAGE_DATA_SUCCESS, data)) .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); }; -export const setSelectedStage = ({ commit, state: { stages } }, selectedStage = null) => { +export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => { const stage = selectedStage || stages[0]; commit(types.SET_SELECTED_STAGE, stage); + return dispatch('fetchStageData'); +}; + +const refetchData = (dispatch, commit) => { + commit(types.SET_LOADING, true); + return Promise.resolve() + .then(() => dispatch('fetchValueStreams')) + .then(() => dispatch('fetchCycleAnalyticsData')) + .finally(() => commit(types.SET_LOADING, false)); }; -export const setDateRange = ({ commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => +export const setDateRange = ({ dispatch, commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => { commit(types.SET_DATE_RANGE, { startDate }); + return refetchData(dispatch, commit); +}; export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { commit(types.INITIALIZE_VSA, initialData); - return dispatch('fetchCycleAnalyticsData'); + return refetchData(dispatch, commit); }; diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js new file mode 100644 index 00000000000..c60a70ef147 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -0,0 +1,10 @@ +import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; + +export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => { + return transformStagesForPathNavigation({ + stages: filterStagesByHiddenStatus(stages, false), + medians, + stageCounts, + selectedStage, + }); +}; diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/cycle_analytics/store/index.js index ab47538dcf5..c6ca88ea492 100644 --- a/app/assets/javascripts/cycle_analytics/store/index.js +++ b/app/assets/javascripts/cycle_analytics/store/index.js @@ -8,6 +8,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import state from './state'; @@ -16,6 +17,7 @@ Vue.use(Vuex); export default () => new Vuex.Store({ actions, + getters, mutations, state, }); diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js index 00aae49ae9f..4f3d430ec9f 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -1,8 +1,18 @@ export const INITIALIZE_VSA = 'INITIALIZE_VSA'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM'; export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; export const SET_DATE_RANGE = 'SET_DATE_RANGE'; +export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS'; +export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS'; +export const RECEIVE_VALUE_STREAMS_ERROR = 'RECEIVE_VALUE_STREAMS_ERROR'; + +export const REQUEST_VALUE_STREAM_STAGES = 'REQUEST_VALUE_STREAM_STAGES'; +export const RECEIVE_VALUE_STREAM_STAGES_SUCCESS = 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS'; +export const RECEIVE_VALUE_STREAM_STAGES_ERROR = 'RECEIVE_VALUE_STREAM_STAGES_ERROR'; + export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA'; export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS'; export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 8fd5c78339a..0ae80116cd2 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -1,33 +1,61 @@ -import { decorateData, decorateEvents } from '../utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { decorateData, decorateEvents, formatMedianValues } from '../utils'; import * as types from './mutation_types'; export default { - [types.INITIALIZE_VSA](state, { requestPath }) { + [types.INITIALIZE_VSA](state, { requestPath, fullPath }) { state.requestPath = requestPath; + state.fullPath = fullPath; + }, + [types.SET_LOADING](state, loadingState) { + state.isLoading = loadingState; + }, + [types.SET_SELECTED_VALUE_STREAM](state, selectedValueStream = {}) { + state.selectedValueStream = convertObjectPropsToCamelCase(selectedValueStream, { deep: true }); }, [types.SET_SELECTED_STAGE](state, stage) { - state.isLoadingStage = true; state.selectedStage = stage; - state.isLoadingStage = false; }, [types.SET_DATE_RANGE](state, { startDate }) { state.startDate = startDate; }, + [types.REQUEST_VALUE_STREAMS](state) { + state.valueStreams = []; + }, + [types.RECEIVE_VALUE_STREAMS_SUCCESS](state, valueStreams = []) { + state.valueStreams = valueStreams; + }, + [types.RECEIVE_VALUE_STREAMS_ERROR](state) { + state.valueStreams = []; + }, + [types.REQUEST_VALUE_STREAM_STAGES](state) { + state.stages = []; + }, + [types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS](state, { stages = [] }) { + state.stages = stages.map((s) => ({ + ...convertObjectPropsToCamelCase(s, { deep: true }), + // NOTE: we set the component type here to match the current behaviour + // this can be removed when we migrate to the update stage table + // https://gitlab.com/gitlab-org/gitlab/-/issues/326704 + component: `stage-${s.id}-component`, + })); + }, + [types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) { + state.stages = []; + }, [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { state.isLoading = true; - state.stages = []; state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { - state.isLoading = false; - const { stages, summary } = decorateData(data); - state.stages = stages; + const { summary, medians } = decorateData(data); + state.permissions = data.permissions; state.summary = summary; + state.medians = formatMedianValues(medians); state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { state.isLoading = false; - state.stages = []; state.hasError = true; }, [types.REQUEST_STAGE_DATA](state) { @@ -43,10 +71,11 @@ export default { state.selectedStageEvents = decorateEvents(events, selectedStage); state.hasError = false; }, - [types.RECEIVE_STAGE_DATA_ERROR](state) { + [types.RECEIVE_STAGE_DATA_ERROR](state, error) { state.isLoadingStage = false; state.isEmptyStage = true; state.selectedStageEvents = []; state.hasError = true; + state.selectedStageError = error; }, }; diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js index 5db4e1878a9..02f953d9517 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -2,16 +2,21 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; export default () => ({ requestPath: '', + fullPath: '', startDate: DEFAULT_DAYS_TO_DISPLAY, stages: [], summary: [], analytics: [], stats: [], + valueStreams: [], + selectedValueStream: {}, selectedStage: {}, selectedStageEvents: [], + selectedStageError: '', medians: {}, hasError: false, isLoading: false, isLoadingStage: false, isEmptyStage: false, + permissions: {}, }); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 3afe4b021be..40ad7d8b2fc 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,29 +1,10 @@ -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { dasherize } from '~/lib/utils/text_utility'; -import { __ } from '../locale'; +import { unescape } from 'lodash'; +import { sanitize } from '~/lib/dompurify'; +import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { parseSeconds } from '~/lib/utils/datetime_utility'; +import { s__, sprintf } from '../locale'; import DEFAULT_EVENT_OBJECTS from './default_event_objects'; -const EMPTY_STAGE_TEXTS = { - issue: __( - '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.', - ), - plan: __( - 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - ), - code: __( - 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - ), - test: __( - 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - ), - review: __( - 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - ), - staging: __( - 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - ), -}; - /** * These `decorate` methods will be removed when me migrate to the * new table layout https://gitlab.com/gitlab-org/gitlab/-/issues/326704 @@ -40,24 +21,97 @@ const mapToEvent = (event, stage) => { export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage)); -const mapToStage = (permissions, item) => { - const slug = dasherize(item.name.toLowerCase()); - return { - ...item, - slug, - active: false, - isUserAllowed: permissions[slug], - emptyStageText: EMPTY_STAGE_TEXTS[slug], - component: `stage-${slug}-component`, - }; -}; - const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); +const mapToMedians = ({ name: id, value }) => ({ id, value }); export const decorateData = (data = {}) => { - const { permissions, stats, summary } = data; + const { stats: stages, summary } = data; return { - stages: stats?.map((item) => mapToStage(permissions, item)) || [], summary: summary?.map((item) => mapToSummary(item)) || [], + medians: stages?.map((item) => mapToMedians(item)) || [], }; }; + +/** + * Takes the stages and median data, combined with the selected stage, to build an + * array which is formatted to proivde the data required for the path navigation. + * + * @param {Array} stages - The stages available to the group / project + * @param {Object} medians - The median values for the stages available to the group / project + * @param {Object} stageCounts - The total item count for the stages available + * @param {Object} selectedStage - The currently selected stage + * @returns {Array} An array of stages formatted with data required for the path navigation + */ +export const transformStagesForPathNavigation = ({ + stages, + medians, + stageCounts = {}, + selectedStage, +}) => { + const formattedStages = stages.map((stage) => { + return { + metric: medians[stage?.id], + selected: stage?.id === selectedStage?.id, // Also could null === null cause an issue here? + stageCount: stageCounts && stageCounts[stage?.id], + icon: null, + ...stage, + }; + }); + + return formattedStages; +}; + +export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => { + if (months) { + return sprintf(s__('ValueStreamAnalytics|%{value}M'), { + value: roundToNearestHalf(months), + }); + } else if (weeks) { + return sprintf(s__('ValueStreamAnalytics|%{value}w'), { + value: roundToNearestHalf(weeks), + }); + } else if (days) { + return sprintf(s__('ValueStreamAnalytics|%{value}d'), { + value: roundToNearestHalf(days), + }); + } else if (hours) { + return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours }); + } else if (minutes) { + return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes }); + } else if (seconds) { + return unescape(sanitize(s__('ValueStreamAnalytics|<1m'), { ALLOWED_TAGS: [] })); + } + return '-'; +}; + +/** + * Takes a raw median value in seconds and converts it to a string representation + * ie. converts 172800 => 2d (2 days) + * + * @param {Number} Median - The number of seconds for the median calculation + * @returns {String} String representation ie 2w + */ +export const medianTimeToParsedSeconds = (value) => + timeSummaryForPathNavigation({ + ...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }), + seconds: value, + }); + +/** + * Takes the raw median value arrays and converts them into a useful object + * containing the string for display in the path navigation + * ie. converts [{ id: 'test', value: 172800 }] => { 'test': '2d' } + * + * @param {Array} Medians - Array of stage median objects, each contains a `id`, `value` and `error` + * @returns {Object} Returns key value pair with the stage name and its display median value + */ +export const formatMedianValues = (medians = []) => + medians.reduce((acc, { id, value = 0 }) => { + return { + ...acc, + [id]: value ? medianTimeToParsedSeconds(value) : '-', + }; + }, {}); + +export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => + stages.filter(({ hidden = false }) => hidden === isHidden); |