Commit 97bc6b3a authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '13076-cycle-analytics-add-stage-form-ee' into 'master'

Customizable cycle analytics - Custom stage form ui

See merge request gitlab-org/gitlab!15216
parents d1129ade 3def42f5
......@@ -74,6 +74,11 @@ const Api = {
});
},
groupLabels(namespace) {
const url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace);
return axios.get(url).then(({ data }) => data);
},
// Return namespaces list. Filtered by query
namespaces(query, callback) {
const url = Api.buildUrl(Api.namespacesPath);
......
......@@ -48,6 +48,8 @@ export default () => {
import('ee_component/analytics/cycle_analytics/components/custom_stage_form.vue'),
AddStageButton: () =>
import('ee_component/analytics/cycle_analytics/components/add_stage_button.vue'),
CustomStageFormContainer: () =>
import('ee_component/analytics/cycle_analytics/components/custom_stage_form_container.vue'),
},
mixins: [filterMixins, addStageMixin],
data() {
......
......@@ -55,7 +55,7 @@ export default {
'summary',
'dataTimeframe',
]),
...mapGetters(['currentStage', 'defaultStage', 'hasNoAccessError']),
...mapGetters(['currentStage', 'defaultStage', 'hasNoAccessError', 'currentGroupPath']),
shouldRenderEmptyState() {
return !this.selectedGroup;
},
......@@ -161,24 +161,22 @@ export default {
)
"
/>
<div v-else class="cycle-analytics mt-0">
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading-stage="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:events="events"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
</div>
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading-stage="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:events="events"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
</div>
</div>
</template>
<script>
import { isEqual } from 'underscore';
import { GlButton, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { s__ } from '~/locale';
import LabelsSelector from './labels_selector.vue';
import {
isStartEvent,
isLabelEvent,
getAllowedEndEvents,
eventToOption,
eventsByIdentifier,
getLabelEventsIdentifiers,
} from '../utils';
const initFields = {
name: '',
startEvent: '',
startEventLabel: null,
stopEvent: '',
stopEventLabel: null,
};
export default {
components: {
GlButton,
GlFormGroup,
GlFormInput,
GlFormSelect,
LabelsSelector,
},
props: {
events: {
type: Array,
required: true,
},
labels: {
type: Array,
required: true,
},
initialFields: {
type: Object,
required: false,
default: () => ({
...initFields,
}),
},
},
data() {
return {
fields: {
...initFields,
},
};
},
computed: {
startEventOptions() {
return [
{ value: null, text: s__('CustomCycleAnalytics|Select start event') },
...this.events.filter(isStartEvent).map(eventToOption),
];
},
stopEventOptions() {
const stopEvents = getAllowedEndEvents(this.events, this.fields.startEvent);
return [
{ value: null, text: s__('CustomCycleAnalytics|Select stop event') },
...eventsByIdentifier(this.events, stopEvents).map(eventToOption),
];
},
hasStartEvent() {
return this.fields.startEvent;
},
startEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.startEvent);
},
stopEventRequiresLabel() {
return isLabelEvent(this.labelEvents, this.fields.stopEvent);
},
isComplete() {
if (!this.hasValidStartAndStopEventPair) return false;
const requiredFields = [this.fields.startEvent, this.fields.stopEvent, this.fields.name];
if (this.startEventRequiresLabel) {
requiredFields.push(this.fields.startEventLabel);
}
if (this.stopEventRequiresLabel) {
requiredFields.push(this.fields.stopEventLabel);
}
return requiredFields.every(
fieldValue => fieldValue && (fieldValue.length > 0 || fieldValue > 0),
);
},
isDirty() {
return !isEqual(this.initialFields, this.fields);
},
hasValidStartAndStopEventPair() {
const {
fields: { startEvent, stopEvent },
} = this;
if (startEvent && stopEvent) {
const stopEvents = getAllowedEndEvents(this.events, startEvent);
return stopEvents.length && stopEvents.includes(stopEvent);
}
return true;
},
stopEventError() {
return !this.hasValidStartAndStopEventPair
? s__('CustomCycleAnalytics|Start event changed, please select a valid stop event')
: null;
},
},
mounted() {
this.labelEvents = getLabelEventsIdentifiers(this.events);
},
methods: {
handleCancel() {
this.fields = { ...this.initialFields };
this.$emit('cancel');
},
handleSave() {
this.$emit('submit', this.fields);
},
handleSelectLabel(key, labelId = null) {
this.fields[key] = labelId;
},
handleClearLabel(key) {
this.fields[key] = null;
},
},
};
</script>
<template>
<div class="ml-3">
<h2>{{ s__('New stage') }}</h2>
</div>
<form class="add-stage-form m-4">
<div class="mb-1">
<h4>{{ s__('CustomCycleAnalytics|New stage') }}</h4>
</div>
<gl-form-group :label="s__('CustomCycleAnalytics|Name')">
<gl-form-input
v-model="fields.name"
class="form-control"
type="text"
name="add-stage-name"
:placeholder="s__('CustomCycleAnalytics|Enter a name for the stage')"
required
/>
</gl-form-group>
<div class="d-flex" :class="{ 'justify-content-between': startEventRequiresLabel }">
<div :class="[startEventRequiresLabel ? 'w-50 mr-1' : 'w-100']">
<gl-form-group :label="s__('CustomCycleAnalytics|Start event')">
<gl-form-select
v-model="fields.startEvent"
name="add-stage-start-event"
:required="true"
:options="startEventOptions"
/>
</gl-form-group>
</div>
<div v-if="startEventRequiresLabel" class="w-50 ml-1">
<gl-form-group :label="s__('CustomCycleAnalytics|Start event label')">
<labels-selector
:labels="labels"
:selected-label-id="fields.startEventLabel"
name="add-stage-start-event-label"
@selectLabel="labelId => handleSelectLabel('startEventLabel', labelId)"
@clearLabel="handleClearLabel('startEventLabel')"
/>
</gl-form-group>
</div>
</div>
<div class="d-flex" :class="{ 'justify-content-between': stopEventRequiresLabel }">
<div :class="[stopEventRequiresLabel ? '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"
>
<gl-form-select
v-model="fields.stopEvent"
name="add-stage-stop-event"
:options="stopEventOptions"
:required="true"
:disabled="!hasStartEvent"
/>
</gl-form-group>
</div>
<div v-if="stopEventRequiresLabel" class="w-50 ml-1">
<gl-form-group :label="s__('CustomCycleAnalytics|Stop event label')">
<labels-selector
:labels="labels"
:selected-label-id="fields.stopEventLabel"
name="add-stage-stop-event-label"
@selectLabel="labelId => handleSelectLabel('stopEventLabel', labelId)"
@clearLabel="handleClearLabel('stopEventLabel')"
/>
</gl-form-group>
</div>
</div>
<div class="add-stage-form-actions">
<button
:disabled="!isDirty"
class="btn btn-cancel js-add-stage-cancel"
type="button"
@click="handleCancel"
>
{{ __('Cancel') }}
</button>
<button
:disabled="!isComplete || !isDirty"
type="button"
class="js-add-stage btn btn-success"
@click="handleSave"
>
{{ s__('CustomCycleAnalytics|Add stage') }}
</button>
</div>
</form>
</template>
<script>
// NOTE: this is a temporary component while cycle-analytics is being refactored
// post refactor we will have a vuex store and functionality to fetch data
// https://gitlab.com/gitlab-org/gitlab/issues/32019
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import Api from '~/api';
import CustomStageForm from './custom_stage_form.vue';
export default {
name: 'CustomStageFormContainer',
components: {
CustomStageForm,
GlLoadingIcon,
},
props: {
namespace: {
type: String,
required: true,
},
},
data() {
return {
// NOTE: events will be part of the response from the new cycle analytics backend
// https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/31535
events: [],
labels: [],
isLoading: false,
};
},
created() {
this.isLoading = true;
Api.groupLabels(this.namespace)
.then(labels => {
this.labels = labels.map(({ title, ...rest }) => ({ ...rest, name: title }));
})
.catch(() => {
createFlash(__('There was an error fetching the form data'));
})
.finally(() => {
this.isLoading = false;
});
},
};
</script>
<template>
<gl-loading-icon v-if="isLoading" size="md" class="my-3" />
<custom-stage-form v-else :labels="labels" :events="events" />
</template>
......@@ -6,7 +6,7 @@ import StageNavItem from './stage_nav_item.vue';
import StageEventList from './stage_event_list.vue';
import StageTableHeader from './stage_table_header.vue';
import AddStageButton from './add_stage_button.vue';
import CustomStageForm from './custom_stage_form.vue';
import CustomStageFormContainer from './custom_stage_form_container.vue';
export default {
name: 'StageTable',
......@@ -18,7 +18,7 @@ export default {
StageNavItem,
StageTableHeader,
AddStageButton,
CustomStageForm,
CustomStageFormContainer,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -60,6 +60,11 @@ export default {
type: Boolean,
required: true,
},
groupPath: {
type: String,
required: false,
default: null,
},
},
computed: {
stageName() {
......@@ -149,7 +154,11 @@ export default {
:description="__('Want to see the data? Please ask an administrator for access.')"
:svg-path="noAccessSvgPath"
/>
<custom-stage-form v-else-if="isAddingCustomStage" />
<custom-stage-form-container
v-else-if="isAddingCustomStage"
:events="events"
:namespace="groupPath"
/>
<template v-else>
<stage-event-list v-if="shouldDisplayStage" :stage="currentStage" :events="events" />
<gl-empty-state
......
......@@ -7,3 +7,6 @@ export const currentStage = ({ stages, selectedStageName }) =>
export const defaultStage = state => (state.stages.length ? state.stages[0] : null);
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
export const currentGroupPath = state =>
state.selectedGroup ? state.selectedGroup.full_path : null;
const EVENT_TYPE_LABEL = 'label';
export const isStartEvent = ev => Boolean(ev) && Boolean(ev.canBeStartEvent) && ev.canBeStartEvent;
export const eventToOption = (obj = null) => {
if (!obj || (!obj.text && !obj.identifier)) return null;
const { name: text = '', identifier: value = null } = obj;
return { text, value };
};
export const getAllowedEndEvents = (events = [], targetIdentifier = null) => {
if (!targetIdentifier || !events.length) return [];
const st = events.find(({ identifier }) => identifier === targetIdentifier);
return st && st.allowedEndEvents ? st.allowedEndEvents : [];
};
export const eventsByIdentifier = (events = [], targetIdentifier = []) => {
if (!targetIdentifier || !targetIdentifier.length || !events.length) return [];
return events.filter(({ identifier = '' }) => targetIdentifier.includes(identifier));
};
export const isLabelEvent = (labelEvents = [], ev = null) =>
Boolean(ev) && labelEvents.length && labelEvents.includes(ev);
export const getLabelEventsIdentifiers = (events = []) =>
events.filter(ev => ev.type && ev.type === EVENT_TYPE_LABEL).map(i => i.identifier);
......@@ -78,4 +78,4 @@
%template{ "v-if" => "state.events.length && !isLoadingStage && !isEmptyStage && !isCustomStageForm" }
%component{ ":is" => "currentStage.component", ":stage" => "currentStage", ":items" => "state.events" }
- if customizable_cycle_analytics
%custom-stage-form{ "v-if" => "isCustomStageForm" }
%custom-stage-form-container{ "v-if" => "isCustomStageForm && selectedGroup && selectedGroup.full_path", ":namespace" => "selectedGroup.full_path" }
......@@ -51,3 +51,200 @@ export const codeEvents = stageFixtures.code;
export const testEvents = stageFixtures.test;
export const stagingEvents = stageFixtures.staging;
export const productionEvents = stageFixtures.production;
// NOTE: once the backend is complete, we can generate this as a JSON fixture
// https://gitlab.com/gitlab-org/gitlab/issues/32112
export const apiResponse = {
events: [
{
name: 'Issue created',
identifier: 'issue_created',
type: 'simple',
canBeStartEvent: true,
allowedEndEvents: ['issue_stage_end'],
},
{
name: 'Issue first mentioned in a commit',
identifier: 'issue_first_mentioned_in_commit',
type: 'simple',
canBeStartEvent: false,
allowedEndEvents: [],
},
{
name: 'Merge request created',
identifier: 'merge_request_created',
type: 'simple',
canBeStartEvent: true,
allowedEndEvents: ['merge_request_merged'],
},
{
name: 'Merge request first deployed to production',
identifier: 'merge_request_first_deployed_to_production',
type: 'simple',
canBeStartEvent: false,
allowedEndEvents: [],
},
{
name: 'Merge request last build finish time',
identifier: 'merge_request_last_build_finished',
type: 'simple',
canBeStartEvent: false,
allowedEndEvents: [],
},
{
name: 'Merge request last build start time',
identifier: 'merge_request_last_build_started',
type: 'simple',
canBeStartEvent: true,
allowedEndEvents: ['merge_request_last_build_finished'],
},
{
name: 'Merge request merged',
identifier: 'merge_request_merged',
type: 'simple',
canBeStartEvent: true,
allowedEndEvents: ['merge_request_first_deployed_to_production'],
},
{
name: 'Issue first mentioned in a commit',
identifier: 'code_stage_start',
type: 'simple',
canBeStartEvent: true,
allowedEndEvents: ['merge_request_created'],
},
{
name: 'Issue first associated with a milestone or issue first added to a board',
identifier: 'issue_stage_end',
type: 'simple',
canBeStartEvent: false,
allowedEndEvents: [],
},
{
name: 'Issue first associated with a milestone or issue first added to a board',
identifier: 'plan_stage_start',
type: 'simple',
canBeStartEvent: true,
allowedEndEvents: ['issue_first_mentioned_in_commit'],
},
{
identifier: 'issue_label_added',
name: 'Issue Label Added',
type: 'label',
canBeStartEvent: true,
allowedEndEvents: ['issue_closed', 'issue_label_removed'],
},
{
identifier: 'issue_label_removed',
name: 'Issue Label Removed',
type: 'label',
canBeStartEvent: false,
allowedEndEvents: [],
},
],
stages: [
{
name: 'issue',
legend: 'Related Issues',
description: 'Time before an issue gets scheduled',
id: 'issue',
position: 1,
hidden: false,
custom: false,
startEventIdentifier: 'issue_created',
endEventIdentifier: 'issue_stage_end',
},
{
name: 'plan',
legend: 'Related Issues',
description: 'Time before an issue starts implementation',
id: 'plan',
position: 2,
hidden: false,
custom: false,
startEventIdentifier: 'plan_stage_start',
endEventIdentifier: 'issue_first_mentioned_in_commit',
},
{
name: 'code',
legend: 'Related Merged Requests',
description: 'Time until first merge request',
id: 'code',
position: 3,
hidden: false,
custom: false,
startEventIdentifier: 'code_stage_start',
endEventIdentifier: 'merge_request_created',
},
{
name: 'test',
legend: 'Related Merged Requests',
description: 'Total test time for all commits/merges',
id: 'test',
position: 4,
hidden: false,
custom: false,
startEventIdentifier: 'merge_request_last_build_started',
endEventIdentifier: 'merge_request_last_build_finished',
},
{
name: 'review',
legend: 'Related Merged Requests',
description: 'Time between merge request creation and merge/close',
id: 'review',
position: 5,
hidden: false,
custom: false,
startEventIdentifier: 'merge_request_created',
endEventIdentifier: 'merge_request_merged',
},
{
name: 'staging',
legend: 'Related Merged Requests',
description: 'From merge request merge until deploy to production',
id: 'staging',
position: 6,
hidden: false,
custom: false,
startEventIdentifier: 'merge_request_merged',
endEventIdentifier: 'merge_request_first_deployed_to_production',
},
{
name: 'production',
legend: 'Related Merged Requests',
description: 'From issue creation until deploy to production',
id: 'production',
position: 7,
hidden: false,
custom: false,
startEventIdentifier: 'merge_request_merged',
endEventIdentifier: 'merge_request_first_deployed_to_production',
},
],
summary: [
{
value: 2,
title: 'New Issues',
},
{
value: 0,
title: 'Commits',
},
{
value: 0,
title: 'Deploys',
},
],
permissions: {
issue: true,
plan: true,
code: true,
test: true,
review: true,
staging: true,
production: true,
},
};
export default {
apiResponse,
};
import {
isStartEvent,
isLabelEvent,
getAllowedEndEvents,
eventToOption,
eventsByIdentifier,
getLabelEventsIdentifiers,
} from 'ee/analytics/cycle_analytics/utils';
import { apiResponse } from './mock_data';
const { events } = apiResponse;
const startEvent = events[0];
const endEvent = events[1];
const labelEvent = events[11];
const labelEvents = [events[10], events[11]].map(i => i.identifier);
describe('Cycle analytics utils', () => {
describe('isStartEvent', () => {
it('will return true for a valid start event', () => {
expect(isStartEvent(startEvent)).toEqual(true);
});
it('will return false for input that is not a start event', () => {
[endEvent, {}, [], null, undefined].forEach(ev => {
expect(isStartEvent(ev)).toEqual(false);
});
});
});
describe('isLabelEvent', () => {
it('will return true if the given event identifier is in the labelEvents array', () => {
expect(isLabelEvent(labelEvents, labelEvent.identifier)).toEqual(true);
});
it('will return false if the given event identifier is not in the labelEvents array', () => {
[startEvent.identifier, null, undefined, ''].forEach(ev => {
expect(isLabelEvent(labelEvents, ev)).toEqual(false);
});
expect(isLabelEvent(labelEvents)).toEqual(false);
});
});
describe('eventToOption', () => {
it('will return null if no valid object is passed in', () => {
[{}, [], null, undefined].forEach(i => {
expect(eventToOption(i)).toEqual(null);
});
});
it('will set the "value" property to the events identifier', () => {
events.forEach(ev => {
const res = eventToOption(ev);
expect(res.value).toEqual(ev.identifier);
});
});
it('will set the "text" property to the events name', () => {
events.forEach(ev => {
const res = eventToOption(ev);
expect(res.text).toEqual(ev.name);
});
});
});
describe('getLabelEventsIdentifiers', () => {
it('will return an array of identifiers for the label events', () => {
const res = getLabelEventsIdentifiers(events);
expect(res.length).toEqual(labelEvents.length);
expect(res).toEqual(labelEvents);
});
it('will return an empty array when there are no matches', () => {
const ev = [{ _type: 'simple' }, { type: 'simple' }, { t: 'simple' }];
expect(getLabelEventsIdentifiers(ev)).toEqual([]);
expect(getLabelEventsIdentifiers([])).toEqual([]);
});
});
describe('getAllowedEndEvents', () => {
it('will return the relevant end events for a given start event identifier', () => {
const se = events[10].allowedEndEvents;
expect(getAllowedEndEvents(events, 'issue_label_added')).toEqual(se);
});
it('will return an empty array if there are no end events available', () => {
['cool_issue_label_added', [], {}, null, undefined].forEach(ev => {
expect(getAllowedEndEvents(events, ev)).toEqual([]);
});
});
});
describe('eventsByIdentifier', () => {
it('will return the events with an identifier in the provided array', () => {
expect(eventsByIdentifier(events, labelEvents)).toEqual([events[10], events[11]]);
});
it('will return an empty array if there are no matching events', () => {
[['lol', 'bad'], [], {}, null, undefined].forEach(items => {
expect(eventsByIdentifier(events, items)).toEqual([]);
});
expect(eventsByIdentifier([], labelEvents)).toEqual([]);
});
});
});
......@@ -4547,6 +4547,42 @@ msgstr ""
msgid "CustomCycleAnalytics|Add a stage"
msgstr ""
msgid "CustomCycleAnalytics|Add stage"
msgstr ""
msgid "CustomCycleAnalytics|Enter a name for the stage"
msgstr ""
msgid "CustomCycleAnalytics|Name"
msgstr ""
msgid "CustomCycleAnalytics|New stage"
msgstr ""
msgid "CustomCycleAnalytics|Please select a start event first"
msgstr ""
msgid "CustomCycleAnalytics|Select start event"
msgstr ""
msgid "CustomCycleAnalytics|Select stop event"
msgstr ""
msgid "CustomCycleAnalytics|Start event"
msgstr ""
msgid "CustomCycleAnalytics|Start event changed, please select a valid stop event"
msgstr ""
msgid "CustomCycleAnalytics|Start event label"
msgstr ""
msgid "CustomCycleAnalytics|Stop event"
msgstr ""
msgid "CustomCycleAnalytics|Stop event label"
msgstr ""
msgid "Customize colors"
msgstr ""
......@@ -10208,9 +10244,6 @@ msgstr ""
msgid "New snippet"
msgstr ""
msgid "New stage"
msgstr ""
msgid "New subgroup"
msgstr ""
......@@ -15698,6 +15731,9 @@ msgstr ""
msgid "There was an error fetching configuration for charts"
msgstr ""
msgid "There was an error fetching the form data"
msgstr ""
msgid "There was an error gathering the chart data"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment