Commit 55432f0f authored by Phil Hughes's avatar Phil Hughes

Merge branch '197335-move-custom-stage-form-into-base-component' into 'master'

Move custom stage form and stage nav to base vue

See merge request gitlab-org/gitlab!30359
parents f7ab044a 1e245759
......@@ -187,7 +187,6 @@
.stage-events {
width: 60%;
overflow: scroll;
min-height: 467px;
}
......
......@@ -57,7 +57,7 @@
%nav.stage-nav
%ul
%stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" }
.section.stage-events
.section.stage-events.overflow-auto
%gl-loading-icon{ "v-show" => "isLoadingStage", "size" => "lg" }
%template{ "v-if" => "currentStage && !currentStage.isUserAllowed" }
= render partial: "no_access"
......
......@@ -3,7 +3,7 @@ import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE } from '../constants';
import { PROJECTS_PER_PAGE, STAGE_ACTIONS } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import { LAST_ACTIVITY_AT, DATE_RANGE_LIMIT } from '../../shared/constants';
......@@ -14,6 +14,8 @@ import TypeOfWorkCharts from './type_of_work_charts.vue';
import UrlSyncMixin from '../../shared/mixins/url_sync_mixin';
import { toYmd } from '../../shared/utils';
import RecentActivityCard from './recent_activity_card.vue';
import StageTableNav from './stage_table_nav.vue';
import CustomStageForm from './custom_stage_form.vue';
export default {
name: 'CycleAnalytics',
......@@ -27,6 +29,8 @@ export default {
StageTable,
TypeOfWorkCharts,
RecentActivityCard,
CustomStageForm,
StageTableNav,
},
mixins: [glFeatureFlagsMixin(), UrlSyncMixin],
props: {
......@@ -68,6 +72,7 @@ export default {
'endDate',
'medians',
'customStageFormErrors',
'customStageFormInitialData',
]),
...mapGetters([
'hasNoAccessError',
......@@ -76,6 +81,7 @@ export default {
'selectedProjectIds',
'enableCustomOrdering',
'cycleAnalyticsRequestParams',
'customStageFormActive',
]),
shouldRenderEmptyState() {
return !this.selectedGroup;
......@@ -180,10 +186,11 @@ export default {
include_subgroups: true,
},
maxDateRange: DATE_RANGE_LIMIT,
STAGE_ACTIONS,
};
</script>
<template>
<div class="js-cycle-analytics">
<div>
<div class="mb-3">
<h3>{{ __('Value Stream Analytics') }}</h3>
</div>
......@@ -261,30 +268,42 @@ export default {
:key="stageCount"
class="js-stage-table"
:current-stage="selectedStage"
:stages="activeStages"
:medians="medians"
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-saving-custom-stage="isSavingCustomStage"
:is-creating-custom-stage="isCreatingCustomStage"
:is-editing-custom-stage="isEditingCustomStage"
:custom-stage-form-active="customStageFormActive"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:custom-stage-form-errors="customStageFormErrors"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
:custom-ordering="enableCustomOrdering"
@clearCustomStageFormErrors="clearCustomStageFormErrors"
@selectStage="onStageSelect"
@editStage="onShowEditStageForm"
@showAddStageForm="onShowAddStageForm"
@hideStage="onUpdateCustomStage"
@removeStage="onRemoveStage"
@createStage="onCreateCustomStage"
@updateStage="onUpdateCustomStage"
@reorderStage="onStageReorder"
/>
>
<template #nav>
<stage-table-nav
:current-stage="selectedStage"
:stages="activeStages"
:medians="medians"
:is-creating-custom-stage="isCreatingCustomStage"
:custom-stage-form-active="customStageFormActive"
:can-edit-stages="hasCustomizableCycleAnalytics"
:custom-ordering="enableCustomOrdering"
@reorderStage="onStageReorder"
@selectStage="onStageSelect"
@editStage="onShowEditStageForm"
@showAddStageForm="onShowAddStageForm"
@hideStage="onUpdateCustomStage"
@removeStage="onRemoveStage"
/>
</template>
<template v-if="customStageFormActive" #content>
<custom-stage-form
:events="customStageFormEvents"
:is-saving-custom-stage="isSavingCustomStage"
:initial-fields="customStageFormInitialData"
:is-editing-custom-stage="isEditingCustomStage"
:errors="customStageFormErrors"
@createStage="onCreateCustomStage"
@updateStage="onUpdateCustomStage"
@clearErrors="$emit('clearCustomStageFormErrors')"
/>
</template>
</stage-table>
</div>
</div>
<duration-chart v-if="shouldDisplayDurationChart" class="mt-3" :stages="activeStages" />
......
<script>
import { mapState } from 'vuex';
import Sortable from 'sortablejs';
import { GlTooltipDirective, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __, s__ } from '~/locale';
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 { STAGE_ACTIONS } from '../constants';
import { NO_DRAG_CLASS } from '../../shared/constants';
import sortableDefaultOptions from '../../shared/mixins/sortable_default_options';
export default {
name: 'StageTable',
......@@ -18,23 +11,12 @@ export default {
GlLoadingIcon,
GlEmptyState,
StageEventList,
StageNavItem,
StageTableHeader,
AddStageButton,
CustomStageForm,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
stages: {
type: Array,
required: true,
},
medians: {
type: Object,
required: true,
},
currentStage: {
type: Object,
required: true,
......@@ -47,15 +29,7 @@ export default {
type: Boolean,
required: true,
},
isCreatingCustomStage: {
type: Boolean,
required: true,
},
isEditingCustomStage: {
type: Boolean,
required: true,
},
isSavingCustomStage: {
customStageFormActive: {
type: Boolean,
required: true,
},
......@@ -63,37 +37,10 @@ export default {
type: Array,
required: true,
},
customStageFormEvents: {
type: Array,
required: true,
},
customStageFormErrors: {
type: Object,
required: false,
default: () => {},
},
noDataSvgPath: {
type: String,
required: true,
},
noAccessSvgPath: {
type: String,
required: true,
},
canEditStages: {
type: Boolean,
required: true,
},
customOrdering: {
type: Boolean,
required: false,
default: false,
},
errorSavingStageOrder: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -112,15 +59,6 @@ export default {
const { currentStageEvents = [], isLoading, isEmptyStage } = this;
return currentStageEvents.length && !isLoading && !isEmptyStage;
},
customStageFormActive() {
return this.isCreatingCustomStage;
},
allowCustomOrdering() {
return this.customOrdering && !this.errorSavingStageOrder;
},
manualOrderingClass() {
return this.allowCustomOrdering ? 'js-manual-ordering' : '';
},
stageHeaders() {
return [
{
......@@ -152,31 +90,7 @@ export default {
},
mounted() {
this.$set(this, 'stageNavHeight', this.$refs.stageNav.clientHeight);
if (this.allowCustomOrdering) {
const options = Object.assign({}, sortableDefaultOptions(), {
onUpdate: event => {
const el = event.item;
const { previousElementSibling, nextElementSibling } = el;
const { id } = el.dataset;
const moveAfterId = previousElementSibling?.dataset?.id || null;
const moveBeforeId = nextElementSibling?.dataset?.id || null;
this.$emit('reorderStage', { id, moveAfterId, moveBeforeId });
},
});
this.sortable = Sortable.create(this.$refs.list, options);
}
},
methods: {
medianValue(id) {
return this.medians[id] ? this.medians[id] : null;
},
},
STAGE_ACTIONS,
noDragClass: NO_DRAG_CLASS,
};
</script>
<template>
......@@ -198,56 +112,25 @@ export default {
</div>
<div class="stage-panel-body">
<nav ref="stageNav" class="stage-nav pl-2">
<ul ref="list" :class="manualOrderingClass">
<stage-nav-item
v-for="stage in stages"
:id="stage.id"
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="medianValue(stage.id)"
:is-active="!isCreatingCustomStage && stage.id === currentStage.id"
:can-edit="canEditStages"
:is-default-stage="!stage.custom"
@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"
:class="$options.noDragClass"
:active="customStageFormActive"
@showform="$emit('showAddStageForm')"
/>
</ul>
<slot name="nav"></slot>
</nav>
<div class="section stage-events" :style="{ height: stageEventsHeight }">
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<custom-stage-form
v-else-if="isCreatingCustomStage || isEditingCustomStage"
:events="customStageFormEvents"
:is-saving-custom-stage="isSavingCustomStage"
:initial-fields="customStageFormInitialData"
:is-editing-custom-stage="isEditingCustomStage"
:errors="customStageFormErrors"
@submit="$emit('submit', $event)"
@createStage="$emit($options.STAGE_ACTIONS.CREATE, $event)"
@updateStage="$emit($options.STAGE_ACTIONS.UPDATE, $event)"
@clearErrors="$emit('clearCustomStageFormErrors')"
/>
<template v-else>
<stage-event-list
v-if="shouldDisplayStage"
:stage="currentStage"
:events="currentStageEvents"
/>
<gl-empty-state
v-if="isEmptyStage"
:title="__('We don\'t have enough data to show this stage.')"
:description="currentStage.emptyStageText"
:svg-path="noDataSvgPath"
/>
</template>
<div class="section stage-events overflow-auto" :style="{ height: stageEventsHeight }">
<slot name="content">
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<template v-else>
<stage-event-list
v-if="shouldDisplayStage"
:stage="currentStage"
:events="currentStageEvents"
/>
<gl-empty-state
v-if="isEmptyStage"
:title="__('We don\'t have enough data to show this stage.')"
:description="currentStage.emptyStageText"
:svg-path="noDataSvgPath"
/>
</template>
</slot>
</div>
</div>
</div>
......
<script>
import Sortable from 'sortablejs';
import StageNavItem from './stage_nav_item.vue';
import AddStageButton from './add_stage_button.vue';
import { STAGE_ACTIONS } from '../constants';
import { NO_DRAG_CLASS } from '../../shared/constants';
import sortableDefaultOptions from '../../shared/mixins/sortable_default_options';
export default {
name: 'StageTableNav',
components: {
AddStageButton,
StageNavItem,
},
props: {
currentStage: {
type: Object,
required: true,
},
medians: {
type: Object,
required: true,
},
stages: {
type: Array,
required: true,
},
isCreatingCustomStage: {
type: Boolean,
required: true,
},
customStageFormActive: {
type: Boolean,
required: true,
},
canEditStages: {
type: Boolean,
required: true,
},
customOrdering: {
type: Boolean,
required: false,
default: false,
},
errorSavingStageOrder: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
allowCustomOrdering() {
return this.customOrdering && !this.errorSavingStageOrder;
},
manualOrderingClass() {
return this.allowCustomOrdering ? 'js-manual-ordering' : null;
},
},
mounted() {
if (this.allowCustomOrdering) {
const options = Object.assign({}, sortableDefaultOptions(), {
onUpdate: event => {
const el = event.item;
const { previousElementSibling, nextElementSibling } = el;
const { id } = el.dataset;
const moveAfterId = previousElementSibling?.dataset?.id || null;
const moveBeforeId = nextElementSibling?.dataset?.id || null;
this.$emit('reorderStage', { id, moveAfterId, moveBeforeId });
},
});
this.sortable = Sortable.create(this.$refs.list, options);
}
},
beforeDestroy() {
if (this.sortable) this.sortable.destroy();
},
methods: {
medianValue(id) {
return this.medians[id] ? this.medians[id] : null;
},
},
STAGE_ACTIONS,
noDragClass: NO_DRAG_CLASS,
};
</script>
<template>
<ul ref="list" :class="manualOrderingClass">
<stage-nav-item
v-for="stage in stages"
:id="stage.id"
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="medianValue(stage.id)"
:is-active="!isCreatingCustomStage && stage.id === currentStage.id"
:can-edit="canEditStages"
:is-default-stage="!stage.custom"
@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"
:class="$options.noDragClass"
:active="customStageFormActive"
@showform="$emit('showAddStageForm')"
/>
</ul>
</template>
......@@ -25,3 +25,6 @@ export const activeStages = ({ stages }) => filterStagesByHiddenStatus(stages, f
export const enableCustomOrdering = ({ stages, errorSavingStageOrder }) =>
stages.some(stage => isNumber(stage.id)) && !errorSavingStageOrder;
export const customStageFormActive = ({ isCreatingCustomStage, isEditingCustomStage }) =>
Boolean(isCreatingCustomStage || isEditingCustomStage);
......@@ -37,6 +37,11 @@
0 0.25rem 0.75rem $gl-btn-active-background;
}
.js-cycle-analytics .show.dropdown .dropdown-menu {
min-height: $grid-size * 21;
.dropdown-groups,
.dropdown-projects {
&.show.dropdown .dropdown-menu {
min-height: $grid-size * 21;
}
}
import { mount, shallowMount } from '@vue/test-utils';
import StageTableNav from 'ee/analytics/cycle_analytics/components/stage_table_nav.vue';
import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue';
import AddStageButton from 'ee/analytics/cycle_analytics/components/add_stage_button.vue';
import { issueStage, allowedStages as stages, stageMedians as medians } from '../mock_data';
describe('StageTableNav', () => {
function createComponent({ props = {}, mountFn = shallowMount } = {}) {
return mountFn(StageTableNav, {
propsData: {
currentStage: issueStage,
medians,
stages,
isCreatingCustomStage: false,
customStageFormActive: false,
canEditStages: false,
customOrdering: false,
errorSavingStageOrder: false,
...props,
},
});
}
let wrapper = null;
afterEach(() => {
wrapper.destroy();
});
function selectStage(index) {
wrapper
.findAll(StageNavItem)
.at(index)
.trigger('click');
}
describe('when a stage is clicked', () => {
beforeEach(() => {
wrapper = createComponent({ mountFn: mount });
});
it('will emit `selectStage`', () => {
expect(wrapper.emitted('selectStage')).toBeUndefined();
selectStage(1);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted().selectStage.length).toEqual(1);
});
});
it('will emit `selectStage` with the new stage title', () => {
const secondStage = stages[1];
selectStage(1);
return wrapper.vm.$nextTick().then(() => {
const [params] = wrapper.emitted('selectStage')[0];
expect(params).toMatchObject({ title: secondStage.title });
});
});
});
describe('canEditStages = true', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
canEditStages: true,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the add a stage button', () => {
expect(wrapper.find(AddStageButton).exists()).toBe(true);
});
});
describe.each`
flag | value
${'customOrdering'} | ${true}
${'customOrdering'} | ${false}
${'errorSavingStageOrder'} | ${false}
`('Manual ordering', ({ flag, value }) => {
const result = value ? 'enabled' : 'disabled';
beforeEach(() => {
wrapper = createComponent({
props: {
[flag]: value,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it(`with ${flag} = ${value} manual ordering is ${result}`, () => {
expect(wrapper.find('.js-manual-ordering').exists()).toBe(value);
});
});
});
import Vue from 'vue';
import { shallowMount, mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import {
issueEvents,
issueStage,
allowedStages,
groupLabels,
customStageEvents,
stageMedians as medians,
} from '../mock_data';
import { issueEvents, issueStage, allowedStages } from '../mock_data';
let wrapper = null;
const $sel = {
nav: '.stage-nav',
navItems: '.stage-nav-item',
eventList: '.stage-events',
events: '.stage-event-item',
description: '.events-description',
......@@ -25,30 +16,32 @@ const $sel = {
const headers = ['Stage', 'Median', issueStage.legend, 'Time'];
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const StageTableNavSlot = {
name: 'stage-table-nav-slot-stub',
template: '<ul><li v-for="stage in stages">{{ stage.title }}</li></ul>',
};
function createComponent(props = {}, shallow = false) {
const func = shallow ? shallowMount : mount;
return func(StageTable, {
propsData: {
stages: allowedStages,
currentStage: issueStage,
currentStageEvents: issueEvents,
labels: groupLabels,
isLoading: false,
isLoadingSummaryData: false,
isEmptyStage: false,
isSavingCustomStage: false,
isCreatingCustomStage: false,
isEditingCustomStage: false,
currentStageEvents: issueEvents,
noDataSvgPath,
noAccessSvgPath,
canEditStages: false,
customStageFormEvents: customStageEvents,
medians,
customStageFormActive: false,
...props,
},
slots: {
nav: StageTableNavSlot,
},
mocks: {
stages: allowedStages,
},
stubs: {
'stage-nav-item': true,
'gl-loading-icon': true,
},
});
......@@ -63,6 +56,7 @@ describe('StageTable', () => {
afterEach(() => {
wrapper.destroy();
});
it('will render the headers', () => {
const renderedHeaders = wrapper.findAll($sel.headers);
expect(renderedHeaders).toHaveLength(headers.length);
......@@ -88,7 +82,7 @@ describe('StageTable', () => {
});
it('will render the correct stages', () => {
const evs = wrapper.findAll({ name: 'StageNavItem' });
const evs = wrapper.find(StageTableNavSlot).findAll('li');
expect(evs).toHaveLength(allowedStages.length);
const nav = wrapper.find($sel.nav).html();
......@@ -116,38 +110,6 @@ describe('StageTable', () => {
expect(evshtml).toContain(ev.title);
});
});
function selectStage(index) {
wrapper
.findAll($sel.navItems)
.at(index)
.trigger('click');
}
describe('when a stage is clicked', () => {
it('will emit `selectStage`', done => {
expect(wrapper.emitted('selectStage')).toBeUndefined();
selectStage(1);
Vue.nextTick(() => {
expect(wrapper.emitted().selectStage).toHaveLength(1);
done();
});
});
it('will emit `selectStage` with the new stage title', done => {
const secondStage = allowedStages[1];
selectStage(1);
Vue.nextTick(() => {
const [params] = wrapper.emitted('selectStage')[0];
expect(params).toMatchObject({ title: secondStage.title });
done();
});
});
});
});
it('isLoading = true', () => {
......@@ -173,51 +135,4 @@ describe('StageTable', () => {
expect(wrapper.html()).toContain("We don't have enough data to show this stage.");
});
});
describe('canEditStages = true', () => {
beforeEach(() => {
wrapper = createComponent({
canEditStages: true,
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the add a stage button', () => {
expect(wrapper.html()).toContain('Add a stage');
});
});
describe('customOrdering = true', () => {
beforeEach(() => {
wrapper = createComponent({
customOrdering: true,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders the manual-ordering class', () => {
expect(wrapper.find('.js-manual-ordering').exists()).toBeTruthy();
});
});
describe('customOrdering = false', () => {
beforeEach(() => {
wrapper = createComponent({
customOrdering: false,
});
});
afterEach(() => {
wrapper.destroy();
});
it('does not render the manual-ordering class', () => {
expect(wrapper.find('.js-manual-ordering').exists()).toBeFalsy();
});
});
});
......@@ -142,4 +142,20 @@ describe('Cycle analytics getters', () => {
});
});
});
describe.each`
isEditingCustomStage | isCreatingCustomStage | result
${true} | ${true} | ${true}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${null} | ${true} | ${true}
${true} | ${null} | ${true}
${null} | ${null} | ${false}
${false} | ${false} | ${false}
`('customStageFormActive', ({ isEditingCustomStage, isCreatingCustomStage, result }) => {
it(`returns ${result} when isEditingCustomStage=${isEditingCustomStage} and isCreatingCustomStage=${isCreatingCustomStage}`, () => {
const resp = getters.customStageFormActive({ isCreatingCustomStage, isEditingCustomStage });
expect(resp).toEqual(result);
});
});
});
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