Commit 6de091d8 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch '326704-migrate-project-vsa-stage-table' into 'master'

Replace legacy stage table with updated stage table

See merge request gitlab-org/gitlab!66189
parents a05f26bf e6242d00
<script>
import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import { __ } from '~/locale';
import banner from './banner.vue';
import stageCodeComponent from './stage_code_component.vue';
import stageComponent from './stage_component.vue';
import stageNavItem from './stage_nav_item.vue';
import stageReviewComponent from './stage_review_component.vue';
import stageStagingComponent from './stage_staging_component.vue';
import stageTestComponent from './stage_test_component.vue';
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
......@@ -18,19 +12,10 @@ export default {
name: 'CycleAnalytics',
components: {
GlIcon,
GlEmptyState,
GlLoadingIcon,
GlSprintf,
banner,
'stage-issue-component': stageComponent,
'stage-plan-component': stageComponent,
'stage-code-component': stageCodeComponent,
'stage-test-component': stageTestComponent,
'stage-review-component': stageReviewComponent,
'stage-staging-component': stageStagingComponent,
'stage-production-component': stageComponent,
'stage-nav-item': stageNavItem,
PathNavigation,
StageTable,
},
props: {
noDataSvgPath: {
......@@ -75,12 +60,20 @@ export default {
return !this.isLoadingStage && this.selectedStage;
},
emptyStageTitle() {
if (this.displayNoAccess) {
return __('You need permission.');
}
return this.selectedStageError
? this.selectedStageError
: __("We don't have enough data to show this stage.");
},
emptyStageText() {
return !this.selectedStageError ? this.selectedStage.emptyStageText : '';
if (this.displayNoAccess) {
return __('Want to see the data? Please ask an administrator for access.');
}
return !this.selectedStageError && this.selectedStage?.emptyStageText
? this.selectedStage?.emptyStageText
: '';
},
},
methods: {
......@@ -160,72 +153,16 @@ export default {
</div>
</div>
</div>
<div class="stage-panel-container" data-testid="vsa-stage-table">
<div class="card stage-panel gl-px-5">
<div class="card-header border-bottom-0">
<nav class="col-headers">
<ul class="gl-display-flex gl-justify-content-space-between gl-list-style-none">
<li>
<span v-if="selectedStage" class="stage-name font-weight-bold">{{
selectedStage.legend ? __(selectedStage.legend) : __('Related Issues')
}}</span>
<span
class="has-tooltip"
data-placement="top"
:title="
__('The collection of events added to the data gathered for that stage.')
"
aria-hidden="true"
>
<gl-icon name="question-o" class="gl-text-gray-500" />
</span>
</li>
<li>
<span class="stage-name font-weight-bold">{{ __('Time') }}</span>
<span
class="has-tooltip"
data-placement="top"
:title="__('The time taken by each data entry gathered by that stage.')"
aria-hidden="true"
>
<gl-icon name="question-o" class="gl-text-gray-500" />
</span>
</li>
</ul>
</nav>
</div>
<div class="stage-panel-body">
<section class="stage-events gl-overflow-auto gl-w-full">
<gl-loading-icon v-if="isLoadingStage" size="lg" />
<template v-else>
<gl-empty-state
v-if="displayNoAccess"
class="js-empty-state"
:title="__('You need permission.')"
:svg-path="noAccessSvgPath"
:description="__('Want to see the data? Please ask an administrator for access.')"
/>
<template v-else>
<gl-empty-state
v-if="displayNotEnoughData"
class="js-empty-state"
:description="emptyStageText"
:svg-path="noDataSvgPath"
:title="emptyStageTitle"
/>
<component
:is="selectedStage.component"
v-if="displayStageEvents"
:stage="selectedStage"
:items="selectedStageEvents"
data-testid="stage-table-events"
/>
</template>
</template>
</section>
</div>
</div>
</div>
<stage-table
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
:stage-count="null"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
/>
</div>
</div>
</template>
......@@ -17,6 +17,8 @@ import {
PAGINATION_SORT_FIELD_DURATION,
PAGINATION_SORT_DIRECTION_ASC,
PAGINATION_SORT_DIRECTION_DESC,
STAGE_TITLE_STAGING,
STAGE_TITLE_TEST,
} from '../constants';
import TotalTime from './total_time_component.vue';
......@@ -49,7 +51,8 @@ export default {
props: {
selectedStage: {
type: Object,
required: true,
required: false,
default: () => ({ custom: false }),
},
isLoading: {
type: Boolean,
......@@ -68,6 +71,11 @@ export default {
type: String,
required: true,
},
emptyStateTitle: {
type: String,
required: false,
default: null,
},
emptyStateMessage: {
type: String,
required: false,
......@@ -75,33 +83,41 @@ export default {
},
pagination: {
type: Object,
required: true,
required: false,
default: null,
},
},
data() {
const {
pagination: { sort, direction },
} = this;
return {
sort,
sortDesc: direction === PAGINATION_SORT_DIRECTION_DESC,
};
if (this.pagination) {
const {
pagination: { sort, direction },
} = this;
return {
sort,
direction,
sortDesc: direction === PAGINATION_SORT_DIRECTION_DESC,
};
}
return { sort: null, direction: null, sortDesc: null };
},
computed: {
isEmptyStage() {
return !this.stageEvents.length;
},
emptyStateTitle() {
const { emptyStateMessage } = this;
return emptyStateMessage || NOT_ENOUGH_DATA_ERROR;
emptyStateTitleText() {
return this.emptyStateTitle || NOT_ENOUGH_DATA_ERROR;
},
isDefaultTestStage() {
const { selectedStage } = this;
return !selectedStage.custom && selectedStage.title?.toLowerCase().trim() === 'test';
return (
!selectedStage.custom && selectedStage.title?.toLowerCase().trim() === STAGE_TITLE_TEST
);
},
isDefaultStagingStage() {
const { selectedStage } = this;
return !selectedStage.custom && selectedStage.title?.toLowerCase().trim() === 'staging';
return (
!selectedStage.custom && selectedStage.title?.toLowerCase().trim() === STAGE_TITLE_STAGING
);
},
isMergeRequestStage() {
const [firstEvent] = this.stageEvents;
......@@ -139,6 +155,9 @@ export default {
isMrLink(url = '') {
return url.includes('/merge_request');
},
itemId({ url, iid }) {
return this.isMrLink(url) ? `!${iid}` : `#${iid}`;
},
itemTitle(item) {
return item.title || item.name;
},
......@@ -160,7 +179,12 @@ export default {
<template>
<div data-testid="vsa-stage-table">
<gl-loading-icon v-if="isLoading" class="gl-mt-4" size="md" />
<gl-empty-state v-else-if="isEmptyStage" :title="emptyStateTitle" :svg-path="noDataSvgPath" />
<gl-empty-state
v-else-if="isEmptyStage"
:title="emptyStateTitleText"
:description="emptyStateMessage"
:svg-path="noDataSvgPath"
/>
<gl-table
v-else
head-variant="white"
......@@ -168,18 +192,18 @@ export default {
thead-class="border-bottom"
show-empty
:sort-by.sync="sort"
:sort-direction.sync="pagination.direction"
:sort-direction.sync="direction"
:sort-desc.sync="sortDesc"
:fields="fields"
:items="stageEvents"
:empty-text="emptyStateMessage"
@sort-changed="onSort"
>
<template #head(end_event)="data">
<template v-if="stageCount" #head(end_event)="data">
<span>{{ data.label }}</span
><gl-badge class="gl-ml-2" size="sm">
<formatted-stage-count :stage-count="stageCount" />
</gl-badge>
><gl-badge class="gl-ml-2" size="sm"
><formatted-stage-count :stage-count="stageCount"
/></gl-badge>
</template>
<template #cell(end_event)="{ item }">
<div data-testid="vsa-stage-event">
......@@ -245,12 +269,7 @@ export default {
<gl-link class="gl-text-black-normal" :href="item.url">{{ itemTitle(item) }}</gl-link>
</h5>
<p class="gl-m-0">
<template v-if="isMrLink(item.url)">
<gl-link class="gl-text-black-normal" :href="item.url">!{{ item.iid }}</gl-link>
</template>
<template v-else>
<gl-link class="gl-text-black-normal" :href="item.url">#{{ item.iid }}</gl-link>
</template>
<gl-link class="gl-text-black-normal" :href="item.url">{{ itemId(item) }}</gl-link>
<span class="gl-font-lg">&middot;</span>
<span data-testid="vsa-stage-event-date">
{{ s__('OpenedNDaysAgo|Opened') }}
......@@ -273,7 +292,7 @@ export default {
</template>
</gl-table>
<gl-pagination
v-if="!isLoading && !isEmptyStage"
v-if="pagination && !isLoading && !isEmptyStage"
:value="pagination.page"
:prev-page="prevPage"
:next-page="nextPage"
......
<script>
import { n__, s__ } from '~/locale';
export default {
props: {
time: {
......@@ -11,24 +13,48 @@ export default {
hasData() {
return Object.keys(this.time).length;
},
calculatedTime() {
const {
time: { days = null, mins = null, hours = null, seconds = null },
} = this;
if (days) {
return {
duration: days,
units: n__('day', 'days', days),
};
}
if (hours) {
return {
duration: hours,
units: n__('Time|hr', 'Time|hrs', hours),
};
}
if (mins && !days) {
return {
duration: mins,
units: n__('Time|min', 'Time|mins', mins),
};
}
if ((seconds && this.hasData === 1) || seconds === 0) {
return {
duration: seconds,
units: s__('Time|s'),
};
}
return { duration: null, units: null };
},
},
};
</script>
<template>
<span class="total-time">
<template v-if="hasData">
<template v-if="time.days">
{{ time.days }} <span> {{ n__('day', 'days', time.days) }} </span>
</template>
<template v-if="time.hours">
{{ time.hours }} <span> {{ n__('Time|hr', 'Time|hrs', time.hours) }} </span>
</template>
<template v-if="time.mins && !time.days">
{{ time.mins }} <span> {{ n__('Time|min', 'Time|mins', time.mins) }} </span>
</template>
<template v-if="(time.seconds && hasData === 1) || time.seconds === 0">
{{ time.seconds }} <span> {{ s__('Time|s') }} </span>
</template>
{{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span>
</template>
<template v-else> -- </template>
</span>
......
import { s__ } from '~/locale';
export const DEFAULT_DAYS_IN_PAST = 30;
export const DEFAULT_DAYS_TO_DISPLAY = 30;
export const OVERVIEW_STAGE_ID = 'overview';
......@@ -7,3 +9,16 @@ export const DEFAULT_VALUE_STREAM = {
slug: 'default',
name: 'default',
};
export const NOT_ENOUGH_DATA_ERROR = s__(
"ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
);
export const PAGINATION_TYPE = 'keyset';
export const PAGINATION_SORT_FIELD_END_EVENT = 'end_event';
export const PAGINATION_SORT_FIELD_DURATION = 'duration';
export const PAGINATION_SORT_DIRECTION_DESC = 'desc';
export const PAGINATION_SORT_DIRECTION_ASC = 'asc';
export const STAGE_TITLE_STAGING = 'staging';
export const STAGE_TITLE_TEST = 'test';
......@@ -47,13 +47,7 @@ export default {
state.stages = [];
},
[types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS](state, { stages = [] }) {
state.stages = stages.map((s) => ({
...convertObjectPropsToCamelCase(s, { deep: true }),
// NOTE: we set the component type here to match the current behaviour
// this can be removed when we migrate to the update stage table
// https://gitlab.com/gitlab-org/gitlab/-/issues/326704
component: `stage-${s.id}-component`,
}));
state.stages = stages.map((s) => convertObjectPropsToCamelCase(s, { deep: true }));
},
[types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) {
state.stages = [];
......
......@@ -2,13 +2,13 @@
import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { toYmd } from '../../shared/utils';
import DurationChart from './duration_chart.vue';
import Metrics from './metrics.vue';
import StageTable from './stage_table.vue';
import TypeOfWorkCharts from './type_of_work_charts.vue';
import ValueStreamSelect from './value_stream_select.vue';
......
<script>
import { n__, s__ } from '~/locale';
export default {
props: {
time: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
hasData() {
return Object.keys(this.time).length;
},
calculatedTime() {
const {
time: { days = null, mins = null, hours = null, seconds = null },
} = this;
if (days) {
return {
duration: days,
units: n__('day', 'days', days),
};
} else if (hours) {
return {
duration: hours,
units: n__('Time|hr', 'Time|hrs', hours),
};
} else if (mins && !days) {
return {
duration: mins,
units: n__('Time|min', 'Time|mins', mins),
};
} else if ((seconds && this.hasData === 1) || seconds === 0) {
return {
duration: seconds,
units: s__('Time|s'),
};
}
return { duration: null, units: null };
},
},
};
</script>
<template>
<span class="total-time">
<template v-if="hasData">
{{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span>
</template>
<template v-else> -- </template>
</span>
</template>
......@@ -33,16 +33,6 @@ export const OVERVIEW_STAGE_CONFIG = {
icon: 'home',
};
export const NOT_ENOUGH_DATA_ERROR = s__(
"ValueStreamAnalyticsStage|We don't have enough data to show this stage.",
);
export const PAGINATION_TYPE = 'keyset';
export const PAGINATION_SORT_FIELD_END_EVENT = 'end_event';
export const PAGINATION_SORT_FIELD_DURATION = 'duration';
export const PAGINATION_SORT_DIRECTION_DESC = 'desc';
export const PAGINATION_SORT_DIRECTION_ASC = 'asc';
export const METRICS_POPOVER_CONTENT = {
'lead-time': {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
......
import dateFormat from 'dateformat';
import { isNumber } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { OVERVIEW_STAGE_ID, PAGINATION_TYPE } from '~/cycle_analytics/constants';
import { pathNavigationData as basePathNavigationData } from '~/cycle_analytics/store/getters';
import { filterStagesByHiddenStatus } from '~/cycle_analytics/utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import httpStatus from '~/lib/utils/http_status';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { DEFAULT_VALUE_STREAM_ID, OVERVIEW_STAGE_CONFIG, PAGINATION_TYPE } from '../constants';
import { DEFAULT_VALUE_STREAM_ID, OVERVIEW_STAGE_CONFIG } from '../constants';
export const hasNoAccessError = (state) => state.errorCode === httpStatus.FORBIDDEN;
......
import Vue from 'vue';
import {
PAGINATION_SORT_FIELD_END_EVENT,
PAGINATION_SORT_DIRECTION_DESC,
} from '~/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import { transformRawStages, prepareStageErrors, formatMedianValuesWithOverview } from '../utils';
import * as types from './mutation_types';
......
import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import {
PAGINATION_SORT_FIELD_END_EVENT,
PAGINATION_SORT_DIRECTION_DESC,
} from '~/cycle_analytics/constants';
export default () => ({
featureFlags: {},
......
......@@ -6,7 +6,6 @@ import Vuex from 'vuex';
import Component from 'ee/analytics/cycle_analytics/components/base.vue';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import Metrics from 'ee/analytics/cycle_analytics/components/metrics.vue';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import TypeOfWorkCharts from 'ee/analytics/cycle_analytics/components/type_of_work_charts.vue';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import createStore from 'ee/analytics/cycle_analytics/store';
......@@ -19,6 +18,7 @@ import {
selectedProjects,
} from 'jest/cycle_analytics/mock_data';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import createFlash from '~/flash';
......
import { mount } from '@vue/test-utils';
import TotalTimeComponent from 'ee/analytics/cycle_analytics/components/total_time_component.vue';
describe('TotalTimeComponent', () => {
function createComponent(propsData) {
return mount(TotalTimeComponent, {
propsData,
});
}
let wrapper = null;
afterEach(() => {
wrapper.destroy();
});
describe('with a valid time object', () => {
it.each`
time
${{ seconds: 35 }}
${{ mins: 47, seconds: 3 }}
${{ days: 3, mins: 47, seconds: 3 }}
${{ hours: 23, mins: 10 }}
${{ hours: 7, mins: 20, seconds: 10 }}
`('with $time', ({ time }) => {
wrapper = createComponent({
time,
});
expect(wrapper.html()).toMatchSnapshot();
});
});
describe('with a blank object', () => {
beforeEach(() => {
wrapper = createComponent({
time: {},
});
});
it('to render --', () => {
expect(wrapper.html()).toMatchSnapshot();
});
});
});
......@@ -2,9 +2,6 @@ import { uniq } from 'lodash';
import {
TASKS_BY_TYPE_SUBJECT_ISSUE,
OVERVIEW_STAGE_CONFIG,
PAGINATION_TYPE,
PAGINATION_SORT_DIRECTION_DESC,
PAGINATION_SORT_FIELD_END_EVENT,
} from 'ee/analytics/cycle_analytics/constants';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
......@@ -21,6 +18,11 @@ import {
createdBefore,
createdAfter,
} from 'jest/cycle_analytics/mock_data';
import {
PAGINATION_TYPE,
PAGINATION_SORT_DIRECTION_DESC,
PAGINATION_SORT_FIELD_END_EVENT,
} from '~/cycle_analytics/constants';
import { transformStagesForPathNavigation } from '~/cycle_analytics/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
......
import {
PAGINATION_SORT_DIRECTION_DESC,
PAGINATION_SORT_FIELD_END_EVENT,
} from 'ee/analytics/cycle_analytics/constants';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import { createdAfter, createdBefore, selectedProjects } from 'jest/cycle_analytics/mock_data';
import {
PAGINATION_SORT_DIRECTION_DESC,
PAGINATION_SORT_FIELD_END_EVENT,
} from '~/cycle_analytics/constants';
import {
issueStage,
planStage,
......
......@@ -27061,9 +27061,6 @@ msgstr ""
msgid "Rejected (closed)"
msgstr ""
msgid "Related Issues"
msgstr ""
msgid "Related feature flags"
msgstr ""
......@@ -32488,9 +32485,6 @@ msgstr ""
msgid "The character highlighter helps you keep the subject line to %{titleLength} characters and wrap the body at %{bodyLength} so they are readable in git."
msgstr ""
msgid "The collection of events added to the data gathered for that stage."
msgstr ""
msgid "The comment you are editing has been changed by another user. Would you like to keep your changes and overwrite the new description or discard your changes?"
msgstr ""
......@@ -32853,9 +32847,6 @@ msgstr ""
msgid "The tag name can't be changed for an existing release."
msgstr ""
msgid "The time taken by each data entry gathered by that stage."
msgstr ""
msgid "The update action will time out after %{number_of_minutes} minutes. For big repositories, use a clone/push combination."
msgstr ""
......
......@@ -6,6 +6,7 @@ RSpec.describe 'Value Stream Analytics', :js do
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let(:milestone) { create(:milestone, project: project) }
......@@ -119,13 +120,13 @@ RSpec.describe 'Value Stream Analytics', :js do
end
it 'needs permissions to see restricted stages' do
expect(find('.stage-events')).to have_content(issue.title)
expect(find(stage_table_selector)).to have_content(issue.title)
click_stage('Code')
expect(find('.stage-events')).to have_content('You need permission.')
expect(find(stage_table_selector)).to have_content('You need permission.')
click_stage('Review')
expect(find('.stage-events')).to have_content('You need permission.')
expect(find(stage_table_selector)).to have_content('You need permission.')
end
end
......@@ -154,21 +155,21 @@ RSpec.describe 'Value Stream Analytics', :js do
end
def expect_issue_to_be_present
expect(find('.stage-events')).to have_content(issue.title)
expect(find('.stage-events')).to have_content(issue.author.name)
expect(find('.stage-events')).to have_content("##{issue.iid}")
expect(find(stage_table_selector)).to have_content(issue.title)
expect(find(stage_table_selector)).to have_content(issue.author.name)
expect(find(stage_table_selector)).to have_content("##{issue.iid}")
end
def expect_build_to_be_present
expect(find('.stage-events')).to have_content(@build.ref)
expect(find('.stage-events')).to have_content(@build.short_sha)
expect(find('.stage-events')).to have_content("##{@build.id}")
expect(find(stage_table_selector)).to have_content(@build.ref)
expect(find(stage_table_selector)).to have_content(@build.short_sha)
expect(find(stage_table_selector)).to have_content("##{@build.id}")
end
def expect_merge_request_to_be_present
expect(find('.stage-events')).to have_content(mr.title)
expect(find('.stage-events')).to have_content(mr.author.name)
expect(find('.stage-events')).to have_content("!#{mr.iid}")
expect(find(stage_table_selector)).to have_content(mr.title)
expect(find(stage_table_selector)).to have_content(mr.author.name)
expect(find(stage_table_selector)).to have_content("!#{mr.iid}")
end
def click_stage(stage_name)
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Value stream analytics component isEmptyStage = true renders the empty stage with \`Not enough data\` message 1`] = `"<gl-empty-state-stub title=\\"We don't have enough data to show this stage.\\" svgpath=\\"path/to/no/data\\" description=\\"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
exports[`Value stream analytics component isEmptyStage = true with a selectedStageError renders the empty stage with \`There is too much data to calculate\` message 1`] = `"<gl-empty-state-stub title=\\"There is too much data to calculate\\" svgpath=\\"path/to/no/data\\" description=\\"\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
exports[`Value stream analytics component isLoading = true renders the path navigation component with prop \`loading\` set to true 1`] = `"<path-navigation-stub loading=\\"true\\" stages=\\"\\" selectedstage=\\"[object Object]\\" class=\\"js-path-navigation gl-w-full gl-pb-2\\"></path-navigation-stub>"`;
exports[`Value stream analytics component without enough permissions renders the empty stage with \`You need permission\` message 1`] = `"<gl-empty-state-stub title=\\"You need permission.\\" svgpath=\\"path/to/no/access\\" description=\\"Want to see the data? Please ask an administrator for access.\\" class=\\"js-empty-state\\"></gl-empty-state-stub>"`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TotalTimeComponent with a blank object to render -- 1`] = `"<span class=\\"total-time\\"> -- </span>"`;
exports[`TotalTimeComponent with a blank object should render -- 1`] = `"<span class=\\"total-time\\"> -- </span>"`;
exports[`TotalTimeComponent with a valid time object with {"days": 3, "mins": 47, "seconds": 3} 1`] = `
"<span class=\\"total-time\\">
......
......@@ -5,6 +5,8 @@ import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BaseComponent from '~/cycle_analytics/components/base.vue';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
import initState from '~/cycle_analytics/store/state';
import { selectedStage, convertedEvents as selectedStageEvents } from './mock_data';
......@@ -38,6 +40,9 @@ function createComponent({ initialState } = {}) {
noDataSvgPath,
noAccessSvgPath,
},
stubs: {
StageTable,
},
}),
);
}
......@@ -45,9 +50,9 @@ function createComponent({ initialState } = {}) {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
const findStageTable = () => wrapper.findByTestId('vsa-stage-table');
const findEmptyStage = () => wrapper.findComponent(GlEmptyState);
const findStageEvents = () => wrapper.findByTestId('stage-table-events');
const findStageTable = () => wrapper.findComponent(StageTable);
const findStageEvents = () => findStageTable().props('stageEvents');
const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
describe('Value stream analytics component', () => {
beforeEach(() => {
......@@ -81,8 +86,7 @@ describe('Value stream analytics component', () => {
});
it('renders the stage table events', () => {
expect(findEmptyStage().exists()).toBe(false);
expect(findStageEvents().exists()).toBe(true);
expect(findStageEvents()).toEqual(selectedStageEvents);
});
it('does not render the loading icon', () => {
......@@ -135,7 +139,7 @@ describe('Value stream analytics component', () => {
});
it('renders the empty stage with `Not enough data` message', () => {
expect(findEmptyStage().html()).toMatchSnapshot();
expect(findEmptyStageTitle()).toBe(NOT_ENOUGH_DATA_ERROR);
});
describe('with a selectedStageError', () => {
......@@ -150,7 +154,7 @@ describe('Value stream analytics component', () => {
});
it('renders the empty stage with `There is too much data to calculate` message', () => {
expect(findEmptyStage().html()).toMatchSnapshot();
expect(findEmptyStageTitle()).toBe('There is too much data to calculate');
});
});
});
......@@ -166,8 +170,8 @@ describe('Value stream analytics component', () => {
});
});
it('renders the empty stage with `You need permission` message', () => {
expect(findEmptyStage().html()).toMatchSnapshot();
it('renders the empty stage with `You need permission.` message', () => {
expect(findEmptyStageTitle()).toBe('You need permission.');
});
});
......@@ -187,7 +191,7 @@ describe('Value stream analytics component', () => {
});
it('does not render the stage table events', () => {
expect(findStageEvents().exists()).toBe(false);
expect(findStageEvents()).toHaveLength(0);
});
it('does not render the loading icon', () => {
......
......@@ -18,7 +18,7 @@ export const summary = [
{ value: null, title: 'Deployment Frequency', unit: 'per day' },
];
const issueStage = {
export const issueStage = {
id: 'issue',
title: 'Issue',
name: 'issue',
......@@ -27,7 +27,7 @@ const issueStage = {
value: null,
};
const planStage = {
export const planStage = {
id: 'plan',
title: 'Plan',
name: 'plan',
......@@ -36,7 +36,7 @@ const planStage = {
value: 75600,
};
const codeStage = {
export const codeStage = {
id: 'code',
title: 'Code',
name: 'code',
......@@ -45,7 +45,7 @@ const codeStage = {
value: 172800,
};
const testStage = {
export const testStage = {
id: 'test',
title: 'Test',
name: 'test',
......@@ -54,7 +54,7 @@ const testStage = {
value: 17550,
};
const reviewStage = {
export const reviewStage = {
id: 'review',
title: 'Review',
name: 'review',
......@@ -63,7 +63,7 @@ const reviewStage = {
value: null,
};
const stagingStage = {
export const stagingStage = {
id: 'staging',
title: 'Staging',
name: 'staging',
......@@ -79,7 +79,7 @@ export const selectedStage = {
isUserAllowed: true,
emptyStageText:
'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
component: 'stage-issue-component',
slug: 'issue',
};
......@@ -290,7 +290,189 @@ export const rawValueStreamStages = [
},
];
export const valueStreamStages = rawValueStreamStages.map((s) => ({
...convertObjectPropsToCamelCase(s, { deep: true }),
component: `stage-${s.id}-component`,
}));
export const valueStreamStages = rawValueStreamStages.map((s) =>
convertObjectPropsToCamelCase(s, { deep: true }),
);
// Temporary workaronud until we have relevant backend fixtures endpoints
export const testEvents = [
{
name: 'test',
id: 53,
branch: {
name: 'master',
url: 'http://localhost/group3/project9/-/tree/master',
},
shortSha: 'b83d6e39',
author: {
id: 18,
name: 'John Doe21',
username: 'user12',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/70a85d1042e02066f7451ae831689be0?s=80&d=identicon',
webUrl: 'http://localhost/user12',
showStatus: false,
path: '/user12',
},
date: 'about 1 hour ago',
totalTime: { mins: 2 },
url: 'http://localhost/group3/project9/-/jobs/53',
commitUrl: 'http://localhost/group3/project9/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0',
},
{
name: 'test',
id: 54,
branch: {
name: 'master',
url: 'http://localhost/group3/project9/-/tree/master',
},
shortSha: 'b83d6e39',
author: {
id: 18,
name: 'John Doe21',
username: 'user12',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/70a85d1042e02066f7451ae831689be0?s=80&d=identicon',
webUrl: 'http://localhost/user12',
showStatus: false,
path: '/user12',
},
date: 'about 1 hour ago',
totalTime: { mins: 2 },
url: 'http://localhost/group3/project9/-/jobs/54',
commitUrl: 'http://localhost/group3/project9/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0',
},
];
export const stagingEvents = [
{
name: 'test',
id: 83,
branch: {
name: 'master',
url: 'http://localhost/group3/project9/-/tree/master',
},
shortSha: 'b83d6e39',
author: {
id: 18,
name: 'John Doe21',
username: 'user12',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/70a85d1042e02066f7451ae831689be0?s=80&d=identicon',
webUrl: 'http://localhost/user12',
showStatus: false,
path: '/user12',
},
date: 'about 1 hour ago',
totalTime: { mins: 2 },
url: 'http://localhost/group3/project9/-/jobs/83',
commitUrl: 'http://localhost/group3/project9/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0',
},
{
name: 'test',
id: 84,
branch: {
name: 'master',
url: 'http://localhost/group3/project9/-/tree/master',
},
shortSha: 'b83d6e39',
author: {
id: 18,
name: 'John Doe21',
username: 'user12',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/70a85d1042e02066f7451ae831689be0?s=80&d=identicon',
webUrl: 'http://localhost/user12',
showStatus: false,
path: '/user12',
},
date: 'about 1 hour ago',
totalTime: { mins: 2 },
url: 'http://localhost/group3/project9/-/jobs/84',
commitUrl: 'http://localhost/group3/project9/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0',
},
];
export const reviewEvents = [
{
title: 'My title 98',
author: {
id: 17,
name: 'John Doe20',
username: 'user11',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/fb32cf62136a195ec4f40ec6d1cfffdc?s=80&d=identicon',
webUrl: 'http://localhost/user11',
showStatus: false,
path: '/user11',
},
iid: '3',
totalTime: { days: 15 },
createdAt: '20 days ago',
url: 'http://localhost/group3/project9/-/merge_requests/3',
state: 'opened',
},
{
title: 'My title 99',
author: {
id: 17,
name: 'John Doe20',
username: 'user11',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/fb32cf62136a195ec4f40ec6d1cfffdc?s=80&d=identicon',
webUrl: 'http://localhost/user11',
showStatus: false,
path: '/user11',
},
iid: '4',
totalTime: { days: 9 },
createdAt: '19 days ago',
url: 'http://localhost/group3/project9/-/merge_requests/4',
state: 'opened',
},
];
export const issueEvents = [
{
title: 'My title 24',
author: {
id: 17,
name: 'John Doe20',
username: 'user11',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/fb32cf62136a195ec4f40ec6d1cfffdc?s=80&d=identicon',
webUrl: 'http://localhost/user11',
showStatus: false,
path: '/user11',
},
iid: '3',
totalTime: { days: 2 },
createdAt: '4 days ago',
url: 'http://localhost/group3/project9/-/issues/3',
},
{
title: 'My title 23',
author: {
id: 17,
name: 'John Doe20',
username: 'user11',
state: 'active',
avatarUrl:
'https://www.gravatar.com/avatar/fb32cf62136a195ec4f40ec6d1cfffdc?s=80&d=identicon',
webUrl: 'http://localhost/user11',
showStatus: false,
path: '/user11',
},
iid: '2',
totalTime: { days: 2 },
createdAt: '5 days ago',
url: 'http://localhost/group3/project9/-/issues/2',
},
];
import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import StageTable from 'ee/analytics/cycle_analytics/components/stage_table.vue';
import { PAGINATION_SORT_FIELD_DURATION } from 'ee/analytics/cycle_analytics/constants';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
import { PAGINATION_SORT_FIELD_DURATION } from '~/cycle_analytics/constants';
import {
stagingEvents,
stagingStage,
......@@ -13,13 +13,13 @@ import {
testStage,
reviewStage,
reviewEvents,
} from '../mock_data';
} from './mock_data';
let wrapper = null;
let trackingSpy = null;
const noDataSvgPath = 'path/to/no/data';
const emptyStateMessage = 'Too much data';
const emptyStateTitle = 'Too much data';
const notEnoughDataError = "We don't have enough data to show this stage.";
const [firstIssueEvent] = issueEvents;
const [firstStagingEvent] = stagingEvents;
......@@ -273,14 +273,14 @@ describe('StageTable', () => {
});
});
describe('emptyStateMessage set', () => {
describe('emptyStateTitle set', () => {
beforeEach(() => {
wrapper = createComponent({ stageEvents: [], emptyStateMessage });
wrapper = createComponent({ stageEvents: [], emptyStateTitle });
});
it('will display the custom message', () => {
expect(wrapper.html()).not.toContain(notEnoughDataError);
expect(wrapper.html()).toContain(emptyStateMessage);
expect(wrapper.html()).toContain(emptyStateTitle);
});
});
......@@ -300,6 +300,8 @@ describe('StageTable', () => {
});
it('clicking prev or next will emit an event', async () => {
expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
findPagination().vm.$emit('input', 2);
await wrapper.vm.$nextTick();
......@@ -349,6 +351,7 @@ describe('StageTable', () => {
});
it('clicking a table column will update the sort field', () => {
expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
triggerTableSort();
expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
......@@ -360,6 +363,7 @@ describe('StageTable', () => {
});
it('with sortDesc=false will toggle the direction field', async () => {
expect(wrapper.emitted('handleUpdatePagination')).toBeUndefined();
triggerTableSort(false);
expect(wrapper.emitted('handleUpdatePagination')[0]).toEqual([
......
import { shallowMount } from '@vue/test-utils';
import TotalTime from '~/cycle_analytics/components/total_time_component.vue';
import { mount } from '@vue/test-utils';
import TotalTimeComponent from '~/cycle_analytics/components/total_time_component.vue';
describe('Total time component', () => {
let wrapper;
describe('TotalTimeComponent', () => {
let wrapper = null;
const createComponent = (propsData) => {
wrapper = shallowMount(TotalTime, {
return mount(TotalTimeComponent, {
propsData,
});
};
......@@ -14,45 +14,32 @@ describe('Total time component', () => {
wrapper.destroy();
});
describe('With data', () => {
it('should render information for days and hours', () => {
createComponent({
time: {
days: 3,
hours: 4,
},
describe('with a valid time object', () => {
it.each`
time
${{ seconds: 35 }}
${{ mins: 47, seconds: 3 }}
${{ days: 3, mins: 47, seconds: 3 }}
${{ hours: 23, mins: 10 }}
${{ hours: 7, mins: 20, seconds: 10 }}
`('with $time', ({ time }) => {
wrapper = createComponent({
time,
});
expect(wrapper.text()).toMatchInterpolatedText('3 days 4 hrs');
});
it('should render information for hours and minutes', () => {
createComponent({
time: {
hours: 4,
mins: 35,
},
});
expect(wrapper.text()).toMatchInterpolatedText('4 hrs 35 mins');
expect(wrapper.html()).toMatchSnapshot();
});
});
it('should render information for seconds', () => {
createComponent({
time: {
seconds: 45,
},
describe('with a blank object', () => {
beforeEach(() => {
wrapper = createComponent({
time: {},
});
expect(wrapper.text()).toMatchInterpolatedText('45 s');
});
});
describe('Without data', () => {
it('should render no information', () => {
createComponent();
expect(wrapper.text()).toBe('--');
it('should render --', () => {
expect(wrapper.html()).toMatchSnapshot();
});
});
});
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