Commit 65e476a8 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '13076-edit-custom-cycle-analytics-stages' into 'master'

Edit custom cycle analytics stages

See merge request gitlab-org/gitlab!18921
parents 33ce19a8 68f8c586
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
import $ from 'jquery'; import $ from 'jquery';
import axios from './axios_utils'; import axios from './axios_utils';
import { getLocationHash } from './url_utility'; import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility'; import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility'; import { isObject } from './type_utility';
import breakpointInstance from '../../breakpoints'; import breakpointInstance from '../../breakpoints';
...@@ -697,6 +697,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { ...@@ -697,6 +697,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
}, initial); }, initial);
}; };
/**
* Converts all the object keys to snake case
*
* @param {Object} obj Object to transform
* @returns {Object}
*/
// Follow up to add additional options param:
// https://gitlab.com/gitlab-org/gitlab/issues/39173
export const convertObjectPropsToSnakeCase = (obj = {}) =>
obj
? Object.entries(obj).reduce(
(acc, [key, value]) => ({ ...acc, [convertToSnakeCase(key)]: value }),
{},
)
: {};
export const imagePath = imgUrl => export const imagePath = imgUrl =>
`${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
......
...@@ -55,10 +55,11 @@ export default { ...@@ -55,10 +55,11 @@ export default {
'isLoadingChartData', 'isLoadingChartData',
'isLoadingDurationChart', 'isLoadingDurationChart',
'isEmptyStage', 'isEmptyStage',
'isAddingCustomStage',
'isSavingCustomStage', 'isSavingCustomStage',
'isCreatingCustomStage',
'isEditingCustomStage',
'selectedGroup', 'selectedGroup',
'selectedStageId', 'selectedStage',
'stages', 'stages',
'summary', 'summary',
'labels', 'labels',
...@@ -69,12 +70,7 @@ export default { ...@@ -69,12 +70,7 @@ export default {
'endDate', 'endDate',
'tasksByType', 'tasksByType',
]), ]),
...mapGetters([ ...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']),
'currentStage',
'defaultStage',
'hasNoAccessError',
'durationChartPlottableData',
]),
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.selectedGroup; return !this.selectedGroup;
}, },
...@@ -112,16 +108,18 @@ export default { ...@@ -112,16 +108,18 @@ export default {
'fetchStageData', 'fetchStageData',
'setSelectedGroup', 'setSelectedGroup',
'setSelectedProjects', 'setSelectedProjects',
'setSelectedStageId', 'setSelectedStage',
'hideCustomStageForm', 'hideCustomStageForm',
'showCustomStageForm', 'showCustomStageForm',
'setDateRange', 'setDateRange',
'fetchTasksByTypeData', 'fetchTasksByTypeData',
'updateSelectedDurationChartStages',
'createCustomStage', 'createCustomStage',
'updateStage', 'updateStage',
'removeStage', 'removeStage',
'updateSelectedDurationChartStages',
'setFeatureFlags', 'setFeatureFlags',
'editCustomStage',
'updateStage',
]), ]),
onGroupSelect(group) { onGroupSelect(group) {
this.setSelectedGroup(group); this.setSelectedGroup(group);
...@@ -134,12 +132,15 @@ export default { ...@@ -134,12 +132,15 @@ export default {
}, },
onStageSelect(stage) { onStageSelect(stage) {
this.hideCustomStageForm(); this.hideCustomStageForm();
this.setSelectedStageId(stage.id); this.setSelectedStage(stage);
this.fetchStageData(this.currentStage.slug); this.fetchStageData(this.selectedStage.slug);
}, },
onShowAddStageForm() { onShowAddStageForm() {
this.showCustomStageForm(); this.showCustomStageForm();
}, },
onShowEditStageForm(initData = {}) {
this.editCustomStage(initData);
},
initDateRange() { initDateRange() {
const endDate = new Date(Date.now()); const endDate = new Date(Date.now());
const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST); const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST);
...@@ -148,7 +149,7 @@ export default { ...@@ -148,7 +149,7 @@ export default {
onCreateCustomStage(data) { onCreateCustomStage(data) {
this.createCustomStage(data); this.createCustomStage(data);
}, },
onUpdateStage(data) { onUpdateCustomStage(data) {
this.updateStage(data); this.updateStage(data);
}, },
onRemoveStage(id) { onRemoveStage(id) {
...@@ -237,14 +238,15 @@ export default { ...@@ -237,14 +238,15 @@ export default {
<div v-else> <div v-else>
<summary-table class="js-summary-table" :items="summary" /> <summary-table class="js-summary-table" :items="summary" />
<stage-table <stage-table
v-if="currentStage" v-if="selectedStage"
class="js-stage-table" class="js-stage-table"
:current-stage="currentStage" :current-stage="selectedStage"
:stages="stages" :stages="stages"
:is-loading="isLoadingStage" :is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage" :is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:is-saving-custom-stage="isSavingCustomStage" :is-saving-custom-stage="isSavingCustomStage"
:is-creating-custom-stage="isCreatingCustomStage"
:is-editing-custom-stage="isEditingCustomStage"
:current-stage-events="currentStageEvents" :current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents" :custom-stage-form-events="customStageFormEvents"
:labels="labels" :labels="labels"
...@@ -252,10 +254,12 @@ export default { ...@@ -252,10 +254,12 @@ export default {
:no-access-svg-path="noAccessSvgPath" :no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics" :can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect" @selectStage="onStageSelect"
@editStage="onShowEditStageForm"
@showAddStageForm="onShowAddStageForm" @showAddStageForm="onShowAddStageForm"
@submit="onCreateCustomStage" @hideStage="onUpdateCustomStage"
@hideStage="onUpdateStage"
@removeStage="onRemoveStage" @removeStage="onRemoveStage"
@createStage="onCreateCustomStage"
@updateStage="onUpdateCustomStage"
/> />
</div> </div>
</div> </div>
......
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
import { isEqual } from 'underscore'; import { isEqual } from 'underscore';
import { GlButton, GlFormGroup, GlFormInput, GlFormSelect, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlFormGroup, GlFormInput, GlFormSelect, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import LabelsSelector from './labels_selector.vue'; import LabelsSelector from './labels_selector.vue';
import { STAGE_ACTIONS } from '../constants';
import { import {
isStartEvent, isStartEvent,
isLabelEvent, isLabelEvent,
...@@ -13,11 +15,12 @@ import { ...@@ -13,11 +15,12 @@ import {
} from '../utils'; } from '../utils';
const initFields = { const initFields = {
name: '', id: null,
startEvent: '', name: null,
startEventLabel: null, startEventIdentifier: null,
stopEvent: '', startEventLabelId: null,
stopEventLabel: null, endEventIdentifier: null,
endEventLabelId: null,
}; };
export default { export default {
...@@ -50,11 +53,16 @@ export default { ...@@ -50,11 +53,16 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
isEditingCustomStage: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
fields: { fields: {
...initFields, ...this.initialFields,
}, },
}; };
}, },
...@@ -65,30 +73,42 @@ export default { ...@@ -65,30 +73,42 @@ export default {
...this.events.filter(isStartEvent).map(eventToOption), ...this.events.filter(isStartEvent).map(eventToOption),
]; ];
}, },
stopEventOptions() { endEventOptions() {
const stopEvents = getAllowedEndEvents(this.events, this.fields.startEvent); const endEvents = getAllowedEndEvents(this.events, this.fields.startEventIdentifier);
return [ return [
{ value: null, text: s__('CustomCycleAnalytics|Select stop event') }, { value: null, text: s__('CustomCycleAnalytics|Select stop event') },
...eventsByIdentifier(this.events, stopEvents).map(eventToOption), ...eventsByIdentifier(this.events, endEvents).map(eventToOption),
]; ];
}, },
hasStartEvent() { hasStartEvent() {
return this.fields.startEvent; return this.fields.startEventIdentifier;
}, },
startEventRequiresLabel() { startEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.startEvent); return isLabelEvent(this.labelEvents, this.fields.startEventIdentifier);
}, },
stopEventRequiresLabel() { endEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.stopEvent); return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier);
}, },
isComplete() { isComplete() {
if (!this.hasValidStartAndStopEventPair) return false; if (!this.hasValidStartAndEndEventPair) {
const requiredFields = [this.fields.startEvent, this.fields.stopEvent, this.fields.name]; return false;
}
const {
fields: {
name,
startEventIdentifier,
startEventLabelId,
endEventIdentifier,
endEventLabelId,
},
} = this;
const requiredFields = [startEventIdentifier, endEventIdentifier, name];
if (this.startEventRequiresLabel) { if (this.startEventRequiresLabel) {
requiredFields.push(this.fields.startEventLabel); requiredFields.push(startEventLabelId);
} }
if (this.stopEventRequiresLabel) { if (this.endEventRequiresLabel) {
requiredFields.push(this.fields.stopEventLabel); requiredFields.push(endEventLabelId);
} }
return requiredFields.every( return requiredFields.every(
fieldValue => fieldValue && (fieldValue.length > 0 || fieldValue > 0), fieldValue => fieldValue && (fieldValue.length > 0 || fieldValue > 0),
...@@ -97,21 +117,31 @@ export default { ...@@ -97,21 +117,31 @@ export default {
isDirty() { isDirty() {
return !isEqual(this.initialFields, this.fields); return !isEqual(this.initialFields, this.fields);
}, },
hasValidStartAndStopEventPair() { hasValidStartAndEndEventPair() {
const { const {
fields: { startEvent, stopEvent }, fields: { startEventIdentifier, endEventIdentifier },
} = this; } = this;
if (startEvent && stopEvent) { if (startEventIdentifier && endEventIdentifier) {
const stopEvents = getAllowedEndEvents(this.events, startEvent); const endEvents = getAllowedEndEvents(this.events, startEventIdentifier);
return stopEvents.length && stopEvents.includes(stopEvent); return endEvents.length && endEvents.includes(endEventIdentifier);
} }
return true; return true;
}, },
stopEventError() { endEventError() {
return !this.hasValidStartAndStopEventPair return !this.hasValidStartAndEndEventPair
? s__('CustomCycleAnalytics|Start event changed, please select a valid stop event') ? s__('CustomCycleAnalytics|Start event changed, please select a valid stop event')
: null; : null;
}, },
saveStageText() {
return this.isEditingCustomStage
? s__('CustomCycleAnalytics|Update stage')
: s__('CustomCycleAnalytics|Add stage');
},
formTitle() {
return this.isEditingCustomStage
? s__('CustomCycleAnalytics|Editing stage')
: s__('CustomCycleAnalytics|New stage');
},
}, },
mounted() { mounted() {
this.labelEvents = getLabelEventsIdentifiers(this.events); this.labelEvents = getLabelEventsIdentifiers(this.events);
...@@ -122,16 +152,15 @@ export default { ...@@ -122,16 +152,15 @@ export default {
this.$emit('cancel'); this.$emit('cancel');
}, },
handleSave() { handleSave() {
const { startEvent, startEventLabel, stopEvent, stopEventLabel, name } = this.fields; const data = convertObjectPropsToSnakeCase(this.fields);
this.$emit('submit', { if (this.isEditingCustomStage) {
name, const { id } = this.initialFields;
start_event_identifier: startEvent, this.$emit(STAGE_ACTIONS.UPDATE, { ...data, id });
start_event_label_id: startEventLabel, } else {
end_event_identifier: stopEvent, this.$emit(STAGE_ACTIONS.CREATE, data);
end_event_label_id: stopEventLabel, }
}); },
}, handleSelectLabel(key, labelId) {
handleSelectLabel(key, labelId = null) {
this.fields[key] = labelId; this.fields[key] = labelId;
}, },
handleClearLabel(key) { handleClearLabel(key) {
...@@ -143,7 +172,7 @@ export default { ...@@ -143,7 +172,7 @@ export default {
<template> <template>
<form class="custom-stage-form m-4 mt-0"> <form class="custom-stage-form m-4 mt-0">
<div class="mb-1"> <div class="mb-1">
<h4>{{ s__('CustomCycleAnalytics|New stage') }}</h4> <h4>{{ formTitle }}</h4>
</div> </div>
<gl-form-group :label="s__('CustomCycleAnalytics|Name')"> <gl-form-group :label="s__('CustomCycleAnalytics|Name')">
<gl-form-input <gl-form-input
...@@ -159,7 +188,7 @@ export default { ...@@ -159,7 +188,7 @@ export default {
<div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']"> <div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group :label="s__('CustomCycleAnalytics|Start event')"> <gl-form-group :label="s__('CustomCycleAnalytics|Start event')">
<gl-form-select <gl-form-select
v-model="fields.startEvent" v-model="fields.startEventIdentifier"
name="custom-stage-start-event" name="custom-stage-start-event"
:required="true" :required="true"
:options="startEventOptions" :options="startEventOptions"
...@@ -170,41 +199,41 @@ export default { ...@@ -170,41 +199,41 @@ export default {
<gl-form-group :label="s__('CustomCycleAnalytics|Start event label')"> <gl-form-group :label="s__('CustomCycleAnalytics|Start event label')">
<labels-selector <labels-selector
:labels="labels" :labels="labels"
:selected-label-id="fields.startEventLabel" :selected-label-id="fields.startEventLabelId"
name="custom-stage-start-event-label" name="custom-stage-start-event-label"
@selectLabel="labelId => handleSelectLabel('startEventLabel', labelId)" @selectLabel="handleSelectLabel('startEventLabelId', $event)"
@clearLabel="handleClearLabel('startEventLabel')" @clearLabel="handleClearLabel('startEventLabelId')"
/> />
</gl-form-group> </gl-form-group>
</div> </div>
</div> </div>
<div class="d-flex" :class="{ 'justify-content-between': stopEventRequiresLabel }"> <div class="d-flex" :class="{ 'justify-content-between': endEventRequiresLabel }">
<div :class="[stopEventRequiresLabel ? 'w-50 mr-1' : 'w-100']"> <div :class="[endEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group <gl-form-group
:label="s__('CustomCycleAnalytics|Stop event')" :label="s__('CustomCycleAnalytics|Stop event')"
:description=" :description="
!hasStartEvent ? s__('CustomCycleAnalytics|Please select a start event first') : '' !hasStartEvent ? s__('CustomCycleAnalytics|Please select a start event first') : ''
" "
:state="hasValidStartAndStopEventPair" :state="hasValidStartAndEndEventPair"
:invalid-feedback="stopEventError" :invalid-feedback="endEventError"
> >
<gl-form-select <gl-form-select
v-model="fields.stopEvent" v-model="fields.endEventIdentifier"
name="custom-stage-stop-event" name="custom-stage-stop-event"
:options="stopEventOptions" :options="endEventOptions"
:required="true" :required="true"
:disabled="!hasStartEvent" :disabled="!hasStartEvent"
/> />
</gl-form-group> </gl-form-group>
</div> </div>
<div v-if="stopEventRequiresLabel" class="w-50 ml-1"> <div v-if="endEventRequiresLabel" class="w-50 ml-1">
<gl-form-group :label="s__('CustomCycleAnalytics|Stop event label')"> <gl-form-group :label="s__('CustomCycleAnalytics|Stop event label')">
<labels-selector <labels-selector
:labels="labels" :labels="labels"
:selected-label-id="fields.stopEventLabel" :selected-label-id="fields.endEventLabelId"
name="custom-stage-stop-event-label" name="custom-stage-stop-event-label"
@selectLabel="labelId => handleSelectLabel('stopEventLabel', labelId)" @selectLabel="handleSelectLabel('endEventLabelId', $event)"
@clearLabel="handleClearLabel('stopEventLabel')" @clearLabel="handleClearLabel('endEventLabelId')"
/> />
</gl-form-group> </gl-form-group>
</div> </div>
...@@ -213,7 +242,7 @@ export default { ...@@ -213,7 +242,7 @@ export default {
<div class="custom-stage-form-actions"> <div class="custom-stage-form-actions">
<button <button
:disabled="!isDirty" :disabled="!isDirty"
class="btn btn-cancel js-custom-stage-form-cancel" class="btn btn-cancel js-save-stage-cancel"
type="button" type="button"
@click="handleCancel" @click="handleCancel"
> >
...@@ -222,11 +251,11 @@ export default { ...@@ -222,11 +251,11 @@ export default {
<button <button
:disabled="!isComplete || !isDirty" :disabled="!isComplete || !isDirty"
type="button" type="button"
class="js-custom-stage-form-submit btn btn-success" class="js-save-stage btn btn-success"
@click="handleSave" @click="handleSave"
> >
<gl-loading-icon v-if="isSavingCustomStage" size="sm" inline /> <gl-loading-icon v-if="isSavingCustomStage" size="sm" inline />
{{ s__('CustomCycleAnalytics|Add stage') }} {{ saveStageText }}
</button> </button>
</div> </div>
</form> </form>
......
...@@ -41,7 +41,11 @@ export default { ...@@ -41,7 +41,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
isAddingCustomStage: { isCreatingCustomStage: {
type: Boolean,
required: true,
},
isEditingCustomStage: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
...@@ -82,6 +86,9 @@ export default { ...@@ -82,6 +86,9 @@ export default {
const { currentStageEvents = [], isLoading, isEmptyStage } = this; const { currentStageEvents = [], isLoading, isEmptyStage } = this;
return currentStageEvents.length && !isLoading && !isEmptyStage; return currentStageEvents.length && !isLoading && !isEmptyStage;
}, },
customStageFormActive() {
return this.isCreatingCustomStage;
},
stageHeaders() { stageHeaders() {
return [ return [
{ {
...@@ -100,26 +107,21 @@ export default { ...@@ -100,26 +107,21 @@ export default {
title: this.stageName, title: this.stageName,
description: __('The collection of events added to the data gathered for that stage.'), description: __('The collection of events added to the data gathered for that stage.'),
classes: 'event-header pl-3', classes: 'event-header pl-3',
displayHeader: !this.isAddingCustomStage, displayHeader: !this.customStageFormActive,
}, },
{ {
title: __('Total Time'), title: __('Total Time'),
description: __('The time taken by each data entry gathered by that stage.'), description: __('The time taken by each data entry gathered by that stage.'),
classes: 'total-time-header pr-5 text-right', classes: 'total-time-header pr-5 text-right',
displayHeader: !this.isAddingCustomStage, displayHeader: !this.customStageFormActive,
}, },
]; ];
}, },
}, customStageInitialData() {
methods: { return this.isEditingCustomStage ? this.currentStage : {};
// TODO: DRY These up
hideStage(stageId) {
this.$emit(STAGE_ACTIONS.HIDE, { id: stageId, hidden: true });
},
removeStage(stageId) {
this.$emit(STAGE_ACTIONS.REMOVE, stageId);
}, },
}, },
STAGE_ACTIONS,
}; };
</script> </script>
<template> <template>
...@@ -147,16 +149,17 @@ export default { ...@@ -147,16 +149,17 @@ export default {
:key="`ca-stage-title-${stage.title}`" :key="`ca-stage-title-${stage.title}`"
:title="stage.title" :title="stage.title"
:value="stage.value" :value="stage.value"
:is-active="!isAddingCustomStage && stage.id === currentStage.id" :is-active="!isCreatingCustomStage && stage.id === currentStage.id"
:can-edit="canEditStages" :can-edit="canEditStages"
:is-default-stage="!stage.custom" :is-default-stage="!stage.custom"
@select="$emit('selectStage', stage)" @remove="$emit($options.STAGE_ACTIONS.REMOVE, stage.id)"
@remove="removeStage(stage.id)" @hide="$emit($options.STAGE_ACTIONS.HIDE, { id: stage.id, hidden: true })"
@hide="hideStage(stage.id)" @select="$emit($options.STAGE_ACTIONS.SELECT, stage)"
@edit="$emit($options.STAGE_ACTIONS.EDIT, stage)"
/> />
<add-stage-button <add-stage-button
v-if="canEditStages" v-if="canEditStages"
:active="isAddingCustomStage" :active="customStageFormActive"
@showform="$emit('showAddStageForm')" @showform="$emit('showAddStageForm')"
/> />
</ul> </ul>
...@@ -164,11 +167,15 @@ export default { ...@@ -164,11 +167,15 @@ export default {
<div class="section stage-events"> <div class="section stage-events">
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" /> <gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<custom-stage-form <custom-stage-form
v-else-if="isAddingCustomStage" v-else-if="isCreatingCustomStage || isEditingCustomStage"
:events="customStageFormEvents" :events="customStageFormEvents"
:labels="labels" :labels="labels"
:is-saving-custom-stage="isSavingCustomStage" :is-saving-custom-stage="isSavingCustomStage"
:initial-fields="customStageInitialData"
:is-editing-custom-stage="isEditingCustomStage"
@submit="$emit('submit', $event)" @submit="$emit('submit', $event)"
@createStage="$emit($options.STAGE_ACTIONS.CREATE, $event)"
@updateStage="$emit($options.STAGE_ACTIONS.UPDATE, $event)"
/> />
<template v-else> <template v-else>
<stage-event-list <stage-event-list
......
...@@ -34,8 +34,10 @@ export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue'; ...@@ -34,8 +34,10 @@ export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest'; export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
export const STAGE_ACTIONS = { export const STAGE_ACTIONS = {
SELECT: 'selectStage',
EDIT: 'editStage', EDIT: 'editStage',
REMOVE: 'removeStage', REMOVE: 'removeStage',
SAVE: 'saveStage',
HIDE: 'hideStage', HIDE: 'hideStage',
CREATE: 'createStage',
UPDATE: 'updateStage',
}; };
...@@ -14,10 +14,11 @@ const removeError = () => { ...@@ -14,10 +14,11 @@ const removeError = () => {
export const setFeatureFlags = ({ commit }, featureFlags) => export const setFeatureFlags = ({ commit }, featureFlags) =>
commit(types.SET_FEATURE_FLAGS, featureFlags); commit(types.SET_FEATURE_FLAGS, featureFlags);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group); export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) => export const setSelectedProjects = ({ commit }, projectIds) =>
commit(types.SET_SELECTED_PROJECTS, projectIds); commit(types.SET_SELECTED_PROJECTS, projectIds);
export const setSelectedStageId = ({ commit }, stageId) =>
commit(types.SET_SELECTED_STAGE_ID, stageId); export const setSelectedStage = ({ commit }, stage) => commit(types.SET_SELECTED_STAGE, stage);
export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => { export const setDateRange = ({ commit, dispatch }, { skipFetch = false, startDate, endDate }) => {
commit(types.SET_DATE_RANGE, { startDate, endDate }); commit(types.SET_DATE_RANGE, { startDate, endDate });
...@@ -38,12 +39,12 @@ export const receiveStageDataError = ({ commit }) => { ...@@ -38,12 +39,12 @@ export const receiveStageDataError = ({ commit }) => {
export const fetchStageData = ({ state, dispatch, getters }, slug) => { export const fetchStageData = ({ state, dispatch, getters }, slug) => {
const { cycleAnalyticsRequestParams = {} } = getters; const { cycleAnalyticsRequestParams = {} } = getters;
dispatch('requestStageData');
const { const {
selectedGroup: { fullPath }, selectedGroup: { fullPath },
} = state; } = state;
dispatch('requestStageData');
return Api.cycleAnalyticsStageEvents( return Api.cycleAnalyticsStageEvents(
fullPath, fullPath,
slug, slug,
...@@ -83,6 +84,14 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => { ...@@ -83,6 +84,14 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
.catch(error => dispatch('receiveCycleAnalyticsDataError', error)); .catch(error => dispatch('receiveCycleAnalyticsDataError', error));
}; };
export const hideCustomStageForm = ({ commit }) => commit(types.HIDE_CUSTOM_STAGE_FORM);
export const showCustomStageForm = ({ commit }) => commit(types.SHOW_CUSTOM_STAGE_FORM);
export const editCustomStage = ({ commit, dispatch }, selectedStage = {}) => {
commit(types.EDIT_CUSTOM_STAGE);
dispatch('setSelectedStage', selectedStage);
};
export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA); export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA);
export const receiveSummaryDataError = ({ commit }, error) => { export const receiveSummaryDataError = ({ commit }, error) => {
...@@ -112,9 +121,6 @@ export const fetchSummaryData = ({ state, dispatch, getters }) => { ...@@ -112,9 +121,6 @@ export const fetchSummaryData = ({ state, dispatch, getters }) => {
export const requestGroupStagesAndEvents = ({ commit }) => export const requestGroupStagesAndEvents = ({ commit }) =>
commit(types.REQUEST_GROUP_STAGES_AND_EVENTS); commit(types.REQUEST_GROUP_STAGES_AND_EVENTS);
export const hideCustomStageForm = ({ commit }) => commit(types.HIDE_CUSTOM_STAGE_FORM);
export const showCustomStageForm = ({ commit }) => commit(types.SHOW_CUSTOM_STAGE_FORM);
export const receiveGroupLabelsSuccess = ({ commit }, data) => export const receiveGroupLabelsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_GROUP_LABELS_SUCCESS, data); commit(types.RECEIVE_GROUP_LABELS_SUCCESS, data);
...@@ -136,8 +142,8 @@ export const fetchGroupLabels = ({ dispatch, state }) => { ...@@ -136,8 +142,8 @@ export const fetchGroupLabels = ({ dispatch, state }) => {
.catch(error => dispatch('receiveGroupLabelsError', error)); .catch(error => dispatch('receiveGroupLabelsError', error));
}; };
export const receiveGroupStagesAndEventsError = ({ commit }) => { export const receiveGroupStagesAndEventsError = ({ commit }, error) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR); commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR, error);
createFlash(__('There was an error fetching cycle analytics stages.')); createFlash(__('There was an error fetching cycle analytics stages.'));
}; };
...@@ -145,8 +151,9 @@ export const receiveGroupStagesAndEventsSuccess = ({ state, commit, dispatch }, ...@@ -145,8 +151,9 @@ export const receiveGroupStagesAndEventsSuccess = ({ state, commit, dispatch },
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS, data); commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS, data);
const { stages = [] } = state; const { stages = [] } = state;
if (stages && stages.length) { if (stages && stages.length) {
const { slug } = stages[0]; const [firstStage] = stages;
dispatch('fetchStageData', slug); dispatch('setSelectedStage', firstStage);
dispatch('fetchStageData', firstStage.slug);
} else { } else {
createFlash(__('There was an error while fetching cycle analytics data.')); createFlash(__('There was an error while fetching cycle analytics data.'));
} }
...@@ -197,7 +204,6 @@ export const createCustomStage = ({ dispatch, state }, data) => { ...@@ -197,7 +204,6 @@ export const createCustomStage = ({ dispatch, state }, data) => {
const { const {
selectedGroup: { fullPath }, selectedGroup: { fullPath },
} = state; } = state;
dispatch('requestCreateCustomStage'); dispatch('requestCreateCustomStage');
return Api.cycleAnalyticsCreateStage(fullPath, data) return Api.cycleAnalyticsCreateStage(fullPath, data)
...@@ -245,11 +251,12 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { ...@@ -245,11 +251,12 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
}; };
export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE); export const requestUpdateStage = ({ commit }) => commit(types.REQUEST_UPDATE_STAGE);
export const receiveUpdateStageSuccess = ({ commit, dispatch }) => { export const receiveUpdateStageSuccess = ({ commit, dispatch }, updatedData) => {
commit(types.RECEIVE_UPDATE_STAGE_RESPONSE); commit(types.RECEIVE_UPDATE_STAGE_RESPONSE);
createFlash(__(`Stage data updated`), 'notice'); createFlash(__('Stage data updated'), 'notice');
dispatch('fetchCycleAnalyticsData'); dispatch('fetchGroupStagesAndEvents');
dispatch('setSelectedStage', updatedData);
}; };
export const receiveUpdateStageError = ({ commit }) => { export const receiveUpdateStageError = ({ commit }) => {
......
...@@ -3,10 +3,6 @@ import httpStatus from '~/lib/utils/http_status'; ...@@ -3,10 +3,6 @@ import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants'; import { dateFormats } from '../../shared/constants';
import { getDurationChartData } from '../utils'; import { getDurationChartData } from '../utils';
export const currentStage = ({ stages, selectedStageId }) =>
stages.length && selectedStageId ? stages.find(stage => stage.id === selectedStageId) : null;
export const defaultStage = state => (state.stages.length ? state.stages[0] : null);
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN; export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
export const currentGroupPath = ({ selectedGroup }) => export const currentGroupPath = ({ selectedGroup }) =>
......
...@@ -2,8 +2,7 @@ export const SET_FEATURE_FLAGS = 'SET_FEATURE_FLAGS'; ...@@ -2,8 +2,7 @@ export const SET_FEATURE_FLAGS = 'SET_FEATURE_FLAGS';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP'; export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS'; export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_STAGE_ID = 'SET_SELECTED_STAGE_ID'; export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE'; export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const UPDATE_SELECTED_DURATION_CHART_STAGES = 'UPDATE_SELECTED_DURATION_CHART_STAGES'; export const UPDATE_SELECTED_DURATION_CHART_STAGES = 'UPDATE_SELECTED_DURATION_CHART_STAGES';
...@@ -18,6 +17,7 @@ export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; ...@@ -18,6 +17,7 @@ export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
export const HIDE_CUSTOM_STAGE_FORM = 'HIDE_CUSTOM_STAGE_FORM'; export const HIDE_CUSTOM_STAGE_FORM = 'HIDE_CUSTOM_STAGE_FORM';
export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM'; export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const EDIT_CUSTOM_STAGE = 'EDIT_CUSTOM_STAGE';
export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS'; export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS';
export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS'; export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
......
...@@ -13,8 +13,8 @@ export default { ...@@ -13,8 +13,8 @@ export default {
[types.SET_SELECTED_PROJECTS](state, projectIds) { [types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds; state.selectedProjectIds = projectIds;
}, },
[types.SET_SELECTED_STAGE_ID](state, stageId) { [types.SET_SELECTED_STAGE](state, rawData) {
state.selectedStageId = stageId; state.selectedStage = convertObjectPropsToCamelCase(rawData);
}, },
[types.SET_DATE_RANGE](state, { startDate, endDate }) { [types.SET_DATE_RANGE](state, { startDate, endDate }) {
state.startDate = startDate; state.startDate = startDate;
...@@ -25,7 +25,8 @@ export default { ...@@ -25,7 +25,8 @@ export default {
}, },
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) { [types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true; state.isLoading = true;
state.isAddingCustomStage = false; state.isCreatingCustomStage = false;
state.isEditingCustomStage = false;
}, },
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state) { [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state) {
state.errorCode = null; state.errorCode = null;
...@@ -75,11 +76,20 @@ export default { ...@@ -75,11 +76,20 @@ export default {
labelIds: [], labelIds: [],
}; };
}, },
[types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isCreatingCustomStage = true;
state.customStageFormInitData = {};
},
[types.EDIT_CUSTOM_STAGE](state) {
state.isEditingCustomStage = true;
},
[types.HIDE_CUSTOM_STAGE_FORM](state) { [types.HIDE_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = false; state.isEditingCustomStage = false;
state.isCreatingCustomStage = false;
state.customStageFormInitData = {};
}, },
[types.SHOW_CUSTOM_STAGE_FORM](state) { [types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = true; state.isCreatingCustomStage = true;
}, },
[types.RECEIVE_SUMMARY_DATA_ERROR](state) { [types.RECEIVE_SUMMARY_DATA_ERROR](state) {
state.summary = []; state.summary = [];
...@@ -122,11 +132,6 @@ export default { ...@@ -122,11 +132,6 @@ export default {
state.customStageFormEvents = events.map(ev => state.customStageFormEvents = events.map(ev =>
convertObjectPropsToCamelCase(ev, { deep: true }), convertObjectPropsToCamelCase(ev, { deep: true }),
); );
if (state.stages.length) {
const { id } = state.stages[0];
state.selectedStageId = id;
}
}, },
[types.REQUEST_TASKS_BY_TYPE_DATA](state) { [types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true; state.isLoadingChartData = true;
...@@ -152,6 +157,7 @@ export default { ...@@ -152,6 +157,7 @@ export default {
}, },
[types.RECEIVE_UPDATE_STAGE_RESPONSE](state) { [types.RECEIVE_UPDATE_STAGE_RESPONSE](state) {
state.isLoading = false; state.isLoading = false;
state.isSavingCustomStage = false;
}, },
[types.REQUEST_REMOVE_STAGE](state) { [types.REQUEST_REMOVE_STAGE](state) {
state.isLoading = true; state.isLoading = true;
......
...@@ -14,12 +14,13 @@ export default () => ({ ...@@ -14,12 +14,13 @@ export default () => ({
isEmptyStage: false, isEmptyStage: false,
errorCode: null, errorCode: null,
isAddingCustomStage: false,
isSavingCustomStage: false, isSavingCustomStage: false,
isCreatingCustomStage: false,
isEditingCustomStage: false,
selectedGroup: null, selectedGroup: null,
selectedProjectIds: [], selectedProjectIds: [],
selectedStageId: null, selectedStage: null,
currentStageEvents: [], currentStageEvents: [],
......
...@@ -32,12 +32,29 @@ export const isLabelEvent = (labelEvents = [], ev = null) => ...@@ -32,12 +32,29 @@ export const isLabelEvent = (labelEvents = [], ev = null) =>
export const getLabelEventsIdentifiers = (events = []) => export const getLabelEventsIdentifiers = (events = []) =>
events.filter(ev => ev.type && ev.type === EVENT_TYPE_LABEL).map(i => i.identifier); events.filter(ev => ev.type && ev.type === EVENT_TYPE_LABEL).map(i => i.identifier);
/**
* Checks if the specified stage is in memory or persisted to storage based on the id
*
* Default cycle analytics stages are initially stored in memory, when they are first
* created the id for the stage is the name of the stage in lowercase. This string id
* is used to fetch stage data (events, median calculation)
*
* When either a custom stage is created or an edit is made to a default stage then the
* default stages get persisted to storage and will have a numeric id. The new numeric
* id should then be used to access stage data
*
* This will be fixed in https://gitlab.com/gitlab-org/gitlab/merge_requests/19278
*/
export const transformRawStages = (stages = []) => export const transformRawStages = (stages = []) =>
stages stages
.map(({ title, ...rest }) => ({ .map(({ id, title, custom = false, ...rest }) => ({
...convertObjectPropsToCamelCase(rest, { deep: true }), ...convertObjectPropsToCamelCase(rest, { deep: true }),
slug: convertToSnakeCase(title), id,
title, title,
slug: custom ? id : convertToSnakeCase(title),
custom,
name: title, // editing a stage takes 'name' as a parameter, but the api returns title
})) }))
.sort((a, b) => a.id > b.id); .sort((a, b) => a.id > b.id);
......
...@@ -10,6 +10,8 @@ describe 'Group Cycle Analytics', :js do ...@@ -10,6 +10,8 @@ describe 'Group Cycle Analytics', :js do
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } 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(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
stage_nav_selector = '.stage-nav'
3.times do |i| 3.times do |i|
let!("issue_#{i}".to_sym) { create(:issue, title: "New Issue #{i}", project: project, created_at: 2.days.ago) } let!("issue_#{i}".to_sym) { create(:issue, title: "New Issue #{i}", project: project, created_at: 2.days.ago) }
end end
...@@ -119,11 +121,11 @@ describe 'Group Cycle Analytics', :js do ...@@ -119,11 +121,11 @@ describe 'Group Cycle Analytics', :js do
context 'stage nav' do context 'stage nav' do
it 'displays the list of stages' do it 'displays the list of stages' do
expect(page).to have_selector('.stage-nav', visible: true) expect(page).to have_selector(stage_nav_selector, visible: true)
end end
it 'displays the default list of stages' do it 'displays the default list of stages' do
stage_nav = page.find('.stage-nav') stage_nav = page.find(stage_nav_selector)
%w[Issue Plan Code Test Review Staging Production].each do |item| %w[Issue Plan Code Test Review Staging Production].each do |item|
expect(stage_nav).to have_content(item) expect(stage_nav).to have_content(item)
...@@ -212,7 +214,24 @@ describe 'Group Cycle Analytics', :js do ...@@ -212,7 +214,24 @@ describe 'Group Cycle Analytics', :js do
end end
describe 'Customizable cycle analytics', :js do describe 'Customizable cycle analytics', :js do
custom_stage_name = "Cool beans"
start_event_identifier = :merge_request_created
end_event_identifier = :merge_request_merged
let(:button_class) { '.js-add-stage-button' } let(:button_class) { '.js-add-stage-button' }
let(:params) { { name: custom_stage_name, start_event_identifier: start_event_identifier, end_event_identifier: end_event_identifier } }
let(:first_default_stage) { page.find('.stage-nav-item-cell', text: "Issue").ancestor(".stage-nav-item") }
let(:first_custom_stage) { page.find('.stage-nav-item-cell', text: custom_stage_name).ancestor(".stage-nav-item") }
def create_custom_stage
Analytics::CycleAnalytics::Stages::CreateService.new(parent: group, params: params, current_user: user).execute
end
def toggle_more_options(stage)
stage.hover
stage.find(".more-actions-toggle").click
end
def select_dropdown_option(name, elem = "option", index = 1) def select_dropdown_option(name, elem = "option", index = 1)
page.find("select[name='#{name}']").all(elem)[index].select_option page.find("select[name='#{name}']").all(elem)[index].select_option
...@@ -267,8 +286,6 @@ describe 'Group Cycle Analytics', :js do ...@@ -267,8 +286,6 @@ describe 'Group Cycle Analytics', :js do
end end
context 'with all required fields set' do context 'with all required fields set' do
custom_stage_name = "cool beans"
before do before do
fill_in 'custom-stage-name', with: custom_stage_name fill_in 'custom-stage-name', with: custom_stage_name
select_dropdown_option 'custom-stage-start-event' select_dropdown_option 'custom-stage-start-event'
...@@ -317,22 +334,75 @@ describe 'Group Cycle Analytics', :js do ...@@ -317,22 +334,75 @@ describe 'Group Cycle Analytics', :js do
end end
end end
context 'Stage table' do context 'Edit stage form' do
custom_stage = "Cool beans" stage_form_class = '.custom-stage-form'
let(:params) { { name: custom_stage, start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged } } stage_save_button = '.js-save-stage'
let(:first_default_stage) { page.find('.stage-nav-item-cell', text: "Issue").ancestor(".stage-nav-item") } name_field = "custom-stage-name"
let(:first_custom_stage) { page.find('.stage-nav-item-cell', text: custom_stage).ancestor(".stage-nav-item") } start_event_field = "custom-stage-start-event"
end_event_field = "custom-stage-stop-event"
updated_custom_stage_name = 'Extra uber cool stage'
def select_edit_stage
toggle_more_options(first_custom_stage)
click_button "Edit stage"
end
before do
create_custom_stage
select_group
expect(page).to have_text custom_stage_name
end
context 'with no changes to the data' do
before do
select_edit_stage
end
def create_custom_stage it 'displays the editing stage form' do
Analytics::CycleAnalytics::Stages::CreateService.new(parent: group, params: params, current_user: user).execute expect(page.find(stage_form_class)).to have_text 'Editing stage'
end
it 'prepoulates the stage data' do
expect(page.find_field(name_field).value).to eq custom_stage_name
expect(page.find_field(start_event_field).value).to eq start_event_identifier.to_s
expect(page.find_field(end_event_field).value).to eq end_event_identifier.to_s
end
it 'disables the submit form button' do
expect(page.find(stage_save_button)[:disabled]).to eq "true"
end
end end
def toggle_more_options(stage) context 'with changes' do
stage.hover before do
select_edit_stage
end
it 'enables the submit button' do
fill_in name_field, with: updated_custom_stage_name
expect(page.find(stage_save_button)[:disabled]).to eq nil
end
it 'will persist updates to the stage' do
fill_in name_field, with: updated_custom_stage_name
page.find(stage_save_button).click
expect(page.find('.flash-notice')).to have_text 'Stage data updated'
expect(page.find(stage_nav_selector)).not_to have_text custom_stage_name
expect(page.find(stage_nav_selector)).to have_text updated_custom_stage_name
end
stage.find(".more-actions-toggle").click it 'disables the submit form button if incomplete' do
fill_in name_field, with: ""
expect(page.find(stage_save_button)[:disabled]).to eq "true"
end
end end
end
context 'Stage table' do
context 'default stages' do context 'default stages' do
before do before do
select_group select_group
...@@ -353,7 +423,7 @@ describe 'Group Cycle Analytics', :js do ...@@ -353,7 +423,7 @@ describe 'Group Cycle Analytics', :js do
end end
it 'will not appear in the stage table after being hidden' do it 'will not appear in the stage table after being hidden' do
nav = page.find('.stage-nav') nav = page.find(stage_nav_selector)
expect(nav).to have_text("Issue") expect(nav).to have_text("Issue")
click_button "Hide stage" click_button "Hide stage"
...@@ -368,7 +438,7 @@ describe 'Group Cycle Analytics', :js do ...@@ -368,7 +438,7 @@ describe 'Group Cycle Analytics', :js do
create_custom_stage create_custom_stage
select_group select_group
expect(page).to have_text custom_stage expect(page).to have_text custom_stage_name
toggle_more_options(first_custom_stage) toggle_more_options(first_custom_stage)
end end
...@@ -386,13 +456,13 @@ describe 'Group Cycle Analytics', :js do ...@@ -386,13 +456,13 @@ describe 'Group Cycle Analytics', :js do
end end
it 'will not appear in the stage table after being removed' do it 'will not appear in the stage table after being removed' do
nav = page.find('.stage-nav') nav = page.find(stage_nav_selector)
expect(nav).to have_text(custom_stage) expect(nav).to have_text(custom_stage_name)
click_button "Remove stage" click_button "Remove stage"
expect(page.find('.flash-notice')).to have_text 'Stage removed' expect(page.find('.flash-notice')).to have_text 'Stage removed'
expect(nav).not_to have_text(custom_stage) expect(nav).not_to have_text(custom_stage_name)
end end
end end
end end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Cycle analytics utils transformRawStages retains all the stage properties 1`] = `
Array [
Object {
"custom": false,
"description": "Time before an issue gets scheduled",
"hidden": false,
"id": "issue",
"legend": "",
"name": "Issue",
"slug": "issue",
"title": "Issue",
},
Object {
"custom": true,
"description": "",
"endEventIdentifier": "issue_first_added_to_board",
"hidden": false,
"id": 18,
"legend": "",
"name": "Coolest beans stage",
"slug": 18,
"startEventIdentifier": "issue_first_mentioned_in_commit",
"title": "Coolest beans stage",
},
]
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustomStageForm does not have a loading icon 1`] = ` exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true displays a loading icon 1`] = `
"<button type=\\"button\\" class=\\"js-custom-stage-form-submit btn btn-success\\"><!----> "<button disabled=\\"disabled\\" type=\\"button\\" class=\\"js-save-stage btn btn-success\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" aria-hidden=\\"true\\" class=\\"gl-spinner gl-spinner-orange gl-spinner-sm\\"></span></span>
Add stage Update stage
</button>" </button>"
`; `;
exports[`CustomStageForm isSavingCustomStage=true displays a loading icon 1`] = ` exports[`CustomStageForm Empty form isSavingCustomStage=true displays a loading icon 1`] = `
"<button disabled=\\"disabled\\" type=\\"button\\" class=\\"js-custom-stage-form-submit btn btn-success\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" aria-hidden=\\"true\\" class=\\"gl-spinner gl-spinner-orange gl-spinner-sm\\"></span></span> "<button disabled=\\"disabled\\" type=\\"button\\" class=\\"js-save-stage btn btn-success\\"><span class=\\"gl-spinner-container\\"><span aria-label=\\"Loading\\" aria-hidden=\\"true\\" class=\\"gl-spinner gl-spinner-orange gl-spinner-sm\\"></span></span>
Add stage Add stage
</button>" </button>"
`; `;
...@@ -36,8 +36,9 @@ function createComponent(props = {}, shallow = false) { ...@@ -36,8 +36,9 @@ function createComponent(props = {}, shallow = false) {
isLoading: false, isLoading: false,
isLoadingSummaryData: false, isLoadingSummaryData: false,
isEmptyStage: false, isEmptyStage: false,
isAddingCustomStage: false,
isSavingCustomStage: false, isSavingCustomStage: false,
isCreatingCustomStage: false,
isEditingCustomStage: false,
noDataSvgPath, noDataSvgPath,
noAccessSvgPath, noAccessSvgPath,
canEditStages: false, canEditStages: false,
......
...@@ -75,6 +75,16 @@ export const codeEvents = stageFixtures.code; ...@@ -75,6 +75,16 @@ export const codeEvents = stageFixtures.code;
export const testEvents = stageFixtures.test; export const testEvents = stageFixtures.test;
export const stagingEvents = stageFixtures.staging; export const stagingEvents = stageFixtures.staging;
export const productionEvents = stageFixtures.production; export const productionEvents = stageFixtures.production;
export const rawCustomStage = {
title: 'Coolest beans stage',
hidden: false,
legend: '',
description: '',
id: 18,
custom: true,
start_event_identifier: 'issue_first_mentioned_in_commit',
end_event_identifier: 'issue_first_added_to_board',
};
const { events: rawCustomStageEvents } = customizableStagesAndEvents; const { events: rawCustomStageEvents } = customizableStagesAndEvents;
const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase); const camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
......
...@@ -22,7 +22,8 @@ const stageData = { events: [] }; ...@@ -22,7 +22,8 @@ const stageData = { events: [] };
const error = new Error('Request failed with status code 404'); const error = new Error('Request failed with status code 404');
const flashErrorMessage = 'There was an error while fetching cycle analytics data.'; const flashErrorMessage = 'There was an error while fetching cycle analytics data.';
const selectedGroup = { fullPath: group.path }; const selectedGroup = { fullPath: group.path };
const [{ id: selectedStageSlug }] = stages; const [selectedStage] = stages;
const selectedStageSlug = selectedStage.slug;
const endpoints = { const endpoints = {
groupLabels: `/groups/${group.path}/-/labels`, groupLabels: `/groups/${group.path}/-/labels`,
cycleAnalyticsData: `/groups/${group.path}/-/cycle_analytics`, cycleAnalyticsData: `/groups/${group.path}/-/cycle_analytics`,
...@@ -62,7 +63,7 @@ describe('Cycle analytics actions', () => { ...@@ -62,7 +63,7 @@ describe('Cycle analytics actions', () => {
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }} ${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedGroup'} | ${'SET_SELECTED_GROUP'} | ${'selectedGroup'} | ${'someNewGroup'} ${'setSelectedGroup'} | ${'SET_SELECTED_GROUP'} | ${'selectedGroup'} | ${'someNewGroup'}
${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]} ${'setSelectedProjects'} | ${'SET_SELECTED_PROJECTS'} | ${'selectedProjectIds'} | ${[10, 20, 30, 40]}
${'setSelectedStageId'} | ${'SET_SELECTED_STAGE_ID'} | ${'selectedStageId'} | ${'someNewGroup'} ${'setSelectedStage'} | ${'SET_SELECTED_STAGE'} | ${'selectedStage'} | ${{ id: 'someStageId' }}
`('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => { `('$action should set $stateKey with $payload and type $type', ({ action, type, payload }) => {
testAction( testAction(
actions[action], actions[action],
...@@ -114,21 +115,30 @@ describe('Cycle analytics actions', () => { ...@@ -114,21 +115,30 @@ describe('Cycle analytics actions', () => {
); );
}); });
it('dispatches receiveStageDataError on error', done => { describe('with a failing request', () => {
testAction( beforeEach(() => {
actions.fetchStageData, mock = new MockAdapter(axios);
null, mock.onGet(endpoints.stageData).replyOnce(404, { error });
state, });
[],
[ it('dispatches receiveStageDataError on error', done => {
{ type: 'requestStageData' }, testAction(
{ actions.fetchStageData,
type: 'receiveStageDataError', selectedStage,
payload: error, state,
}, [],
], [
done, {
); type: 'requestStageData',
},
{
type: 'receiveStageDataError',
payload: error,
},
],
done,
);
});
}); });
describe('receiveStageDataSuccess', () => { describe('receiveStageDataSuccess', () => {
...@@ -387,33 +397,13 @@ describe('Cycle analytics actions', () => { ...@@ -387,33 +397,13 @@ describe('Cycle analytics actions', () => {
}); });
}); });
it("dispatches the 'fetchStageData' action", done => {
const stateWithStages = {
...state,
stages,
};
testAction(
actions.receiveGroupStagesAndEventsSuccess,
{ ...customizableStagesAndEvents },
stateWithStages,
[
{
type: types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS,
payload: { ...customizableStagesAndEvents },
},
],
[{ type: 'fetchStageData', payload: selectedStageSlug }],
done,
);
});
it('will flash an error when there are no stages', () => { it('will flash an error when there are no stages', () => {
[[], null].forEach(emptyStages => { [[], null].forEach(emptyStages => {
actions.receiveGroupStagesAndEventsSuccess( actions.receiveGroupStagesAndEventsSuccess(
{ {
commit: () => {}, commit: () => {},
state: { stages: emptyStages }, state: { stages: emptyStages },
getters,
}, },
{}, {},
); );
...@@ -496,7 +486,7 @@ describe('Cycle analytics actions', () => { ...@@ -496,7 +486,7 @@ describe('Cycle analytics actions', () => {
); );
}); });
it("dispatches the 'fetchStageData' actions", done => { it("dispatches the 'fetchStageData' action", done => {
const stateWithStages = { const stateWithStages = {
...state, ...state,
stages, stages,
...@@ -512,7 +502,10 @@ describe('Cycle analytics actions', () => { ...@@ -512,7 +502,10 @@ describe('Cycle analytics actions', () => {
payload: { ...customizableStagesAndEvents }, payload: { ...customizableStagesAndEvents },
}, },
], ],
[{ type: 'fetchStageData', payload: selectedStageSlug }], [
{ type: 'setSelectedStage', payload: selectedStage },
{ type: 'fetchStageData', payload: selectedStageSlug },
],
done, done,
); );
}); });
......
import * as getters from 'ee/analytics/cycle_analytics/store/getters'; import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import { import {
allowedStages as stages,
startDate, startDate,
endDate, endDate,
transformedDurationData, transformedDurationData,
...@@ -11,75 +10,6 @@ let state = null; ...@@ -11,75 +10,6 @@ let state = null;
const selectedProjectIds = [5, 8, 11]; const selectedProjectIds = [5, 8, 11];
describe('Cycle analytics getters', () => { describe('Cycle analytics getters', () => {
describe('with default state', () => {
beforeEach(() => {
state = {
stages: [],
selectedStageId: 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,
selectedStageId: 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,
selectedStageId: stages[2].id,
};
});
afterEach(() => {
state = null;
});
describe('currentStage', () => {
it('will return null', () => {
expect(getters.currentStage(state)).toEqual(stages[2]);
});
});
});
describe('hasNoAccessError', () => { describe('hasNoAccessError', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
......
...@@ -33,8 +33,9 @@ describe('Cycle analytics mutations', () => { ...@@ -33,8 +33,9 @@ describe('Cycle analytics mutations', () => {
it.each` it.each`
mutation | stateKey | value mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false} ${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true} ${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true}
${types.EDIT_CUSTOM_STAGE} | ${'isEditingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true} ${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
...@@ -69,7 +70,7 @@ describe('Cycle analytics mutations', () => { ...@@ -69,7 +70,7 @@ describe('Cycle analytics mutations', () => {
${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }} ${types.SET_SELECTED_GROUP} | ${{ fullPath: 'cool-beans' }} | ${{ selectedGroup: { fullPath: 'cool-beans' }, selectedProjectIds: [] }}
${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }} ${types.SET_SELECTED_PROJECTS} | ${[606, 707, 808, 909]} | ${{ selectedProjectIds: [606, 707, 808, 909] }}
${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }} ${types.SET_DATE_RANGE} | ${{ startDate, endDate }} | ${{ startDate, endDate }}
${types.SET_SELECTED_STAGE_ID} | ${'first-stage'} | ${{ selectedStageId: 'first-stage' }} ${types.SET_SELECTED_STAGE} | ${{ id: 'first-stage' }} | ${{ selectedStage: { id: 'first-stage' } }}
${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${transformedDurationData} | ${{ durationData: transformedDurationData }} ${types.UPDATE_SELECTED_DURATION_CHART_STAGES} | ${transformedDurationData} | ${{ durationData: transformedDurationData }}
`( `(
'$mutation with payload $payload will update state with $expectedState', '$mutation with payload $payload will update state with $expectedState',
...@@ -160,10 +161,6 @@ describe('Cycle analytics mutations', () => { ...@@ -160,10 +161,6 @@ describe('Cycle analytics mutations', () => {
}, },
); );
}); });
it('will set the selectedStageId to the id of the first stage', () => {
expect(state.selectedStageId).toEqual('issue');
});
}); });
}); });
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
nestQueryStringKeys, nestQueryStringKeys,
flattenDurationChartData, flattenDurationChartData,
getDurationChartData, getDurationChartData,
transformRawStages,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { import {
customStageEvents as events, customStageEvents as events,
...@@ -19,6 +20,8 @@ import { ...@@ -19,6 +20,8 @@ import {
durationChartPlottableData, durationChartPlottableData,
startDate, startDate,
endDate, endDate,
issueStage,
rawCustomStage,
} from './mock_data'; } from './mock_data';
const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier); const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier);
...@@ -153,4 +156,26 @@ describe('Cycle analytics utils', () => { ...@@ -153,4 +156,26 @@ describe('Cycle analytics utils', () => {
expect(plottableData).toStrictEqual(durationChartPlottableData); expect(plottableData).toStrictEqual(durationChartPlottableData);
}); });
}); });
describe('transformRawStages', () => {
it('retains all the stage properties', () => {
const transformed = transformRawStages([issueStage, rawCustomStage]);
expect(transformed).toMatchSnapshot();
});
it('converts object properties from snake_case to camelCase', () => {
const [transformedCustomStage] = transformRawStages([rawCustomStage]);
expect(transformedCustomStage).toMatchObject({
endEventIdentifier: 'issue_first_added_to_board',
startEventIdentifier: 'issue_first_mentioned_in_commit',
});
});
it('sets the slug to the value of the stage id', () => {
const transformed = transformRawStages([issueStage, rawCustomStage]);
transformed.forEach(t => {
expect(t.slug).toEqual(t.id);
});
});
});
}); });
...@@ -5202,6 +5202,9 @@ msgstr "" ...@@ -5202,6 +5202,9 @@ msgstr ""
msgid "CustomCycleAnalytics|Add stage" msgid "CustomCycleAnalytics|Add stage"
msgstr "" msgstr ""
msgid "CustomCycleAnalytics|Editing stage"
msgstr ""
msgid "CustomCycleAnalytics|Enter a name for the stage" msgid "CustomCycleAnalytics|Enter a name for the stage"
msgstr "" msgstr ""
...@@ -5235,6 +5238,9 @@ msgstr "" ...@@ -5235,6 +5238,9 @@ msgstr ""
msgid "CustomCycleAnalytics|Stop event label" msgid "CustomCycleAnalytics|Stop event label"
msgstr "" msgstr ""
msgid "CustomCycleAnalytics|Update stage"
msgstr ""
msgid "Customize colors" msgid "Customize colors"
msgstr "" msgstr ""
......
...@@ -721,6 +721,28 @@ describe('common_utils', () => { ...@@ -721,6 +721,28 @@ describe('common_utils', () => {
}); });
}); });
describe('convertObjectPropsToSnakeCase', () => {
it('converts each object key to snake case', () => {
const obj = {
some: 'some',
'cool object': 'cool object',
likeThisLongOne: 'likeThisLongOne',
};
expect(commonUtils.convertObjectPropsToSnakeCase(obj)).toEqual({
some: 'some',
cool_object: 'cool object',
like_this_long_one: 'likeThisLongOne',
});
});
it('returns an empty object if there are no keys', () => {
['', {}, [], null].forEach(badObj => {
expect(commonUtils.convertObjectPropsToSnakeCase(badObj)).toEqual({});
});
});
});
describe('with options', () => { describe('with options', () => {
const objWithoutChildren = { const objWithoutChildren = {
project_name: 'GitLab CE', project_name: 'GitLab CE',
......
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