Commit 5bd93d1e authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'group-level-cycle-analytics-refactor' into 'master'

FE for group level Cycle Analytics - refactor old feature into Vue

See merge request gitlab-org/gitlab!15039
parents c2332321 115b1956
......@@ -32,7 +32,7 @@ export default {
<div class="item-details">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="mergeRequest.author.avatarUrl" />
<h5 class="item-title merge-merquest-title">
<h5 class="item-title merge-request-title">
<a :href="mergeRequest.url"> {{ mergeRequest.title }} </a>
</h5>
<a :href="mergeRequest.url" class="issue-link"> !{{ mergeRequest.iid }} </a> &middot;
......
......@@ -34,7 +34,7 @@ export default {
<div class="item-details">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image :img-src="mergeRequest.author.avatarUrl" />
<h5 class="item-title merge-merquest-title">
<h5 class="item-title merge-request-title">
<a :href="mergeRequest.url"> {{ mergeRequest.title }} </a>
</h5>
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> &middot;
......
#cycle-analytics {
max-width: 1000px;
#cycle-analytics,
.cycle-analytics {
margin: 24px auto 0;
position: relative;
......@@ -41,7 +41,8 @@
width: 20%;
}
.fa {
.fa,
svg {
color: $cycle-analytics-light-gray;
&:hover {
......@@ -233,7 +234,7 @@
&.issue-title,
&.commit-title,
&.merge-merquest-title {
&.merge-request-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
......@@ -266,6 +267,7 @@
color: $gl-text-color;
}
.mr-link,
.issue-link,
.commit-author-link,
.issue-author-link {
......
......@@ -20,7 +20,7 @@ export default {
<li
:class="[active ? activeClass : inactiveClass]"
class="js-add-stage-button stage-nav-item ml-2 mb-1 rounded d-flex justify-content-center border-width-1px"
@click="$emit('showform')"
@click.prevent="$emit('showform')"
>
{{ s__('CustomCycleAnalytics|Add a stage') }}
</li>
......
<script>
import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import DateRangeDropdown from '../../shared/components/date_range_dropdown.vue';
import SummaryTable from './summary_table.vue';
import StageTable from './stage_table.vue';
export default {
name: 'CycleAnalytics',
components: {
GlEmptyState,
GroupsDropdownFilter,
ProjectsDropdownFilter,
DateRangeDropdown,
SummaryTable,
StageTable,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
noDataSvgPath: {
type: String,
required: true,
},
noAccessSvgPath: {
type: String,
required: true,
},
},
data() {
return {
multiProjectSelect: true,
dateOptions: [7, 30, 90],
groupsQueryParams: {
min_access_level: featureAccessLevel.EVERYONE,
},
};
},
computed: {
...mapState([
'isLoading',
'isLoadingStage',
'isEmptyStage',
'isAddingCustomStage',
'selectedGroup',
'selectedProjectIds',
'selectedStageName',
'events',
'stages',
'summary',
'dataTimeframe',
]),
...mapGetters(['currentStage', 'defaultStage']),
shouldRenderEmptyState() {
return !this.selectedGroup;
},
hasCustomizableCycleAnalytics() {
return gon && gon.features ? gon.features.customizableCycleAnalytics : false;
},
},
methods: {
...mapActions([
'setCycleAnalyticsDataEndpoint',
'setStageDataEndpoint',
'setSelectedGroup',
'fetchCycleAnalyticsData',
'setSelectedProjects',
'setSelectedTimeframe',
'fetchStageData',
'setSelectedStageName',
'showCustomStageForm',
'hideCustomStageForm',
]),
onGroupSelect(group) {
this.setCycleAnalyticsDataEndpoint(group.path);
this.setSelectedGroup(group);
this.fetchCycleAnalyticsData();
},
onProjectsSelect(projects) {
const projectIds = projects.map(value => value.id);
this.setSelectedProjects(projectIds);
this.fetchCycleAnalyticsData();
},
onTimeframeSelect(days) {
this.setSelectedTimeframe(days);
this.fetchCycleAnalyticsData();
},
onStageSelect(stage) {
this.hideCustomStageForm();
this.setSelectedStageName(stage.name);
this.setStageDataEndpoint(this.currentStage.slug);
this.fetchStageData(this.currentStage.name);
},
onShowAddStageForm() {
this.showCustomStageForm();
},
},
};
</script>
<template>
<div>{{ __('Hello World!') }}</div>
<div>
<div class="page-title-holder d-flex align-items-center">
<h3 class="page-title">{{ __('Cycle Analytics') }}</h3>
</div>
<div class="mw-100">
<div
class="mt-3 py-2 px-3 d-flex bg-gray-light border-top border-bottom flex-column flex-md-row justify-content-between"
>
<groups-dropdown-filter
class="js-groups-dropdown-filter dropdown-select"
:query-params="groupsQueryParams"
@selected="onGroupSelect"
/>
<projects-dropdown-filter
v-if="selectedGroup"
:key="selectedGroup.id"
class="js-projects-dropdown-filter ml-md-1 mt-1 mt-md-0 dropdown-select"
:group-id="selectedGroup.id"
:multi-select="multiProjectSelect"
@selected="onProjectsSelect"
/>
<div
v-if="selectedGroup"
class="ml-0 ml-md-auto mt-2 mt-md-0 d-flex flex-column flex-md-row align-items-md-center justify-content-md-end"
>
<label class="text-bold mb-0 mr-1">{{ __('Timeframe') }}</label>
<date-range-dropdown
class="js-timeframe-dropdown"
:available-days-in-past="dateOptions"
:default-selected="dataTimeframe"
@selected="onTimeframeSelect"
/>
</div>
</div>
</div>
<gl-empty-state
v-if="shouldRenderEmptyState"
:title="__('Cycle Analytics can help you determine your team’s velocity')"
:description="
__(
'Start by choosing a group to see how your team is spending time. You can then drill down to the project level.',
)
"
:svg-path="emptyStateSvgPath"
/>
<div v-else class="cycle-analytics mt-0">
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading-stage="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:events="events"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
</div>
</div>
</template>
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { EVENTS_LIST_ITEM_LIMIT } from '../constants';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
count: {
type: Number,
required: true,
},
},
data() {
return { EVENTS_LIST_ITEM_LIMIT };
},
};
</script>
<template>
<!-- TODO: im not sure why this is rendered only for exactly 50 items, why not >= 50? -->
<span v-if="count >= EVENTS_LIST_ITEM_LIMIT" class="events-info float-right">
<i
v-gl-tooltip
:title="
n__(
'Limited to showing %d event at most',
'Limited to showing %d events at most',
EVENTS_LIST_ITEM_LIMIT,
)
"
class="fa fa-warning"
aria-hidden="true"
>
</i>
{{ n__('Showing %d event', 'Showing %d events', EVENTS_LIST_ITEM_LIMIT) }}
</span>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import LimitWarning from './limit_warning_component.vue';
import TotalTime from './total_time_component.vue';
import Icon from '~/vue_shared/components/icon.vue';
import iconBranch from '../svg/icon_branch.svg';
import iconBuildStatus from '../svg/icon_build_status.svg';
export default {
components: {
UserAvatarImage,
TotalTime,
LimitWarning,
Icon,
GlLink,
},
props: {
events: {
type: Array,
required: true,
},
stage: {
type: Object,
required: true,
},
withBuildStatus: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
iconBuildStatus() {
return iconBuildStatus;
},
iconBranch() {
return iconBranch;
},
},
};
</script>
<template>
<ul class="stage-event-list">
<li
v-for="({ id, author, url, branch, name, commitUrl, shortSha, date, totalTime }, i) in events"
:key="i"
class="stage-event-item item-build-component"
>
<div class="item-details">
<template v-if="!withBuildStatus">
<user-avatar-image :img-src="author.avatarUrl" />
</template>
<h5 class="item-title">
<template v-if="withBuildStatus">
<span class="icon-build-status" v-html="iconBuildStatus"></span>
<gl-link :href="url" class="item-build-name">{{ name }}</gl-link> &middot;
</template>
<gl-link :href="url" class="pipeline-id">#{{ id }}</gl-link>
<icon :size="16" name="fork" />
<gl-link :href="branch.url" class="ref-name">{{ branch.name }}</gl-link>
<span class="icon-branch" v-html="iconBranch"></span>
<gl-link :href="commitUrl" class="commit-sha">{{ shortSha }}</gl-link>
</h5>
<span v-if="withBuildStatus">
<gl-link :href="url" class="issue-date">{{ date }}</gl-link>
</span>
<span v-else>
<gl-link :href="url" class="build-date">{{ date }}</gl-link>
{{ s__('ByAuthor|by') }}
<gl-link :href="author.webUrl" class="issue-author-link">{{ author.name }}</gl-link>
</span>
</div>
<div class="item-time">
<total-time :time="totalTime" />
</div>
</li>
</ul>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import { GlButton } from '@gitlab/ui';
export default {
name: 'StageCardListItem',
components: {
Icon,
GlButton,
},
props: {
isActive: {
type: Boolean,
required: true,
},
canEdit: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
activeClass() {
return 'active font-weight-bold border-style-solid border-color-blue-300';
},
inactiveClass() {
return 'bg-transparent border-style-dashed border-color-default';
},
},
};
</script>
<template>
<div
:class="[isActive ? activeClass : inactiveClass]"
class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-width-1px"
>
<slot></slot>
<div v-if="canEdit" class="dropdown">
<gl-button
:title="__('More actions')"
class="more-actions-toggle btn btn-transparent p-0"
data-toggle="dropdown"
>
<icon css-classes="icon" name="ellipsis_v" />
</gl-button>
<ul class="more-actions-dropdown dropdown-menu dropdown-open-left">
<slot name="dropdown-options"></slot>
</ul>
</div>
</div>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import LimitWarning from './limit_warning_component.vue';
import TotalTime from './total_time_component.vue';
export default {
components: {
GlLink,
UserAvatarImage,
LimitWarning,
TotalTime,
},
props: {
events: {
type: Array,
required: true,
},
stage: {
type: Object,
required: true,
},
},
methods: {
isMrLink(url = '') {
return url.includes('/merge_request');
},
},
};
</script>
<template>
<ul class="stage-event-list">
<li
v-for="({ iid, title, url, author, totalTime, createdAt }, i) in events"
:key="i"
class="stage-event-item"
>
<div class="item-details">
<user-avatar-image
:img-src="author.avatarUrl"
:alt="
sprintf(__('Merge request %{iid} authored by %{authorName}'), {
iid,
authorName: author.name,
})
"
/>
<h5 class="item-title issue-title">
<gl-link :href="url" class="issue-title">{{ title }}</gl-link>
</h5>
<template v-if="isMrLink(url)">
<gl-link :href="url" class="mr-link">!{{ iid }}</gl-link>
</template>
<template v-else>
<gl-link :href="url" class="issue-link">#{{ iid }}</gl-link>
</template>
&middot;
<span>
{{ s__('OpenedNDaysAgo|Opened') }}
<gl-link :href="url" class="issue-date">{{ createdAt }}</gl-link>
</span>
<span>
{{ s__('ByAuthor|by') }}
<gl-link :href="author.webUrl" class="issue-author-link">{{ author.name }}</gl-link>
</span>
</div>
<div class="item-time">
<total-time :time="totalTime" />
</div>
</li>
</ul>
</template>
<script>
import StageEventItem from './stage_event_item.vue';
import StageBuildItem from './stage_build_item.vue';
import LimitWarning from './limit_warning_component.vue';
export default {
name: 'StageEventList',
components: {
LimitWarning,
StageEventItem,
StageBuildItem,
},
props: {
stage: {
type: Object,
required: true,
},
events: {
type: Array,
required: true,
},
},
data() {
return {
STAGE_NAME_TEST: 'test',
STAGE_NAME_STAGING: 'staging',
};
},
methods: {
isCurrentStage(current, target) {
return current.toLowerCase() === target;
},
},
};
</script>
<template>
<div>
<div class="events-description">
{{ stage.description }}
<limit-warning :count="events.length" />
</div>
<stage-build-item
v-if="isCurrentStage(stage.name, STAGE_NAME_TEST)"
:stage="stage"
:events="events"
:with-build-status="true"
/>
<stage-build-item
v-else-if="isCurrentStage(stage.name, STAGE_NAME_STAGING)"
:stage="stage"
:events="events"
/>
<stage-event-item v-else :stage="stage" :events="events" />
</div>
</template>
<script>
import StageCardListItem from './stage_card_list_item.vue';
export default {
name: 'StageNavItem',
components: {
StageCardListItem,
},
props: {
isDefaultStage: {
type: Boolean,
default: false,
required: false,
},
isActive: {
type: Boolean,
default: false,
required: false,
},
isUserAllowed: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
},
value: {
type: String,
default: '',
required: false,
},
canEdit: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
hasValue() {
return this.value && this.value.length > 0;
},
editable() {
return this.isUserAllowed && this.canEdit;
},
},
};
</script>
<template>
<li @click="$emit('select')">
<stage-card-list-item :is-active="isActive" :can-edit="editable">
<div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }">
{{ title }}
</div>
<div class="stage-nav-item-cell stage-median mr-4">
<template v-if="isUserAllowed">
<span v-if="hasValue">{{ value }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</template>
<template v-else>
<span class="not-available">{{ __('Not available') }}</span>
</template>
</div>
<template v-slot:dropdown-options>
<template v-if="isDefaultStage">
<li>
<button type="button" class="btn-default btn-transparent">
{{ __('Hide stage') }}
</button>
</li>
</template>
<template v-else>
<li>
<button type="button" class="btn-default btn-transparent">
{{ __('Edit stage') }}
</button>
</li>
<li>
<button type="button" class="btn-danger danger">
{{ __('Remove stage') }}
</button>
</li>
</template>
</template>
</stage-card-list-item>
</li>
</template>
<script>
import { __, s__ } from '~/locale';
import { GlTooltipDirective, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import StageNavItem from './stage_nav_item.vue';
import StageEventList from './stage_event_list.vue';
import StageTableHeader from './stage_table_header.vue';
import AddStageButton from './add_stage_button.vue';
import CustomStageForm from './custom_stage_form.vue';
export default {
name: 'StageTable',
components: {
Icon,
GlLoadingIcon,
GlEmptyState,
StageEventList,
StageNavItem,
StageTableHeader,
AddStageButton,
CustomStageForm,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
stages: {
type: Array,
required: true,
},
currentStage: {
type: Object,
required: true,
},
isLoadingStage: {
type: Boolean,
required: true,
},
isEmptyStage: {
type: Boolean,
required: true,
},
isAddingCustomStage: {
type: Boolean,
required: true,
},
events: {
type: Array,
required: true,
},
noDataSvgPath: {
type: String,
required: true,
},
noAccessSvgPath: {
type: String,
required: true,
},
canEditStages: {
type: Boolean,
required: true,
},
},
computed: {
stageName() {
return this.currentStage ? this.currentStage.legend : __('Related Issues');
},
shouldDisplayStage() {
const { events = [], isLoadingStage, isEmptyStage } = this;
return events.length && !isLoadingStage && !isEmptyStage;
},
stageHeaders() {
return [
{
title: s__('ProjectLifecycle|Stage'),
description: __('The phase of the development lifecycle.'),
classes: 'stage-header pl-5',
},
{
title: __('Median'),
description: __(
'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.',
),
classes: 'median-header',
},
{
title: this.stageName,
description: __('The collection of events added to the data gathered for that stage.'),
classes: 'event-header pl-3',
},
{
title: __('Total Time'),
description: __('The time taken by each data entry gathered by that stage.'),
classes: 'total-time-header pr-5 text-right',
},
];
},
},
methods: {
selectStage(stage) {
this.$emit('selectStage', stage);
},
showAddStageForm() {
this.$emit('showAddStageForm');
},
},
};
</script>
<template>
<div class="stage-panel-container">
<div class="card stage-panel">
<div class="card-header border-bottom-0">
<nav class="col-headers">
<ul>
<stage-table-header
v-for="({ title, description, classes }, i) in stageHeaders"
:key="`stage-header-${i}`"
:header-classes="classes"
:title="title"
:tooltip-title="description"
/>
</ul>
</nav>
</div>
<div class="stage-panel-body">
<nav class="stage-nav">
<ul>
<stage-nav-item
v-for="stage in stages"
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="stage.value"
:is-active="!isAddingCustomStage && stage.name === currentStage.name"
:is-user-allowed="stage.isUserAllowed"
@select="selectStage(stage)"
/>
<add-stage-button
v-if="canEditStages"
:active="isAddingCustomStage"
@showform="showAddStageForm"
/>
</ul>
</nav>
<div class="section stage-events">
<gl-loading-icon v-if="isLoadingStage" class="mt-4" size="md" />
<gl-empty-state
v-else-if="currentStage && !currentStage.isUserAllowed"
:title="__('You need permission.')"
:description="__('Want to see the data? Please ask an administrator for access.')"
:svg-path="noAccessSvgPath"
/>
<custom-stage-form v-else-if="isAddingCustomStage" />
<template v-else>
<stage-event-list v-if="shouldDisplayStage" :stage="currentStage" :events="events" />
<gl-empty-state
v-if="isEmptyStage"
:title="__('We don\'t have enough data to show this stage.')"
:description="currentStage.emptyStageText"
:svg-path="noDataSvgPath"
/>
</template>
</div>
</div>
</div>
</div>
</template>
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'StageTableHeader',
components: {
Icon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
headerClasses: {
type: String,
required: false,
default: '',
},
title: {
type: String,
required: true,
},
tooltipTitle: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<li :class="headerClasses">
<span class="stage-name align-middle font-weight-bold">{{ title }}</span>
<icon
v-gl-tooltip
class="align-middle"
:size="14"
name="question"
container=".stage-name"
:title="tooltipTitle"
/>
</li>
</template>
<script>
export default {
name: 'SummaryTable',
props: {
items: {
type: Array,
required: true,
},
},
};
</script>
<template>
<div class="wrapper mt-3">
<div class="card">
<div class="card-header font-weight-bold">{{ __('Recent Activity') }}</div>
<div class="content-block">
<div class="container-fluid">
<div class="row">
<div class="col-sm-3"></div>
<div
v-for="{ title, value } in items"
:key="title"
class="col-sm-3 col-12 column text-center"
>
<h3 class="header">{{ value }}</h3>
<p class="text">{{ title }}</p>
</div>
<div class="col-sm-3"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<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),
};
} else if (hours) {
return {
duration: hours,
units: n__('Time|hr', 'Time|hrs', hours),
};
} else if (mins && !days) {
return {
duration: mins,
units: n__('Time|min', 'Time|mins', mins),
};
} else if ((seconds && this.hasData === 1) || seconds === 0) {
return {
duration: seconds,
units: s__('Time|s'),
};
}
return { duration: null, units: null };
},
},
};
</script>
<template>
<span class="total-time">
<template v-if="hasData">
{{ calculatedTime.duration }} <span> {{ calculatedTime.units }} </span>
</template>
<template v-else>
--
</template>
</span>
</template>
import { __ } from '~/locale';
export const DEFAULT_DATA_TIME_FRAME = 30;
export const EVENTS_LIST_ITEM_LIMIT = 50;
export const EMPTY_STAGE_TEXT = {
issue: __(
'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
),
plan: __(
'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
),
code: __(
'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
),
test: __(
'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
),
review: __(
'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
),
staging: __(
'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
),
production: __(
'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
),
};
import Vue from 'vue';
import CycleAnalytics from './components/base.vue';
import createStore from './store';
export default () => {
// eslint-disable-next-line no-new
new Vue({
const el = document.querySelector('#js-cycle-analytics-app');
const { emptyStateSvgPath, noDataSvgPath, noAccessSvgPath } = el.dataset;
return new Vue({
el: '#js-cycle-analytics-app',
name: 'CycleAnalyticsApp',
store: createStore(),
components: {
CycleAnalytics,
},
render: createElement => createElement('cycle-analytics', {}),
render: createElement =>
createElement(CycleAnalytics, {
props: {
emptyStateSvgPath,
noDataSvgPath,
noAccessSvgPath,
},
}),
});
};
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
export const setCycleAnalyticsDataEndpoint = ({ commit }, groupPath) =>
commit(types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT, groupPath);
export const setStageDataEndpoint = ({ commit }, stageSlug) =>
commit(types.SET_STAGE_DATA_ENDPOINT, stageSlug);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) =>
commit(types.SET_SELECTED_PROJECTS, projectIds);
export const setSelectedTimeframe = ({ commit }, timeframe) =>
commit(types.SET_SELECTED_TIMEFRAME, timeframe);
export const setSelectedStageName = ({ commit }, stageName) =>
commit(types.SET_SELECTED_STAGE_NAME, stageName);
export const requestStageData = ({ commit }) => commit(types.REQUEST_STAGE_DATA);
export const receiveStageDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
export const receiveStageDataError = ({ commit }) => {
commit(types.RECEIVE_STAGE_DATA_ERROR);
createFlash(__('There was an error while fetching cycle analytics data.'));
};
export const fetchStageData = ({ state, dispatch }) => {
dispatch('requestStageData');
axios
.get(state.endpoints.stageData, {
params: {
'cycle_analytics[start_date]': state.dataTimeframe,
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
})
.then(({ data }) => dispatch('receiveStageDataSuccess', data))
.catch(error => dispatch('receiveStageDataError', error));
};
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
export const receiveCycleAnalyticsDataSuccess = ({ state, commit, dispatch }, data) => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data);
const { stages = [] } = state;
if (stages && stages.length) {
const { slug } = stages[0];
dispatch('setStageDataEndpoint', slug);
dispatch('fetchStageData');
} else {
createFlash(__('There was an error while fetching cycle analytics data.'));
}
};
export const receiveCycleAnalyticsDataError = ({ commit }) => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR);
createFlash(__('There was an error while fetching cycle analytics data.'));
};
export const fetchCycleAnalyticsData = ({ state, dispatch }) => {
dispatch('requestCycleAnalyticsData');
axios
.get(state.endpoints.cycleAnalyticsData, {
params: {
'cycle_analytics[start_date]': state.dataTimeframe,
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
})
.then(({ data }) => dispatch('receiveCycleAnalyticsDataSuccess', data))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
};
export const showCustomStageForm = ({ commit }) => {
commit(types.SHOW_CUSTOM_STAGE_FORM);
};
export const hideCustomStageForm = ({ commit }) => {
commit(types.HIDE_CUSTOM_STAGE_FORM);
};
export const currentStage = ({ stages, selectedStageName }) =>
stages.length && selectedStageName
? stages.find(stage => stage.name === selectedStageName)
: null;
export const defaultStage = state => (state.stages.length ? state.stages[0] : null);
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
getters,
mutations,
state,
});
export const SET_CYCLE_ANALYTICS_DATA_ENDPOINT = 'SET_CYCLE_ANALYTICS_DATA_ENDPOINT';
export const SET_STAGE_DATA_ENDPOINT = 'SET_STAGE_DATA_ENDPOINT';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_TIMEFRAME = 'SET_SELECTED_TIMEFRAME';
export const SET_SELECTED_STAGE_NAME = 'SET_SELECTED_STAGE_NAME';
export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_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 SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const HIDE_CUSTOM_STAGE_FORM = 'HIDE_CUSTOM_STAGE_FORM';
import { dasherize } from '~/lib/utils/text_utility';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import { EMPTY_STAGE_TEXT } from '../constants';
export default {
[types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT](state, groupPath) {
state.endpoints.cycleAnalyticsData = `/groups/${groupPath}/-/cycle_analytics`;
},
[types.SET_STAGE_DATA_ENDPOINT](state, stageSlug) {
state.endpoints.stageData = `${state.endpoints.cycleAnalyticsData}/events/${stageSlug}.json`;
},
[types.SET_SELECTED_GROUP](state, group) {
state.selectedGroup = group;
state.selectedProjectIds = [];
},
[types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds;
},
[types.SET_SELECTED_TIMEFRAME](state, timeframe) {
state.dataTimeframe = timeframe;
},
[types.SET_SELECTED_STAGE_NAME](state, stageName) {
state.selectedStageName = stageName;
},
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true;
state.isAddingCustomStage = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
state.summary = data.summary.map(item => ({
...item,
value: item.value || '-',
}));
state.stages = data.stats.map(item => {
const slug = dasherize(item.name.toLowerCase());
return {
...item,
isUserAllowed: data.permissions[slug],
emptyStageText: EMPTY_STAGE_TEXT[slug],
slug,
};
});
if (state.stages.length) {
const { name } = state.stages[0];
state.selectedStageName = name;
}
state.isLoading = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
state.isLoading = false;
},
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data) {
state.events = data.events.map(({ name = '', ...rest }) =>
convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }),
);
state.isEmptyStage = state.events.length === 0;
state.isLoadingStage = false;
},
[types.RECEIVE_STAGE_DATA_ERROR](state) {
state.isEmptyStage = true;
state.isLoadingStage = false;
},
[types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = true;
state.isEmptyStage = false;
state.isLoadingStage = false;
},
[types.HIDE_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = false;
state.isEmptyStage = false;
state.isLoadingStage = false;
},
};
import { DEFAULT_DATA_TIME_FRAME } from '../constants';
export default () => ({
endpoints: {
cycleAnalyticsData: '',
stageData: '',
},
dataTimeframe: DEFAULT_DATA_TIME_FRAME,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
isAddingCustomStage: false,
selectedGroup: null,
selectedProjectIds: [],
selectedStageName: null,
events: [],
stages: [],
summary: [],
});
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path fill="#8C8C8C" fill-rule="evenodd" d="M9.678 6.722C9.353 5.167 8.053 4 6.5 4S3.647 5.167 3.322 6.722h-2.6c-.397 0-.722.35-.722.778 0 .428.325.778.722.778h2.6C3.647 9.833 4.947 11 6.5 11s2.853-1.167 3.178-2.722h2.6c.397 0 .722-.35.722-.778 0-.428-.325-.778-.722-.778h-2.6zM4.694 7.5c0-1.09.795-1.944 1.806-1.944 1.01 0 1.806.855 1.806 1.944 0 1.09-.795 1.944-1.806 1.944-1.01 0-1.806-.855-1.806-1.944z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"><g fill="#31AF64" fill-rule="evenodd"><path d="M12.5 7c0-3.038-2.462-5.5-5.5-5.5S1.5 3.962 1.5 7s2.462 5.5 5.5 5.5 5.5-2.462 5.5-5.5zM0 7c0-3.866 3.134-7 7-7s7 3.134 7 7-3.134 7-7 7-7-3.134-7-7z"/><path d="M6.28 7.697L5.045 6.464c-.117-.117-.305-.117-.42-.002l-.614.614c-.11.113-.11.303.007.42l1.91 1.91c.19.19.51.197.703.004l.264-.265L9.997 6.04c.108-.107.107-.293-.01-.408l-.612-.614c-.114-.113-.298-.12-.41-.01L6.28 7.7z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path fill="#8F8F8F" fill-rule="evenodd" d="M28.777 18c-.91-4.008-4.494-7-8.777-7-4.283 0-7.868 2.992-8.777 7H4.01C2.9 18 2 18.895 2 20c0 1.112.9 2 2.01 2h7.213c.91 4.008 4.494 7 8.777 7 4.283 0 7.868-2.992 8.777-7h7.214C37.1 22 38 21.105 38 20c0-1.112-.9-2-2.01-2h-7.213zM20 25c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5z"/></svg>
import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
import initCycleAnalyticsApp from 'ee/analytics/cycle_analytics/cycle_analytics_app';
import initCycleAnalyticsApp from 'ee/analytics/cycle_analytics/index';
import { parseBoolean } from '~/lib/utils/common_utils';
import Cookies from 'js-cookie';
......
......@@ -3,4 +3,8 @@
class Analytics::CycleAnalyticsController < Analytics::ApplicationController
check_feature_flag Gitlab::Analytics::CYCLE_ANALYTICS_FEATURE_FLAG
increment_usage_counter Gitlab::UsageDataCounters::CycleAnalyticsCounter, :views, only: :show
before_action do
push_frontend_feature_flag(:customizable_cycle_analytics)
end
end
- page_title _('Cycle Analytics')
- page_title _("Cycle Analytics")
- customizable_cycle_analytics = Feature.enabled?(:customizable_cycle_analytics)
- if cookies[:cycle_analytics_app] == 'true'
#js-cycle-analytics-app
#js-cycle-analytics-app{ data: { "empty-state-svg-path" => image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), "no-data-svg-path" => image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), "no-access-svg-path" => image_path("illustrations/analytics/no-access.svg") } }
- else
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Cycle Analytics')
%h1.page-title
= page_title
#cycle-analytics.m-0.mw-100
.mt-3.py-2.px-3.d-flex.bg-gray-light.border-top.border-bottom.flex-column.flex-md-row.justify-content-between
......
......@@ -2,11 +2,20 @@
require 'spec_helper'
describe 'Group Cycle Analytics', :js do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :repository, group: group) }
let!(:user) { create(:user) }
let!(:group) { create(:group, name: "CA-test-group") }
let!(:project) { create(:project, :repository, namespace: group, group: group, name: "Cool fun project") }
let(:milestone) { create(:milestone, project: project) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
3.times do |i|
let!("issue_#{i}".to_sym) { create(:issue, title: "New Issue #{i}", project: project, created_at: 2.days.ago) }
end
before do
stub_licensed_features(cycle_analytics_for_groups: true)
group.add_owner(user)
project.add_maintainer(user)
......@@ -40,6 +49,187 @@ describe 'Group Cycle Analytics', :js do
it 'shows the date filter' do
expect(page).to have_selector('.js-timeframe-filter', visible: true)
end
it 'smoke test' do
expect(page).not_to have_selector('.cycle-analytics', visible: true)
expect(page).to have_selector('#cycle-analytics', visible: true)
end
end
# TODO: Followup should have tests for stub_licensed_features(cycle_analytics_for_groups: false)
def select_group
dropdown = page.find('.dropdown-groups')
dropdown.click
dropdown.find('a').click
end
def select_project
select_group
dropdown = page.find('.dropdown-projects')
dropdown.click
dropdown.find('a').click
dropdown.click
end
context 'with cycle_analytics_app cookie set', :js do
before do
set_cookie('cycle_analytics_app', 'true')
group.add_owner(user)
project.add_maintainer(user)
sign_in(user)
visit analytics_cycle_analytics_path
end
it 'displays empty text' do
[
'Cycle Analytics can help you determine your team’s velocity',
'Start by choosing a group to see how your team is spending time. You can then drill down to the project level.'
].each do |content|
expect(page).to have_content(content)
end
end
context 'with a group selected' do
before do
select_group
end
it 'smoke test' do
expect(page).to have_selector('.cycle-analytics', visible: true)
expect(page).not_to have_selector('#cycle-analytics', visible: true)
end
context 'summary table', :js do
it 'will display recent activity' do
page.within(find('.js-summary-table')) do
expect(page).to have_selector('.card-header')
expect(page).to have_content('Recent Activity')
end
end
it 'displays the number of issues' do
expect(page).to have_content('New Issues')
issue_count = find(".card .header", match: :first)
expect(issue_count).to have_content('3')
end
it 'displays the number of deploys' do
expect(page).to have_content('Deploys')
deploys_count = page.all(".card .header").last
expect(deploys_count).to have_content('-')
end
end
# These should probably move to more unit / integration type tests
# should have a group set and some data
context 'stage panel' do
it 'displays the stage table headers' do
expect(page).to have_selector('.stage-header', visible: true)
expect(page).to have_selector('.median-header', visible: true)
expect(page).to have_selector('.event-header', visible: true)
expect(page).to have_selector('.total-time-header', visible: true)
end
end
context 'stage nav' do
it 'displays the list of stages' do
expect(page).to have_selector('.stage-nav', visible: true)
end
it 'displays the default list of stages' do
stage_nav = page.find('.stage-nav')
%w[Issue Plan Code Test Review Staging Production].each do |item|
expect(stage_nav).to have_content(item)
end
end
end
end
def select_stage(name)
page.find('.stage-nav .stage-nav-item .stage-name', text: name, match: :prefer_exact).click
wait_for_requests
end
def create_merge_request(id, extra_params = {})
params = {
id: id,
target_branch: 'master',
source_project: project2,
source_branch: "feature-branch-#{id}",
title: "mr name#{id}",
created_at: 2.days.ago
}.merge(extra_params)
create(:merge_request, params)
end
context 'with lots of data', :js do
let!(:issue) { create(:issue, project: project, created_at: 5.days.ago) }
before do
create_cycle(user, project, issue, mr, milestone, pipeline)
deploy_master(user, project, environment: 'staging')
deploy_master(user, project)
select_group
end
dummy_stages = [
{ title: "Issue", description: "Time before an issue gets scheduled", events_count: 1, median: "5 days" },
{ title: "Plan", description: "Time before an issue starts implementation", events_count: 1, median: "Not enough data" },
{ title: "Code", description: "Time until first merge request", events_count: 1, median: "less than a minute" },
{ title: "Test", description: "Total test time for all commits/merges", events_count: 1, median: "Not enough data" },
{ title: "Review", description: "Time between merge request creation and merge/close", events_count: 1, median: "less than a minute" },
{ title: "Staging", description: "From merge request merge until deploy to production", events_count: 1, median: "less than a minute" },
{ title: "Production", description: "From issue creation until deploy to production", events_count: 1, median: "5 days" }
]
it 'each stage will have median values' do
stages = page.all(".stage-nav .stage-median").collect(&:text)
stages.each_with_index do |median, index|
expect(median).to eq(dummy_stages[index][:median])
end
end
it 'each stage will display the events description when selected' do
dummy_stages.each do |stage|
select_stage(stage[:title])
expect(page.find('.stage-events .events-description').text).to have_text(stage[:description])
end
end
it 'each stage with events will display the stage events list when selected' do
dummy_stages.each do |stage|
select_stage(stage[:title])
if stage[:events_count] == 0
expect(page).not_to have_selector('.stage-events .stage-event-item')
else
expect(page).to have_selector('.stage-events .stage-event-list')
expect(page.all('.stage-events .stage-event-item').length).to eq(stage[:events_count])
end
end
end
it 'each stage will be selectable' do
dummy_stages.each do |stage|
select_stage(stage[:title])
expect(page.find('.stage-nav .active .stage-name').text).to eq(stage[:title])
end
end
end
end
describe 'Customizable cycle analytics', :js do
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import store from 'ee/analytics/cycle_analytics/store';
import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import { GlEmptyState } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import GroupsDropdownFilter from 'ee/analytics/shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
import DateRangeDropdown from 'ee/analytics/shared/components/date_range_dropdown.vue';
import SummaryTable from 'ee/analytics/cycle_analytics/components/summary_table.vue';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import { TEST_HOST } from 'helpers/test_constants';
import 'bootstrap';
import '~/gl_dropdown';
import * as mockData from '../mock_data';
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Cycle Analytics component', () => {
const emptyStateSvgPath = `${TEST_HOST}/images/home/nasa.svg`;
let wrapper;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = shallowMount(localVue.extend(Component), {
localVue,
store,
sync: false,
propsData: {
emptyStateSvgPath,
noDataSvgPath,
noAccessSvgPath,
},
});
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('displays the components as required', () => {
describe('before a filter has been selected', () => {
it('displays an empty state', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
});
it('displays the groups filter', () => {
expect(wrapper.find(GroupsDropdownFilter).exists()).toBe(true);
});
it('does not display the projects or timeframe filters', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBe(false);
expect(wrapper.find(DateRangeDropdown).exists()).toBe(false);
});
});
describe('after a filter has been selected', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('setSelectedGroup', {
...mockData.group,
});
wrapper.vm.$store.dispatch('receiveCycleAnalyticsDataSuccess', {
...mockData.cycleAnalyticsData,
});
wrapper.vm.$store.dispatch('receiveStageDataSuccess', {
events: mockData.issueEvents,
});
});
it('hides the empty state', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(false);
});
it('displays the projects and timeframe filters', () => {
expect(wrapper.find(ProjectsDropdownFilter).exists()).toBe(true);
expect(wrapper.find(DateRangeDropdown).exists()).toBe(true);
});
it('displays summary table', () => {
expect(wrapper.find(SummaryTable).exists()).toBe(true);
});
it('displays the stage table', () => {
expect(wrapper.find(StageTable).exists()).toBe(true);
});
});
});
});
import { shallowMount, mount } from '@vue/test-utils';
import StageBuildItem from 'ee/analytics/cycle_analytics/components/stage_build_item.vue';
import { renderTotalTime } from '../helpers';
import { testStage as stage, testEvents as events } from '../mock_data';
function createComponent(props = {}, shallow = true) {
const func = shallow ? shallowMount : mount;
return func(StageBuildItem, {
propsData: {
stage,
events,
...props,
},
});
}
const $sel = {
item: '.stage-event-item',
description: '.events-description',
issueDate: '.issue-date',
author: '.issue-author-link',
time: '.item-time',
commit: '.commit-sha',
branch: '.ref-name',
mrBranch: '.merge-request-branch',
pipeline: '.pipeline-id',
buildName: '.item-build-name',
buildDate: '.build-date',
avatar: '.avatar',
};
describe('StageBuildItem', () => {
let wrapper = null;
beforeEach(() => {
wrapper = createComponent({}, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the events list', () => {
const items = wrapper.findAll($sel.item);
expect(items.length > 0).toBe(true);
expect(items.length).toEqual(events.length);
});
it('will render the build pipeline id', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.pipeline).text()).toEqual(`#${item.id}`);
});
});
it('will render the branch', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.branch).text()).toEqual(item.branch.name);
expect(elem.find($sel.branch).attributes('href')).toEqual(item.branch.url);
});
});
it('will render the commit sha of the event', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.commit).text()).toEqual(item.shortSha);
});
});
it('will render the total time', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
renderTotalTime($sel.time, elem, item.totalTime);
});
});
describe('withBuildStatus = false', () => {
beforeEach(() => {
wrapper = createComponent({ withBuildStatus: false }, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the build date', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.buildDate).at(index);
expect(elem.find($sel.buildDate).text()).toEqual(item.date);
});
});
it('will render the authors avatar', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.avatar).exists()).toEqual(true);
expect(elem.find($sel.avatar).attributes('src')).toContain(item.author.avatarUrl);
});
});
it('will render a link to the author', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.author).text()).toEqual(item.author.name);
expect(elem.find($sel.author).attributes('href')).toEqual(item.author.webUrl);
});
});
});
describe('withBuildStatus = true', () => {
beforeEach(() => {
wrapper = createComponent({ withBuildStatus: true }, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the build pipeline id', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.buildName).text()).toContain(item.name);
expect(elem.find('.icon-build-status').exists()).toBe(true);
});
});
it('will render the issue created date', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.issueDate).text()).toEqual(item.date);
});
});
});
});
import { shallowMount, mount } from '@vue/test-utils';
import StageEventItem from 'ee/analytics/cycle_analytics/components/stage_event_item.vue';
import { renderTotalTime } from '../helpers';
import { issueStage as stage, issueEvents as events } from '../mock_data';
function createComponent(props = {}, shallow = true) {
const func = shallow ? shallowMount : mount;
return func(StageEventItem, {
propsData: {
stage,
events,
...props,
},
});
}
const $sel = {
item: '.stage-event-item',
title: '.item-title',
issueLink: '.issue-link',
issueDate: '.issue-date',
author: '.issue-author-link',
avatar: '.avatar',
time: '.item-time',
};
describe('StageEventItem', () => {
let wrapper = null;
beforeEach(() => {
wrapper = createComponent({}, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the events list', () => {
const items = wrapper.findAll($sel.item);
expect(items.length > 0).toBe(true);
expect(items.length).toEqual(events.length);
});
it('will render the title of each event', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.title);
});
});
it('will render the issue link', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.issueLink).text()).toEqual(`#${item.iid}`);
});
});
it('will render the total time', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
renderTotalTime($sel.time, elem, item.totalTime);
});
});
it('will render the issue created date', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.issueDate).text()).toEqual(item.createdAt);
});
});
it('will render a link to the author', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.author).text()).toEqual(item.author.name);
expect(elem.find($sel.author).attributes('href')).toEqual(item.author.webUrl);
});
});
it('will render the authors avatar', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.avatar).exists()).toEqual(true);
expect(elem.find($sel.avatar).attributes('src')).toContain(item.author.avatarUrl);
});
});
});
import { shallowMount, mount } from '@vue/test-utils';
import StageEventList from 'ee/analytics/cycle_analytics/components/stage_event_list.vue';
import {
issueStage,
issueEvents,
planStage,
planEvents,
reviewStage,
reviewEvents,
testStage,
testEvents,
stagingStage,
stagingEvents,
productionStage,
productionEvents,
codeStage,
codeEvents,
} from '../mock_data';
const generateEvents = n =>
Array(n)
.fill(issueEvents[0])
.map((ev, k) => ({ ...ev, title: `event-${k}`, id: k }));
const mockStubs = {
'stage-event-item': true,
'stage-build-item': true,
};
function createComponent({ props = {}, shallow = false, Component = StageEventList, stubs }) {
const func = shallow ? shallowMount : mount;
return func(Component, {
propsData: {
stage: issueStage,
events: issueEvents,
...props,
},
stubs,
});
}
const $sel = {
item: '.stage-event-item',
description: '.events-description',
title: '.item-title',
eventsInfo: '.events-info',
};
describe('Stage', () => {
let wrapper = null;
describe('With too many events', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
events: generateEvents(50),
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the limit warning', () => {
const desc = wrapper.find($sel.description);
expect(desc.find($sel.eventsInfo).exists()).toBe(true);
});
it('will render the limit warning message ', () => {
const desc = wrapper.find($sel.description);
expect(desc.find($sel.eventsInfo).text()).toContain('Showing 50 events');
});
});
describe('Default stages', () => {
it.each`
name | stage
${'Issue'} | ${issueStage}
${'Plan'} | ${planStage}
${'Review'} | ${reviewStage}
${'Test'} | ${testStage}
${'Code'} | ${codeStage}
${'Staging'} | ${stagingStage}
${'Production'} | ${productionStage}
`('$name stage will render the stage description', ({ stage }) => {
wrapper = createComponent({ props: { stage, events: [] } });
expect(wrapper.find($sel.description).text()).toEqual(stage.description);
});
it.each`
name | stage | eventList
${'Issue'} | ${issueStage} | ${issueEvents}
${'Plan'} | ${planStage} | ${planEvents}
${'Review'} | ${reviewStage} | ${reviewEvents}
${'Code'} | ${codeStage} | ${codeEvents}
${'Production'} | ${productionStage} | ${productionEvents}
`('$name stage will render the list of events', ({ stage, eventList }) => {
wrapper = createComponent({ props: { stage, events: eventList } });
eventList.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.title);
});
});
it.each`
name | stage | eventList
${'Issue'} | ${issueStage} | ${issueEvents}
${'Plan'} | ${planStage} | ${planEvents}
${'Review'} | ${reviewStage} | ${reviewEvents}
${'Code'} | ${codeStage} | ${codeEvents}
${'Production'} | ${productionStage} | ${productionEvents}
`('$name stage will render the items as StageEventItems', ({ stage, eventList }) => {
wrapper = createComponent({ props: { events: eventList, stage }, stubs: mockStubs });
expect(wrapper.find('stage-event-item-stub').exists()).toBe(true);
});
it.each`
name | stage | eventList
${'Test'} | ${testStage} | ${testEvents}
${'Staging'} | ${stagingStage} | ${stagingEvents}
`('$name stage will render the items as StageBuildItems', ({ stage, eventList }) => {
wrapper = createComponent({ props: { events: eventList, stage }, stubs: mockStubs });
expect(wrapper.find('stage-build-item-stub').exists()).toBe(true);
});
describe('Test stage', () => {
beforeEach(() => {
wrapper = createComponent({
props: { stage: testStage, events: testEvents },
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the list of events', () => {
testEvents.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.name);
});
});
});
describe('Staging stage', () => {
beforeEach(() => {
wrapper = createComponent({
props: { stage: stagingStage, events: stagingEvents },
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the list of events', () => {
stagingEvents.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
const title = elem.find($sel.title).text();
expect(title).toContain(item.id);
expect(title).toContain(item.branch.name);
expect(title).toContain(item.shortSha);
});
});
});
});
});
import Vue from 'vue';
import { shallowMount, mount } from '@vue/test-utils';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import { issueEvents, issueStage, allowedStages } from '../mock_data';
let wrapper = null;
const $sel = {
nav: '.stage-nav',
navItems: '.stage-nav-item',
eventList: '.stage-events',
events: '.stage-event-item',
description: '.events-description',
headers: '.col-headers li',
headersList: '.col-headers',
illustration: '.empty-state .svg-content',
};
const headers = ['Stage', 'Median', issueStage.legend, 'Total Time'];
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
function createComponent(props = {}, shallow = false) {
const func = shallow ? shallowMount : mount;
return func(StageTable, {
propsData: {
stages: allowedStages,
currentStage: issueStage,
events: issueEvents,
isLoadingStage: false,
isEmptyStage: false,
isUserAllowed: true,
isAddingCustomStage: false,
noDataSvgPath,
noAccessSvgPath,
canEditStages: false,
...props,
},
stubs: {
'gl-loading-icon': true,
},
sync: false,
});
}
describe('StageTable', () => {
describe('headers', () => {
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('will render the headers', () => {
const renderedHeaders = wrapper.findAll($sel.headers);
expect(renderedHeaders.length).toEqual(headers.length);
const headerText = wrapper.find($sel.headersList).text();
headers.forEach(title => {
expect(headerText).toContain(title);
});
});
});
describe('is loaded with data', () => {
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('will render the events list', () => {
expect(wrapper.find($sel.eventList).exists()).toBeTruthy();
});
it('will render the correct stages', () => {
const evs = wrapper.findAll({ name: 'StageNavItem' });
expect(evs.length).toEqual(allowedStages.length);
const nav = wrapper.find($sel.nav).html();
allowedStages.forEach(stage => {
expect(nav).toContain(stage.title);
});
});
it('will render the current stage', () => {
expect(wrapper.find($sel.description).exists()).toBeTruthy();
expect(wrapper.find($sel.description).text()).toEqual(issueStage.description);
});
it('will render the event list', () => {
expect(wrapper.find($sel.eventList).exists()).toBeTruthy();
expect(wrapper.findAll($sel.events).exists()).toBeTruthy();
});
it('will render the correct events', () => {
const evs = wrapper.findAll($sel.events);
expect(evs.length).toEqual(issueEvents.length);
const evshtml = wrapper.find($sel.eventList).html();
issueEvents.forEach(ev => {
expect(evshtml).toContain(ev.title);
});
});
function selectStage(index) {
wrapper
.findAll($sel.navItems)
.at(index)
.trigger('click');
}
describe('when a stage is clicked', () => {
it('will emit `selectStage`', done => {
expect(wrapper.emitted('selectStage')).toBeUndefined();
selectStage(1);
Vue.nextTick()
.then(() => {
expect(wrapper.emitted().selectStage.length).toEqual(1);
})
.then(done)
.catch(done.fail);
});
it('will emit `selectStage` with the new stage title', done => {
const secondStage = allowedStages[1];
selectStage(1);
Vue.nextTick()
.then(() => {
const [params] = wrapper.emitted('selectStage')[0];
expect(params).toMatchObject({ title: secondStage.title });
})
.then(done)
.catch(done.fail);
});
});
});
it('isLoadingStage = true', () => {
wrapper = createComponent({ isLoadingStage: true }, true);
expect(wrapper.find('gl-loading-icon-stub').exists()).toEqual(true);
});
describe('isEmptyStage = true', () => {
beforeEach(() => {
wrapper = createComponent({ isEmptyStage: true });
});
afterEach(() => {
wrapper.destroy();
});
it('will render the empty stage illustration', () => {
expect(wrapper.find($sel.illustration).exists()).toBeTruthy();
expect(wrapper.find($sel.illustration).html()).toContain(noDataSvgPath);
});
it('will display the no data title', () => {
expect(wrapper.html()).toContain("We don't have enough data to show this stage.");
});
it('will display the no data description', () => {
expect(wrapper.html()).toContain(
'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
);
});
});
describe('isUserAllowed = false', () => {
beforeEach(() => {
wrapper = createComponent({
currentStage: {
...issueStage,
isUserAllowed: false,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the no access illustration', () => {
expect(wrapper.find($sel.illustration).exists()).toBeTruthy();
expect(wrapper.find($sel.illustration).html()).toContain(noAccessSvgPath);
});
it('will display the no access title', () => {
expect(wrapper.html()).toContain('You need permission.');
});
it('will display the no access description', () => {
expect(wrapper.html()).toContain(
'Want to see the data? Please ask an administrator for access.',
);
});
});
describe('canEditStages = true', () => {
beforeEach(() => {
wrapper = createComponent({
canEditStages: true,
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the add a stage button', () => {
expect(wrapper.html()).toContain('Add a stage');
});
});
});
export function renderTotalTime(selector, element, totalTime = {}) {
const { days, hours, mins, seconds } = totalTime;
if (days) {
expect(element.find(selector).text()).toContain(days);
} else if (hours) {
expect(element.find(selector).text()).toContain(hours);
} else if (mins) {
expect(element.find(selector).text()).toContain(mins);
} else if (seconds) {
expect(element.find(selector).text()).toContain(seconds);
} else {
// events that havent started have totalTime = {}
expect(element.find(selector).text()).toEqual('--');
}
}
export default {
renderTotalTime,
};
import { TEST_HOST } from 'helpers/test_constants';
import { getJSONFixture } from 'helpers/fixtures';
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const group = {
id: 1,
name: 'foo',
path: 'foo',
avatar_url: `${TEST_HOST}/images/home/nasa.svg`,
};
const getStageBySlug = (stages, slug) => stages.find(stage => stage.slug === slug) || {};
export const cycleAnalyticsData = getJSONFixture('cycle_analytics/mock_data.json');
const dummyState = {};
// prepare the raw stage data for our components
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](dummyState, cycleAnalyticsData);
export const issueStage = getStageBySlug(dummyState.stages, 'issue');
export const planStage = getStageBySlug(dummyState.stages, 'plan');
export const reviewStage = getStageBySlug(dummyState.stages, 'review');
export const codeStage = getStageBySlug(dummyState.stages, 'code');
export const testStage = getStageBySlug(dummyState.stages, 'test');
export const stagingStage = getStageBySlug(dummyState.stages, 'staging');
export const productionStage = getStageBySlug(dummyState.stages, 'production');
export const allowedStages = [issueStage, planStage, codeStage];
const rawIssueEvents = getJSONFixture('cycle_analytics/events/issue.json');
export const rawEvents = rawIssueEvents.events;
const deepCamelCase = obj => convertObjectPropsToCamelCase(obj, { deep: true });
const defaultStages = ['issue', 'plan', 'review', 'code', 'test', 'staging', 'production'];
const stageFixtures = defaultStages.reduce((acc, stage) => {
const { events } = getJSONFixture(`cycle_analytics/events/${stage}.json`);
return {
...acc,
[stage]: deepCamelCase(events),
};
}, {});
export const issueEvents = stageFixtures.issue;
export const planEvents = stageFixtures.plan;
export const reviewEvents = stageFixtures.review;
export const codeEvents = stageFixtures.code;
export const testEvents = stageFixtures.test;
export const stagingEvents = stageFixtures.staging;
export const productionEvents = stageFixtures.production;
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import * as actions from 'ee/analytics/cycle_analytics/store/actions';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { group, cycleAnalyticsData, allowedStages as stages } from '../mock_data';
const stageData = { events: [] };
const error = new Error('Request failed with status code 404');
describe('Cycle analytics actions', () => {
let state;
let mock;
function shouldFlashAnError(msg = 'There was an error while fetching cycle analytics data.') {
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(msg);
}
beforeEach(() => {
state = {
endpoints: {
cycleAnalyticsData: `${TEST_HOST}/groups/${group.path}/-/cycle_analytics`,
stageData: `${TEST_HOST}/groups/${group.path}/-/cycle_analytics/events/${cycleAnalyticsData.stats[0].name}.json`,
},
stages: [],
};
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it.each`
action | type | stateKey | payload
${'setCycleAnalyticsDataEndpoint'} | ${'SET_CYCLE_ANALYTICS_DATA_ENDPOINT'} | ${'endpoints.cycleAnalyticsData'} | ${'coolGroupName'}
${'setStageDataEndpoint'} | ${'SET_STAGE_DATA_ENDPOINT'} | ${'endpoints.stageData'} | ${'new_stage_name'}
${'setSelectedGroup'} | ${'SET_SELECTED_GROUP'} | ${'selectedGroup'} | ${'someNewGroup'}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStageName'} | ${'SET_SELECTED_STAGE_NAME'} | ${'selectedStageName'} | ${'someNewGroup'}
${'setSelectedTimeframe'} | ${'SET_SELECTED_TIMEFRAME'} | ${'dataTimeframe'} | ${20}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
testAction(
actions[action],
payload,
state,
[
{
type,
payload,
},
],
[],
);
});
describe('fetchStageData', () => {
beforeEach(() => {
mock.onGet(state.endpoints.stageData).replyOnce(200, { events: [] });
});
it('dispatches receiveStageDataSuccess with received data on success', done => {
testAction(
actions.fetchStageData,
null,
state,
[],
[
{ type: 'requestStageData' },
{
type: 'receiveStageDataSuccess',
payload: { events: [] },
},
],
done,
);
});
it('dispatches receiveStageDataError on error', done => {
const brokenState = {
...state,
endpoints: {
stageData: 'this will break',
},
};
testAction(
actions.fetchStageData,
null,
brokenState,
[],
[
{ type: 'requestStageData' },
{
type: 'receiveStageDataError',
payload: error,
},
],
done,
);
});
describe('receiveStageDataSuccess', () => {
it(`commits the ${types.RECEIVE_STAGE_DATA_SUCCESS} mutation`, done => {
testAction(
actions.receiveStageDataSuccess,
{ ...stageData },
state,
[{ type: types.RECEIVE_STAGE_DATA_SUCCESS, payload: { events: [] } }],
[],
done,
);
});
});
});
describe('receiveStageDataError', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it(`commits the ${types.RECEIVE_STAGE_DATA_ERROR} mutation`, done => {
testAction(
actions.receiveStageDataError,
null,
state,
[
{
type: types.RECEIVE_STAGE_DATA_ERROR,
},
],
[],
done,
);
});
it('will flash an error message', () => {
actions.receiveStageDataError({
commit: () => {},
});
shouldFlashAnError();
});
});
describe('fetchCycleAnalyticsData', () => {
beforeEach(() => {
mock.onGet(state.endpoints.cycleAnalyticsData).replyOnce(200, cycleAnalyticsData);
});
it('dispatches receiveCycleAnalyticsDataSuccess with received data', done => {
testAction(
actions.fetchCycleAnalyticsData,
null,
state,
[],
[
{ type: 'requestCycleAnalyticsData' },
{
type: 'receiveCycleAnalyticsDataSuccess',
payload: { ...cycleAnalyticsData },
},
],
done,
);
});
it('dispatches receiveCycleAnalyticsError on error', done => {
const brokenState = {
...state,
endpoints: {
cycleAnalyticsData: 'this will break',
},
};
testAction(
actions.fetchCycleAnalyticsData,
null,
brokenState,
[],
[
{ type: 'requestCycleAnalyticsData' },
{
type: 'receiveCycleAnalyticsDataError',
payload: error,
},
],
done,
);
});
describe('requestCycleAnalyticsData', () => {
it(`commits the ${types.REQUEST_CYCLE_ANALYTICS_DATA} mutation`, done => {
testAction(
actions.requestCycleAnalyticsData,
{ ...cycleAnalyticsData },
state,
[
{
type: types.REQUEST_CYCLE_ANALYTICS_DATA,
},
],
[],
done,
);
});
});
});
describe('receiveCycleAnalyticsDataSuccess', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it(`commits the ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} mutation`, done => {
testAction(
actions.receiveCycleAnalyticsDataSuccess,
{ ...cycleAnalyticsData },
state,
[
{
type: types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS,
payload: { ...cycleAnalyticsData },
},
],
[],
done,
);
});
it("dispatches the 'setStageDataEndpoint' and 'fetchStageData' actions", done => {
const { slug } = stages[0];
const stateWithStages = {
...state,
stages,
};
testAction(
actions.receiveCycleAnalyticsDataSuccess,
{ ...cycleAnalyticsData },
stateWithStages,
[
{
type: types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS,
payload: { ...cycleAnalyticsData },
},
],
[{ type: 'setStageDataEndpoint', payload: slug }, { type: 'fetchStageData' }],
done,
);
});
it('will flash an error when there are no stages', () => {
[[], null].forEach(emptyStages => {
actions.receiveCycleAnalyticsDataSuccess(
{
commit: () => {},
state: { stages: emptyStages },
},
{},
);
shouldFlashAnError();
});
});
});
describe('receiveCycleAnalyticsDataError', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it(`commits the ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} mutation`, done => {
testAction(
actions.receiveCycleAnalyticsDataError,
null,
state,
[
{
type: types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR,
},
],
[],
done,
);
});
it('will flash an error', () => {
actions.receiveCycleAnalyticsDataError(
{
commit: () => {},
},
{},
);
shouldFlashAnError();
});
});
});
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import { allowedStages as stages } from '../mock_data';
let state = null;
describe('Cycle analytics getters', () => {
describe('with default state', () => {
beforeEach(() => {
state = {
stages: [],
selectedStageName: null,
};
});
afterEach(() => {
state = null;
});
describe('currentStage', () => {
it('will return null', () => {
expect(getters.currentStage(state)).toEqual(null);
});
});
describe('defaultStage', () => {
it('will return null', () => {
expect(getters.defaultStage(state)).toEqual(null);
});
});
});
describe('with a set of stages', () => {
beforeEach(() => {
state = {
stages,
selectedStageName: null,
};
});
afterEach(() => {
state = null;
});
describe('currentStage', () => {
it('will return null', () => {
expect(getters.currentStage(state)).toEqual(null);
});
});
describe('defaultStage', () => {
it('will return the first stage', () => {
expect(getters.defaultStage(state)).toEqual(stages[0]);
});
});
});
describe('with a set of stages and a stage selected', () => {
beforeEach(() => {
state = {
stages,
selectedStageName: stages[2].name,
};
});
afterEach(() => {
state = null;
});
describe('currentStage', () => {
it('will return null', () => {
expect(getters.currentStage(state)).toEqual(stages[2]);
});
});
});
});
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import {
cycleAnalyticsData,
rawEvents as events,
issueEvents as transformedEvents,
issueStage,
planStage,
codeStage,
stagingStage,
reviewStage,
productionStage,
} from '../mock_data';
describe('Cycle analytics mutations', () => {
it.each`
mutation | stateKey | value
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${'isLoading'} | ${true}
${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${'isLoading'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
`('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
const state = {};
mutations[mutation](state);
expect(state[stateKey]).toBe(value);
});
it.each`
mutation | payload | expectedState
${types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT} | ${'cool-beans'} | ${{ endpoints: { cycleAnalyticsData: '/groups/cool-beans/-/cycle_analytics' } }}
${types.SET_STAGE_DATA_ENDPOINT} | ${'rad-stage'} | ${{ endpoints: { stageData: '/fake/api/events/rad-stage.json' } }}
${types.SET_SELECTED_GROUP} | ${'cool-beans'} | ${{ selectedGroup: 'cool-beans', selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_SELECTED_TIMEFRAME} | ${60} | ${{ dataTimeframe: 60 }}
${types.SET_SELECTED_STAGE_NAME} | ${'first-stage'} | ${{ selectedStageName: 'first-stage' }}
`(
'$mutation with payload $payload will update state with $expectedState',
({ mutation, payload, expectedState }) => {
const state = { endpoints: { cycleAnalyticsData: '/fake/api' } };
mutations[mutation](state, payload);
expect(state).toMatchObject(expectedState);
},
);
describe(`${types.RECEIVE_STAGE_DATA_SUCCESS}`, () => {
it('will set the events state item with the camelCased events', () => {
const state = {};
mutations[types.RECEIVE_STAGE_DATA_SUCCESS](state, { events });
expect(state.events).toEqual(transformedEvents);
expect(state.isLoadingStage).toBe(false);
expect(state.isEmptyStage).toBe(false);
});
});
describe(`${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS}`, () => {
it('will set isLoading=false', () => {
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, {
stats: [],
summary: [],
stages: [],
});
expect(state.isLoading).toBe(false);
});
describe('with data', () => {
it('will convert the stats object to stages', () => {
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData);
[issueStage, planStage, codeStage, stagingStage, reviewStage, productionStage].forEach(
stage => {
expect(state.stages).toContainEqual(stage);
},
);
});
it('will set the selectedStageName to the name of the first stage', () => {
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, cycleAnalyticsData);
expect(state.selectedStageName).toEqual('issue');
});
it('will set each summary item with a value of 0 to "-"', () => {
// { value: '-', title: 'New Issues' }, { value: '-', title: 'Deploys' }
const state = {};
mutations[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, {
...cycleAnalyticsData,
summary: [{ value: 0, title: 'New Issues' }, { value: 0, title: 'Deploys' }],
});
expect(state.summary).toEqual([
{ value: '-', title: 'New Issues' },
{ value: '-', title: 'Deploys' },
]);
});
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe 'Analytics (JavaScript fixtures)' do
include JavaScriptFixturesHelpers
let(:group) { create(:group)}
let(:project) { create(:project, :repository, namespace: group) }
let(:user) { create(:user, :admin) }
let(:issue) { create(:issue, project: project, created_at: 4.days.ago) }
let(:milestone) { create(:milestone, project: project) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
let(:build) { create(:ci_build, :success, pipeline: pipeline, author: user) }
let!(:issue_1) { create(:issue, project: project, created_at: 5.days.ago) }
let!(:issue_2) { create(:issue, project: project, created_at: 4.days.ago) }
let!(:issue_3) { create(:issue, project: project, created_at: 3.days.ago) }
let!(:mr_1) { create_merge_request_closing_issue(user, project, issue_1) }
let!(:mr_2) { create_merge_request_closing_issue(user, project, issue_2) }
let!(:mr_3) { create_merge_request_closing_issue(user, project, issue_3) }
def prepare_cycle_analytics_data
group.add_maintainer(user)
project.add_maintainer(user)
create_cycle(user, project, issue, mr, milestone, pipeline)
create_cycle(user, project, issue_2, mr_2, milestone, pipeline)
create_commit_referencing_issue(issue_1)
create_commit_referencing_issue(issue_2)
create_merge_request_closing_issue(user, project, issue_1)
create_merge_request_closing_issue(user, project, issue_2)
merge_merge_requests_closing_issue(user, project, issue_3)
deploy_master(user, project, environment: 'staging')
deploy_master(user, project)
end
before(:all) do
clean_frontend_fixtures('analytics/')
end
describe Groups::CycleAnalytics::EventsController, type: :controller do
using RSpec::Parameterized::TableSyntax
render_views
before do
stub_licensed_features(cycle_analytics_for_groups: true)
prepare_cycle_analytics_data
sign_in(user)
end
default_stages = %w[issue plan review code test staging production]
default_stages.each do |endpoint|
it "cycle_analytics/events/#{endpoint}.json" do
get endpoint, params: { group_id: group, format: :json }
expect(response).to be_successful
end
end
end
describe Groups::CycleAnalyticsController, type: :controller do
render_views
before do
stub_licensed_features(cycle_analytics_for_groups: true)
prepare_cycle_analytics_data
sign_in(user)
end
it 'cycle_analytics/mock_data.json' do
get(:show, params: {
group_id: group.name,
cycle_analytics: { start_date: 30 }
}, format: :json)
expect(response).to be_successful
end
end
end
......@@ -7923,9 +7923,6 @@ msgstr ""
msgid "HealthCheck|Unhealthy"
msgstr ""
msgid "Hello World!"
msgstr ""
msgid "Hello there"
msgstr ""
......@@ -9540,6 +9537,9 @@ msgstr ""
msgid "Merge request"
msgstr ""
msgid "Merge request %{iid} authored by %{authorName}"
msgstr ""
msgid "Merge request approvals"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment