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 @@
import $ from 'jquery';
import axios from './axios_utils';
import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility';
import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility';
import breakpointInstance from '../../breakpoints';
......@@ -697,6 +697,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
}, 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 =>
`${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
......
......@@ -55,10 +55,11 @@ export default {
'isLoadingChartData',
'isLoadingDurationChart',
'isEmptyStage',
'isAddingCustomStage',
'isSavingCustomStage',
'isCreatingCustomStage',
'isEditingCustomStage',
'selectedGroup',
'selectedStageId',
'selectedStage',
'stages',
'summary',
'labels',
......@@ -69,12 +70,7 @@ export default {
'endDate',
'tasksByType',
]),
...mapGetters([
'currentStage',
'defaultStage',
'hasNoAccessError',
'durationChartPlottableData',
]),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']),
shouldRenderEmptyState() {
return !this.selectedGroup;
},
......@@ -112,16 +108,18 @@ export default {
'fetchStageData',
'setSelectedGroup',
'setSelectedProjects',
'setSelectedStageId',
'setSelectedStage',
'hideCustomStageForm',
'showCustomStageForm',
'setDateRange',
'fetchTasksByTypeData',
'updateSelectedDurationChartStages',
'createCustomStage',
'updateStage',
'removeStage',
'updateSelectedDurationChartStages',
'setFeatureFlags',
'editCustomStage',
'updateStage',
]),
onGroupSelect(group) {
this.setSelectedGroup(group);
......@@ -134,12 +132,15 @@ export default {
},
onStageSelect(stage) {
this.hideCustomStageForm();
this.setSelectedStageId(stage.id);
this.fetchStageData(this.currentStage.slug);
this.setSelectedStage(stage);
this.fetchStageData(this.selectedStage.slug);
},
onShowAddStageForm() {
this.showCustomStageForm();
},
onShowEditStageForm(initData = {}) {
this.editCustomStage(initData);
},
initDateRange() {
const endDate = new Date(Date.now());
const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST);
......@@ -148,7 +149,7 @@ export default {
onCreateCustomStage(data) {
this.createCustomStage(data);
},
onUpdateStage(data) {
onUpdateCustomStage(data) {
this.updateStage(data);
},
onRemoveStage(id) {
......@@ -237,14 +238,15 @@ export default {
<div v-else>
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
v-if="selectedStage"
class="js-stage-table"
:current-stage="currentStage"
:current-stage="selectedStage"
:stages="stages"
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:is-saving-custom-stage="isSavingCustomStage"
:is-creating-custom-stage="isCreatingCustomStage"
:is-editing-custom-stage="isEditingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:labels="labels"
......@@ -252,10 +254,12 @@ export default {
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@editStage="onShowEditStageForm"
@showAddStageForm="onShowAddStageForm"
@submit="onCreateCustomStage"
@hideStage="onUpdateStage"
@hideStage="onUpdateCustomStage"
@removeStage="onRemoveStage"
@createStage="onCreateCustomStage"
@updateStage="onUpdateCustomStage"
/>
</div>
</div>
......
......@@ -2,7 +2,9 @@
import { isEqual } from 'underscore';
import { GlButton, GlFormGroup, GlFormInput, GlFormSelect, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import LabelsSelector from './labels_selector.vue';
import { STAGE_ACTIONS } from '../constants';
import {
isStartEvent,
isLabelEvent,
......@@ -13,11 +15,12 @@ import {
} from '../utils';
const initFields = {
name: '',
startEvent: '',
startEventLabel: null,
stopEvent: '',
stopEventLabel: null,
id: null,
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
};
export default {
......@@ -50,11 +53,16 @@ export default {
required: false,
default: false,
},
isEditingCustomStage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
fields: {
...initFields,
...this.initialFields,
},
};
},
......@@ -65,30 +73,42 @@ export default {
...this.events.filter(isStartEvent).map(eventToOption),
];
},
stopEventOptions() {
const stopEvents = getAllowedEndEvents(this.events, this.fields.startEvent);
endEventOptions() {
const endEvents = getAllowedEndEvents(this.events, this.fields.startEventIdentifier);
return [
{ value: null, text: s__('CustomCycleAnalytics|Select stop event') },
...eventsByIdentifier(this.events, stopEvents).map(eventToOption),
...eventsByIdentifier(this.events, endEvents).map(eventToOption),
];
},
hasStartEvent() {
return this.fields.startEvent;
return this.fields.startEventIdentifier;
},
startEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.startEvent);
return isLabelEvent(this.labelEvents, this.fields.startEventIdentifier);
},
stopEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.stopEvent);
endEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.endEventIdentifier);
},
isComplete() {
if (!this.hasValidStartAndStopEventPair) return false;
const requiredFields = [this.fields.startEvent, this.fields.stopEvent, this.fields.name];
if (!this.hasValidStartAndEndEventPair) {
return false;
}
const {
fields: {
name,
startEventIdentifier,
startEventLabelId,
endEventIdentifier,
endEventLabelId,
},
} = this;
const requiredFields = [startEventIdentifier, endEventIdentifier, name];
if (this.startEventRequiresLabel) {
requiredFields.push(this.fields.startEventLabel);
requiredFields.push(startEventLabelId);
}
if (this.stopEventRequiresLabel) {
requiredFields.push(this.fields.stopEventLabel);
if (this.endEventRequiresLabel) {
requiredFields.push(endEventLabelId);
}
return requiredFields.every(
fieldValue => fieldValue && (fieldValue.length > 0 || fieldValue > 0),
......@@ -97,21 +117,31 @@ export default {
isDirty() {
return !isEqual(this.initialFields, this.fields);
},
hasValidStartAndStopEventPair() {
hasValidStartAndEndEventPair() {
const {
fields: { startEvent, stopEvent },
fields: { startEventIdentifier, endEventIdentifier },
} = this;
if (startEvent && stopEvent) {
const stopEvents = getAllowedEndEvents(this.events, startEvent);
return stopEvents.length && stopEvents.includes(stopEvent);
if (startEventIdentifier && endEventIdentifier) {
const endEvents = getAllowedEndEvents(this.events, startEventIdentifier);
return endEvents.length && endEvents.includes(endEventIdentifier);
}
return true;
},
stopEventError() {
return !this.hasValidStartAndStopEventPair
endEventError() {
return !this.hasValidStartAndEndEventPair
? s__('CustomCycleAnalytics|Start event changed, please select a valid stop event')
: 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() {
this.labelEvents = getLabelEventsIdentifiers(this.events);
......@@ -122,16 +152,15 @@ export default {
this.$emit('cancel');
},
handleSave() {
const { startEvent, startEventLabel, stopEvent, stopEventLabel, name } = this.fields;
this.$emit('submit', {
name,
start_event_identifier: startEvent,
start_event_label_id: startEventLabel,
end_event_identifier: stopEvent,
end_event_label_id: stopEventLabel,
});
},
handleSelectLabel(key, labelId = null) {
const data = convertObjectPropsToSnakeCase(this.fields);
if (this.isEditingCustomStage) {
const { id } = this.initialFields;
this.$emit(STAGE_ACTIONS.UPDATE, { ...data, id });
} else {
this.$emit(STAGE_ACTIONS.CREATE, data);
}
},
handleSelectLabel(key, labelId) {
this.fields[key] = labelId;
},
handleClearLabel(key) {
......@@ -143,7 +172,7 @@ export default {
<template>
<form class="custom-stage-form m-4 mt-0">
<div class="mb-1">
<h4>{{ s__('CustomCycleAnalytics|New stage') }}</h4>
<h4>{{ formTitle }}</h4>
</div>
<gl-form-group :label="s__('CustomCycleAnalytics|Name')">
<gl-form-input
......@@ -159,7 +188,7 @@ export default {
<div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group :label="s__('CustomCycleAnalytics|Start event')">
<gl-form-select
v-model="fields.startEvent"
v-model="fields.startEventIdentifier"
name="custom-stage-start-event"
:required="true"
:options="startEventOptions"
......@@ -170,41 +199,41 @@ export default {
<gl-form-group :label="s__('CustomCycleAnalytics|Start event label')">
<labels-selector
:labels="labels"
:selected-label-id="fields.startEventLabel"
:selected-label-id="fields.startEventLabelId"
name="custom-stage-start-event-label"
@selectLabel="labelId => handleSelectLabel('startEventLabel', labelId)"
@clearLabel="handleClearLabel('startEventLabel')"
@selectLabel="handleSelectLabel('startEventLabelId', $event)"
@clearLabel="handleClearLabel('startEventLabelId')"
/>
</gl-form-group>
</div>
</div>
<div class="d-flex" :class="{ 'justify-content-between': stopEventRequiresLabel }">
<div :class="[stopEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<div class="d-flex" :class="{ 'justify-content-between': endEventRequiresLabel }">
<div :class="[endEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group
:label="s__('CustomCycleAnalytics|Stop event')"
:description="
!hasStartEvent ? s__('CustomCycleAnalytics|Please select a start event first') : ''
"
:state="hasValidStartAndStopEventPair"
:invalid-feedback="stopEventError"
:state="hasValidStartAndEndEventPair"
:invalid-feedback="endEventError"
>
<gl-form-select
v-model="fields.stopEvent"
v-model="fields.endEventIdentifier"
name="custom-stage-stop-event"
:options="stopEventOptions"
:options="endEventOptions"
:required="true"
:disabled="!hasStartEvent"
/>
</gl-form-group>
</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')">
<labels-selector
:labels="labels"
:selected-label-id="fields.stopEventLabel"
:selected-label-id="fields.endEventLabelId"
name="custom-stage-stop-event-label"
@selectLabel="labelId => handleSelectLabel('stopEventLabel', labelId)"
@clearLabel="handleClearLabel('stopEventLabel')"
@selectLabel="handleSelectLabel('endEventLabelId', $event)"
@clearLabel="handleClearLabel('endEventLabelId')"
/>
</gl-form-group>
</div>
......@@ -213,7 +242,7 @@ export default {
<div class="custom-stage-form-actions">
<button
:disabled="!isDirty"
class="btn btn-cancel js-custom-stage-form-cancel"
class="btn btn-cancel js-save-stage-cancel"
type="button"
@click="handleCancel"
>
......@@ -222,11 +251,11 @@ export default {
<button
:disabled="!isComplete || !isDirty"
type="button"
class="js-custom-stage-form-submit btn btn-success"
class="js-save-stage btn btn-success"
@click="handleSave"
>
<gl-loading-icon v-if="isSavingCustomStage" size="sm" inline />
{{ s__('CustomCycleAnalytics|Add stage') }}
{{ saveStageText }}
</button>
</div>
</form>
......
......@@ -41,7 +41,11 @@ export default {
type: Boolean,
required: true,
},
isAddingCustomStage: {
isCreatingCustomStage: {
type: Boolean,
required: true,
},
isEditingCustomStage: {
type: Boolean,
required: true,
},
......@@ -82,6 +86,9 @@ export default {
const { currentStageEvents = [], isLoading, isEmptyStage } = this;
return currentStageEvents.length && !isLoading && !isEmptyStage;
},
customStageFormActive() {
return this.isCreatingCustomStage;
},
stageHeaders() {
return [
{
......@@ -100,26 +107,21 @@ export default {
title: this.stageName,
description: __('The collection of events added to the data gathered for that stage.'),
classes: 'event-header pl-3',
displayHeader: !this.isAddingCustomStage,
displayHeader: !this.customStageFormActive,
},
{
title: __('Total Time'),
description: __('The time taken by each data entry gathered by that stage.'),
classes: 'total-time-header pr-5 text-right',
displayHeader: !this.isAddingCustomStage,
displayHeader: !this.customStageFormActive,
},
];
},
},
methods: {
// TODO: DRY These up
hideStage(stageId) {
this.$emit(STAGE_ACTIONS.HIDE, { id: stageId, hidden: true });
},
removeStage(stageId) {
this.$emit(STAGE_ACTIONS.REMOVE, stageId);
customStageInitialData() {
return this.isEditingCustomStage ? this.currentStage : {};
},
},
STAGE_ACTIONS,
};
</script>
<template>
......@@ -147,16 +149,17 @@ export default {
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="stage.value"
:is-active="!isAddingCustomStage && stage.id === currentStage.id"
:is-active="!isCreatingCustomStage && stage.id === currentStage.id"
:can-edit="canEditStages"
:is-default-stage="!stage.custom"
@select="$emit('selectStage', stage)"
@remove="removeStage(stage.id)"
@hide="hideStage(stage.id)"
@remove="$emit($options.STAGE_ACTIONS.REMOVE, stage.id)"
@hide="$emit($options.STAGE_ACTIONS.HIDE, { id: stage.id, hidden: true })"
@select="$emit($options.STAGE_ACTIONS.SELECT, stage)"
@edit="$emit($options.STAGE_ACTIONS.EDIT, stage)"
/>
<add-stage-button
v-if="canEditStages"
:active="isAddingCustomStage"
:active="customStageFormActive"
@showform="$emit('showAddStageForm')"
/>
</ul>
......@@ -164,11 +167,15 @@ export default {
<div class="section stage-events">
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<custom-stage-form
v-else-if="isAddingCustomStage"
v-else-if="isCreatingCustomStage || isEditingCustomStage"
:events="customStageFormEvents"
:labels="labels"
:is-saving-custom-stage="isSavingCustomStage"
:initial-fields="customStageInitialData"
:is-editing-custom-stage="isEditingCustomStage"
@submit="$emit('submit', $event)"
@createStage="$emit($options.STAGE_ACTIONS.CREATE, $event)"
@updateStage="$emit($options.STAGE_ACTIONS.UPDATE, $event)"
/>
<template v-else>
<stage-event-list
......
......@@ -34,8 +34,10 @@ export const TASKS_BY_TYPE_SUBJECT_ISSUE = 'Issue';
export const TASKS_BY_TYPE_SUBJECT_MERGE_REQUEST = 'MergeRequest';
export const STAGE_ACTIONS = {
SELECT: 'selectStage',
EDIT: 'editStage',
REMOVE: 'removeStage',
SAVE: 'saveStage',
HIDE: 'hideStage',
CREATE: 'createStage',
UPDATE: 'updateStage',
};
......@@ -14,10 +14,11 @@ const removeError = () => {
export const setFeatureFlags = ({ commit }, featureFlags) =>
commit(types.SET_FEATURE_FLAGS, featureFlags);
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, 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 }) => {
commit(types.SET_DATE_RANGE, { startDate, endDate });
......@@ -38,12 +39,12 @@ export const receiveStageDataError = ({ commit }) => {
export const fetchStageData = ({ state, dispatch, getters }, slug) => {
const { cycleAnalyticsRequestParams = {} } = getters;
dispatch('requestStageData');
const {
selectedGroup: { fullPath },
} = state;
dispatch('requestStageData');
return Api.cycleAnalyticsStageEvents(
fullPath,
slug,
......@@ -83,6 +84,14 @@ export const fetchCycleAnalyticsData = ({ dispatch }) => {
.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 receiveSummaryDataError = ({ commit }, error) => {
......@@ -112,9 +121,6 @@ export const fetchSummaryData = ({ state, dispatch, getters }) => {
export const requestGroupStagesAndEvents = ({ commit }) =>
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) =>
commit(types.RECEIVE_GROUP_LABELS_SUCCESS, data);
......@@ -136,8 +142,8 @@ export const fetchGroupLabels = ({ dispatch, state }) => {
.catch(error => dispatch('receiveGroupLabelsError', error));
};
export const receiveGroupStagesAndEventsError = ({ commit }) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR);
export const receiveGroupStagesAndEventsError = ({ commit }, error) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR, error);
createFlash(__('There was an error fetching cycle analytics stages.'));
};
......@@ -145,8 +151,9 @@ export const receiveGroupStagesAndEventsSuccess = ({ state, commit, dispatch },
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS, data);
const { stages = [] } = state;
if (stages && stages.length) {
const { slug } = stages[0];
dispatch('fetchStageData', slug);
const [firstStage] = stages;
dispatch('setSelectedStage', firstStage);
dispatch('fetchStageData', firstStage.slug);
} else {
createFlash(__('There was an error while fetching cycle analytics data.'));
}
......@@ -197,7 +204,6 @@ export const createCustomStage = ({ dispatch, state }, data) => {
const {
selectedGroup: { fullPath },
} = state;
dispatch('requestCreateCustomStage');
return Api.cycleAnalyticsCreateStage(fullPath, data)
......@@ -245,11 +251,12 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
};
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);
createFlash(__(`Stage data updated`), 'notice');
createFlash(__('Stage data updated'), 'notice');
dispatch('fetchCycleAnalyticsData');
dispatch('fetchGroupStagesAndEvents');
dispatch('setSelectedStage', updatedData);
};
export const receiveUpdateStageError = ({ commit }) => {
......
......@@ -3,10 +3,6 @@ import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants';
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 currentGroupPath = ({ selectedGroup }) =>
......
......@@ -2,8 +2,7 @@ export const SET_FEATURE_FLAGS = 'SET_FEATURE_FLAGS';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
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 UPDATE_SELECTED_DURATION_CHART_STAGES = 'UPDATE_SELECTED_DURATION_CHART_STAGES';
......@@ -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 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 RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
......
......@@ -13,8 +13,8 @@ export default {
[types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds;
},
[types.SET_SELECTED_STAGE_ID](state, stageId) {
state.selectedStageId = stageId;
[types.SET_SELECTED_STAGE](state, rawData) {
state.selectedStage = convertObjectPropsToCamelCase(rawData);
},
[types.SET_DATE_RANGE](state, { startDate, endDate }) {
state.startDate = startDate;
......@@ -25,7 +25,8 @@ export default {
},
[types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
state.isLoading = true;
state.isAddingCustomStage = false;
state.isCreatingCustomStage = false;
state.isEditingCustomStage = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state) {
state.errorCode = null;
......@@ -75,11 +76,20 @@ export default {
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) {
state.isAddingCustomStage = false;
state.isEditingCustomStage = false;
state.isCreatingCustomStage = false;
state.customStageFormInitData = {};
},
[types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = true;
state.isCreatingCustomStage = true;
},
[types.RECEIVE_SUMMARY_DATA_ERROR](state) {
state.summary = [];
......@@ -122,11 +132,6 @@ export default {
state.customStageFormEvents = events.map(ev =>
convertObjectPropsToCamelCase(ev, { deep: true }),
);
if (state.stages.length) {
const { id } = state.stages[0];
state.selectedStageId = id;
}
},
[types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true;
......@@ -152,6 +157,7 @@ export default {
},
[types.RECEIVE_UPDATE_STAGE_RESPONSE](state) {
state.isLoading = false;
state.isSavingCustomStage = false;
},
[types.REQUEST_REMOVE_STAGE](state) {
state.isLoading = true;
......
......@@ -14,12 +14,13 @@ export default () => ({
isEmptyStage: false,
errorCode: null,
isAddingCustomStage: false,
isSavingCustomStage: false,
isCreatingCustomStage: false,
isEditingCustomStage: false,
selectedGroup: null,
selectedProjectIds: [],
selectedStageId: null,
selectedStage: null,
currentStageEvents: [],
......
......@@ -32,12 +32,29 @@ export const isLabelEvent = (labelEvents = [], ev = null) =>
export const getLabelEventsIdentifiers = (events = []) =>
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 = []) =>
stages
.map(({ title, ...rest }) => ({
.map(({ id, title, custom = false, ...rest }) => ({
...convertObjectPropsToCamelCase(rest, { deep: true }),
slug: convertToSnakeCase(title),
id,
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);
......
......@@ -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(: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|
let!("issue_#{i}".to_sym) { create(:issue, title: "New Issue #{i}", project: project, created_at: 2.days.ago) }
end
......@@ -119,11 +121,11 @@ describe 'Group Cycle Analytics', :js do
context 'stage nav' 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
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|
expect(stage_nav).to have_content(item)
......@@ -212,7 +214,24 @@ describe 'Group Cycle Analytics', :js do
end
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(: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)
page.find("select[name='#{name}']").all(elem)[index].select_option
......@@ -267,8 +286,6 @@ describe 'Group Cycle Analytics', :js do
end
context 'with all required fields set' do
custom_stage_name = "cool beans"
before do
fill_in 'custom-stage-name', with: custom_stage_name
select_dropdown_option 'custom-stage-start-event'
......@@ -317,22 +334,75 @@ describe 'Group Cycle Analytics', :js do
end
end
context 'Stage table' do
custom_stage = "Cool beans"
let(:params) { { name: custom_stage, start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged } }
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).ancestor(".stage-nav-item") }
context 'Edit stage form' do
stage_form_class = '.custom-stage-form'
stage_save_button = '.js-save-stage'
name_field = "custom-stage-name"
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
Analytics::CycleAnalytics::Stages::CreateService.new(parent: group, params: params, current_user: user).execute
it 'displays the editing stage form' do
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
def toggle_more_options(stage)
stage.hover
context 'with changes' do
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
context 'Stage table' do
context 'default stages' do
before do
select_group
......@@ -353,7 +423,7 @@ describe 'Group Cycle Analytics', :js do
end
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")
click_button "Hide stage"
......@@ -368,7 +438,7 @@ describe 'Group Cycle Analytics', :js do
create_custom_stage
select_group
expect(page).to have_text custom_stage
expect(page).to have_text custom_stage_name
toggle_more_options(first_custom_stage)
end
......@@ -386,13 +456,13 @@ describe 'Group Cycle Analytics', :js do
end
it 'will not appear in the stage table after being removed' do
nav = page.find('.stage-nav')
expect(nav).to have_text(custom_stage)
nav = page.find(stage_nav_selector)
expect(nav).to have_text(custom_stage_name)
click_button "Remove stage"
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
......
// 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
exports[`CustomStageForm does not have a loading icon 1`] = `
"<button type=\\"button\\" class=\\"js-custom-stage-form-submit btn btn-success\\"><!---->
Add stage
exports[`CustomStageForm Editing a custom stage isSavingCustomStage=true displays a loading icon 1`] = `
"<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>
Update stage
</button>"
`;
exports[`CustomStageForm 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>
exports[`CustomStageForm Empty form isSavingCustomStage=true displays a loading icon 1`] = `
"<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
</button>"
`;
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import CustomStageForm from 'ee/analytics/cycle_analytics/components/custom_stage_form.vue';
import { STAGE_ACTIONS } from 'ee/analytics/cycle_analytics/constants';
import {
groupLabels,
customStageEvents as events,
......@@ -11,11 +12,12 @@ import {
} from '../mock_data';
const initData = {
id: 74,
name: 'Cool stage pre',
startEvent: labelStartEvent.identifier,
startEventLabel: groupLabels[0].id,
stopEvent: labelStopEvent.identifier,
stopEventLabel: groupLabels[1].id,
startEventIdentifier: labelStartEvent.identifier,
startEventLabelId: groupLabels[0].id,
endEventIdentifier: labelStopEvent.identifier,
endEventLabelId: groupLabels[1].id,
};
describe('CustomStageForm', () => {
......@@ -31,15 +33,16 @@ describe('CustomStageForm', () => {
}
let wrapper = null;
const findEvent = ev => wrapper.emitted()[ev];
const sel = {
name: '[name="custom-stage-name"]',
startEvent: '[name="custom-stage-start-event"]',
startEventLabel: '[name="custom-stage-start-event-label"]',
stopEvent: '[name="custom-stage-stop-event"]',
stopEventLabel: '[name="custom-stage-stop-event-label"]',
submit: '.js-custom-stage-form-submit',
cancel: '.js-custom-stage-form-cancel',
endEvent: '[name="custom-stage-stop-event"]',
endEventLabel: '[name="custom-stage-stop-event-label"]',
submit: '.js-save-stage',
cancel: '.js-save-stage-cancel',
invalidFeedback: '.invalid-feedback',
};
......@@ -66,7 +69,7 @@ describe('CustomStageForm', () => {
describe.each([
['Name', sel.name, true],
['Start event', sel.startEvent, true],
['Stop event', sel.stopEvent, false],
['Stop event', sel.endEvent, false],
['Submit', sel.submit, false],
['Cancel', sel.cancel, false],
])('by default', (field, $sel, enabledState) => {
......@@ -119,7 +122,7 @@ describe('CustomStageForm', () => {
it('will display the start event label field if a label event is selected', done => {
wrapper.setData({
fields: {
startEvent: labelStartEvent.identifier,
startEventIdentifier: labelStartEvent.identifier,
},
});
......@@ -129,9 +132,9 @@ describe('CustomStageForm', () => {
});
});
it('will set the "startEventLabel" field when selected', done => {
it('will set the "startEventLabelId" field when selected', done => {
const selectedLabelId = groupLabels[0].id;
expect(wrapper.vm.fields.startEventLabel).toEqual(null);
expect(wrapper.vm.fields.startEventLabelId).toEqual(null);
wrapper.find(sel.startEvent).setValue(labelStartEvent.identifier);
Vue.nextTick(() => {
......@@ -142,7 +145,7 @@ describe('CustomStageForm', () => {
.trigger('click');
Vue.nextTick(() => {
expect(wrapper.vm.fields.startEventLabel).toEqual(selectedLabelId);
expect(wrapper.vm.fields.startEventLabelId).toEqual(selectedLabelId);
done();
});
});
......@@ -171,7 +174,7 @@ describe('CustomStageForm', () => {
});
it('is enabled when a start event is selected', done => {
const el = wrapper.find(sel.stopEvent);
const el = wrapper.find(sel.endEvent);
expect(el.attributes('disabled')).toEqual('disabled');
selectDropdownOption(wrapper, sel.startEvent, 1);
......@@ -182,7 +185,7 @@ describe('CustomStageForm', () => {
});
it('will update the list of stop events when a start event is changed', done => {
let stopOptions = wrapper.find(sel.stopEvent).findAll('option');
let stopOptions = wrapper.find(sel.endEvent).findAll('option');
const selectedStartEventIndex = 1;
const selectedStartEvent = startEvents[selectedStartEventIndex];
expect(stopOptions.length).toEqual(1);
......@@ -190,7 +193,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, selectedStartEventIndex);
Vue.nextTick(() => {
stopOptions = wrapper.find(sel.stopEvent);
stopOptions = wrapper.find(sel.endEvent);
selectedStartEvent.allowedEndEvents.forEach(identifier => {
expect(stopOptions.html()).toContain(identifier);
});
......@@ -199,7 +202,7 @@ describe('CustomStageForm', () => {
});
it('will display all the valid stop events', done => {
let stopOptions = wrapper.find(sel.stopEvent).findAll('option');
let stopOptions = wrapper.find(sel.endEvent).findAll('option');
const possibleEndEvents = stopEvents.filter(ev => currAllowed.includes(ev.identifier));
expect(stopOptions.at(0).html()).toEqual('<option value="">Select stop event</option>');
......@@ -207,7 +210,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, index);
Vue.nextTick(() => {
stopOptions = wrapper.find(sel.stopEvent);
stopOptions = wrapper.find(sel.endEvent);
possibleEndEvents.forEach(({ name, identifier }) => {
expect(stopOptions.html()).toContain(`<option value="${identifier}">${name}</option>`);
......@@ -217,7 +220,7 @@ describe('CustomStageForm', () => {
});
it('will not display stop events that are not in the list of allowed stop events', done => {
let stopOptions = wrapper.find(sel.stopEvent).findAll('option');
let stopOptions = wrapper.find(sel.endEvent).findAll('option');
const excludedEndEvents = stopEvents.filter(ev => !currAllowed.includes(ev.identifier));
expect(stopOptions.at(0).html()).toEqual('<option value="">Select stop event</option>');
......@@ -225,10 +228,10 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, index);
Vue.nextTick(() => {
stopOptions = wrapper.find(sel.stopEvent);
stopOptions = wrapper.find(sel.endEvent);
excludedEndEvents.forEach(({ name, identifier }) => {
expect(wrapper.find(sel.stopEvent).html()).not.toHaveHtml(
expect(wrapper.find(sel.endEvent).html()).not.toHaveHtml(
`<option value="${identifier}">${name}</option>`,
);
});
......@@ -243,10 +246,10 @@ describe('CustomStageForm', () => {
wrapper.setData({
fields: {
name: 'Cool stage',
startEvent: 'issue_created',
startEventLabel: null,
stopEvent: 'issue_stage_end',
stopEventLabel: null,
startEventIdentifier: 'issue_created',
startEventLabelId: null,
endEventIdentifier: 'issue_stage_end',
endEventLabelId: null,
},
});
});
......@@ -270,15 +273,15 @@ describe('CustomStageForm', () => {
});
it('will update the list of stop events', done => {
const se = wrapper.vm.stopEventOptions;
const se = wrapper.vm.endEventOptions;
selectDropdownOption(wrapper, sel.startEvent, 2);
Vue.nextTick(() => {
expect(se[1].value).not.toEqual(wrapper.vm.stopEventOptions[1].value);
expect(se[1].value).not.toEqual(wrapper.vm.endEventOptions[1].value);
done();
});
});
it('will disable the submit button until a valid stopEvent is selected', done => {
it('will disable the submit button until a valid endEvent is selected', done => {
selectDropdownOption(wrapper, sel.startEvent, 2);
Vue.nextTick(() => {
expect(wrapper.find(sel.submit).attributes('disabled')).toEqual('disabled');
......@@ -301,41 +304,41 @@ describe('CustomStageForm', () => {
});
it('will display the stop event label field if a label event is selected', done => {
expect(wrapper.find(sel.stopEventLabel).exists()).toEqual(false);
expect(wrapper.find(sel.endEventLabel).exists()).toEqual(false);
wrapper.setData({
fields: {
stopEvent: labelStopEvent.identifier,
startEvent: labelStartEvent.identifier,
endEventIdentifier: labelStopEvent.identifier,
startEventIdentifier: labelStartEvent.identifier,
},
});
Vue.nextTick(() => {
expect(wrapper.find(sel.stopEventLabel).exists()).toEqual(true);
expect(wrapper.find(sel.endEventLabel).exists()).toEqual(true);
done();
});
});
it('will set the "stopEventLabel" field when selected', done => {
it('will set the "endEventLabelId" field when selected', done => {
const selectedLabelId = groupLabels[1].id;
expect(wrapper.vm.fields.stopEventLabel).toEqual(null);
expect(wrapper.vm.fields.endEventLabelId).toEqual(null);
wrapper.setData({
fields: {
startEvent: labelStartEvent.identifier,
stopEvent: labelStopEvent.identifier,
startEventIdentifier: labelStartEvent.identifier,
endEventIdentifier: labelStopEvent.identifier,
},
});
Vue.nextTick(() => {
wrapper
.find(sel.stopEventLabel)
.find(sel.endEventLabel)
.findAll('.dropdown-item')
.at(2) // item at index 0 is 'select a label'
.trigger('click');
Vue.nextTick(() => {
expect(wrapper.vm.fields.stopEventLabel).toEqual(selectedLabelId);
expect(wrapper.vm.fields.endEventLabelId).toEqual(selectedLabelId);
done();
});
});
......@@ -350,7 +353,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, 1);
return Vue.nextTick(() => {
selectDropdownOption(wrapper, sel.stopEvent, 1);
selectDropdownOption(wrapper, sel.endEvent, 1);
});
});
......@@ -358,6 +361,10 @@ describe('CustomStageForm', () => {
wrapper.destroy();
});
it('has text `Add stage`', () => {
expect(wrapper.find(sel.submit).text('value')).toEqual('Add stage');
});
it('is enabled when all required fields are filled', done => {
const btn = wrapper.find(sel.submit);
......@@ -380,7 +387,7 @@ describe('CustomStageForm', () => {
selectDropdownOption(wrapper, sel.startEvent, startEventIndex);
return Vue.nextTick(() => {
selectDropdownOption(wrapper, sel.stopEvent, stopEventIndex);
selectDropdownOption(wrapper, sel.endEvent, stopEventIndex);
wrapper.find(sel.name).setValue('Cool stage');
});
});
......@@ -389,21 +396,30 @@ describe('CustomStageForm', () => {
wrapper.destroy();
});
it('emits a `submit` event when clicked', () => {
expect(wrapper.emitted().submit).toBeUndefined();
it(`emits a ${STAGE_ACTIONS.CREATE} event when clicked`, done => {
let event = findEvent(STAGE_ACTIONS.CREATE);
expect(event).toBeUndefined();
wrapper.find(sel.submit).trigger('click');
expect(wrapper.emitted().submit).toBeTruthy();
expect(wrapper.emitted().submit.length).toEqual(1);
Vue.nextTick(() => {
event = findEvent(STAGE_ACTIONS.CREATE);
expect(event).toBeTruthy();
expect(event.length).toEqual(1);
done();
});
});
it('`submit` event receives the latest data', () => {
expect(wrapper.emitted().submit).toBeUndefined();
it(`${STAGE_ACTIONS.CREATE} event receives the latest data`, () => {
const startEv = startEvents[startEventIndex];
const selectedStopEvent = getDropdownOption(wrapper, sel.stopEvent, stopEventIndex);
const selectedStopEvent = getDropdownOption(wrapper, sel.endEvent, stopEventIndex);
let event = findEvent(STAGE_ACTIONS.CREATE);
expect(event).toBeUndefined();
const res = [
{
id: null,
name: 'Cool stage',
start_event_identifier: startEv.identifier,
start_event_label_id: null,
......@@ -413,7 +429,8 @@ describe('CustomStageForm', () => {
];
wrapper.find(sel.submit).trigger('click');
expect(wrapper.emitted().submit[0]).toEqual(res);
event = findEvent(STAGE_ACTIONS.CREATE);
expect(event[0]).toEqual(res);
});
});
});
......@@ -443,8 +460,8 @@ describe('CustomStageForm', () => {
wrapper.setData({
fields: {
name: 'Cool stage pre',
startEvent: labelStartEvent.identifier,
stopEvent: labelStopEvent.identifier,
startEventIdentifier: labelStartEvent.identifier,
endEventIdentifier: labelStopEvent.identifier,
},
});
......@@ -453,11 +470,12 @@ describe('CustomStageForm', () => {
Vue.nextTick(() => {
expect(wrapper.vm.fields).toEqual({
name: '',
startEvent: '',
startEventLabel: null,
stopEvent: '',
stopEventLabel: null,
id: null,
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
});
done();
});
......@@ -465,7 +483,8 @@ describe('CustomStageForm', () => {
});
it('will emit the `cancel` event when clicked', done => {
expect(wrapper.emitted().cancel).toBeUndefined();
let ev = findEvent('cancel');
expect(ev).toBeUndefined();
wrapper.setData({
fields: {
......@@ -477,19 +496,36 @@ describe('CustomStageForm', () => {
wrapper.find(sel.cancel).trigger('click');
Vue.nextTick(() => {
expect(wrapper.emitted().cancel).toBeTruthy();
expect(wrapper.emitted().cancel.length).toEqual(1);
ev = findEvent('cancel');
expect(ev).toBeTruthy();
expect(ev.length).toEqual(1);
done();
});
});
});
});
describe('isSavingCustomStage=true', () => {
beforeEach(() => {
wrapper = createComponent(
{
isSavingCustomStage: true,
},
false,
);
});
it('displays a loading icon', () => {
expect(wrapper.find(sel.submit).html()).toMatchSnapshot();
});
});
});
describe('Prepopulated form', () => {
describe('Editing a custom stage', () => {
beforeEach(() => {
wrapper = createComponent(
{
isEditingCustomStage: true,
initialFields: {
...initData,
},
......@@ -515,8 +551,8 @@ describe('CustomStageForm', () => {
wrapper.setData({
fields: {
name: 'Cool stage pre',
startEvent: labelStartEvent.identifier,
stopEvent: labelStopEvent.identifier,
startEventIdentifier: labelStartEvent.identifier,
endEventIdentifier: labelStopEvent.identifier,
},
});
......@@ -533,7 +569,11 @@ describe('CustomStageForm', () => {
});
});
describe('Add stage button', () => {
describe('Update stage button', () => {
it('has text `Update stage`', () => {
expect(wrapper.find(sel.submit).text('value')).toEqual('Update stage');
});
it('is disabled by default', () => {
expect(wrapper.find(sel.submit).attributes('disabled')).toEqual('disabled');
});
......@@ -564,8 +604,9 @@ describe('CustomStageForm', () => {
});
});
it('emits a `submit` event when clicked', done => {
expect(wrapper.emitted().submit).toBeUndefined();
it(`emits a ${STAGE_ACTIONS.UPDATE} event when clicked`, done => {
let ev = findEvent(STAGE_ACTIONS.UPDATE);
expect(ev).toBeUndefined();
wrapper.setData({
fields: {
......@@ -577,8 +618,9 @@ describe('CustomStageForm', () => {
wrapper.find(sel.submit).trigger('click');
Vue.nextTick(() => {
expect(wrapper.emitted().submit).toBeTruthy();
expect(wrapper.emitted().submit.length).toEqual(1);
ev = findEvent(STAGE_ACTIONS.UPDATE);
expect(ev).toBeTruthy();
expect(ev.length).toEqual(1);
done();
});
});
......@@ -595,10 +637,11 @@ describe('CustomStageForm', () => {
wrapper.find(sel.submit).trigger('click');
Vue.nextTick(() => {
const submitted = wrapper.emitted().submit[0];
const submitted = findEvent(STAGE_ACTIONS.UPDATE)[0];
expect(submitted).not.toEqual([initData]);
expect(submitted).toEqual([
{
id: initData.id,
start_event_identifier: labelStartEvent.identifier,
start_event_label_id: groupLabels[0].id,
end_event_identifier: labelStopEvent.identifier,
......@@ -606,29 +649,30 @@ describe('CustomStageForm', () => {
name: 'Cool updated form',
},
]);
done();
});
});
});
});
});
it('does not have a loading icon', () => {
expect(wrapper.find(sel.submit).html()).toMatchSnapshot();
});
describe('isSavingCustomStage=true', () => {
beforeEach(() => {
wrapper = createComponent(
{
isSavingCustomStage: true,
},
false,
);
});
describe('isSavingCustomStage=true', () => {
beforeEach(() => {
wrapper = createComponent(
{
isEditingCustomStage: true,
initialFields: {
...initData,
},
isSavingCustomStage: true,
},
false,
);
});
it('displays a loading icon', () => {
expect(wrapper.find(sel.submit).html()).toMatchSnapshot();
it('displays a loading icon', () => {
expect(wrapper.find(sel.submit).html()).toMatchSnapshot();
});
});
});
});
......@@ -36,8 +36,9 @@ function createComponent(props = {}, shallow = false) {
isLoading: false,
isLoadingSummaryData: false,
isEmptyStage: false,
isAddingCustomStage: false,
isSavingCustomStage: false,
isCreatingCustomStage: false,
isEditingCustomStage: false,
noDataSvgPath,
noAccessSvgPath,
canEditStages: false,
......
......@@ -75,6 +75,16 @@ export const codeEvents = stageFixtures.code;
export const testEvents = stageFixtures.test;
export const stagingEvents = stageFixtures.staging;
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 camelCasedStageEvents = rawCustomStageEvents.map(deepCamelCase);
......
......@@ -22,7 +22,8 @@ const stageData = { events: [] };
const error = new Error('Request failed with status code 404');
const flashErrorMessage = 'There was an error while fetching cycle analytics data.';
const selectedGroup = { fullPath: group.path };
const [{ id: selectedStageSlug }] = stages;
const [selectedStage] = stages;
const selectedStageSlug = selectedStage.slug;
const endpoints = {
groupLabels: `/groups/${group.path}/-/labels`,
cycleAnalyticsData: `/groups/${group.path}/-/cycle_analytics`,
......@@ -62,7 +63,7 @@ describe('Cycle analytics actions', () => {
${'setFeatureFlags'} | ${'SET_FEATURE_FLAGS'} | ${'featureFlags'} | ${{ hasDurationChart: true }}
${'setSelectedGroup'} | ${'SET_SELECTED_GROUP'} | ${'selectedGroup'} | ${'someNewGroup'}
${'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 }) => {
testAction(
actions[action],
......@@ -114,21 +115,30 @@ describe('Cycle analytics actions', () => {
);
});
it('dispatches receiveStageDataError on error', done => {
testAction(
actions.fetchStageData,
null,
state,
[],
[
{ type: 'requestStageData' },
{
type: 'receiveStageDataError',
payload: error,
},
],
done,
);
describe('with a failing request', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(endpoints.stageData).replyOnce(404, { error });
});
it('dispatches receiveStageDataError on error', done => {
testAction(
actions.fetchStageData,
selectedStage,
state,
[],
[
{
type: 'requestStageData',
},
{
type: 'receiveStageDataError',
payload: error,
},
],
done,
);
});
});
describe('receiveStageDataSuccess', () => {
......@@ -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', () => {
[[], null].forEach(emptyStages => {
actions.receiveGroupStagesAndEventsSuccess(
{
commit: () => {},
state: { stages: emptyStages },
getters,
},
{},
);
......@@ -496,7 +486,7 @@ describe('Cycle analytics actions', () => {
);
});
it("dispatches the 'fetchStageData' actions", done => {
it("dispatches the 'fetchStageData' action", done => {
const stateWithStages = {
...state,
stages,
......@@ -512,7 +502,10 @@ describe('Cycle analytics actions', () => {
payload: { ...customizableStagesAndEvents },
},
],
[{ type: 'fetchStageData', payload: selectedStageSlug }],
[
{ type: 'setSelectedStage', payload: selectedStage },
{ type: 'fetchStageData', payload: selectedStageSlug },
],
done,
);
});
......
import * as getters from 'ee/analytics/cycle_analytics/store/getters';
import {
allowedStages as stages,
startDate,
endDate,
transformedDurationData,
......@@ -11,75 +10,6 @@ let state = null;
const selectedProjectIds = [5, 8, 11];
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', () => {
beforeEach(() => {
state = {
......
......@@ -33,8 +33,9 @@ describe('Cycle analytics mutations', () => {
it.each`
mutation | stateKey | value
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isAddingCustomStage'} | ${true}
${types.HIDE_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${false}
${types.SHOW_CUSTOM_STAGE_FORM} | ${'isCreatingCustomStage'} | ${true}
${types.EDIT_CUSTOM_STAGE} | ${'isEditingCustomStage'} | ${true}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
......@@ -69,7 +70,7 @@ describe('Cycle analytics mutations', () => {
${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_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 }}
`(
'$mutation with payload $payload will update state with $expectedState',
......@@ -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 {
nestQueryStringKeys,
flattenDurationChartData,
getDurationChartData,
transformRawStages,
} from 'ee/analytics/cycle_analytics/utils';
import {
customStageEvents as events,
......@@ -19,6 +20,8 @@ import {
durationChartPlottableData,
startDate,
endDate,
issueStage,
rawCustomStage,
} from './mock_data';
const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier);
......@@ -153,4 +156,26 @@ describe('Cycle analytics utils', () => {
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 ""
msgid "CustomCycleAnalytics|Add stage"
msgstr ""
msgid "CustomCycleAnalytics|Editing stage"
msgstr ""
msgid "CustomCycleAnalytics|Enter a name for the stage"
msgstr ""
......@@ -5235,6 +5238,9 @@ msgstr ""
msgid "CustomCycleAnalytics|Stop event label"
msgstr ""
msgid "CustomCycleAnalytics|Update stage"
msgstr ""
msgid "Customize colors"
msgstr ""
......
......@@ -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', () => {
const objWithoutChildren = {
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