summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/analytics/cycle_analytics
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/analytics/cycle_analytics')
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/base.vue192
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue153
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue32
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue51
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue114
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue305
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue61
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue109
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/constants.js47
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/index.js50
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/actions.js209
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/getters.js57
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/index.js25
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js28
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/mutations.js112
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/store/state.js31
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/utils.js117
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">&middot;</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),
+ };
+};