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
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-08-19 12:08:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-08-19 12:08:42 +0300
commitb76ae638462ab0f673e5915986070518dd3f9ad3 (patch)
treebdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/cycle_analytics
parent434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff)
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/cycle_analytics')
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue228
-rw-r--r--app/assets/javascripts/cycle_analytics/components/path_navigation.vue9
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_code_component.vue56
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_component.vue54
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue60
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_review_component.vue70
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue60
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue266
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_test_component.vue56
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time_component.vue50
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue107
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js59
-rw-r--r--app/assets/javascripts/cycle_analytics/default_event_objects.js98
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js9
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js101
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js15
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js63
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js9
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js68
20 files changed, 716 insertions, 726 deletions
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index 8492f0b73e1..c9ecac6829b 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -1,16 +1,12 @@
<script>
-import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
+import StageTable from '~/cycle_analytics/components/stage_table.vue';
+import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { __ } from '~/locale';
-import banner from './banner.vue';
-import stageCodeComponent from './stage_code_component.vue';
-import stageComponent from './stage_component.vue';
-import stageNavItem from './stage_nav_item.vue';
-import stageReviewComponent from './stage_review_component.vue';
-import stageStagingComponent from './stage_staging_component.vue';
-import stageTestComponent from './stage_test_component.vue';
+import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
@@ -18,19 +14,11 @@ export default {
name: 'CycleAnalytics',
components: {
GlIcon,
- GlEmptyState,
GlLoadingIcon,
GlSprintf,
- banner,
- 'stage-issue-component': stageComponent,
- 'stage-plan-component': stageComponent,
- 'stage-code-component': stageCodeComponent,
- 'stage-test-component': stageTestComponent,
- 'stage-review-component': stageReviewComponent,
- 'stage-staging-component': stageStagingComponent,
- 'stage-production-component': stageComponent,
- 'stage-nav-item': stageNavItem,
PathNavigation,
+ StageTable,
+ ValueStreamMetrics,
},
props: {
noDataSvgPath: {
@@ -57,30 +45,56 @@ export default {
'selectedStageError',
'stages',
'summary',
- 'startDate',
+ 'daysInPast',
'permissions',
+ 'stageCounts',
+ 'endpoints',
+ 'features',
]),
- ...mapGetters(['pathNavigationData']),
+ ...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
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) {
+ return __('You need permission.');
+ }
return this.selectedStageError
? this.selectedStageError
: __("We don't have enough data to show this stage.");
},
emptyStageText() {
- return !this.selectedStageError ? this.selectedStage.emptyStageText : '';
+ if (this.displayNoAccess) {
+ return __('Want to see the data? Please ask an administrator for access.');
+ }
+ return !this.selectedStageError && this.selectedStage?.emptyStageText
+ ? this.selectedStage?.emptyStageText
+ : '';
+ },
+ selectedStageCount() {
+ if (this.selectedStage) {
+ const {
+ stageCounts,
+ selectedStage: { id },
+ } = this;
+ return stageCounts[id];
+ }
+ return 0;
+ },
+ metricsRequests() {
+ return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
},
},
methods: {
@@ -90,8 +104,8 @@ export default {
'setSelectedStage',
'setDateRange',
]),
- handleDateSelect(startDate) {
- this.setDateRange({ startDate });
+ handleDateSelect(daysInPast) {
+ this.setDateRange(daysInPast);
},
onSelectStage(stage) {
this.setSelectedStage(stage);
@@ -108,124 +122,62 @@ 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">
- <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">
- <!--
- 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">
- <h3 class="header">{{ item.value }}</h3>
- <p class="text">{{ item.title }}</p>
- </div>
- <div class="flex-grow align-self-center text-center">
- <div class="js-ca-dropdown dropdown inline">
- <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
- <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
- <span class="dropdown-label">
- <gl-sprintf :message="$options.i18n.dropdownText">
- <template #days>{{ startDate }}</template>
- </gl-sprintf>
- <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
- </span>
- </button>
- <ul class="dropdown-menu dropdown-menu-right">
- <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
- <a href="#" @click.prevent="handleDateSelect(days)">
- <gl-sprintf :message="$options.i18n.dropdownText">
- <template #days>{{ days }}</template>
- </gl-sprintf>
- </a>
- </li>
- </ul>
- </div>
- </div>
- </div>
- </div>
- <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 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>
- <span
- class="has-tooltip"
- data-placement="top"
- :title="
- __('The collection of events added to the data gathered for that stage.')
- "
- aria-hidden="true"
- >
- <gl-icon name="question-o" class="gl-text-gray-500" />
- </span>
- </li>
- <li>
- <span class="stage-name font-weight-bold">{{ __('Time') }}</span>
- <span
- class="has-tooltip"
- data-placement="top"
- :title="__('The time taken by each data entry gathered by that stage.')"
- aria-hidden="true"
- >
- <gl-icon name="question-o" class="gl-text-gray-500" />
- </span>
- </li>
- </ul>
- </nav>
- </div>
- <div class="stage-panel-body">
- <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 v-else>
- <gl-empty-state
- v-if="displayNotEnoughData"
- class="js-empty-state"
- :description="emptyStageText"
- :svg-path="noDataSvgPath"
- :title="emptyStageTitle"
- />
- <component
- :is="selectedStage.component"
- v-if="displayStageEvents"
- :stage="selectedStage"
- :items="selectedStageEvents"
- data-testid="stage-table-events"
- />
- </template>
- </template>
- </section>
- </div>
+ <h3>{{ $options.i18n.pageTitle }}</h3>
+ <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
+ <path-navigation
+ v-if="displayPathNavigation"
+ class="js-path-navigation gl-w-full gl-pb-2"
+ :loading="isLoading || isLoadingStage"
+ :stages="pathNavigationData"
+ :selected-stage="selectedStage"
+ @selected="onSelectStage"
+ />
+ <div class="gl-flex-grow gl-align-self-end">
+ <div class="js-ca-dropdown dropdown inline">
+ <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
+ <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
+ <span class="dropdown-label">
+ <gl-sprintf :message="$options.i18n.dropdownText">
+ <template #days>{{ daysInPast }}</template>
+ </gl-sprintf>
+ <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
+ </span>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right">
+ <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
+ <a href="#" @click.prevent="handleDateSelect(days)">
+ <gl-sprintf :message="$options.i18n.dropdownText">
+ <template #days>{{ days }}</template>
+ </gl-sprintf>
+ </a>
+ </li>
+ </ul>
</div>
</div>
</div>
+ <value-stream-metrics
+ :request-path="endpoints.fullPath"
+ :request-params="filterParams"
+ :requests="metricsRequests"
+ />
+ <gl-loading-icon v-if="isLoading" size="lg" />
+ <stage-table
+ v-else
+ :is-loading="isLoading || isLoadingStage"
+ :stage-events="selectedStageEvents"
+ :selected-stage="selectedStage"
+ :stage-count="selectedStageCount"
+ :empty-state-title="emptyStageTitle"
+ :empty-state-message="emptyStageText"
+ :no-data-svg-path="noDataSvgPath"
+ :pagination="null"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
index 47fafc3b90c..f8f89772fd6 100644
--- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
+++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
@@ -34,12 +34,7 @@ export default {
selectedStage: {
type: Object,
required: false,
- default: () => {},
- },
- withStageCounts: {
- type: Boolean,
- required: false,
- default: true,
+ default: () => ({}),
},
},
methods: {
@@ -81,7 +76,7 @@ export default {
<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-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') }}
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue
deleted file mode 100644
index 6b757c6972a..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
-import limitWarning from './limit_warning_component.vue';
-import totalTime from './total_time_component.vue';
-
-export default {
- components: {
- userAvatarImage,
- limitWarning,
- totalTime,
- },
- props: {
- items: {
- type: Array,
- default: () => [],
- required: false,
- },
- stage: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="(mergeRequest, i) in items" :key="i" class="stage-event-item">
- <div class="item-details">
- <!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image :img-src="mergeRequest.author.avatarUrl" />
- <h5 class="item-title merge-request-title">
- <a :href="mergeRequest.url"> {{ mergeRequest.title }} </a>
- </h5>
- <a :href="mergeRequest.url" class="issue-link"> !{{ mergeRequest.iid }} </a> &middot;
- <span>
- {{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="mergeRequest.url" class="issue-date"> {{ mergeRequest.createdAt }} </a>
- </span>
- <span>
- {{ s__('ByAuthor|by') }}
- <a :href="mergeRequest.author.webUrl" class="issue-author-link">
- {{ mergeRequest.author.name }}
- </a>
- </span>
- </div>
- <div class="item-time"><total-time :time="mergeRequest.totalTime" /></div>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_component.vue
deleted file mode 100644
index cc7ae74dd3a..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_component.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<script>
-import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
-import limitWarning from './limit_warning_component.vue';
-import totalTime from './total_time_component.vue';
-
-export default {
- components: {
- userAvatarImage,
- limitWarning,
- totalTime,
- },
- props: {
- items: {
- type: Array,
- default: () => [],
- required: false,
- },
- stage: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="(issue, i) in items" :key="i" class="stage-event-item">
- <div class="item-details">
- <!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image :img-src="issue.author.avatarUrl" />
- <h5 class="item-title issue-title">
- <a :href="issue.url" class="issue-title"> {{ issue.title }} </a>
- </h5>
- <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> &middot;
- <span>
- {{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
- </span>
- <span>
- {{ s__('ByAuthor|by') }}
- <a :href="issue.author.webUrl" class="issue-author-link"> {{ issue.author.name }} </a>
- </span>
- </div>
- <div class="item-time"><total-time :time="issue.totalTime" /></div>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
deleted file mode 100644
index 4b15bd55cbd..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<script>
-export default {
- name: 'StageNavItem',
- props: {
- isDefaultStage: {
- type: Boolean,
- default: false,
- required: false,
- },
- isActive: {
- type: Boolean,
- default: false,
- required: false,
- },
- isUserAllowed: {
- type: Boolean,
- required: true,
- },
- title: {
- type: String,
- required: true,
- },
- value: {
- type: String,
- default: '',
- required: false,
- },
- },
- computed: {
- hasValue() {
- return this.value && this.value.length > 0;
- },
- },
-};
-</script>
-
-<template>
- <li @click="$emit('select')">
- <div
- :class="{ active: isActive }"
- class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px"
- >
- <div
- class="stage-nav-item-cell stage-name w-50 pr-2"
- :class="{ 'font-weight-bold': isActive }"
- >
- {{ title }}
- </div>
- <div class="stage-nav-item-cell stage-median w-50">
- <template v-if="isUserAllowed">
- <span v-if="hasValue">{{ value }}</span>
- <span v-else class="stage-empty">{{ __('Not enough data') }}</span>
- </template>
- <template v-else>
- <span class="not-available">{{ __('Not available') }}</span>
- </template>
- </div>
- </div>
- </li>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue
deleted file mode 100644
index 33b4e649ab0..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
-import limitWarning from './limit_warning_component.vue';
-import totalTime from './total_time_component.vue';
-
-export default {
- components: {
- userAvatarImage,
- totalTime,
- limitWarning,
- GlIcon,
- },
- props: {
- items: {
- type: Array,
- default: () => [],
- required: false,
- },
- stage: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="(mergeRequest, i) in items" :key="i" class="stage-event-item">
- <div class="item-details">
- <!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image :img-src="mergeRequest.author.avatarUrl" />
- <h5 class="item-title merge-request-title">
- <a :href="mergeRequest.url"> {{ mergeRequest.title }} </a>
- </h5>
- <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> &middot;
- <span>
- {{ s__('OpenedNDaysAgo|Opened') }}
- <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
- </span>
- <span>
- {{ s__('ByAuthor|by') }}
- <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{
- mergeRequest.author.name
- }}</a>
- </span>
- <template v-if="mergeRequest.state === 'closed'">
- <span class="merge-request-state">
- <gl-icon name="cancel" class="gl-vertical-align-text-bottom" />
- {{ __('CLOSED') }}
- </span>
- </template>
- <template v-else>
- <span v-if="mergeRequest.branch" class="merge-request-branch">
- <gl-icon :size="16" name="fork" />
- <a :href="mergeRequest.branch.url"> {{ mergeRequest.branch.name }} </a>
- </span>
- </template>
- </div>
- <div class="item-time"><total-time :time="mergeRequest.totalTime" /></div>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
deleted file mode 100644
index 6d8f711c13b..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<script>
-import { GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
-import limitWarning from './limit_warning_component.vue';
-import totalTime from './total_time_component.vue';
-
-export default {
- components: {
- userAvatarImage,
- totalTime,
- limitWarning,
- GlIcon,
- },
- directives: {
- SafeHtml,
- },
- props: {
- items: {
- type: Array,
- default: () => [],
- required: false,
- },
- stage: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="(build, i) in items" :key="i" class="stage-event-item item-build-component">
- <div class="item-details">
- <!-- FIXME: Pass an alt attribute here for accessibility -->
- <user-avatar-image :img-src="build.author.avatarUrl" />
- <h5 class="item-title">
- <a :href="build.url" class="pipeline-id"> #{{ build.id }} </a>
- <gl-icon :size="16" name="fork" />
- <a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a>
- <span class="icon-branch gl-text-gray-400">
- <gl-icon name="commit" :size="14" />
- </span>
- <a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a>
- </h5>
- <span>
- <a :href="build.url" class="build-date"> {{ build.date }} </a> {{ s__('ByAuthor|by') }}
- <a :href="build.author.webUrl" class="issue-author-link"> {{ build.author.name }} </a>
- </span>
- </div>
- <div class="item-time"><total-time :time="build.totalTime" /></div>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
new file mode 100644
index 00000000000..0c47838c773
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -0,0 +1,266 @@
+<script>
+import {
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlTable,
+ GlBadge,
+} from '@gitlab/ui';
+import FormattedStageCount from '~/cycle_analytics/components/formatted_stage_count.vue';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ NOT_ENOUGH_DATA_ERROR,
+ PAGINATION_SORT_FIELD_END_EVENT,
+ PAGINATION_SORT_FIELD_DURATION,
+ PAGINATION_SORT_DIRECTION_ASC,
+ PAGINATION_SORT_DIRECTION_DESC,
+} from '../constants';
+import TotalTime from './total_time_component.vue';
+
+const DEFAULT_WORKFLOW_TITLE_PROPERTIES = {
+ thClass: 'gl-w-half',
+ key: PAGINATION_SORT_FIELD_END_EVENT,
+ sortable: true,
+};
+const WORKFLOW_COLUMN_TITLES = {
+ issues: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Issues') },
+ jobs: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Jobs') },
+ deployments: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Deployments') },
+ mergeRequests: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Merge requests') },
+};
+
+export default {
+ name: 'StageTable',
+ components: {
+ GlEmptyState,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlTable,
+ GlBadge,
+ TotalTime,
+ FormattedStageCount,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ selectedStage: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ stageEvents: {
+ type: Array,
+ required: true,
+ },
+ stageCount: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ noDataSvgPath: {
+ type: String,
+ required: true,
+ },
+ emptyStateTitle: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ emptyStateMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ pagination: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ if (this.pagination) {
+ const {
+ pagination: { sort, direction },
+ } = this;
+ return {
+ sort,
+ direction,
+ sortDesc: direction === PAGINATION_SORT_DIRECTION_DESC,
+ };
+ }
+ return { sort: null, direction: null, sortDesc: null };
+ },
+ computed: {
+ isEmptyStage() {
+ return !this.selectedStage || !this.stageEvents.length;
+ },
+ emptyStateTitleText() {
+ return this.emptyStateTitle || NOT_ENOUGH_DATA_ERROR;
+ },
+ isMergeRequestStage() {
+ const [firstEvent] = this.stageEvents;
+ return this.isMrLink(firstEvent.url);
+ },
+ workflowTitle() {
+ if (this.isMergeRequestStage) {
+ return WORKFLOW_COLUMN_TITLES.mergeRequests;
+ }
+ return WORKFLOW_COLUMN_TITLES.issues;
+ },
+ fields() {
+ return [
+ this.workflowTitle,
+ {
+ key: PAGINATION_SORT_FIELD_DURATION,
+ label: __('Time'),
+ thClass: 'gl-w-half',
+ sortable: true,
+ },
+ ];
+ },
+ prevPage() {
+ return Math.max(this.pagination.page - 1, 0);
+ },
+ nextPage() {
+ return this.pagination.hasNextPage ? this.pagination.page + 1 : null;
+ },
+ },
+ methods: {
+ isMrLink(url = '') {
+ return url.includes('/merge_request');
+ },
+ itemId({ url, iid }) {
+ return this.isMrLink(url) ? `!${iid}` : `#${iid}`;
+ },
+ itemTitle(item) {
+ return item.title || item.name;
+ },
+ onSelectPage(page) {
+ const { sort, direction } = this.pagination;
+ this.track('click_button', { label: 'pagination' });
+ this.$emit('handleUpdatePagination', { sort, direction, page });
+ },
+ onSort({ sortBy, sortDesc }) {
+ const direction = sortDesc ? PAGINATION_SORT_DIRECTION_DESC : PAGINATION_SORT_DIRECTION_ASC;
+ this.sort = sortBy;
+ this.sortDesc = sortDesc;
+ this.$emit('handleUpdatePagination', { sort: sortBy, direction });
+ this.track('click_button', { label: `sort_${sortBy}_${direction}` });
+ },
+ },
+};
+</script>
+<template>
+ <div data-testid="vsa-stage-table">
+ <gl-loading-icon v-if="isLoading" class="gl-mt-4" size="md" />
+ <gl-empty-state
+ v-else-if="isEmptyStage"
+ :title="emptyStateTitleText"
+ :description="emptyStateMessage"
+ :svg-path="noDataSvgPath"
+ />
+ <gl-table
+ v-else
+ head-variant="white"
+ stacked="lg"
+ thead-class="border-bottom"
+ show-empty
+ :sort-by.sync="sort"
+ :sort-direction.sync="direction"
+ :sort-desc.sync="sortDesc"
+ :fields="fields"
+ :items="stageEvents"
+ :empty-text="emptyStateMessage"
+ @sort-changed="onSort"
+ >
+ <template v-if="stageCount" #head(end_event)="data">
+ <span>{{ data.label }}</span
+ ><gl-badge class="gl-ml-2" size="sm"
+ ><formatted-stage-count :stage-count="stageCount"
+ /></gl-badge>
+ </template>
+ <template #cell(end_event)="{ item }">
+ <div data-testid="vsa-stage-event">
+ <div v-if="item.id" data-testid="vsa-stage-content">
+ <p class="gl-m-0">
+ <gl-link class="gl-text-black-normal pipeline-id" :href="item.url"
+ >#{{ item.id }}</gl-link
+ >
+ <gl-icon :size="16" name="fork" />
+ <gl-link
+ v-if="item.branch"
+ :href="item.branch.url"
+ class="gl-text-black-normal ref-name"
+ >{{ item.branch.name }}</gl-link
+ >
+ <span class="icon-branch gl-text-gray-400">
+ <gl-icon name="commit" :size="14" />
+ </span>
+ <gl-link
+ class="commit-sha"
+ :href="item.commitUrl"
+ data-testid="vsa-stage-event-build-sha"
+ >{{ item.shortSha }}</gl-link
+ >
+ </p>
+ <p class="gl-m-0">
+ <span data-testid="vsa-stage-event-build-author-and-date">
+ <gl-link class="gl-text-black-normal build-date" :href="item.url">{{
+ item.date
+ }}</gl-link>
+ {{ s__('ByAuthor|by') }}
+ <gl-link
+ class="gl-text-black-normal issue-author-link"
+ :href="item.author.webUrl"
+ >{{ item.author.name }}</gl-link
+ >
+ </span>
+ </p>
+ </div>
+ <div v-else data-testid="vsa-stage-content">
+ <h5 class="gl-font-weight-bold gl-my-1" data-testid="vsa-stage-event-title">
+ <gl-link class="gl-text-black-normal" :href="item.url">{{ itemTitle(item) }}</gl-link>
+ </h5>
+ <p class="gl-m-0">
+ <gl-link class="gl-text-black-normal" :href="item.url">{{ itemId(item) }}</gl-link>
+ <span class="gl-font-lg">&middot;</span>
+ <span data-testid="vsa-stage-event-date">
+ {{ s__('OpenedNDaysAgo|Opened') }}
+ <gl-link class="gl-text-black-normal" :href="item.url">{{
+ item.createdAt
+ }}</gl-link>
+ </span>
+ <span data-testid="vsa-stage-event-author">
+ {{ s__('ByAuthor|by') }}
+ <gl-link class="gl-text-black-normal" :href="item.author.webUrl">{{
+ item.author.name
+ }}</gl-link>
+ </span>
+ </p>
+ </div>
+ </div>
+ </template>
+ <template #cell(duration)="{ item }">
+ <total-time :time="item.totalTime" data-testid="vsa-stage-event-time" />
+ </template>
+ </gl-table>
+ <gl-pagination
+ v-if="pagination && !isLoading && !isEmptyStage"
+ :value="pagination.page"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-mt-3"
+ data-testid="vsa-stage-pagination"
+ @input="onSelectPage"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
deleted file mode 100644
index c165c8cee78..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import limitWarning from './limit_warning_component.vue';
-import totalTime from './total_time_component.vue';
-
-export default {
- components: {
- totalTime,
- limitWarning,
- GlIcon,
- },
- props: {
- items: {
- type: Array,
- default: () => [],
- required: false,
- },
- stage: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="events-description">
- {{ stage.description }}
- <limit-warning :count="items.length" />
- </div>
- <ul class="stage-event-list">
- <li v-for="(build, i) in items" :key="i" class="stage-event-item item-build-component">
- <div class="item-details">
- <h5 class="item-title">
- <span class="icon-build-status gl-text-green-500">
- <gl-icon name="status_success" :size="14" />
- </span>
- <a :href="build.url" class="item-build-name"> {{ build.name }} </a> &middot;
- <a :href="build.url" class="pipeline-id"> #{{ build.id }} </a>
- <gl-icon :size="16" name="fork" />
- <a :href="build.branch.url" class="ref-name"> {{ build.branch.name }} </a>
- <span class="icon-branch gl-text-gray-400">
- <gl-icon name="commit" :size="14" />
- </span>
- <a :href="build.commitUrl" class="commit-sha"> {{ build.shortSha }} </a>
- </h5>
- <span>
- <a :href="build.url" class="issue-date"> {{ build.date }} </a>
- </span>
- </div>
- <div class="item-time"><total-time :time="build.totalTime" /></div>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
index f52438ca2cb..a5a90a56974 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue
@@ -1,4 +1,6 @@
<script>
+import { n__, s__ } from '~/locale';
+
export default {
props: {
time: {
@@ -11,24 +13,48 @@ export default {
hasData() {
return Object.keys(this.time).length;
},
+ calculatedTime() {
+ const {
+ time: { days = null, mins = null, hours = null, seconds = null },
+ } = this;
+
+ if (days) {
+ return {
+ duration: days,
+ units: n__('day', 'days', days),
+ };
+ }
+
+ if (hours) {
+ return {
+ duration: hours,
+ units: n__('Time|hr', 'Time|hrs', hours),
+ };
+ }
+
+ if (mins && !days) {
+ return {
+ duration: mins,
+ units: n__('Time|min', 'Time|mins', mins),
+ };
+ }
+
+ if ((seconds && this.hasData === 1) || seconds === 0) {
+ return {
+ duration: seconds,
+ units: s__('Time|s'),
+ };
+ }
+
+ return { duration: null, units: null };
+ },
},
};
</script>
<template>
<span class="total-time">
<template v-if="hasData">
- <template v-if="time.days">
- {{ time.days }} <span> {{ n__('day', 'days', time.days) }} </span>
- </template>
- <template v-if="time.hours">
- {{ time.hours }} <span> {{ n__('Time|hr', 'Time|hrs', time.hours) }} </span>
- </template>
- <template v-if="time.mins && !time.days">
- {{ time.mins }} <span> {{ n__('Time|min', 'Time|mins', time.mins) }} </span>
- </template>
- <template v-if="(time.seconds && hasData === 1) || time.seconds === 0">
- {{ time.seconds }} <span> {{ s__('Time|s') }} </span>
- </template>
+ {{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span>
</template>
<template v-else> -- </template>
</span>
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
new file mode 100644
index 00000000000..7371ffd2c7c
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { flatten } from 'lodash';
+import createFlash from '~/flash';
+import { sprintf, s__ } from '~/locale';
+import { METRICS_POPOVER_CONTENT } from '../constants';
+import { removeFlash, prepareTimeMetricsData } from '../utils';
+
+const requestData = ({ request, endpoint, path, params, name }) => {
+ return request({ endpoint, params, requestPath: path })
+ .then(({ data }) => data)
+ .catch(() => {
+ const message = sprintf(
+ s__(
+ 'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.',
+ ),
+ { requestTypeName: name },
+ );
+ createFlash({ message });
+ });
+};
+
+const fetchMetricsData = (reqs = [], path, params) => {
+ const promises = reqs.map((r) => requestData({ ...r, path, params }));
+ return Promise.all(promises).then((responses) =>
+ prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
+ );
+};
+
+export default {
+ name: 'ValueStreamMetrics',
+ components: {
+ GlPopover,
+ GlSingleStat,
+ GlSkeletonLoading,
+ },
+ props: {
+ requestPath: {
+ type: String,
+ required: true,
+ },
+ requestParams: {
+ type: Object,
+ required: true,
+ },
+ requests: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ metrics: [],
+ isLoading: false,
+ };
+ },
+ watch: {
+ requestParams() {
+ this.fetchData();
+ },
+ },
+ mounted() {
+ this.fetchData();
+ },
+ methods: {
+ fetchData() {
+ removeFlash();
+ this.isLoading = true;
+ return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
+ .then((data) => {
+ this.metrics = data;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics">
+ <div v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6">
+ <gl-skeleton-loading />
+ </div>
+ <template v-else>
+ <div v-for="metric in metrics" :key="metric.key" class="gl-my-6 gl-pr-9">
+ <gl-single-stat
+ :id="metric.key"
+ :value="`${metric.value}`"
+ :title="metric.label"
+ :unit="metric.unit || ''"
+ :should-animate="true"
+ :animation-decimal-places="1"
+ tabindex="0"
+ />
+ <gl-popover :target="metric.key" placement="bottom">
+ <template #title>
+ <span class="gl-display-block gl-text-left">{{ metric.label }}</span>
+ </template>
+ <span v-if="metric.description">{{ metric.description }}</span>
+ </gl-popover>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index 97f502326e5..c1be2ce7096 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -1,3 +1,10 @@
+import {
+ getValueStreamMetrics,
+ METRIC_TYPE_SUMMARY,
+ METRIC_TYPE_TIME_SUMMARY,
+} from '~/api/analytics_api';
+import { __, s__ } from '~/locale';
+
export const DEFAULT_DAYS_IN_PAST = 30;
export const DEFAULT_DAYS_TO_DISPLAY = 30;
export const OVERVIEW_STAGE_ID = 'overview';
@@ -7,3 +14,55 @@ export const DEFAULT_VALUE_STREAM = {
slug: 'default',
name: 'default',
};
+
+export const NOT_ENOUGH_DATA_ERROR = s__(
+ "ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
+);
+
+export const PAGINATION_TYPE = 'keyset';
+export const PAGINATION_SORT_FIELD_END_EVENT = 'end_event';
+export const PAGINATION_SORT_FIELD_DURATION = 'duration';
+export const PAGINATION_SORT_DIRECTION_DESC = 'desc';
+export const PAGINATION_SORT_DIRECTION_ASC = 'asc';
+
+export const I18N_VSA_ERROR_STAGES = __(
+ 'There was an error fetching value stream analytics stages.',
+);
+export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching median data for stages');
+export const I18N_VSA_ERROR_SELECTED_STAGE = __(
+ 'There was an error fetching data for the selected stage',
+);
+
+export const OVERVIEW_METRICS = {
+ TIME_SUMMARY: 'TIME_SUMMARY',
+ RECENT_ACTIVITY: 'RECENT_ACTIVITY',
+};
+
+export const METRICS_POPOVER_CONTENT = {
+ 'lead-time': {
+ description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
+ },
+ 'cycle-time': {
+ description: s__(
+ 'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
+ ),
+ },
+ 'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
+ 'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
+ deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
+ 'deployment-frequency': {
+ description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
+ },
+ commits: {
+ description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
+ },
+};
+
+export const SUMMARY_METRICS_REQUEST = [
+ { endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
+];
+
+export const METRICS_REQUESTS = [
+ { endpoint: METRIC_TYPE_TIME_SUMMARY, name: __('time summary'), request: getValueStreamMetrics },
+ ...SUMMARY_METRICS_REQUEST,
+];
diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js
deleted file mode 100644
index 57f9019d2f8..00000000000
--- a/app/assets/javascripts/cycle_analytics/default_event_objects.js
+++ /dev/null
@@ -1,98 +0,0 @@
-export default {
- issue: {
- created_at: '',
- url: '',
- iid: '',
- title: '',
- total_time: {},
- author: {
- avatar_url: '',
- id: '',
- name: '',
- web_url: '',
- },
- },
- plan: {
- title: '',
- commit_url: '',
- short_sha: '',
- total_time: {},
- author: {
- name: '',
- id: '',
- avatar_url: '',
- web_url: '',
- },
- },
- code: {
- title: '',
- iid: '',
- created_at: '',
- url: '',
- total_time: {},
- author: {
- name: '',
- id: '',
- avatar_url: '',
- web_url: '',
- },
- },
- test: {
- name: '',
- id: '',
- date: '',
- url: '',
- short_sha: '',
- commit_url: '',
- total_time: {},
- branch: {
- name: '',
- url: '',
- },
- },
- review: {
- title: '',
- iid: '',
- created_at: '',
- url: '',
- state: '',
- total_time: {},
- author: {
- name: '',
- id: '',
- avatar_url: '',
- web_url: '',
- },
- },
- staging: {
- id: '',
- short_sha: '',
- date: '',
- url: '',
- commit_url: '',
- total_time: {},
- author: {
- name: '',
- id: '',
- avatar_url: '',
- web_url: '',
- },
- branch: {
- name: '',
- url: '',
- },
- },
- production: {
- title: '',
- created_at: '',
- url: '',
- iid: '',
- total_time: {},
- author: {
- name: '',
- id: '',
- avatar_url: '',
- web_url: '',
- },
- },
-};
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index 615f96c3860..3827db4d9b2 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -20,11 +20,12 @@ export default () => {
store.dispatch('initializeVsa', {
projectId: parseInt(projectId, 10),
groupPath,
- requestPath,
- fullPath,
+ endpoints: {
+ requestPath,
+ fullPath,
+ },
features: {
- cycleAnalyticsForGroups:
- (groupPath && gon?.licensed_features?.cycleAnalyticsForGroups) || false,
+ cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
},
});
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index 955f0c7271e..a7a2c8ea9d3 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -1,25 +1,29 @@
import {
getProjectValueStreamStages,
getProjectValueStreams,
- getProjectValueStreamStageData,
getProjectValueStreamMetrics,
getValueStreamStageMedian,
+ getValueStreamStageRecords,
+ getValueStreamStageCounts,
} from '~/api/analytics_api';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import { DEFAULT_DAYS_TO_DISPLAY, DEFAULT_VALUE_STREAM } 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);
@@ -37,16 +41,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', 'fetchStageCountValues'];
return getProjectValueStreams(fullPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
.then(() => Promise.all(stageRequests.map((r) => dispatch(r))))
@@ -54,9 +53,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,
}) => {
@@ -72,18 +72,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) {
@@ -120,8 +112,37 @@ export const fetchStageMedians = ({
.then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data))
.catch((error) => {
commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error);
+ createFlash({ message: I18N_VSA_ERROR_STAGE_MEDIAN });
+ });
+};
+
+const getStageCounts = ({ stageId, vsaParams, filterParams = {} }) => {
+ return getValueStreamStageCounts({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({
+ id: stageId,
+ ...data,
+ }));
+};
+
+export const fetchStageCountValues = ({
+ state: { stages },
+ getters: { requestParams: vsaParams, filterParams },
+ commit,
+}) => {
+ commit(types.REQUEST_STAGE_COUNTS);
+ return Promise.all(
+ stages.map(({ id: stageId }) =>
+ getStageCounts({
+ vsaParams,
+ stageId,
+ filterParams,
+ }),
+ ),
+ )
+ .then((data) => commit(types.RECEIVE_STAGE_COUNTS_SUCCESS, data))
+ .catch((error) => {
+ commit(types.RECEIVE_STAGE_COUNTS_ERROR, error);
createFlash({
- message: __('There was an error fetching median data for stages'),
+ message: __('There was an error fetching stage total counts'),
});
});
};
@@ -132,22 +153,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 66971ea8a2e..9faccabcaad 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/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
index 11ed62a4081..0d94aad2ca5 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -24,3 +24,7 @@ export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS';
export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS';
export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR';
+
+export const REQUEST_STAGE_COUNTS = 'REQUEST_STAGE_COUNTS';
+export const RECEIVE_STAGE_COUNTS_SUCCESS = 'RECEIVE_STAGE_COUNTS_SUCCESS';
+export const RECEIVE_STAGE_COUNTS_ERROR = 'RECEIVE_STAGE_COUNTS_ERROR';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index a8b7a607b66..e41de85c1fa 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -1,19 +1,11 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
-import {
- decorateData,
- decorateEvents,
- formatMedianValues,
- calculateFormattedDayInPast,
-} from '../utils';
+import { formatMedianValues, calculateFormattedDayInPast } from '../utils';
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, features }) {
+ state.endpoints = endpoints;
const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
state.createdBefore = now;
state.createdAfter = past;
@@ -28,9 +20,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;
},
@@ -47,13 +39,7 @@ export default {
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`,
- }));
+ state.stages = stages.map((s) => convertObjectPropsToCamelCase(s, { deep: true }));
},
[types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) {
state.stages = [];
@@ -61,25 +47,14 @@ 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;
- state.summary = summary;
+ state.permissions = data?.permissions || {};
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;
@@ -87,11 +62,12 @@ export default {
state.selectedStageEvents = [];
state.hasError = false;
},
- [types.RECEIVE_STAGE_DATA_SUCCESS](state, { events = [] }) {
- const { selectedStage } = state;
+ [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
state.isLoadingStage = false;
state.isEmptyStage = !events.length;
- state.selectedStageEvents = decorateEvents(events, selectedStage);
+ state.selectedStageEvents = events.map((ev) =>
+ convertObjectPropsToCamelCase(ev, { deep: true }),
+ );
state.hasError = false;
},
[types.RECEIVE_STAGE_DATA_ERROR](state, error) {
@@ -110,4 +86,19 @@ export default {
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
},
+ [types.REQUEST_STAGE_COUNTS](state) {
+ state.stageCounts = {};
+ },
+ [types.RECEIVE_STAGE_COUNTS_SUCCESS](state, stageCounts = []) {
+ state.stageCounts = stageCounts.reduce(
+ (acc, { id, count }) => ({
+ ...acc,
+ [id]: count,
+ }),
+ {},
+ );
+ },
+ [types.RECEIVE_STAGE_COUNTS_ERROR](state) {
+ state.stageCounts = {};
+ },
};
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index 4d61077fb99..e6da3f609b2 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -1,11 +1,10 @@
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({
- features: {},
id: null,
- requestPath: '',
- fullPath: '',
- startDate: DEFAULT_DAYS_TO_DISPLAY,
+ features: {},
+ endpoints: {},
+ daysInPast: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null,
createdBefore: null,
stages: [],
@@ -18,10 +17,10 @@ export default () => ({
selectedStageEvents: [],
selectedStageError: '',
medians: {},
+ stageCounts: {},
hasError: false,
isLoading: false,
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 a1690dd1513..fa02fdf914a 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,38 +1,19 @@
import dateFormat from 'dateformat';
import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
+import { hideFlash } from '~/flash';
import { sanitize } from '~/lib/dompurify';
-import { roundToNearestHalf, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
+import { slugify } from '~/lib/utils/text_utility';
import { s__, sprintf } from '../locale';
-import DEFAULT_EVENT_OBJECTS from './default_event_objects';
-/**
- * These `decorate` methods will be removed when me migrate to the
- * new table layout https://gitlab.com/gitlab-org/gitlab/-/issues/326704
- */
-const mapToEvent = (event, stage) => {
- return convertObjectPropsToCamelCase(
- {
- ...DEFAULT_EVENT_OBJECTS[stage.slug],
- ...event,
- },
- { deep: true },
- );
-};
-
-export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage));
-
-const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
-const mapToMedians = ({ name: id, value }) => ({ id, value });
-
-export const decorateData = (data = {}) => {
- const { stats: stages, summary } = data;
- return {
- summary: summary?.map((item) => mapToSummary(item)) || [],
- medians: stages?.map((item) => mapToMedians(item)) || [],
- };
+export const removeFlash = (type = 'alert') => {
+ const flashEl = document.querySelector(`.flash-${type}`);
+ if (flashEl) {
+ hideFlash(flashEl);
+ }
};
/**
@@ -135,3 +116,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
past: toIsoFormat(getDateInPast(today, daysInPast)),
};
};
+
+/**
+ * @typedef {Object} MetricData
+ * @property {String} title - Title of the metric measured
+ * @property {String} value - String representing the decimal point value, e.g '1.5'
+ * @property {String} [unit] - String representing the decimal point value, e.g '1.5'
+ *
+ * @typedef {Object} TransformedMetricData
+ * @property {String} label - Title of the metric measured
+ * @property {String} value - String representing the decimal point value, e.g '1.5'
+ * @property {String} key - Slugified string based on the 'title'
+ * @property {String} description - String to display for a description
+ * @property {String} unit - String representing the decimal point value, e.g '1.5'
+ */
+
+/**
+ * Prepares metric data to be rendered in the metric_card component
+ *
+ * @param {MetricData[]} data - The metric data to be rendered
+ * @param {Object} popoverContent - Key value pair of data to display in the popover
+ * @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
+ */
+
+export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
+ data.map(({ title: label, ...rest }) => {
+ const key = slugify(label);
+ return {
+ ...rest,
+ label,
+ key,
+ description: popoverContent[key]?.description || '',
+ };
+ });