Move cycle analytics stages templates to vue

The existing stage list items are rendered
in haml, migrating them to vuejs for future
work.

Fix alignment of median value

Test for stage_nav_item.vue
This commit is contained in:
Ezekiel Kigbo 2019-08-19 06:51:06 +00:00 committed by Kushal Pandya
parent 6f4350e46e
commit 5e8f16bd00
7 changed files with 336 additions and 60 deletions

View file

@ -0,0 +1,41 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
import { GlButton } from '@gitlab/ui';
export default {
name: 'StageCardListItem',
components: {
Icon,
GlButton,
},
props: {
isActive: {
type: Boolean,
required: true,
},
canEdit: {
type: Boolean,
default: false,
required: false,
},
},
};
</script>
<template>
<div :class="{ active: isActive }" class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded">
<slot></slot>
<div v-if="canEdit" class="dropdown">
<gl-button
:title="__('More actions')"
class="more-actions-toggle btn btn-transparent p-0"
data-toggle="dropdown"
>
<icon css-classes="icon" name="ellipsis_v" />
</gl-button>
<ul class="more-actions-dropdown dropdown-menu dropdown-open-left">
<slot name="dropdown-options"></slot>
</ul>
</div>
</div>
</template>

View file

@ -0,0 +1,88 @@
<script>
import StageCardListItem from './stage_card_list_item.vue';
export default {
name: 'StageNavItem',
components: {
StageCardListItem,
},
props: {
isDefaultStage: {
type: Boolean,
default: false,
required: false,
},
isActive: {
type: Boolean,
default: false,
required: false,
},
isUserAllowed: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
},
value: {
type: String,
default: '',
required: false,
},
canEdit: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
hasValue() {
return this.value && this.value.length > 0;
},
editable() {
return this.isUserAllowed && this.canEdit;
},
},
};
</script>
<template>
<li @click="$emit('select')">
<stage-card-list-item :is-active="isActive" :can-edit="editable">
<div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }">
{{ title }}
</div>
<div class="stage-nav-item-cell stage-median mr-4">
<template v-if="isUserAllowed">
<span v-if="hasValue">{{ value }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</template>
<template v-else>
<span class="not-available">{{ __('Not available') }}</span>
</template>
</div>
<template v-slot:dropdown-options>
<template v-if="isDefaultStage">
<li>
<button type="button" class="btn-default btn-transparent">
{{ __('Hide stage') }}
</button>
</li>
</template>
<template v-else>
<li>
<button type="button" class="btn-default btn-transparent">
{{ __('Edit stage') }}
</button>
</li>
<li>
<button type="button" class="btn-danger danger">
{{ __('Remove stage') }}
</button>
</li>
</template>
</template>
</stage-card-list-item>
</li>
</template>

View file

@ -12,6 +12,7 @@ import stageComponent from './components/stage_component.vue';
import stageReviewComponent from './components/stage_review_component.vue';
import stageStagingComponent from './components/stage_staging_component.vue';
import stageTestComponent from './components/stage_test_component.vue';
import stageNavItem from './components/stage_nav_item.vue';
import CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store';
@ -41,6 +42,7 @@ export default () => {
import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'),
DateRangeDropdown: () =>
import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
},
mixins: [filterMixins],
data() {

View file

@ -51,27 +51,19 @@
}
.stage-header {
width: 26%;
padding-left: $gl-padding;
width: 18.5%;
}
.median-header {
width: 14%;
width: 21.5%;
}
.event-header {
width: 45%;
padding-left: $gl-padding;
}
.total-time-header {
width: 15%;
text-align: right;
padding-right: $gl-padding;
}
.stage-name {
font-weight: $gl-font-weight-bold;
}
}
@ -153,23 +145,13 @@
}
.stage-nav-item {
display: flex;
line-height: 65px;
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
border-right: 1px solid $border-color;
background-color: $gray-light;
border: 1px solid $border-color;
&.active {
background-color: transparent;
border-right-color: transparent;
border-top-color: $border-color;
border-bottom-color: $border-color;
box-shadow: inset 2px 0 0 0 $blue-500;
.stage-name {
font-weight: $gl-font-weight-bold;
}
background: $blue-50;
border-color: $blue-300;
box-shadow: inset 4px 0 0 0 $blue-500;
}
&:hover:not(.active) {
@ -178,24 +160,12 @@
cursor: pointer;
}
&:first-child {
border-top: 0;
.stage-nav-item-cell.stage-name {
width: 44.5%;
}
&:last-child {
border-bottom: 0;
}
.stage-nav-item-cell {
&.stage-median {
margin-left: auto;
margin-right: $gl-padding;
min-width: calc(35% - #{$gl-padding});
}
}
.stage-name {
padding-left: 16px;
.stage-nav-item-cell.stage-median {
min-width: 43%;
}
.stage-empty,

View file

@ -34,40 +34,29 @@
{{ n__('Last %d day', 'Last %d days', 90) }}
.stage-panel-container
.card.stage-panel
.card-header
.card-header.border-bottom-0
%nav.col-headers
%ul
%li.stage-header
%span.stage-name
%li.stage-header.pl-5
%span.stage-name.font-weight-bold
{{ s__('ProjectLifecycle|Stage') }}
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" }
%li.median-header
%span.stage-name
%span.stage-name.font-weight-bold
{{ __('Median') }}
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" }
%li.event-header
%span.stage-name
%li.event-header.pl-3
%span.stage-name.font-weight-bold
{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" }
%li.total-time-header
%span.stage-name
%li.total-time-header.pr-5.text-right
%span.stage-name.font-weight-bold
{{ __('Total Time') }}
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" }
.stage-panel-body
%nav.stage-nav
%ul
%li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" }
.stage-nav-item-cell.stage-name
{{ stage.title }}
.stage-nav-item-cell.stage-median
%template{ "v-if" => "stage.isUserAllowed" }
%span{ "v-if" => "stage.value" }
{{ stage.value }}
%span.stage-empty{ "v-else" => true }
{{ __('Not enough data') }}
%template{ "v-else" => true }
%span.not-available
{{ __('Not available') }}
%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
%template{ "v-if" => "isLoadingStage" }
= icon("spinner spin")

View file

@ -4144,6 +4144,9 @@ msgstr ""
msgid "Edit public deploy key"
msgstr ""
msgid "Edit stage"
msgstr ""
msgid "Edit wiki page"
msgstr ""
@ -5658,6 +5661,9 @@ msgstr ""
msgid "Hide shared projects"
msgstr ""
msgid "Hide stage"
msgstr ""
msgid "Hide value"
msgid_plural "Hide values"
msgstr[0] ""
@ -9357,6 +9363,9 @@ msgstr ""
msgid "Remove spent time"
msgstr ""
msgid "Remove stage"
msgstr ""
msgid "Remove time estimate"
msgstr ""

View file

@ -0,0 +1,177 @@
import { mount, shallowMount } from '@vue/test-utils';
import StageNavItem from '~/cycle_analytics/components/stage_nav_item.vue';
describe('StageNavItem', () => {
let wrapper = null;
const title = 'Cool stage';
const value = '1 day';
function createComponent(props, shallow = true) {
const func = shallow ? shallowMount : mount;
return func(StageNavItem, {
propsData: {
canEdit: false,
isActive: false,
isUserAllowed: false,
isDefaultStage: true,
title,
value,
...props,
},
});
}
function hasStageName() {
const stageName = wrapper.find('.stage-name');
expect(stageName.exists()).toBe(true);
expect(stageName.text()).toEqual(title);
}
it('renders stage name', () => {
wrapper = createComponent({ isUserAllowed: true });
hasStageName();
wrapper.destroy();
});
describe('User has access', () => {
describe('with a value', () => {
beforeEach(() => {
wrapper = createComponent({ isUserAllowed: true });
});
afterEach(() => {
wrapper.destroy();
});
it('renders the value for median value', () => {
expect(wrapper.find('.stage-empty').exists()).toBe(false);
expect(wrapper.find('.not-available').exists()).toBe(false);
expect(wrapper.find('.stage-median').text()).toEqual(value);
});
});
describe('without a value', () => {
beforeEach(() => {
wrapper = createComponent({ isUserAllowed: true, value: null });
});
afterEach(() => {
wrapper.destroy();
});
it('has the stage-empty class', () => {
expect(wrapper.find('.stage-empty').exists()).toBe(true);
});
it('renders Not enough data for the median value', () => {
expect(wrapper.find('.stage-median').text()).toEqual('Not enough data');
});
});
});
describe('is active', () => {
beforeEach(() => {
wrapper = createComponent({ isActive: true }, false);
});
afterEach(() => {
wrapper.destroy();
});
it('has the active class', () => {
expect(wrapper.find('.stage-nav-item').classes('active')).toBe(true);
});
});
describe('is not active', () => {
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('emits the `select` event when clicked', () => {
expect(wrapper.emitted().select).toBeUndefined();
wrapper.trigger('click');
expect(wrapper.emitted().select.length).toBe(1);
});
});
describe('User does not have access', () => {
beforeEach(() => {
wrapper = createComponent({ isUserAllowed: false }, false);
});
afterEach(() => {
wrapper.destroy();
});
it('renders stage name', () => {
hasStageName();
});
it('has class not-available', () => {
expect(wrapper.find('.stage-empty').exists()).toBe(false);
expect(wrapper.find('.not-available').exists()).toBe(true);
});
it('renders Not available for the median value', () => {
expect(wrapper.find('.stage-median').text()).toBe('Not available');
});
it('does not render options menu', () => {
expect(wrapper.find('.more-actions-toggle').exists()).toBe(false);
});
});
describe('User can edit stages', () => {
beforeEach(() => {
wrapper = createComponent({ canEdit: true, isUserAllowed: true }, false);
});
afterEach(() => {
wrapper.destroy();
});
it('renders stage name', () => {
hasStageName();
});
it('renders options menu', () => {
expect(wrapper.find('.more-actions-toggle').exists()).toBe(true);
});
describe('Default stages', () => {
beforeEach(() => {
wrapper = createComponent(
{ canEdit: true, isUserAllowed: true, isDefaultStage: true },
false,
);
});
it('can hide the stage', () => {
expect(wrapper.text()).toContain('Hide stage');
});
it('can not edit the stage', () => {
expect(wrapper.text()).not.toContain('Edit stage');
});
it('can not remove the stage', () => {
expect(wrapper.text()).not.toContain('Remove stage');
});
});
describe('Custom stages', () => {
beforeEach(() => {
wrapper = createComponent(
{ canEdit: true, isUserAllowed: true, isDefaultStage: false },
false,
);
});
it('can edit the stage', () => {
expect(wrapper.text()).toContain('Edit stage');
});
it('can remove the stage', () => {
expect(wrapper.text()).toContain('Remove stage');
});
it('can not hide the stage', () => {
expect(wrapper.text()).not.toContain('Hide stage');
});
});
});
});