Commit 62ac45a6 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'ek-migrate-ce-cycle-analytics-to-vue' into 'master'

Migrate project cycle analytics template to vue

See merge request gitlab-org/gitlab!54882
parents 78b68941 a0b0a6e4
<script>
import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { deprecatedCreateFlash as Flash } from '~/flash';
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';
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,
},
props: {
noDataSvgPath: {
type: String,
required: true,
},
noAccessSvgPath: {
type: String,
required: true,
},
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
state: this.store.state,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
hasError: true,
startDate: 30,
isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
};
},
computed: {
currentStage() {
return this.store.currentActiveStage();
},
},
created() {
this.fetchCycleAnalyticsData();
},
methods: {
handleError() {
this.store.setErrorState(true);
return new Flash(__('There was an error while fetching value stream analytics data.'));
},
handleDateSelect(startDate) {
this.startDate = startDate;
this.fetchCycleAnalyticsData({ startDate: this.startDate });
},
fetchCycleAnalyticsData(options) {
const fetchOptions = options || { startDate: this.startDate };
this.isLoading = true;
this.service
.fetchCycleAnalyticsData(fetchOptions)
.then((response) => {
this.store.setCycleAnalyticsData(response);
this.selectDefaultStage();
})
.catch(() => {
this.handleError();
})
.finally(() => {
this.isLoading = false;
});
},
selectDefaultStage() {
const stage = this.state.stages[0];
this.selectStage(stage);
},
selectStage(stage) {
if (this.isLoadingStage) return;
if (this.currentStage === stage) return;
if (!stage.isUserAllowed) {
this.store.setActiveStage(stage);
return;
}
this.isLoadingStage = true;
this.store.setStageEvents([], stage);
this.store.setActiveStage(stage);
this.service
.fetchStageData({
stage,
startDate: this.startDate,
projectIds: this.selectedProjectIds,
})
.then((response) => {
this.isEmptyStage = !response.events.length;
this.store.setStageEvents(response.events, stage);
})
.catch(() => {
this.isEmptyStage = true;
})
.finally(() => {
this.isLoadingStage = false;
});
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
},
},
dayRangeOptions: [7, 30, 90],
i18n: {
dropdownText: __('Last %{days} days'),
},
};
</script>
<template>
<div class="cycle-analytics">
<gl-loading-icon v-if="isLoading" size="lg" />
<div v-else class="wrapper">
<div class="card">
<div class="card-header">{{ __('Recent Project Activity') }}</div>
<div class="d-flex justify-content-between">
<div v-for="item in state.summary" :key="item.title" class="flex-grow text-center">
<h3 class="header">{{ item.value }}</h3>
<p class="text">{{ item.title }}</p>
</div>
<div class="flex-grow align-self-center text-center">
<div class="js-ca-dropdown dropdown inline">
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<span class="dropdown-label">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ startDate }}</template>
</gl-sprintf>
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
</span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
<a href="#" @click.prevent="handleDateSelect(days)">
<gl-sprintf :message="$options.i18n.dropdownText">
<template #days>{{ days }}</template>
</gl-sprintf>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="stage-panel-container">
<div class="card stage-panel">
<div class="card-header border-bottom-0">
<nav class="col-headers">
<ul>
<li class="stage-header pl-5">
<span class="stage-name font-weight-bold">{{
s__('ProjectLifecycle|Stage')
}}</span>
<span
class="has-tooltip"
data-placement="top"
:title="__('The phase of the development lifecycle.')"
aria-hidden="true"
>
<gl-icon name="question-o" class="gl-text-gray-500" />
</span>
</li>
<li class="median-header">
<span class="stage-name font-weight-bold">{{ __('Median') }}</span>
<span
class="has-tooltip"
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"
>
<gl-icon name="question-o" class="gl-text-gray-500" />
</span>
</li>
<li class="event-header pl-3">
<span
v-if="currentStage && currentStage.legend"
class="stage-name font-weight-bold"
>{{ currentStage ? __(currentStage.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 class="total-time-header pr-5 text-right">
<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">
<nav class="stage-nav">
<ul>
<stage-nav-item
v-for="stage in state.stages"
:key="stage.title"
:title="stage.title"
:is-user-allowed="stage.isUserAllowed"
:value="stage.value"
:is-active="stage.active"
@select="selectStage(stage)"
/>
</ul>
</nav>
<section class="stage-events overflow-auto">
<gl-loading-icon v-show="isLoadingStage" size="lg" />
<template v-if="currentStage && !currentStage.isUserAllowed">
<gl-empty-state
class="js-empty-state"
:title="__('You need permission.')"
:svg-path="noAccessSvgPath"
:description="__('Want to see the data? Please ask an administrator for access.')"
/>
</template>
<template v-else>
<template v-if="currentStage && isEmptyStage && !isLoadingStage">
<gl-empty-state
class="js-empty-state"
:description="currentStage.emptyStageText"
:svg-path="noDataSvgPath"
:title="__('We don\'t have enough data to show this stage.')"
/>
</template>
<template v-if="state.events.length && !isLoadingStage && !isEmptyStage">
<component
:is="currentStage.component"
:stage="currentStage"
:items="state.events"
/>
</template>
</template>
</section>
</div>
</div>
</div>
</div>
</div>
</template>
// This is a true violation of @gitlab/no-runtime-template-compiler, as it
// relies on app/views/projects/cycle_analytics/show.html.haml for its
// template.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import $ from 'jquery';
import Cookies from 'js-cookie';
import Vue from 'vue';
import { __ } from '~/locale';
import { deprecatedCreateFlash as Flash } from '../flash';
import Translate from '../vue_shared/translate';
import banner from './components/banner.vue';
import stageCodeComponent from './components/stage_code_component.vue';
import stageComponent from './components/stage_component.vue';
import stageNavItem from './components/stage_nav_item.vue';
import stageReviewComponent from './components/stage_review_component.vue';
import stageStagingComponent from './components/stage_staging_component.vue';
import stageTestComponent from './components/stage_test_component.vue';
import CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store';
Vue.use(Translate);
export default () => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
// eslint-disable-next-line no-new
new Vue({
el: '#cycle-analytics',
name: 'CycleAnalytics',
components: {
GlEmptyState,
GlLoadingIcon,
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,
},
data() {
return {
store: CycleAnalyticsStore,
state: CycleAnalyticsStore.state,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
hasError: false,
startDate: 30,
isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath),
};
},
computed: {
currentStage() {
return this.store.currentActiveStage();
},
},
created() {
// Conditional check placed here to prevent this method from being called on the
// new Value Stream Analytics page (i.e. the new page will be initialized blank and only
// after a group is selected the cycle analyitcs data will be fetched). Once the
// old (current) page has been removed this entire created method as well as the
// variable itself can be completely removed.
// Follow up issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/64490
if (cycleAnalyticsEl.dataset.requestPath) this.fetchCycleAnalyticsData();
},
methods: {
handleError() {
this.store.setErrorState(true);
return new Flash(__('There was an error while fetching value stream analytics data.'));
},
initDropdown() {
const $dropdown = $('.js-ca-dropdown');
const $label = $dropdown.find('.dropdown-label');
// eslint-disable-next-line @gitlab/no-global-event-off
$dropdown
.find('li a')
.off('click')
.on('click', (e) => {
e.preventDefault();
const $target = $(e.currentTarget);
this.startDate = $target.data('value');
$label.text($target.text().trim());
this.fetchCycleAnalyticsData({ startDate: this.startDate });
});
},
fetchCycleAnalyticsData(options) {
const fetchOptions = options || { startDate: this.startDate };
this.isLoading = true;
this.service
.fetchCycleAnalyticsData(fetchOptions)
.then((response) => {
this.store.setCycleAnalyticsData(response);
this.selectDefaultStage();
this.initDropdown();
this.isLoading = false;
})
.catch(() => {
this.handleError();
this.isLoading = false;
});
},
selectDefaultStage() {
const stage = this.state.stages[0];
this.selectStage(stage);
},
selectStage(stage) {
if (this.isLoadingStage) return;
if (this.currentStage === stage) return;
if (!stage.isUserAllowed) {
this.store.setActiveStage(stage);
return;
}
this.isLoadingStage = true;
this.store.setStageEvents([], stage);
this.store.setActiveStage(stage);
this.service
.fetchStageData({
stage,
startDate: this.startDate,
projectIds: this.selectedProjectIds,
})
.then((response) => {
this.isEmptyStage = !response.events.length;
this.store.setStageEvents(response.events, stage);
this.isLoadingStage = false;
})
.catch(() => {
this.isEmptyStage = true;
this.isLoadingStage = false;
});
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
},
createCycleAnalyticsService(requestPath) {
return new CycleAnalyticsService({
requestPath,
});
},
},
});
};
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import CycleAnalytics from './components/base.vue';
import CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store';
Vue.use(Translate);
const createCycleAnalyticsService = (requestPath) =>
new CycleAnalyticsService({
requestPath,
});
export default () => {
const el = document.querySelector('#js-cycle-analytics');
const { noAccessSvgPath, noDataSvgPath } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
name: 'CycleAnalytics',
render: (createElement) =>
createElement(CycleAnalytics, {
props: {
noDataSvgPath,
noAccessSvgPath,
store: CycleAnalyticsStore,
service: createCycleAnalyticsService(el.dataset.requestPath),
},
}),
});
};
import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle';
import initCycleAnalytics from '~/cycle_analytics';
document.addEventListener('DOMContentLoaded', initCycleAnalytics);
@import 'mixins_and_variables_and_functions';
#cycle-analytics,
.cycle-analytics {
margin: 24px auto 0;
position: relative;
......@@ -316,35 +315,4 @@
}
}
}
.empty-stage,
.no-access-stage {
text-align: center;
width: 75%;
margin: 0 auto;
padding-top: 130px;
color: var(--gray-500, $gray-500);
h4 {
color: var(--gl-text-color, $gl-text-color);
}
}
.empty-stage {
.icon-no-data {
height: 36px;
width: 78px;
display: inline-block;
margin-bottom: 20px;
}
}
.no-access-stage {
.icon-lock {
height: 36px;
width: 78px;
display: inline-block;
margin-bottom: 20px;
}
}
}
.empty-stage-container
.empty-stage
.icon-no-data
= custom_icon ('icon_no_data')
%h4 {{ __('We don\'t have enough data to show this stage.') }}
%p
{{currentStage.emptyStageText}}
.no-access-stage-container
.no-access-stage
.icon-lock
= custom_icon ('icon_lock')
%h4 {{ __('You need permission.') }}
%p
{{ __('Want to see the data? Please ask an administrator for access.') }}
- page_title _("Value Stream Analytics")
- add_page_specific_style 'page_bundles/cycle_analytics'
- svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") }
- initial_data = { request_path: project_cycle_analytics_path(@project) }.merge!(svgs)
#cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
%gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" }
.wrapper{ "v-show" => "!isLoading && !hasError" }
.card
.card-header
{{ __('Recent Project Activity') }}
.d-flex.justify-content-between
.flex-grow.text-center{ "v-for" => "item in state.summary" }
%h3.header {{ item.value }}
%p.text {{ item.title }}
.flex-grow.align-self-center.text-center
.dropdown.inline.js-ca-dropdown
%button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
%span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
= sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3")
%ul.dropdown-menu.dropdown-menu-right
%li
%a{ "href" => "#", "data-value" => "7" }
{{ n__('Last %d day', 'Last %d days', 7) }}
%li
%a{ "href" => "#", "data-value" => "30" }
{{ n__('Last %d day', 'Last %d days', 30) }}
%li
%a{ "href" => "#", "data-value" => "90" }
{{ n__('Last %d day', 'Last %d days', 90) }}
.stage-panel-container
.card.stage-panel
.card-header.border-bottom-0
%nav.col-headers
%ul
%li.stage-header.pl-5
%span.stage-name.font-weight-bold
{{ s__('ProjectLifecycle|Stage') }}
%span.has-tooltip{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" }
= sprite_icon('question-o', css_class: 'gl-text-gray-500')
%li.median-header
%span.stage-name.font-weight-bold
{{ __('Median') }}
%span.has-tooltip{ "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" }
= sprite_icon('question-o', css_class: 'gl-text-gray-500')
%li.event-header.pl-3
%span.stage-name.font-weight-bold{ "v-if" => "currentStage && currentStage.legend" }
{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}
%span.has-tooltip{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" }
= sprite_icon('question-o', css_class: 'gl-text-gray-500')
%li.total-time-header.pr-5.text-right
%span.stage-name.font-weight-bold
{{ __('Time') }}
%span.has-tooltip{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" }
= sprite_icon('question-o', css_class: 'gl-text-gray-500')
.stage-panel-body
%nav.stage-nav
%ul
%stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" }
.section.stage-events.overflow-auto
%gl-loading-icon{ "v-show" => "isLoadingStage", "size" => "lg" }
%template{ "v-if" => "currentStage && !currentStage.isUserAllowed" }
= render partial: "no_access"
%template{ "v-else" => true }
%template{ "v-if" => "isEmptyStage && !isLoadingStage" }
= render partial: "empty_stage"
%template{ "v-if" => "state.events.length && !isLoadingStage && !isEmptyStage" }
%component{ ":is" => "currentStage.component", ":stage" => "currentStage", ":items" => "state.events" }
#js-cycle-analytics{ data: initial_data }
......@@ -17399,6 +17399,9 @@ msgid_plural "Last %d days"
msgstr[0] ""
msgstr[1] ""
msgid "Last %{days} days"
msgstr ""
msgid "Last 2 weeks"
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