diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 14:22:11 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 14:22:11 +0000 |
commit | 0c872e02b2c822e3397515ec324051ff540f0cd5 (patch) | |
tree | ce2fb6ce7030e4dad0f4118d21ab6453e5938cdd /app/assets/javascripts/analytics | |
parent | f7e05a6853b12f02911494c4b3fe53d9540d74fc (diff) | |
download | gitlab-ce-0c872e02b2c822e3397515ec324051ff540f0cd5.tar.gz |
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/analytics')
17 files changed, 1693 insertions, 0 deletions
diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue new file mode 100644 index 00000000000..a688e2f497b --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -0,0 +1,192 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { getCookie, setCookie } from '~/lib/utils/common_utils'; +import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; +import { VSA_METRICS_GROUPS } from '~/analytics/shared/constants'; +import { toYmd } from '~/analytics/shared/utils'; +import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue'; +import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue'; +import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { __ } from '~/locale'; +import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants'; + +const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; + +export default { + name: 'CycleAnalytics', + components: { + GlLoadingIcon, + PathNavigation, + StageTable, + ValueStreamFilters, + ValueStreamMetrics, + UrlSync, + }, + props: { + noDataSvgPath: { + type: String, + required: true, + }, + noAccessSvgPath: { + type: String, + required: true, + }, + }, + data() { + return { + isOverviewDialogDismissed: getCookie(OVERVIEW_DIALOG_COOKIE), + }; + }, + computed: { + ...mapState([ + 'isLoading', + 'isLoadingStage', + 'isEmptyStage', + 'selectedStage', + 'selectedStageEvents', + 'selectedStageError', + 'stageCounts', + 'endpoints', + 'features', + 'createdBefore', + 'createdAfter', + 'pagination', + 'hasNoAccessError', + ]), + ...mapGetters(['pathNavigationData', 'filterParams']), + isLoaded() { + return !this.isLoading && !this.isLoadingStage; + }, + displayStageEvents() { + const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; + return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; + }, + displayNotEnoughData() { + return !this.isLoadingStage && this.isEmptyStage; + }, + displayNoAccess() { + return !this.isLoadingStage && this.hasNoAccessError; + }, + 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() { + 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; + }, + query() { + return { + created_after: toYmd(this.createdAfter), + created_before: toYmd(this.createdBefore), + stage_id: this.selectedStage?.id || null, + sort: this.pagination?.sort || null, + direction: this.pagination?.direction || null, + page: this.pagination?.page || null, + }; + }, + }, + methods: { + ...mapActions([ + 'fetchStageData', + 'setSelectedStage', + 'setDateRange', + 'updateStageTablePagination', + ]), + onSetDateRange({ startDate, endDate }) { + this.setDateRange({ + createdAfter: new Date(startDate), + createdBefore: new Date(endDate), + }); + }, + onSelectStage(stage) { + this.setSelectedStage(stage); + this.updateStageTablePagination({ ...this.pagination, page: 1 }); + }, + dismissOverviewDialog() { + this.isOverviewDialogDismissed = true; + setCookie(OVERVIEW_DIALOG_COOKIE, '1'); + }, + onHandleUpdatePagination(data) { + this.updateStageTablePagination(data); + }, + }, + dayRangeOptions: [7, 30, 90], + i18n: { + dropdownText: __('Last %{days} days'), + pageTitle: __('Value Stream Analytics'), + recentActivity: __('Recent Project Activity'), + }, + VSA_METRICS_GROUPS, +}; +</script> +<template> + <div> + <h3>{{ $options.i18n.pageTitle }}</h3> + <value-stream-filters + :group-id="endpoints.groupId" + :group-path="endpoints.groupPath" + :has-project-filter="false" + :start-date="createdAfter" + :end-date="createdBefore" + @setDateRange="onSetDateRange" + /> + <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row"> + <path-navigation + v-if="displayPathNavigation" + data-testid="vsa-path-navigation" + class="gl-w-full gl-mt-4" + :loading="isLoading || isLoadingStage" + :stages="pathNavigationData" + :selected-stage="selectedStage" + @selected="onSelectStage" + /> + </div> + <value-stream-metrics + :request-path="endpoints.fullPath" + :request-params="filterParams" + :requests="metricsRequests" + :group-by="$options.VSA_METRICS_GROUPS" + /> + <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="pagination" + @handleUpdatePagination="onHandleUpdatePagination" + /> + <url-sync v-if="isLoaded" :query="query" /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue new file mode 100644 index 00000000000..54b632968e2 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -0,0 +1,153 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + OPERATORS_IS, + OPTIONS_NONE_ANY, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { + prepareTokens, + processFilters, + filterToQueryObject, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +export default { + name: 'FilterBar', + components: { + FilteredSearchBar, + UrlSync, + }, + props: { + groupPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('filters', { + selectedMilestone: (state) => state.milestones.selected, + selectedAuthor: (state) => state.authors.selected, + selectedLabelList: (state) => state.labels.selectedList, + selectedAssigneeList: (state) => state.assignees.selectedList, + milestonesData: (state) => state.milestones.data, + labelsData: (state) => state.labels.data, + authorsData: (state) => state.authors.data, + assigneesData: (state) => state.assignees.data, + }), + tokens() { + return [ + { + icon: 'clock', + title: TOKEN_TITLE_MILESTONE, + type: TOKEN_TYPE_MILESTONE, + token: MilestoneToken, + initialMilestones: this.milestonesData, + unique: true, + symbol: '%', + operators: OPERATORS_IS, + fetchMilestones: this.fetchMilestones, + }, + { + icon: 'labels', + title: TOKEN_TITLE_LABEL, + type: TOKEN_TYPE_LABEL, + token: LabelToken, + defaultLabels: OPTIONS_NONE_ANY, + initialLabels: this.labelsData, + unique: false, + symbol: '~', + operators: OPERATORS_IS, + fetchLabels: this.fetchLabels, + }, + { + icon: 'pencil', + title: TOKEN_TITLE_AUTHOR, + type: TOKEN_TYPE_AUTHOR, + token: UserToken, + initialUsers: this.authorsData, + unique: true, + operators: OPERATORS_IS, + fetchUsers: this.fetchAuthors, + }, + { + icon: 'user', + title: TOKEN_TITLE_ASSIGNEE, + type: TOKEN_TYPE_ASSIGNEE, + token: UserToken, + initialUsers: this.assigneesData, + unique: false, + operators: OPERATORS_IS, + fetchUsers: this.fetchAssignees, + }, + ]; + }, + query() { + return filterToQueryObject({ + milestone_title: this.selectedMilestone, + author_username: this.selectedAuthor, + label_name: this.selectedLabelList, + assignee_username: this.selectedAssigneeList, + }); + }, + }, + methods: { + ...mapActions('filters', [ + 'setFilters', + 'fetchMilestones', + 'fetchLabels', + 'fetchAuthors', + 'fetchAssignees', + ]), + initialFilterValue() { + return prepareTokens({ + [TOKEN_TYPE_MILESTONE]: this.selectedMilestone, + [TOKEN_TYPE_AUTHOR]: this.selectedAuthor, + [TOKEN_TYPE_ASSIGNEE]: this.selectedAssigneeList, + [TOKEN_TYPE_LABEL]: this.selectedLabelList, + }); + }, + handleFilter(filters) { + const { + [TOKEN_TYPE_LABEL]: labels, + [TOKEN_TYPE_MILESTONE]: milestone, + [TOKEN_TYPE_AUTHOR]: author, + [TOKEN_TYPE_ASSIGNEE]: assignees, + } = processFilters(filters); + + this.setFilters({ + selectedAuthor: author ? author[0] : null, + selectedMilestone: milestone ? milestone[0] : null, + selectedAssigneeList: assignees || [], + selectedLabelList: labels || [], + }); + }, + }, +}; +</script> + +<template> + <div> + <filtered-search-bar + class="gl-flex-grow-1" + :namespace="groupPath" + recent-searches-storage-key="value-stream-analytics" + :search-input-placeholder="__('Filter results')" + :tokens="tokens" + :initial-filter-value="initialFilterValue()" + @onFilter="handleFilter" + /> + <url-sync :query="query" /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue new file mode 100644 index 00000000000..b622b0441e2 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue @@ -0,0 +1,32 @@ +<script> +import { s__, n__, sprintf, formatNumber } from '~/locale'; + +export default { + props: { + stageCount: { + type: Number, + required: false, + default: null, + }, + }, + computed: { + formattedStageCount() { + if (!this.stageCount) { + return '-'; + } else if (this.stageCount > 1000) { + return sprintf(s__('ValueStreamAnalytics|%{stageCount}+ items'), { + stageCount: formatNumber(1000), + }); + } + + return sprintf(n__('%{count} item', '%{count} items', this.stageCount), { + count: formatNumber(this.stageCount), + }); + }, + }, +}; +</script> + +<template> + <span>{{ formattedStageCount }}</span> +</template> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue new file mode 100644 index 00000000000..a5c20b237b3 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue @@ -0,0 +1,51 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { redirectTo } from '~/lib/utils/url_utility'; +import MetricPopover from '~/analytics/shared/components/metric_popover.vue'; + +export default { + name: 'MetricTile', + components: { + GlSingleStat, + MetricPopover, + }, + props: { + metric: { + type: Object, + required: true, + }, + }, + computed: { + decimalPlaces() { + const parsedFloat = parseFloat(this.metric.value); + return Number.isNaN(parsedFloat) || Number.isInteger(parsedFloat) ? 0 : 1; + }, + hasLinks() { + return this.metric.links?.length && this.metric.links[0].url; + }, + }, + methods: { + clickHandler({ links }) { + if (this.hasLinks) { + redirectTo(links[0].url); + } + }, + }, +}; +</script> +<template> + <div v-bind="$attrs"> + <gl-single-stat + :id="metric.identifier" + :value="`${metric.value}`" + :title="metric.label" + :unit="metric.unit || ''" + :should-animate="true" + :animation-decimal-places="decimalPlaces" + :class="{ 'gl-hover-cursor-pointer': hasLinks }" + tabindex="0" + @click="clickHandler(metric)" + /> + <metric-popover :metric="metric" :target="metric.identifier" /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue new file mode 100644 index 00000000000..ac41bc4917c --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue @@ -0,0 +1,114 @@ +<script> +import { GlPath, GlPopover, GlSkeletonLoader } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import Tracking from '~/tracking'; +import { OVERVIEW_STAGE_ID } from '../constants'; +import FormattedStageCount from './formatted_stage_count.vue'; + +export default { + name: 'PathNavigation', + components: { + GlPath, + GlSkeletonLoader, + GlPopover, + FormattedStageCount, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + stages: { + type: Array, + required: true, + }, + selectedStage: { + type: Object, + required: false, + default: () => ({}), + }, + }, + methods: { + showPopover({ id }) { + return id && id !== OVERVIEW_STAGE_ID; + }, + onSelectStage($event) { + this.$emit('selected', $event); + this.track('click_path_navigation', { + extra: { + stage_id: $event.id, + }, + }); + }, + }, + popoverOptions: { + triggers: 'hover', + placement: 'bottom', + }, +}; +</script> +<template> + <gl-skeleton-loader v-if="loading" :width="235" :lines="2" /> + <gl-path v-else :key="selectedStage.id" :items="stages" @selected="onSelectStage"> + <template #default="{ pathItem, pathId }"> + <gl-popover + v-if="showPopover(pathItem)" + v-bind="$options.popoverOptions" + :target="pathId" + :css-classes="['stage-item-popover']" + data-testid="stage-item-popover" + > + <template #title>{{ pathItem.title }}</template> + <div class="gl-px-4"> + <div class="gl-display-flex gl-justify-content-space-between"> + <div class="gl-pr-4 gl-pb-4"> + {{ s__('ValueStreamEvent|Stage time (median)') }} + </div> + <div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div> + </div> + </div> + <div class="gl-px-4"> + <div class="gl-display-flex gl-justify-content-space-between"> + <div class="gl-pr-4 gl-pb-4"> + {{ s__('ValueStreamEvent|Items in stage') }} + </div> + <div class="gl-pb-4 gl-font-weight-bold"> + <formatted-stage-count :stage-count="pathItem.stageCount" /> + </div> + </div> + </div> + <div class="gl-px-4 gl-pt-4 gl-border-t-1 gl-border-t-solid gl-border-gray-50"> + <div + v-if="pathItem.startEventHtmlDescription" + class="gl-display-flex gl-flex-direction-row" + > + <div class="gl-display-flex gl-flex-direction-column gl-pr-4 gl-pb-4 metric-label"> + {{ s__('ValueStreamEvent|Start') }} + </div> + <div + v-safe-html="pathItem.startEventHtmlDescription" + class="gl-display-flex gl-flex-direction-column gl-pb-4 stage-event-description" + ></div> + </div> + <div + v-if="pathItem.endEventHtmlDescription" + class="gl-display-flex gl-flex-direction-row" + > + <div class="gl-display-flex gl-flex-direction-column gl-pr-4 metric-label"> + {{ s__('ValueStreamEvent|Stop') }} + </div> + <div + v-safe-html="pathItem.endEventHtmlDescription" + class="gl-display-flex gl-flex-direction-column stage-event-description" + ></div> + </div> + </div> + </gl-popover> + </template> + </gl-path> +</template> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue new file mode 100644 index 00000000000..78ac29426d9 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue @@ -0,0 +1,305 @@ +<script> +import { + GlEmptyState, + GlIcon, + GlLink, + GlLoadingIcon, + GlPagination, + GlTable, + GlBadge, +} from '@gitlab/ui'; +import FormattedStageCount from '~/analytics/cycle_analytics/components/formatted_stage_count.vue'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { + NOT_ENOUGH_DATA_ERROR, + FIELD_KEY_TITLE, + PAGINATION_SORT_FIELD_END_EVENT, + PAGINATION_SORT_FIELD_DURATION, + PAGINATION_SORT_DIRECTION_ASC, + PAGINATION_SORT_DIRECTION_DESC, +} from '../constants'; +import TotalTime from './total_time.vue'; + +const DEFAULT_WORKFLOW_TITLE_PROPERTIES = { + thClass: 'gl-w-half', + key: FIELD_KEY_TITLE, + sortable: false, +}; + +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') }, +}; + +const fullProjectPath = ({ namespaceFullPath = '', projectPath }) => + namespaceFullPath.split('/').length > 1 ? `${namespaceFullPath}/${projectPath}` : projectPath; + +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, + }, + sortable: { + type: Boolean, + required: false, + default: true, + }, + includeProjectName: { + type: Boolean, + required: false, + default: false, + }, + }, + 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_END_EVENT, + label: __('Last event'), + sortable: this.sortable, + }, + { + key: PAGINATION_SORT_FIELD_DURATION, + label: __('Duration'), + sortable: this.sortable, + }, + ]; + }, + 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({ iid, projectPath, namespaceFullPath = '' }, separator = '#') { + const prefix = this.includeProjectName + ? fullProjectPath({ namespaceFullPath, projectPath }) + : ''; + return `${prefix}${separator}${iid}`; + }, + itemDisplayName(item) { + const separator = this.isMrLink(item.url) ? '!' : '#'; + return this.itemId(item, separator); + }, + 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="lg" /> + <gl-empty-state + v-else-if="isEmptyStage" + :title="emptyStateTitleText" + :description="emptyStateMessage" + :svg-path="noDataSvgPath" + /> + <gl-table + v-else + stacked="lg" + 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(title)="data"> + <span>{{ data.label }}</span + ><gl-badge class="gl-ml-2" size="sm" + ><formatted-stage-count :stage-count="stageCount" + /></gl-badge> + </template> + <template #head(duration)="data"> + <span data-testid="vsa-stage-header-duration">{{ data.label }}</span> + </template> + <template #head(end_event)="data"> + <span data-testid="vsa-stage-header-last-event">{{ data.label }}</span> + </template> + <template #cell(title)="{ item }"> + <div data-testid="vsa-stage-event"> + <div v-if="item.id" data-testid="vsa-stage-content"> + <p class="gl-m-0"> + <gl-link + data-testid="vsa-stage-event-link" + class="gl-text-black-normal" + :href="item.url" + >{{ itemId(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" :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 + data-testid="vsa-stage-event-link" + class="gl-text-black-normal" + :href="item.url" + >{{ itemDisplayName(item) }}</gl-link + > + <span class="gl-font-lg">·</span> + <span data-testid="vsa-stage-event-date"> + {{ s__('OpenedNDaysAgo|Created') }} + <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> + <template #cell(end_event)="{ item }"> + <span data-testid="vsa-stage-last-event">{{ item.endEventTimestamp }}</span> + </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/analytics/cycle_analytics/components/total_time.vue b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue new file mode 100644 index 00000000000..725952c3518 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue @@ -0,0 +1,61 @@ +<script> +import { n__, s__ } from '~/locale'; + +export default { + props: { + time: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + 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> + <template v-if="hasData"> + {{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span> + </template> + <template v-else> -- </template> + </span> +</template> diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue new file mode 100644 index 00000000000..17decb6b448 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue @@ -0,0 +1,109 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import DateRange from '~/analytics/shared/components/daterange.vue'; +import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; +import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants'; +import FilterBar from './filter_bar.vue'; + +export default { + name: 'ValueStreamFilters', + components: { + DateRange, + ProjectsDropdownFilter, + FilterBar, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + selectedProjects: { + type: Array, + required: false, + default: () => [], + }, + hasProjectFilter: { + type: Boolean, + required: false, + default: true, + }, + hasDateRangeFilter: { + type: Boolean, + required: false, + default: true, + }, + groupId: { + type: Number, + required: true, + }, + groupPath: { + type: String, + required: true, + }, + startDate: { + type: Date, + required: false, + default: null, + }, + endDate: { + type: Date, + required: false, + default: null, + }, + }, + computed: { + projectsQueryParams() { + return { + first: PROJECTS_PER_PAGE, + includeSubgroups: true, + }; + }, + currentDate() { + const now = new Date(); + return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + }, + }, + multiProjectSelect: true, + maxDateRange: DATE_RANGE_LIMIT, +}; +</script> +<template> + <div + class="gl-mt-3 gl-py-2 gl-px-3 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-t-1 gl-border-t-solid gl-border-gray-100" + > + <filter-bar + data-testid="vsa-filter-bar" + class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none" + :group-path="groupPath" + /> + <div + v-if="hasDateRangeFilter || hasProjectFilter" + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-justify-content-space-between" + > + <div> + <projects-dropdown-filter + v-if="hasProjectFilter" + :key="groupId" + class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0" + :group-id="groupId" + :group-namespace="groupPath" + :query-params="projectsQueryParams" + :multi-select="$options.multiProjectSelect" + :default-projects="selectedProjects" + @selected="$emit('selectProject', $event)" + /> + </div> + <div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row"> + <date-range + v-if="hasDateRangeFilter" + :start-date="startDate" + :end-date="endDate" + :max-date="currentDate" + :max-date-range="$options.maxDateRange" + :include-selected-date="true" + class="js-daterange-picker" + @change="$emit('setDateRange', $event)" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/analytics/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js new file mode 100644 index 00000000000..2758d686fb1 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js @@ -0,0 +1,47 @@ +import { + getValueStreamMetrics, + METRIC_TYPE_SUMMARY, + METRIC_TYPE_TIME_SUMMARY, +} from '~/api/analytics_api'; +import { __, s__ } from '~/locale'; + +export const OVERVIEW_STAGE_ID = 'overview'; + +export const DEFAULT_VALUE_STREAM = { + id: 'default', + 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 FIELD_KEY_TITLE = 'title'; + +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 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/analytics/cycle_analytics/index.js b/app/assets/javascripts/analytics/cycle_analytics/index.js new file mode 100644 index 00000000000..df161f7e563 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/index.js @@ -0,0 +1,50 @@ +import Vue from 'vue'; +import { + extractFilterQueryParameters, + extractPaginationQueryParameters, +} from '~/analytics/shared/utils'; +import Translate from '~/vue_shared/translate'; +import CycleAnalytics from './components/base.vue'; +import createStore from './store'; +import { buildCycleAnalyticsInitialData } from './utils'; + +Vue.use(Translate); + +export default () => { + const store = createStore(); + const el = document.querySelector('#js-cycle-analytics'); + const { noAccessSvgPath, noDataSvgPath } = el.dataset; + const initialData = buildCycleAnalyticsInitialData({ ...el.dataset, gon }); + + const pagination = extractPaginationQueryParameters(window.location.search); + const { + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + } = extractFilterQueryParameters(window.location.search); + + store.dispatch('initializeVsa', { + ...initialData, + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + pagination, + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'CycleAnalytics', + apolloProvider: {}, + store, + render: (createElement) => + createElement(CycleAnalytics, { + props: { + noDataSvgPath, + noAccessSvgPath, + }, + }), + }); +}; diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/actions.js b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js new file mode 100644 index 00000000000..4a201e00582 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js @@ -0,0 +1,209 @@ +import { + getProjectValueStreamStages, + getProjectValueStreams, + getValueStreamStageMedian, + getValueStreamStageRecords, + getValueStreamStageCounts, +} from '~/api/analytics_api'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +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'); +}; + +export const fetchValueStreamStages = ({ commit, state }) => { + const { + endpoints: { fullPath }, + selectedValueStream: { id }, + } = state; + commit(types.REQUEST_VALUE_STREAM_STAGES); + + 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); + }); +}; + +export const receiveValueStreamsSuccess = ({ commit, dispatch }, data = []) => { + commit(types.RECEIVE_VALUE_STREAMS_SUCCESS, data); + if (data.length) { + const [firstStream] = data; + return dispatch('setSelectedValueStream', firstStream); + } + return dispatch('setSelectedValueStream', DEFAULT_VALUE_STREAM); +}; + +export const fetchValueStreams = ({ commit, dispatch, state }) => { + const { + endpoints: { fullPath }, + } = state; + commit(types.REQUEST_VALUE_STREAMS); + + return getProjectValueStreams(fullPath) + .then(({ data }) => dispatch('receiveValueStreamsSuccess', data)) + .catch(({ response: { status } }) => { + commit(types.RECEIVE_VALUE_STREAMS_ERROR, status); + }); +}; + +export const fetchStageData = ({ + getters: { requestParams, filterParams, paginationParams }, + commit, +}) => { + commit(types.REQUEST_STAGE_DATA); + + return getValueStreamStageRecords(requestParams, { ...filterParams, ...paginationParams }) + .then(({ data, headers }) => { + // when there's a query timeout, the request succeeds but the error is encoded in the response data + if (data?.error) { + commit(types.RECEIVE_STAGE_DATA_ERROR, data.error); + } else { + commit(types.RECEIVE_STAGE_DATA_SUCCESS, data); + const { page = null, nextPage = null } = parseIntPagination(normalizeHeaders(headers)); + commit(types.SET_PAGINATION, { ...paginationParams, page, hasNextPage: Boolean(nextPage) }); + } + }) + .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); +}; + +const getStageMedians = ({ stageId, vsaParams, filterParams = {} }) => { + return getValueStreamStageMedian({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({ + id: stageId, + value: data?.value || null, + })); +}; + +export const fetchStageMedians = ({ + state: { stages }, + getters: { requestParams: vsaParams, filterParams }, + commit, +}) => { + commit(types.REQUEST_STAGE_MEDIANS); + return Promise.all( + stages.map(({ id: stageId }) => + getStageMedians({ + vsaParams, + stageId, + filterParams, + }), + ), + ) + .then((data) => commit(types.RECEIVE_STAGE_MEDIANS_SUCCESS, data)) + .catch((error) => { + commit(types.RECEIVE_STAGE_MEDIANS_ERROR, error); + createAlert({ 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); + createAlert({ + message: __('There was an error fetching stage total counts'), + }); + }); +}; + +export const fetchValueStreamStageData = ({ dispatch }) => + Promise.all([ + dispatch('fetchStageData'), + dispatch('fetchStageMedians'), + dispatch('fetchStageCountValues'), + ]); + +export const refetchStageData = async ({ dispatch, commit }) => { + commit(types.SET_LOADING, true); + await dispatch('fetchValueStreamStageData'); + commit(types.SET_LOADING, false); +}; + +export const setSelectedStage = ({ dispatch, commit }, selectedStage = null) => { + commit(types.SET_SELECTED_STAGE, selectedStage); + return dispatch('refetchStageData'); +}; + +export const setFilters = ({ dispatch }) => dispatch('refetchStageData'); + +export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore }) => { + commit(types.SET_DATE_RANGE, { createdAfter, createdBefore }); + return dispatch('refetchStageData'); +}; + +export const setInitialStage = ({ dispatch, commit, state: { stages } }, stage) => { + if (!stages.length && !stage) { + commit(types.SET_NO_ACCESS_ERROR); + return null; + } + + const selectedStage = stage || stages[0]; + commit(types.SET_SELECTED_STAGE, selectedStage); + return dispatch('fetchValueStreamStageData'); +}; + +export const updateStageTablePagination = ( + { commit, dispatch, state: { selectedStage } }, + paginationParams, +) => { + commit(types.SET_PAGINATION, paginationParams); + return dispatch('fetchStageData', selectedStage.id); +}; + +export const initializeVsa = async ({ commit, dispatch }, initialData = {}) => { + commit(types.INITIALIZE_VSA, initialData); + + const { + endpoints: { fullPath, groupPath, milestonesPath = '', labelsPath = '' }, + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + selectedStage = null, + } = initialData; + + dispatch('filters/setEndpoints', { + labelsEndpoint: labelsPath, + milestonesEndpoint: milestonesPath, + groupEndpoint: groupPath, + projectEndpoint: fullPath, + }); + + dispatch('filters/initialize', { + selectedAuthor, + selectedMilestone, + selectedAssigneeList, + selectedLabelList, + }); + + commit(types.SET_LOADING, true); + await dispatch('fetchValueStreams'); + await dispatch('setInitialStage', selectedStage); + commit(types.SET_LOADING, false); +}; diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/getters.js b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js new file mode 100644 index 00000000000..83068cabf0f --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js @@ -0,0 +1,57 @@ +import { dateFormats } from '~/analytics/shared/constants'; +import dateFormat from '~/lib/dateformat'; +import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { PAGINATION_TYPE } from '../constants'; +import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils'; + +export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => { + return transformStagesForPathNavigation({ + stages: filterStagesByHiddenStatus(stages, false), + medians, + stageCounts, + selectedStage, + }); +}; + +export const requestParams = (state) => { + const { + endpoints: { fullPath }, + selectedValueStream: { id: valueStreamId }, + selectedStage: { id: stageId = null }, + } = state; + return { requestPath: fullPath, valueStreamId, stageId }; +}; + +export const paginationParams = ({ pagination: { page, sort, direction } }) => ({ + pagination: PAGINATION_TYPE, + sort, + direction, + page, +}); + +const filterBarParams = ({ filters }) => { + const { + authors: { selected: selectedAuthor }, + milestones: { selected: selectedMilestone }, + assignees: { selectedList: selectedAssigneeList }, + labels: { selectedList: selectedLabelList }, + } = filters; + return filterToQueryObject({ + milestone_title: selectedMilestone, + author_username: selectedAuthor, + label_name: selectedLabelList, + assignee_username: selectedAssigneeList, + }); +}; + +const dateRangeParams = ({ createdAfter, createdBefore }) => ({ + created_after: createdAfter ? dateFormat(createdAfter, dateFormats.isoDate) : null, + created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null, +}); + +export const filterParams = (state) => { + return { + ...filterBarParams(state), + ...dateRangeParams(state), + }; +}; diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/index.js b/app/assets/javascripts/analytics/cycle_analytics/store/index.js new file mode 100644 index 00000000000..76e3e835016 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/store/index.js @@ -0,0 +1,25 @@ +/** + * While we are in the process implementing group level features at the project level + * we will use a simplified vuex store for the project level, eventually this can be + * replaced with the store at ee/app/assets/javascripts/analytics/cycle_analytics/store/index.js + * once we have enough of the same features implemented across the project and group level + */ + +import Vue from 'vue'; +import Vuex from 'vuex'; +import filters from '~/vue_shared/components/filtered_search_bar/store/modules/filters'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state, + modules: { filters }, + }); diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js new file mode 100644 index 00000000000..9376d81f317 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js @@ -0,0 +1,28 @@ +export const INITIALIZE_VSA = 'INITIALIZE_VSA'; + +export const SET_LOADING = 'SET_LOADING'; +export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM'; +export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; +export const SET_DATE_RANGE = 'SET_DATE_RANGE'; +export const SET_PAGINATION = 'SET_PAGINATION'; +export const SET_NO_ACCESS_ERROR = 'SET_NO_ACCESS_ERROR'; + +export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS'; +export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS'; +export const RECEIVE_VALUE_STREAMS_ERROR = 'RECEIVE_VALUE_STREAMS_ERROR'; + +export const REQUEST_VALUE_STREAM_STAGES = 'REQUEST_VALUE_STREAM_STAGES'; +export const RECEIVE_VALUE_STREAM_STAGES_SUCCESS = 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS'; +export const RECEIVE_VALUE_STREAM_STAGES_ERROR = 'RECEIVE_VALUE_STREAM_STAGES_ERROR'; + +export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA'; +export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS'; +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/analytics/cycle_analytics/store/mutations.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js new file mode 100644 index 00000000000..8567529caf2 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js @@ -0,0 +1,112 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants'; +import { formatMedianValues } from '../utils'; +import * as types from './mutation_types'; + +export default { + [types.INITIALIZE_VSA]( + state, + { endpoints, features, createdBefore, createdAfter, pagination = {} }, + ) { + state.endpoints = endpoints; + state.createdBefore = createdBefore; + state.createdAfter = createdAfter; + state.features = features; + + Vue.set(state, 'pagination', { + page: pagination.page ?? state.pagination.page, + sort: pagination.sort ?? state.pagination.sort, + direction: pagination.direction ?? state.pagination.direction, + }); + }, + [types.SET_LOADING](state, loadingState) { + state.isLoading = loadingState; + }, + [types.SET_SELECTED_VALUE_STREAM](state, selectedValueStream = {}) { + state.selectedValueStream = convertObjectPropsToCamelCase(selectedValueStream, { deep: true }); + }, + [types.SET_SELECTED_STAGE](state, stage) { + state.selectedStage = stage; + }, + [types.SET_DATE_RANGE](state, { createdAfter, createdBefore }) { + state.createdBefore = createdBefore; + state.createdAfter = createdAfter; + }, + [types.SET_PAGINATION](state, { page, hasNextPage, sort, direction }) { + Vue.set(state, 'pagination', { + page, + hasNextPage, + sort: sort || PAGINATION_SORT_FIELD_END_EVENT, + direction: direction || PAGINATION_SORT_DIRECTION_DESC, + }); + }, + [types.SET_NO_ACCESS_ERROR](state) { + state.hasNoAccessError = true; + }, + [types.REQUEST_VALUE_STREAMS](state) { + state.valueStreams = []; + }, + [types.RECEIVE_VALUE_STREAMS_SUCCESS](state, valueStreams = []) { + state.valueStreams = valueStreams; + }, + [types.RECEIVE_VALUE_STREAMS_ERROR](state) { + state.valueStreams = []; + }, + [types.REQUEST_VALUE_STREAM_STAGES](state) { + state.stages = []; + }, + [types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS](state, { stages = [] }) { + state.stages = stages.map((s) => convertObjectPropsToCamelCase(s, { deep: true })); + }, + [types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) { + state.stages = []; + }, + [types.REQUEST_STAGE_DATA](state) { + state.isLoadingStage = true; + state.isEmptyStage = false; + state.selectedStageEvents = []; + + state.hasNoAccessError = false; + }, + [types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) { + state.isLoadingStage = false; + state.isEmptyStage = !events.length; + state.selectedStageEvents = events.map((ev) => + convertObjectPropsToCamelCase(ev, { deep: true }), + ); + + state.hasNoAccessError = false; + }, + [types.RECEIVE_STAGE_DATA_ERROR](state, error) { + state.isLoadingStage = false; + state.isEmptyStage = true; + state.selectedStageEvents = []; + + state.selectedStageError = error; + }, + [types.REQUEST_STAGE_MEDIANS](state) { + state.medians = {}; + }, + [types.RECEIVE_STAGE_MEDIANS_SUCCESS](state, medians) { + state.medians = formatMedianValues(medians); + }, + [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/analytics/cycle_analytics/store/state.js b/app/assets/javascripts/analytics/cycle_analytics/store/state.js new file mode 100644 index 00000000000..00dd2e53883 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/store/state.js @@ -0,0 +1,31 @@ +import { + PAGINATION_SORT_FIELD_END_EVENT, + PAGINATION_SORT_DIRECTION_DESC, +} from '~/analytics/cycle_analytics/constants'; + +export default () => ({ + id: null, + features: {}, + endpoints: {}, + createdAfter: null, + createdBefore: null, + stages: [], + analytics: [], + valueStreams: [], + selectedValueStream: {}, + selectedStage: {}, + selectedStageEvents: [], + selectedStageError: '', + medians: {}, + stageCounts: {}, + hasNoAccessError: false, + isLoading: false, + isLoadingStage: false, + isEmptyStage: false, + pagination: { + page: null, + hasNextPage: false, + sort: PAGINATION_SORT_FIELD_END_EVENT, + direction: PAGINATION_SORT_DIRECTION_DESC, + }, +}); diff --git a/app/assets/javascripts/analytics/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js new file mode 100644 index 00000000000..428bb11b950 --- /dev/null +++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js @@ -0,0 +1,117 @@ +import { parseSeconds } from '~/lib/utils/datetime_utility'; +import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility'; + +/** + * Takes the stages and median data, combined with the selected stage, to build an + * array which is formatted to proivde the data required for the path navigation. + * + * @param {Array} stages - The stages available to the group / project + * @param {Object} medians - The median values for the stages available to the group / project + * @param {Object} stageCounts - The total item count for the stages available + * @param {Object} selectedStage - The currently selected stage + * @returns {Array} An array of stages formatted with data required for the path navigation + */ +export const transformStagesForPathNavigation = ({ + stages, + medians, + stageCounts = {}, + selectedStage, +}) => { + const formattedStages = stages.map((stage) => { + return { + metric: medians[stage?.id], + selected: stage?.id === selectedStage?.id, // Also could null === null cause an issue here? + stageCount: stageCounts && stageCounts[stage?.id], + icon: null, + ...stage, + }; + }); + + return formattedStages; +}; + +/** + * Takes a raw median value in seconds and converts it to a string representation + * ie. converts 172800 => 2d (2 days) + * + * @param {Number} Median - The number of seconds for the median calculation + * @returns {String} String representation ie 2w + */ +export const medianTimeToParsedSeconds = (value) => + formatTimeAsSummary({ + ...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }), + seconds: value, + }); + +/** + * Takes the raw median value arrays and converts them into a useful object + * containing the string for display in the path navigation + * ie. converts [{ id: 'test', value: 172800 }] => { 'test': '2d' } + * + * @param {Array} Medians - Array of stage median objects, each contains a `id`, `value` and `error` + * @returns {Object} Returns key value pair with the stage name and its display median value + */ +export const formatMedianValues = (medians = []) => + medians.reduce((acc, { id, value = 0 }) => { + return { + ...acc, + [id]: value ? medianTimeToParsedSeconds(value) : '-', + }; + }, {}); + +export const filterStagesByHiddenStatus = (stages = [], isHidden = true) => + stages.filter(({ hidden = false }) => hidden === isHidden); + +/** + * @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} identifier - Slugified string based on the 'title' or the provided 'identifier' attribute + * @property {String} description - String to display for a description + * @property {String} unit - String representing the decimal point value, e.g '1.5' + */ + +const extractFeatures = (gon) => ({ + cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups), +}); + +/** + * Builds the initial data object for Value Stream Analytics with data loaded from the backend + * + * @param {Object} dataset - dataset object paseed to the frontend via data-* properties + * @returns {Object} - The initial data to load the app with + */ +export const buildCycleAnalyticsInitialData = ({ + fullPath, + requestPath, + projectId, + groupId, + groupPath, + labelsPath, + milestonesPath, + stage, + createdAfter, + createdBefore, + gon, +} = {}) => { + return { + projectId: parseInt(projectId, 10), + endpoints: { + requestPath, + fullPath, + labelsPath, + milestonesPath, + groupId: parseInt(groupId, 10), + groupPath, + }, + createdAfter: new Date(createdAfter), + createdBefore: new Date(createdBefore), + selectedStage: stage ? JSON.parse(stage) : null, + features: extractFeatures(gon), + }; +}; |