diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 12:08:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 12:08:42 +0300 |
commit | b76ae638462ab0f673e5915986070518dd3f9ad3 (patch) | |
tree | bdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/cycle_analytics | |
parent | 434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff) |
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/cycle_analytics')
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> · - <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> · - <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> · - <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">·</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> · - <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 || '', + }; + }); |