Commit 26450fc5 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '323984-remove-legacy-stage-table-and-custom-form-code' into 'master'

Remove legacy VSA stage table components

See merge request gitlab-org/gitlab!61431
parents 0712cc22 3a665105
......@@ -124,7 +124,7 @@ How this works, behind the scenes:
etc.
To sum up, anything that doesn't follow [GitLab flow](../../../topics/gitlab_flow.md) is not tracked and the
Value Stream Analytics dashboard doesn not present any data for:
Value Stream Analytics dashboard does not present any data for:
- Merge requests that do not close an issue.
- Issues not labeled with a label present in the Issue Board or for issues not assigned a milestone.
......@@ -271,85 +271,6 @@ To see which items the stage most recently, sort by the work item column on the
The table displays up to 20 items at a time. If there are more than 20 items, you can use the
**Prev** and **Next** buttons to navigate through the pages.
### Adding a stage
In the following example we're creating a new stage that measures and tracks issues from creation
time until they are closed.
1. Navigate to your group's **Analytics > Value Stream**.
1. Click the **Add a stage** button.
1. Fill in the new stage form:
- Name: Issue start to finish.
- Start event: Issue created.
- End event: Issue closed.
1. Click the **Add stage** button.
![New Value Stream Analytics Stage](img/new_vsm_stage_v12_9.png "Form for creating a new stage")
The new stage is persisted and it will always show up on the Value Stream Analytics page for your
group.
If you want to alter or delete the stage, you can easily do that for customized stages by:
1. Hovering over the stage.
1. Clicking the vertical ellipsis (**{ellipsis_v}**) button that appears.
![Value Stream Analytics Stages](img/vsm_stage_list_v12_9.png)
Creating a custom stage requires specifying two events:
- A start.
- An end.
Be careful to choose a start event that occurs *before* your end event. For example, consider a
stage that:
- Started when an issue is added to a board.
- Ended when the issue is created.
This stage would not work because the end event has already happened when the start event occurs.
To prevent such invalid stages, the UI prohibits incompatible start and end events. After you select
the start event, the stop event dropdown will only list the compatible events.
### Re-ordering stages
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/196698) in GitLab 12.10.
Once a custom stage has been added, you can "drag and drop" stages to rearrange their order. These
changes are automatically saved to the system.
### Label based stages
The pre-defined start and end events can cover many use cases involving both issues and merge requests.
For supporting more complex workflows, use stages based on group labels. These events are based on
labels being added or removed. In particular, [scoped labels](../../project/labels.md#scoped-labels)
are useful for complex workflows.
In this example, we'd like to measure more accurate code review times. The workflow is the following:
- When the code review starts, the reviewer adds `workflow::code_review_start` label to the merge request.
- When the code review is finished, the reviewer adds `workflow::code_review_complete` label to the merge request.
Creating a new stage called "Code Review":
![New Label Based Value Stream Analytics Stage](img/label_based_stage_vsm_v12_9.png "Creating a label based Value Stream Analytics Stage")
### Hiding unused stages
Sometimes certain default stages are not relevant to a team. In this case, you can easily hide stages
so they no longer appear in the list. To hide stages:
1. Add a custom stage to activate customizability.
1. Hover over the default stage you want to hide.
1. Click the vertical ellipsis (**{ellipsis_v}**) button that appears and select **Hide stage**.
To recover a default stage that was previously hidden:
1. Click **Add a stage** button.
1. In the top right corner open the **Recover hidden stage** dropdown.
1. Select a stage.
### Creating a value stream
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/221202) in GitLab 13.3
......
......@@ -49,8 +49,6 @@ export default {
'featureFlags',
'isLoading',
'isLoadingStage',
// NOTE: we can remove the `isEmptyStage` field when we remove the existing stage table
'isEmptyStage',
'currentGroup',
'selectedProjects',
'selectedStage',
......
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import TotalTime from './total_time_component.vue';
export default {
components: {
UserAvatarImage,
TotalTime,
GlIcon,
GlLink,
},
props: {
events: {
type: Array,
required: true,
},
stage: {
type: Object,
required: true,
},
withBuildStatus: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<ul class="stage-event-list">
<li
v-for="({ id, author, url, branch, name, commitUrl, shortSha, date, totalTime }, i) in events"
:key="i"
class="stage-event-item item-build-component"
>
<div class="item-details">
<template v-if="!withBuildStatus">
<user-avatar-image :img-src="author.avatarUrl" />
</template>
<h5 class="item-title">
<template v-if="withBuildStatus">
<span class="icon-build-status gl-text-green-500">
<gl-icon name="status_success" :size="14" />
</span>
<gl-link :href="url" class="item-build-name">{{ name }}</gl-link> &middot;
</template>
<gl-link :href="url" class="pipeline-id">#{{ id }}</gl-link>
<gl-icon :size="16" name="fork" />
<gl-link v-if="branch" :href="branch.url" class="ref-name">{{ branch.name }}</gl-link>
<span class="icon-branch gl-text-gray-400">
<gl-icon name="commit" :size="14" />
</span>
<gl-link :href="commitUrl" class="commit-sha">{{ shortSha }}</gl-link>
</h5>
<span v-if="withBuildStatus">
<gl-link :href="url" class="issue-date">{{ date }}</gl-link>
</span>
<span v-else>
<gl-link :href="url" class="build-date">{{ date }}</gl-link>
{{ s__('ByAuthor|by') }}
<gl-link :href="author.webUrl" class="issue-author-link">{{ author.name }}</gl-link>
</span>
</div>
<div class="item-time">
<total-time :time="totalTime" />
</div>
</li>
</ul>
</template>
<script>
export default {
name: 'StageCardListItem',
props: {
isActive: {
type: Boolean,
required: true,
},
},
data() {
return {
activeClass: 'active font-weight-bold border-color-blue-300',
inactiveClass: 'bg-transparent border-color-default',
};
},
};
</script>
<template>
<div
:class="[isActive ? activeClass : inactiveClass]"
class="stage-nav-item d-flex px-4 m-0 mb-1 rounded border-width-1px border-style-solid"
>
<slot></slot>
</div>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import TotalTime from './total_time_component.vue';
export default {
components: {
GlLink,
UserAvatarImage,
TotalTime,
},
props: {
events: {
type: Array,
required: true,
},
stage: {
type: Object,
required: true,
},
},
methods: {
isMrLink(url = '') {
return url.includes('/merge_request');
},
},
};
</script>
<template>
<ul class="stage-event-list">
<li
v-for="({ iid, title, url, author, totalTime, createdAt }, i) in events"
:key="i"
class="stage-event-item"
>
<div class="item-details">
<user-avatar-image
:img-src="author.avatarUrl"
:alt="
sprintf(__('Merge request %{iid} authored by %{authorName}'), {
iid,
authorName: author.name,
})
"
/>
<h5 class="item-title issue-title">
<gl-link :href="url" class="issue-title">{{ title }}</gl-link>
</h5>
<template v-if="isMrLink(url)">
<gl-link :href="url" class="mr-link">!{{ iid }}</gl-link>
</template>
<template v-else>
<gl-link :href="url" class="issue-link">#{{ iid }}</gl-link>
</template>
&middot;
<span>
{{ s__('OpenedNDaysAgo|Opened') }}
<gl-link :href="url" class="issue-date">{{ createdAt }}</gl-link>
</span>
<span>
{{ s__('ByAuthor|by') }}
<gl-link :href="author.webUrl" class="issue-author-link">{{ author.name }}</gl-link>
</span>
</div>
<div class="item-time">
<total-time :time="totalTime" />
</div>
</li>
</ul>
</template>
<script>
import LimitWarning from './limit_warning_component.vue';
import StageBuildItem from './stage_build_item.vue';
import StageEventItem from './stage_event_item.vue';
export default {
name: 'StageEventList',
components: {
LimitWarning,
StageEventItem,
StageBuildItem,
},
props: {
stage: {
type: Object,
required: true,
},
events: {
type: Array,
required: true,
},
},
data() {
return {
STAGE_NAME_TEST: 'test',
STAGE_NAME_STAGING: 'staging',
};
},
methods: {
isCurrentStage(current, target) {
return current.toLowerCase() === target;
},
},
};
</script>
<template>
<div>
<div class="events-description">
{{ stage.description }}
<limit-warning :count="events.length" />
</div>
<stage-build-item
v-if="isCurrentStage(stage.title, STAGE_NAME_TEST)"
:stage="stage"
:events="events"
:with-build-status="true"
/>
<stage-build-item
v-else-if="isCurrentStage(stage.title, STAGE_NAME_STAGING)"
:stage="stage"
:events="events"
/>
<stage-event-item v-else :stage="stage" :events="events" />
</div>
</template>
<script>
import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { approximateDuration } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import StageCardListItem from './stage_card_list_item.vue';
const ERROR_MESSAGES = {
tooMuchData: __('There is too much data to calculate. Please change your selection.'),
};
const ERROR_NAV_ITEM_CONTENT = {
[ERROR_MESSAGES.tooMuchData]: __('Too much data'),
fallback: __('Not enough data'),
};
export default {
name: 'StageNavItem',
components: {
StageCardListItem,
GlIcon,
GlDropdown,
GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
isDefaultStage: {
type: Boolean,
default: false,
required: false,
},
isActive: {
type: Boolean,
default: false,
required: false,
},
title: {
type: String,
required: true,
},
value: {
type: Number,
default: 0,
required: false,
},
id: {
// The IDs of stages are strings until custom stages have been added.
// Only at this point the IDs become numbers, so we have to allow both.
type: [String, Number],
required: true,
},
errorMessage: {
type: String,
required: false,
default: '',
},
},
data() {
return {
isHover: false,
isTitleOverflowing: false,
};
},
computed: {
hasValue() {
return this.value;
},
median() {
return approximateDuration(this.value);
},
openMenuClasses() {
return this.isHover ? 'gl-display-flex gl-justify-content-end' : '';
},
error() {
return ERROR_NAV_ITEM_CONTENT[this.errorMessage] || ERROR_NAV_ITEM_CONTENT.fallback;
},
stageTitleTooltip() {
return this.isTitleOverflowing ? this.title : null;
},
},
mounted() {
this.checkIfTitleOverflows();
},
updated() {
this.checkIfTitleOverflows();
},
methods: {
handleDropdownAction(action) {
this.$emit(action);
},
handleSelectStage(e) {
// we don't want to emit the select event when we click the more actions dropdown
// But we should still trigger the event if we click anywhere else in the list item
if (this.$refs.dropdown && !this.$refs.dropdown.contains(e.target)) {
this.$emit('select');
}
},
handleHover(hoverState = false) {
this.isHover = hoverState;
},
checkIfTitleOverflows() {
const [titleEl] = this.$refs.title?.children;
if (titleEl) {
this.isTitleOverflowing = titleEl.scrollWidth > this.$refs.title.offsetWidth;
}
},
},
};
</script>
<template>
<li
:data-id="id"
@click="handleSelectStage"
@mouseover="handleHover(true)"
@mouseleave="handleHover()"
>
<stage-card-list-item :is-active="isActive" class="gl-display-flex gl-justify-space-between">
<div
ref="title"
class="stage-nav-item-cell stage-name text-truncate w-50 pr-2"
:class="{ 'font-weight-bold': isActive }"
>
<span v-gl-tooltip="{ title: stageTitleTooltip }" data-testid="stage-title">{{
title
}}</span>
</div>
<div class="stage-nav-item-cell w-50 gl-display-flex gl-justify-content-between">
<div ref="median" class="stage-median w-75 align-items-start">
<span v-if="hasValue">{{ median }}</span>
<span v-else v-gl-tooltip="{ title: errorMessage }" class="stage-empty">{{ error }}</span>
</div>
<div v-show="isHover" ref="dropdown" :class="[openMenuClasses]" class="dropdown w-25">
<gl-dropdown
toggle-class="gl-p-0! gl-bg-transparent! gl-shadow-none!"
data-testid="more-actions-toggle"
>
<template #button-content>
<!-- eslint-disable @gitlab/vue-no-data-toggle -->
<span
v-gl-tooltip
category="tertiary"
:title="__('More actions')"
data-toggle="dropdown"
>
<gl-icon name="ellipsis_v" />
</span>
<!-- eslint-enable @gitlab/vue-no-data-toggle -->
</template>
<template v-if="isDefaultStage">
<gl-dropdown-item
category="tertiary"
data-testid="hide-btn"
@click="handleDropdownAction('hide', $event)"
>
{{ __('Hide stage') }}
</gl-dropdown-item>
</template>
<template v-else>
<gl-dropdown-item
category="tertiary"
data-testid="edit-btn"
@click="handleDropdownAction('edit', $event)"
>
{{ __('Edit stage') }}
</gl-dropdown-item>
<gl-dropdown-item
category="tertiary"
data-testid="remove-btn"
@click="handleDropdownAction('remove', $event)"
>
{{ __('Remove stage') }}
</gl-dropdown-item>
</template>
</gl-dropdown>
</div>
</div>
</stage-card-list-item>
</li>
</template>
<script>
import { GlTooltipDirective, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import StageEventList from './stage_event_list.vue';
import StageTableHeader from './stage_table_header.vue';
const MIN_TABLE_HEIGHT = 420;
const NOT_ENOUGH_DATA_ERROR = s__(
"ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
);
export default {
name: 'StageTable',
components: {
GlLoadingIcon,
GlEmptyState,
StageEventList,
StageTableHeader,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
currentStage: {
type: Object,
required: false,
default: () => {},
},
isLoading: {
type: Boolean,
required: true,
},
isEmptyStage: {
type: Boolean,
required: true,
},
isLoadingStage: {
type: Boolean,
required: true,
},
customStageFormActive: {
type: Boolean,
required: true,
},
currentStageEvents: {
type: Array,
required: true,
},
noDataSvgPath: {
type: String,
required: true,
},
emptyStateMessage: {
type: String,
required: false,
default: '',
},
hasPathNavigation: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
stageNavHeight: MIN_TABLE_HEIGHT,
};
},
computed: {
stageEventsHeight() {
return `${this.stageNavHeight}px`;
},
stageName() {
return this.currentStage?.title || __('Related Issues');
},
shouldDisplayStage() {
const { currentStageEvents = [], isLoading, isEmptyStage } = this;
return currentStageEvents.length && !isLoading && !isEmptyStage;
},
stageHeaders() {
const verticalNavHeaders = !this.hasPathNavigation
? [
{
title: s__('ProjectLifecycle|Stage'),
description: __('The phase of the development lifecycle.'),
classes: 'stage-header pl-5',
},
{
title: __('Median'),
description: __(
'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.',
),
classes: 'median-header',
},
]
: [];
return [
...verticalNavHeaders,
{
title: this.stageName,
description: __('The collection of events added to the data gathered for that stage.'),
classes: !this.hasPathNavigation
? 'event-header pl-3'
: 'event-header gl-align-items-flex-start! gl-w-half!',
displayHeader: !this.customStageFormActive,
},
{
title: __('Time'),
description: __('The time taken by each data entry gathered by that stage.'),
classes: !this.hasPathNavigation
? 'total-time-header pr-5 text-right'
: 'total-time-header gl-align-items-flex-end! gl-text-right! gl-w-half!',
displayHeader: !this.customStageFormActive,
},
];
},
emptyStateTitle() {
const { emptyStateMessage } = this;
return emptyStateMessage.length ? emptyStateMessage : NOT_ENOUGH_DATA_ERROR;
},
},
updated() {
if (!this.isLoading && this.$refs.stageNav) {
this.$set(this, 'stageNavHeight', this.$refs.stageNav.clientHeight);
}
},
};
</script>
<template>
<div class="stage-panel-container" data-testid="vsa-stage-table">
<div
v-if="isLoading"
class="gl-display-flex gl-justify-content-center gl-align-items-center gl-w-full"
:style="{ height: stageEventsHeight }"
>
<gl-loading-icon size="lg" />
</div>
<div v-else class="card stage-panel">
<div class="card-header gl-border-b-0">
<nav class="col-headers">
<ul
:class="{
'gl-display-flex! gl-justify-content-space-between! gl-flex-direction-row! gl-px-5!': hasPathNavigation,
}"
>
<stage-table-header
v-for="({ title, description, classes, displayHeader = true }, i) in stageHeaders"
v-show="displayHeader"
:key="`stage-header-${i}`"
:header-classes="classes"
:title="title"
:tooltip-title="description"
/>
</ul>
</nav>
</div>
<div class="stage-panel-body">
<nav v-if="!hasPathNavigation" ref="stageNav" class="stage-nav gl-pl-2">
<slot name="nav"></slot>
</nav>
<div
class="section stage-events overflow-auto"
:class="{ 'w-100': hasPathNavigation }"
:style="{ height: stageEventsHeight }"
>
<slot name="content">
<gl-loading-icon v-if="isLoadingStage" class="gl-mt-4" size="md" />
<template v-else>
<stage-event-list
v-if="shouldDisplayStage"
:stage="currentStage"
:events="currentStageEvents"
/>
<gl-empty-state
v-if="isEmptyStage"
:title="emptyStateTitle"
:description="currentStage.emptyStageText"
:svg-path="noDataSvgPath"
/>
</template>
</slot>
</div>
</div>
</div>
</div>
</template>
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
export default {
name: 'StageTableHeader',
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
headerClasses: {
type: String,
required: false,
default: '',
},
title: {
type: String,
required: true,
},
tooltipTitle: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<li :class="headerClasses">
<span class="stage-name gl-font-weight-bold">{{ title }}</span>
<gl-icon
v-gl-tooltip
class="gl-vertical-align-middle"
:size="14"
name="question"
container=".stage-name"
:title="tooltipTitle"
/>
</li>
</template>
<script>
import Sortable from 'sortablejs';
import { NO_DRAG_CLASS } from '../../shared/constants';
import sortableDefaultOptions from '../../shared/mixins/sortable_default_options';
import { STAGE_ACTIONS } from '../constants';
import AddStageButton from './add_stage_button.vue';
import StageNavItem from './stage_nav_item.vue';
export default {
name: 'StageTableNav',
components: {
AddStageButton,
StageNavItem,
},
props: {
currentStage: {
type: Object,
required: false,
default: () => {},
},
medians: {
type: Object,
required: true,
},
stages: {
type: Array,
required: true,
},
isCreatingCustomStage: {
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 = {
...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]?.value || null;
},
isActiveStage(stageId) {
const { currentStage, isCreatingCustomStage } = this;
return Boolean(!isCreatingCustomStage && currentStage && stageId === currentStage.id);
},
medianError(id) {
return this.medians[id]?.error || '';
},
},
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="isActiveStage(stage.id)"
:is-default-stage="!stage.custom"
:error-message="medianError(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
:class="$options.noDragClass"
:active="isCreatingCustomStage"
@showform="$emit($options.STAGE_ACTIONS.ADD_STAGE)"
/>
</ul>
</template>
......@@ -31,19 +31,16 @@ export default {
},
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
state.isEmptyStage = false;
state.selectedStageError = '';
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
state.currentStageEvents = events.map((fields) =>
convertObjectPropsToCamelCase(fields, { deep: true }),
);
state.isEmptyStage = !events.length;
state.isLoadingStage = false;
state.selectedStageError = '';
},
[types.RECEIVE_STAGE_DATA_ERROR](state, message) {
state.isEmptyStage = true;
state.isLoadingStage = false;
state.selectedStageError = message;
},
......
......@@ -10,7 +10,6 @@ export default () => ({
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
errorCode: null,
isSavingStageOrder: false,
......
import { shallowMount, mount } from '@vue/test-utils';
import StageBuildItem from 'ee/analytics/cycle_analytics/components/stage_build_item.vue';
import { renderTotalTime } from '../helpers';
import { stagingStage as stage, stagingEvents as events } from '../mock_data';
function createComponent(props = {}, shallow = true) {
const func = shallow ? shallowMount : mount;
return func(StageBuildItem, {
propsData: {
stage,
events,
...props,
},
});
}
const $sel = {
item: '.stage-event-item',
description: '.events-description',
issueDate: '.issue-date',
author: '.issue-author-link',
time: '.item-time',
commit: '.commit-sha',
branch: '.ref-name',
mrBranch: '.merge-request-branch',
pipeline: '.pipeline-id',
buildName: '.item-build-name',
buildDate: '.build-date',
avatar: '.avatar',
};
describe('StageBuildItem', () => {
let wrapper = null;
beforeEach(() => {
wrapper = createComponent({}, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the events list', () => {
const items = wrapper.findAll($sel.item);
expect(items.length > 0).toBe(true);
expect(items).toHaveLength(events.length);
});
it('will render the build pipeline id', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.pipeline).text()).toEqual(`#${item.id}`);
});
});
it('will render the branch', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.branch).text()).toEqual(item.branch.name);
expect(elem.find($sel.branch).attributes('href')).toEqual(item.branch.url);
});
});
it('will render the commit sha of the event', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.commit).text()).toEqual(item.shortSha);
});
});
it('will render the total time', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
renderTotalTime($sel.time, elem, item.totalTime);
});
});
describe('withBuildStatus = false', () => {
beforeEach(() => {
wrapper = createComponent({ withBuildStatus: false }, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the build date', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.buildDate).at(index);
expect(elem.find($sel.buildDate).text()).toEqual(item.date);
});
});
it('will render the authors avatar', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.avatar).exists()).toEqual(true);
expect(elem.find($sel.avatar).attributes('src')).toContain(item.author.avatarUrl);
});
});
it('will render a link to the author', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.author).text()).toEqual(item.author.name);
expect(elem.find($sel.author).attributes('href')).toEqual(item.author.webUrl);
});
});
});
describe('withBuildStatus = true', () => {
beforeEach(() => {
wrapper = createComponent({ withBuildStatus: true }, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the build pipeline id', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.buildName).text()).toContain(item.name);
expect(elem.find('.icon-build-status').exists()).toBe(true);
});
});
it('will render the issue created date', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.issueDate).text()).toEqual(item.date);
});
});
});
});
import { shallowMount, mount } from '@vue/test-utils';
import StageEventItem from 'ee/analytics/cycle_analytics/components/stage_event_item.vue';
import { renderTotalTime } from '../helpers';
import { issueStage as stage, issueEvents as events } from '../mock_data';
function createComponent(props = {}, shallow = true) {
const func = shallow ? shallowMount : mount;
return func(StageEventItem, {
propsData: {
stage,
events,
...props,
},
});
}
const $sel = {
item: '.stage-event-item',
title: '.item-title',
issueLink: '.issue-link',
issueDate: '.issue-date',
author: '.issue-author-link',
avatar: '.avatar',
time: '.item-time',
};
describe('StageEventItem', () => {
let wrapper = null;
beforeEach(() => {
wrapper = createComponent({}, false);
});
afterEach(() => {
wrapper.destroy();
});
it('will render the events list', () => {
const items = wrapper.findAll($sel.item);
expect(items.length > 0).toBe(true);
expect(items).toHaveLength(events.length);
});
it('will render the title of each event', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.title);
});
});
it('will render the issue link', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.issueLink).text()).toEqual(`#${item.iid}`);
});
});
it('will render the total time', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
renderTotalTime($sel.time, elem, item.totalTime);
});
});
it('will render the issue created date', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.issueDate).text()).toEqual(item.createdAt);
});
});
it('will render a link to the author', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.author).text()).toEqual(item.author.name);
expect(elem.find($sel.author).attributes('href')).toEqual(item.author.webUrl);
});
});
it('will render the authors avatar', () => {
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.avatar).exists()).toEqual(true);
expect(elem.find($sel.avatar).attributes('src')).toContain(item.author.avatarUrl);
});
});
});
import { shallowMount, mount } from '@vue/test-utils';
import StageBuildItem from 'ee/analytics/cycle_analytics/components/stage_build_item.vue';
import StageEventItem from 'ee/analytics/cycle_analytics/components/stage_event_item.vue';
import StageEventList from 'ee/analytics/cycle_analytics/components/stage_event_list.vue';
import {
issueStage,
issueEvents,
planStage,
planEvents,
reviewStage,
reviewEvents,
testStage,
testEvents,
stagingStage,
stagingEvents,
codeStage,
codeEvents,
} from '../mock_data';
const generateEvents = (n) =>
Array(n)
.fill(issueEvents[0])
.map((ev, k) => ({ ...ev, title: `event-${k}`, id: k }));
const bulkEvents = generateEvents(50);
const mockStubs = {
'stage-event-item': true,
'stage-build-item': true,
};
function createComponent({ props = {}, shallow = false, Component = StageEventList, stubs }) {
const func = shallow ? shallowMount : mount;
return func(Component, {
propsData: {
stage: issueStage,
events: issueEvents,
...props,
},
stubs,
});
}
const $sel = {
item: '.stage-event-item',
description: '.events-description',
title: '.item-title',
eventsInfo: '.events-info',
};
describe('Stage', () => {
let wrapper = null;
describe('With too many events', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
events: bulkEvents,
},
stubs: mockStubs,
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the limit warning', () => {
const desc = wrapper.find($sel.description);
expect(desc.find($sel.eventsInfo).exists()).toBe(true);
});
it('will render the limit warning message ', () => {
const desc = wrapper.find($sel.description);
expect(desc.find($sel.eventsInfo).text()).toContain('Showing 50 events');
});
});
describe('Default stages', () => {
it.each`
name | stage
${'Issue'} | ${issueStage}
${'Plan'} | ${planStage}
${'Review'} | ${reviewStage}
${'Test'} | ${testStage}
${'Code'} | ${codeStage}
${'Staging'} | ${stagingStage}
`('$name stage will render the stage description', ({ stage }) => {
wrapper = createComponent({ props: { stage, events: [] } });
expect(wrapper.find($sel.description).text()).toEqual(stage.description);
});
it.each`
name | stage | eventList
${'Issue'} | ${issueStage} | ${issueEvents}
${'Plan'} | ${planStage} | ${planEvents}
${'Review'} | ${reviewStage} | ${reviewEvents}
${'Code'} | ${codeStage} | ${codeEvents}
`('$name stage will render the list of events', ({ stage, eventList }) => {
// stages generated from fixtures may not have events
const events = eventList.length ? eventList : generateEvents(5);
wrapper = createComponent({
props: { stage, events },
});
events.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.title);
});
});
it.each`
name | stage | eventList
${'Issue'} | ${issueStage} | ${issueEvents}
${'Plan'} | ${planStage} | ${planEvents}
${'Review'} | ${reviewStage} | ${reviewEvents}
${'Code'} | ${codeStage} | ${codeEvents}
`('$name stage will render the items as StageEventItems', ({ stage, eventList }) => {
wrapper = createComponent({ props: { events: eventList, stage }, stubs: mockStubs });
expect(wrapper.find(StageEventItem).exists()).toBe(true);
});
it.each`
name | stage | eventList
${'Test'} | ${testStage} | ${testEvents}
${'Staging'} | ${stagingStage} | ${stagingEvents}
`('$name stage will render the items as StageBuildItems', ({ stage, eventList }) => {
wrapper = createComponent({ props: { events: eventList, stage }, stubs: mockStubs });
expect(wrapper.find(StageBuildItem).exists()).toBe(true);
});
describe('Test stage', () => {
beforeEach(() => {
wrapper = createComponent({
props: { stage: testStage, events: testEvents },
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the list of events', () => {
testEvents.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
expect(elem.find($sel.title).text()).toContain(item.name);
});
});
});
describe('Staging stage', () => {
beforeEach(() => {
wrapper = createComponent({
props: { stage: stagingStage, events: stagingEvents },
});
});
afterEach(() => {
wrapper.destroy();
});
it('will render the list of events', () => {
stagingEvents.forEach((item, index) => {
const elem = wrapper.findAll($sel.item).at(index);
const title = elem.find($sel.title).text();
expect(title).toContain(item.id);
expect(title).toContain(item.branch.name);
expect(title).toContain(item.shortSha);
});
});
});
});
});
// NOTE: more tests will be added in https://gitlab.com/gitlab-org/gitlab/issues/121613
import { shallowMount } from '@vue/test-utils';
import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { approximateDuration } from '~/lib/utils/datetime_utility';
describe('StageNavItem', () => {
const title = 'Rad stage';
const median = 50;
const id = 1;
function createComponent({ props = {}, opts = {} } = {}) {
return shallowMount(StageNavItem, {
propsData: {
id,
title,
value: median,
...props,
},
directives: {
GlTooltip: createMockDirective(),
},
...opts,
});
}
let wrapper = null;
const findStageTitle = () => wrapper.find('[data-testid="stage-title"]');
const findStageTooltip = () => getBinding(findStageTitle().element, 'gl-tooltip');
const findStageMedian = () => wrapper.find({ ref: 'median' });
const findDropdown = () => wrapper.find({ ref: 'dropdown' });
const setFakeTitleWidth = (value) =>
Object.defineProperty(findStageTitle().element, 'scrollWidth', {
value,
});
afterEach(() => {
wrapper.destroy();
});
it('with no median value', () => {
wrapper = createComponent({ props: { value: null } });
expect(findStageMedian().text()).toEqual('Not enough data');
});
describe('with data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the median value', () => {
expect(findStageMedian().text()).toEqual(approximateDuration(median));
});
it('renders the stage title', () => {
expect(findStageTitle().text()).toEqual(title);
});
it('renders the stage title without a tooltip', () => {
const tt = findStageTooltip();
expect(tt.value.title).toBeNull();
});
it('renders the dropdown with edit and remove options', () => {
expect(findDropdown().exists()).toBe(true);
expect(wrapper.find('[data-testid="edit-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="remove-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="hide-btn"]').exists()).toBe(false);
});
});
describe('with data an a non-default state', () => {
beforeEach(() => {
wrapper = createComponent({ props: { isDefaultStage: true } });
});
it('renders the dropdown with a hide option', () => {
expect(findDropdown().exists()).toBe(true);
expect(wrapper.find('[data-testid="hide-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="edit-btn"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="remove-btn"]').exists()).toBe(false);
});
});
describe('with a really long name', () => {
const longTitle = 'This is a very long stage name that is intended to break the ui';
beforeEach(() => {
wrapper = createComponent({
props: { title: longTitle },
opts: {
data() {
return { isTitleOverflowing: true };
},
},
});
// JSDom does not calculate scrollWidth / offsetWidth so we fake it
setFakeTitleWidth(1000);
wrapper.vm.$forceUpdate();
return wrapper.vm.$nextTick();
});
it('renders the tooltip', () => {
const tt = findStageTooltip();
expect(tt.value).toBeDefined();
expect(tt.value.title).toBe(longTitle);
});
});
});
import { mount, shallowMount } from '@vue/test-utils';
import AddStageButton from 'ee/analytics/cycle_analytics/components/add_stage_button.vue';
import StageNavItem from 'ee/analytics/cycle_analytics/components/stage_nav_item.vue';
import StageTableNav from 'ee/analytics/cycle_analytics/components/stage_table_nav.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,
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('Add stage button', () => {
it('will render', () => {
wrapper = createComponent();
expect(wrapper.find(AddStageButton).exists()).toBe(true);
});
it('will emit showAddStageForm action when clicked', () => {
wrapper = createComponent({ mountFn: mount });
wrapper.find(AddStageButton).trigger('click');
expect(wrapper.emitted('showAddStageForm')).toHaveLength(1);
});
});
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 { GlLoadingIcon } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
import { shallowMount, mount } from '@vue/test-utils';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import { issueEvents, issueStage, allowedStages } from '../mock_data';
let wrapper = null;
const $sel = {
nav: '.stage-nav',
eventList: '.stage-events',
events: '.stage-event-item',
description: '.events-description',
headers: '.col-headers li',
headersList: '.col-headers',
illustration: '.empty-state .svg-content',
};
const headers = ['Stage', 'Median', issueStage.legend, 'Time'];
const noDataSvgPath = 'path/to/no/data';
const tooMuchDataError = "We don't have enough data to show this stage.";
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: {
currentStage: issueStage,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
currentStageEvents: issueEvents,
noDataSvgPath,
customStageFormActive: false,
...props,
},
slots: {
nav: StageTableNavSlot,
},
mocks: {
stages: allowedStages,
},
stubs: {
'stage-nav-item': true,
'gl-loading-icon': true,
},
});
}
describe('StageTable', () => {
afterEach(() => {
wrapper.destroy();
});
describe('headers', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('will render the headers', () => {
const renderedHeaders = wrapper.findAll($sel.headers);
expect(renderedHeaders).toHaveLength(headers.length);
const headerText = wrapper.find($sel.headersList).text();
headers.forEach((title) => {
expect(headerText).toContain(title);
});
});
});
describe('is loaded with data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('will render the events list', () => {
expect(wrapper.find($sel.eventList).exists()).toBeTruthy();
});
it('will render the correct stages', () => {
const evs = wrapper.find(StageTableNavSlot).findAll('li');
expect(evs).toHaveLength(allowedStages.length);
const nav = wrapper.find($sel.nav).html();
allowedStages.forEach((stage) => {
expect(nav).toContain(stage.title);
});
});
it('will render the current stage', () => {
expect(wrapper.find($sel.description).exists()).toBeTruthy();
expect(wrapper.find($sel.description).text()).toEqual(issueStage.description);
});
it('will render the event list', () => {
expect(wrapper.find($sel.eventList).exists()).toBeTruthy();
expect(wrapper.findAll($sel.events).exists()).toBeTruthy();
});
it('will render the correct events', () => {
const evs = wrapper.findAll($sel.events);
expect(evs).toHaveLength(issueEvents.length);
const evshtml = wrapper.find($sel.eventList).html();
issueEvents.forEach((ev) => {
expect(evshtml).toContain(ev.title);
});
});
it('will not display the default data message', () => {
expect(wrapper.html()).not.toContain(tooMuchDataError);
});
});
it('isLoading = true', () => {
wrapper = createComponent({ isLoading: true }, true);
expect(wrapper.find(GlLoadingIcon).exists()).toEqual(true);
});
describe('isLoadingStage = true', () => {
beforeEach(() => {
wrapper = createComponent({ isLoadingStage: true }, true);
});
it('will render the list of stages', () => {
const navEl = wrapper.find($sel.nav).element;
allowedStages.forEach((stage) => {
expect(getByText(navEl, stage.title, { selector: 'li' })).not.toBe(null);
});
});
it('will render a loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toEqual(true);
});
});
describe('isEmptyStage = true', () => {
beforeEach(() => {
wrapper = createComponent({ isEmptyStage: true });
});
it('will render the empty stage illustration', () => {
expect(wrapper.find($sel.illustration).exists()).toBeTruthy();
expect(wrapper.find($sel.illustration).html()).toContain(noDataSvgPath);
});
it('will display the default no data message', () => {
expect(wrapper.html()).toContain(tooMuchDataError);
});
});
describe('emptyStateMessage set', () => {
const emptyStateMessage = 'Too much data';
beforeEach(() => {
wrapper = createComponent({ isEmptyStage: true, emptyStateMessage });
});
it('will not display the default data message', () => {
expect(wrapper.html()).not.toContain(tooMuchDataError);
});
it('will display the custom message', () => {
expect(wrapper.html()).toContain(emptyStateMessage);
});
});
});
......@@ -37,7 +37,6 @@ describe('Value Stream Analytics mutations', () => {
${types.REQUEST_VALUE_STREAMS} | ${'isLoadingValueStreams'} | ${true}
${types.RECEIVE_VALUE_STREAMS_ERROR} | ${'isLoadingValueStreams'} | ${false}
${types.REQUEST_STAGE_DATA} | ${'isLoadingStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isLoadingStage'} | ${false}
${types.REQUEST_VALUE_STREAM_DATA} | ${'isLoading'} | ${true}
${types.RECEIVE_GROUP_STAGES_ERROR} | ${'stages'} | ${[]}
......
......@@ -16318,9 +16318,6 @@ msgstr ""
msgid "Hide shared projects"
msgstr ""
msgid "Hide stage"
msgstr ""
msgid "Hide value"
msgid_plural "Hide values"
msgstr[0] ""
......@@ -20529,9 +20526,6 @@ msgstr ""
msgid "Merge request"
msgstr ""
msgid "Merge request %{iid} authored by %{authorName}"
msgstr ""
msgid "Merge request %{mr_link} was reviewed by %{mr_author}"
msgstr ""
......@@ -27235,9 +27229,6 @@ msgstr ""
msgid "Remove spent time"
msgstr ""
msgid "Remove stage"
msgstr ""
msgid "Remove time estimate"
msgstr ""
......@@ -34067,9 +34058,6 @@ msgstr ""
msgid "Too many projects enabled. You will need to manage them via the console or the API."
msgstr ""
msgid "Too much data"
msgstr ""
msgid "Topics (optional)"
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