Commit 224db2f8 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '13076-stage-card-ui-component-ce' into 'master'

Stage card ui component

See merge request gitlab-org/gitlab-ce!31580
parents 6f4350e4 5e8f16bd
<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>
<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>
...@@ -12,6 +12,7 @@ import stageComponent from './components/stage_component.vue'; ...@@ -12,6 +12,7 @@ import stageComponent from './components/stage_component.vue';
import stageReviewComponent from './components/stage_review_component.vue'; import stageReviewComponent from './components/stage_review_component.vue';
import stageStagingComponent from './components/stage_staging_component.vue'; import stageStagingComponent from './components/stage_staging_component.vue';
import stageTestComponent from './components/stage_test_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 CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store'; import CycleAnalyticsStore from './cycle_analytics_store';
...@@ -41,6 +42,7 @@ export default () => { ...@@ -41,6 +42,7 @@ export default () => {
import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'), import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'),
DateRangeDropdown: () => DateRangeDropdown: () =>
import('ee_component/analytics/shared/components/date_range_dropdown.vue'), import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
}, },
mixins: [filterMixins], mixins: [filterMixins],
data() { data() {
......
...@@ -51,27 +51,19 @@ ...@@ -51,27 +51,19 @@
} }
.stage-header { .stage-header {
width: 26%; width: 18.5%;
padding-left: $gl-padding;
} }
.median-header { .median-header {
width: 14%; width: 21.5%;
} }
.event-header { .event-header {
width: 45%; width: 45%;
padding-left: $gl-padding;
} }
.total-time-header { .total-time-header {
width: 15%; width: 15%;
text-align: right;
padding-right: $gl-padding;
}
.stage-name {
font-weight: $gl-font-weight-bold;
} }
} }
...@@ -153,23 +145,13 @@ ...@@ -153,23 +145,13 @@
} }
.stage-nav-item { .stage-nav-item {
display: flex;
line-height: 65px; line-height: 65px;
border-top: 1px solid transparent; border: 1px solid $border-color;
border-bottom: 1px solid transparent;
border-right: 1px solid $border-color;
background-color: $gray-light;
&.active { &.active {
background-color: transparent; background: $blue-50;
border-right-color: transparent; border-color: $blue-300;
border-top-color: $border-color; box-shadow: inset 4px 0 0 0 $blue-500;
border-bottom-color: $border-color;
box-shadow: inset 2px 0 0 0 $blue-500;
.stage-name {
font-weight: $gl-font-weight-bold;
}
} }
&:hover:not(.active) { &:hover:not(.active) {
...@@ -178,24 +160,12 @@ ...@@ -178,24 +160,12 @@
cursor: pointer; cursor: pointer;
} }
&:first-child { .stage-nav-item-cell.stage-name {
border-top: 0; 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 { .stage-nav-item-cell.stage-median {
padding-left: 16px; min-width: 43%;
} }
.stage-empty, .stage-empty,
......
...@@ -34,40 +34,29 @@ ...@@ -34,40 +34,29 @@
{{ n__('Last %d day', 'Last %d days', 90) }} {{ n__('Last %d day', 'Last %d days', 90) }}
.stage-panel-container .stage-panel-container
.card.stage-panel .card.stage-panel
.card-header .card-header.border-bottom-0
%nav.col-headers %nav.col-headers
%ul %ul
%li.stage-header %li.stage-header.pl-5
%span.stage-name %span.stage-name.font-weight-bold
{{ s__('ProjectLifecycle|Stage') }} {{ s__('ProjectLifecycle|Stage') }}
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" }
%li.median-header %li.median-header
%span.stage-name %span.stage-name.font-weight-bold
{{ __('Median') }} {{ __('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" } %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 %li.event-header.pl-3
%span.stage-name %span.stage-name.font-weight-bold
{{ currentStage ? __(currentStage.legend) : __('Related Issues') }} {{ 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" } %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 %li.total-time-header.pr-5.text-right
%span.stage-name %span.stage-name.font-weight-bold
{{ __('Total Time') }} {{ __('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" } %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 .stage-panel-body
%nav.stage-nav %nav.stage-nav
%ul %ul
%li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } %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" }
.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') }}
.section.stage-events .section.stage-events
%template{ "v-if" => "isLoadingStage" } %template{ "v-if" => "isLoadingStage" }
= icon("spinner spin") = icon("spinner spin")
......
...@@ -4144,6 +4144,9 @@ msgstr "" ...@@ -4144,6 +4144,9 @@ msgstr ""
msgid "Edit public deploy key" msgid "Edit public deploy key"
msgstr "" msgstr ""
msgid "Edit stage"
msgstr ""
msgid "Edit wiki page" msgid "Edit wiki page"
msgstr "" msgstr ""
...@@ -5658,6 +5661,9 @@ msgstr "" ...@@ -5658,6 +5661,9 @@ msgstr ""
msgid "Hide shared projects" msgid "Hide shared projects"
msgstr "" msgstr ""
msgid "Hide stage"
msgstr ""
msgid "Hide value" msgid "Hide value"
msgid_plural "Hide values" msgid_plural "Hide values"
msgstr[0] "" msgstr[0] ""
...@@ -9357,6 +9363,9 @@ msgstr "" ...@@ -9357,6 +9363,9 @@ msgstr ""
msgid "Remove spent time" msgid "Remove spent time"
msgstr "" msgstr ""
msgid "Remove stage"
msgstr ""
msgid "Remove time estimate" msgid "Remove time estimate"
msgstr "" msgstr ""
......
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');
});
});
});
});
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