diff options
Diffstat (limited to 'app')
9 files changed, 272 insertions, 90 deletions
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index 11a263015e4..e25b78c2867 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: { @@ -56,6 +58,7 @@ export default { 'summary', 'startDate', ]), + ...mapGetters(['pathNavigationData']), displayStageEvents() { const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; @@ -68,6 +71,12 @@ export default { const { selectedStage } = this; return selectedStage && !selectedStage.isUserAllowed; }, + selectedStageReady() { + return !this.hasNoAccessError && this.selectedStage; + }, + shouldDisplayPathNavigation() { + return this.selectedStage; + }, }, methods: { ...mapActions([ @@ -83,8 +92,8 @@ export default { isActiveStage(stage) { return stage.slug === this.selectedStage.slug; }, - selectStage(stage) { - if (this.selectedStage === stage) return; + onSelectStage(stage) { + if (this.isLoadingStage || this.selectedStage?.slug === stage?.slug) return; this.setSelectedStage(stage); if (!stage.isUserAllowed) { @@ -106,9 +115,23 @@ export default { </script> <template> <div class="cycle-analytics"> + <path-navigation + v-if="shouldDisplayPathNavigation" + class="js-path-navigation gl-w-full gl-pb-2" + :loading="isLoading" + :stages="pathNavigationData" + :selected-stage="selectedStage" + :with-stage-counts="false" + @selected="(ev) => onSelectStage(ev)" + /> <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 +162,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 +182,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,22 +196,8 @@ 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"> + <section class="stage-events overflow-auto gl-w-full"> <gl-loading-icon v-show="isLoadingStage" size="lg" /> <template v-if="displayNoAccess"> <gl-empty-state 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..abdc546632f --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue @@ -0,0 +1,117 @@ +<script> +import { + GlPath, + GlPopover, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { OVERVIEW_STAGE_ID } from '../constants'; + +export default { + name: 'PathNavigation', + components: { + GlPath, + GlSkeletonLoading, + GlPopover, + }, + directives: { + SafeHtml, + }, + 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; + }, + }, + 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="$emit('selected', $event)"> + <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..50b5ebba583 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -1 +1,2 @@ export const DEFAULT_DAYS_TO_DISPLAY = 30; +export const OVERVIEW_STAGE_ID = 'overview'; 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..80981cc02dc --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/getters.js @@ -0,0 +1,13 @@ +import { transformStagesForPathNavigation } from '../utils'; + +export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => + stages.filter(({ hidden = false }) => hidden === isHidden); + +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/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js index 8fd5c78339a..d5038630503 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -1,4 +1,4 @@ -import { decorateData, decorateEvents } from '../utils'; +import { decorateData, decorateEvents, formatMedianValues } from '../utils'; import * as types from './mutation_types'; export default { @@ -20,9 +20,10 @@ export default { }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { state.isLoading = false; - const { stages, summary } = decorateData(data); + const { stages, summary, medians } = decorateData(data); state.stages = stages; state.summary = summary; + state.medians = formatMedianValues(medians); state.hasError = false; }, [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js index 3afe4b021be..eeb3b8f8793 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -1,6 +1,9 @@ -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +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 { dasherize } from '~/lib/utils/text_utility'; -import { __ } from '../locale'; +import { __, s__, sprintf } from '../locale'; import DEFAULT_EVENT_OBJECTS from './default_event_objects'; const EMPTY_STAGE_TEXTS = { @@ -40,10 +43,17 @@ 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()); +/* + * NOTE: We currently use the `name` field since the project level stages are in memory + * once we migrate to a default value stream https://gitlab.com/gitlab-org/gitlab/-/issues/326705 + * we can use the `id` to identify which median we are using + */ +const mapToStage = (permissions, { name, ...rest }) => { + const slug = dasherize(name.toLowerCase()); return { - ...item, + ...rest, + name, + id: name, slug, active: false, isUserAllowed: permissions[slug], @@ -53,11 +63,95 @@ const mapToStage = (permissions, item) => { }; const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); +const mapToMedians = ({ id, value }) => ({ id, value }); export const decorateData = (data = {}) => { const { permissions, stats, summary } = data; + const stages = stats?.map((item) => mapToStage(permissions, item)) || []; return { - stages: stats?.map((item) => mapToStage(permissions, item)) || [], + stages, 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, + 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) : '-', + }; + }, {}); diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss index 2742c95c6e1..2248d95ae24 100644 --- a/app/assets/stylesheets/page_bundles/cycle_analytics.scss +++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss @@ -30,32 +30,12 @@ .col-headers { ul { - @include clearfix; margin: 0; padding: 0; } li { - display: inline-block; - float: left; line-height: 50px; - width: 20%; - } - - .stage-header { - width: 20.5%; - } - - .median-header { - width: 19.5%; - } - - .event-header { - width: 45%; - } - - .total-time-header { - width: 15%; } } @@ -120,7 +100,6 @@ } li { - @include clearfix; list-style-type: none; } @@ -169,7 +148,6 @@ .events-description { line-height: 65px; - padding: 0 $gl-padding; } .events-info { @@ -178,7 +156,6 @@ } .stage-events { - width: 60%; min-height: 467px; } @@ -190,8 +167,8 @@ .stage-event-item { @include clearfix; list-style-type: none; - padding: 0 0 $gl-padding; - margin: 0 $gl-padding $gl-padding; + padding-bottom: $gl-padding; + margin-bottom: $gl-padding; border-bottom: 1px solid var(--gray-50, $gray-50); &:last-child { diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index eb38b90fb18..8a96eb83a3f 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -8,9 +8,5 @@ class AnalyticsStageEntity < Grape::Entity expose :legend expose :description - expose :project_median, as: :value do |stage| - # median returns a BatchLoader instance which we first have to unwrap by using to_f - # we use to_f to make sure results below 1 are presented to the end-user - stage.project_median.to_f.nonzero? ? distance_of_time_in_words(stage.project_median) : nil - end + expose :project_median, as: :value end |