Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-26 09:10:25 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-26 09:10:25 +0300
commit8bc752f208ea3c2c3218eb407033bd47471c2df5 (patch)
tree4afc87f8e7b8294f79423887723006f1cf96b99c /app
parent5e2b15d28a2e75883faae4c06a4e1320f3143dff (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue85
-rw-r--r--app/assets/javascripts/cycle_analytics/components/path_navigation.vue117
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js13
-rw-r--r--app/assets/javascripts/cycle_analytics/store/index.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js106
-rw-r--r--app/assets/stylesheets/page_bundles/cycle_analytics.scss27
-rw-r--r--app/serializers/analytics_stage_entity.rb6
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|&lt;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