Commit 9c048ea7 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Kushal Pandya

Add the total time chart to each VSA stage

Rename duration chart to Total time

For group VSA we should display the
total time chart on each stage.

Changelog: added
EE: true
parent 93352519
......@@ -193,18 +193,18 @@ export default {
"
/>
<template v-else>
<div class="gl-mt-2">
<template v-if="isOverviewStageSelected">
<value-stream-metrics
:request-path="currentGroupPath"
:request-params="cycleAnalyticsRequestParams"
:requests="$options.METRICS_REQUESTS"
/>
<duration-chart class="gl-mt-3" :stages="activeStages" />
<type-of-work-charts />
</template>
<div :class="[isOverviewStageSelected ? 'gl-mt-2' : 'gl-mt-6']">
<value-stream-metrics
v-if="isOverviewStageSelected"
:request-path="currentGroupPath"
:request-params="cycleAnalyticsRequestParams"
:requests="$options.METRICS_REQUESTS"
/>
<duration-chart class="gl-mt-3" :stages="activeStages" :selected-stage="selectedStage" />
<type-of-work-charts v-if="isOverviewStageSelected" />
<stage-table
v-else
v-if="!isOverviewStageSelected"
class="gl-mt-5"
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
......
<script>
import { GlAlert } from '@gitlab/ui';
import { GlAlert, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { dataVizBlue500 } from '@gitlab/ui/scss_to_js/scss_variables';
import { mapActions, mapState, mapGetters } from 'vuex';
import { dateFormats } from '~/analytics/shared/constants';
import { __ } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { sprintf } from '~/locale';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import Scatterplot from '../../shared/components/scatterplot.vue';
import {
DURATION_STAGE_TIME_DESCRIPTION,
DURATION_STAGE_TIME_NO_DATA,
DURATION_STAGE_TIME_LABEL,
DURATION_TOTAL_TIME_DESCRIPTION,
DURATION_TOTAL_TIME_NO_DATA,
DURATION_TOTAL_TIME_LABEL,
} from '../constants';
import StageDropdownFilter from './stage_dropdown_filter.vue';
export default {
name: 'DurationChart',
components: {
GlAlert,
GlIcon,
Scatterplot,
StageDropdownFilter,
ChartSkeletonLoader,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
stages: {
type: Array,
......@@ -22,15 +36,32 @@ export default {
},
},
computed: {
...mapState(['selectedStage']),
...mapState('durationChart', ['isLoading', 'errorMessage']),
...mapGetters(['isOverviewStageSelected']),
...mapGetters('durationChart', ['durationChartPlottableData']),
hasData() {
return Boolean(!this.isLoading && this.durationChartPlottableData.length);
},
error() {
return this.errorMessage
? this.errorMessage
: __('There is no data available. Please change your selection.');
if (this.errorMessage) {
return this.errorMessage;
}
return this.isOverviewStageSelected
? DURATION_TOTAL_TIME_NO_DATA
: DURATION_STAGE_TIME_NO_DATA;
},
title() {
return this.isOverviewStageSelected
? DURATION_TOTAL_TIME_LABEL
: sprintf(DURATION_STAGE_TIME_LABEL, {
title: capitalizeFirstCharacter(this.selectedStage.title),
});
},
tooltipText() {
return this.isOverviewStageSelected
? DURATION_TOTAL_TIME_DESCRIPTION
: DURATION_STAGE_TIME_DESCRIPTION;
},
},
methods: {
......@@ -40,22 +71,22 @@ export default {
},
},
durationChartTooltipDateFormat: dateFormats.defaultDate,
medianAdditionalOptions: {
lineStyle: {
color: dataVizBlue500,
},
},
};
</script>
<template>
<chart-skeleton-loader v-if="isLoading" size="md" class="gl-my-4 gl-py-4" />
<div v-else class="gl-display-flex gl-flex-direction-column" data-testid="vsa-duration-chart">
<h4 class="gl-mt-0">{{ s__('CycleAnalytics|Days to completion') }}</h4>
<p>
{{
s__(
'CycleAnalytics|The average time spent in the selected stage for the items that were completed on each date. Data limited to the last 500 items.',
)
}}
</p>
<h4 class="gl-mt-0">
{{ title }}&nbsp;<gl-icon v-gl-tooltip.hover name="information-o" :title="tooltipText" />
</h4>
<stage-dropdown-filter
v-if="stages.length"
v-if="isOverviewStageSelected && stages.length"
class="gl-ml-auto"
:stages="stages"
@selected="onDurationStageSelect"
......@@ -63,9 +94,11 @@ export default {
<scatterplot
v-if="hasData"
:x-axis-title="s__('CycleAnalytics|Date')"
:y-axis-title="s__('CycleAnalytics|Average days to completion')"
:y-axis-title="s__('CycleAnalytics|Average time to completion')"
:tooltip-date-format="$options.durationChartTooltipDateFormat"
:scatter-data="durationChartPlottableData"
:median-line-data="durationChartPlottableData"
:median-line-options="$options.medianAdditionalOptions"
/>
<gl-alert v-else variant="info" :dismissible="false" class="gl-mt-3">
{{ error }}
......
import { getGroupValueStreamMetrics } from 'ee/api/analytics_api';
import { METRIC_TYPE_SUMMARY, METRIC_TYPE_TIME_SUMMARY } from '~/api/analytics_api';
import { OVERVIEW_STAGE_ID } from '~/cycle_analytics/constants';
import { __ } from '~/locale';
import { __, s__ } from '~/locale';
export const EVENTS_LIST_ITEM_LIMIT = 50;
......@@ -41,3 +41,18 @@ export const METRICS_REQUESTS = [
name: __('recent activity'),
},
];
export const DURATION_TOTAL_TIME_LABEL = s__('CycleAnalytics|Total time');
export const DURATION_TOTAL_TIME_NO_DATA = s__(
"CycleAnalytics|There is no data for 'Total time' available. Adjust the current filters.",
);
export const DURATION_TOTAL_TIME_DESCRIPTION = s__(
'CycleAnalytics|The total time items spent across each value stream stage. Data limited to items completed within this date range.',
);
export const DURATION_STAGE_TIME_LABEL = s__('CycleAnalytics|Stage time: %{title}');
export const DURATION_STAGE_TIME_NO_DATA = s__(
"CycleAnalytics|There is no data for 'Stage time' available. Adjust the current filters.",
);
export const DURATION_STAGE_TIME_DESCRIPTION = s__(
'CycleAnalytics|The average time items spent in this stage. Data limited to items completed within this date range.',
);
import { getDurationChartData } from '../../../utils';
export const durationChartPlottableData = (state, _, rootState) => {
const { createdAfter, createdBefore } = rootState;
export const durationChartPlottableData = (state, _, rootState, rootGetters) => {
const { createdAfter, createdBefore, selectedStage } = rootState;
const { durationData } = state;
const selectedStagesDurationData = durationData.filter((stage) => stage.selected);
const { isOverviewStageSelected } = rootGetters;
const selectedStagesDurationData = isOverviewStageSelected
? durationData.filter((stage) => stage.selected)
: durationData.filter((stage) => stage.id === selectedStage.id);
const plottableData = getDurationChartData(
selectedStagesDurationData,
createdAfter,
......
......@@ -26,6 +26,11 @@ export default {
required: false,
default: () => [],
},
medianLineOptions: {
type: Object,
required: false,
default: () => ({}),
},
tooltipDateFormat: {
type: String,
required: false,
......@@ -71,6 +76,7 @@ export default {
result.push({
data: this.medianLineData,
...scatterChartLineProps.default,
...this.medianLineOptions,
});
}
......
......@@ -305,6 +305,8 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
stage_name = page.find("#{path_nav_selector} .gl-path-active-item-indigo").text
expect(stage_name).to include(stage[:title])
expect(stage_name).to include(stage[:time])
expect(page).to have_selector('[data-testid="vsa-duration-chart"]')
end
end
......@@ -318,7 +320,7 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
it 'will have data available' do
duration_chart_content = page.find('[data-testid="vsa-duration-chart"]')
expect(duration_chart_content).not_to have_text(_("There is no data available. Please change your selection."))
expect(duration_chart_content).to have_text(s_('CycleAnalytics|Average days to completion'))
expect(duration_chart_content).to have_text(s_('CycleAnalytics|Average time to completion'))
tasks_by_type_chart_content = page.find('.js-tasks-by-type-chart')
expect(tasks_by_type_chart_content).not_to have_text(_("There is no data available. Please change your selection."))
......@@ -333,8 +335,8 @@ RSpec.describe 'Group value stream analytics filters and data', :js do
it 'will filter the data' do
duration_chart_content = page.find('[data-testid="vsa-duration-chart"]')
expect(duration_chart_content).not_to have_text(s_('CycleAnalytics|Average days to completion'))
expect(duration_chart_content).to have_text(_("There is no data available. Please change your selection."))
expect(duration_chart_content).not_to have_text(s_('CycleAnalytics|Average time to completion'))
expect(duration_chart_content).to have_text(s_("CycleAnalytics|There is no data for 'Total time' available. Adjust the current filters."))
tasks_by_type_chart_content = page.find('.js-tasks-by-type-chart')
expect(tasks_by_type_chart_content).to have_text(_("There is no data available. Please change your selection."))
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DurationChart renders the duration chart 1`] = `
exports[`DurationChart with the overiew stage selected renders the duration chart 1`] = `
<div
class="gl-display-flex gl-flex-direction-column"
data-testid="vsa-duration-chart"
......@@ -8,14 +8,14 @@ exports[`DurationChart renders the duration chart 1`] = `
<h4
class="gl-mt-0"
>
Days to completion
</h4>
<p>
The average time spent in the selected stage for the items that were completed on each date. Data limited to the last 500 items.
</p>
Total time 
<gl-icon-stub
name="information-o"
size="16"
title="The total time items spent across each value stream stage. Data limited to items completed within this date range."
/>
</h4>
<stagedropdownfilter-stub
class="gl-ml-auto"
......@@ -24,11 +24,12 @@ exports[`DurationChart renders the duration chart 1`] = `
/>
<scatterplot-stub
medianlinedata=""
scatterdata="2019-01-01,14,2019-01-01,2019-01-02,50,2019-01-02"
medianlinedata="2019-01-01,17,2019-01-01,2019-01-02,40,2019-01-02"
medianlineoptions="[object Object]"
scatterdata="2019-01-01,17,2019-01-01,2019-01-02,40,2019-01-02"
tooltipdateformat="mmm d, yyyy"
xaxistitle="Date"
yaxistitle="Average days to completion"
yaxistitle="Average time to completion"
/>
</div>
`;
......@@ -326,6 +326,10 @@ describe('EE Value Stream Analytics component', () => {
it('displays the path navigation', () => {
displaysPathNavigation(true);
});
it('displays the duration chart', () => {
displaysDurationChart(true);
});
});
});
......
import { GlDropdownItem } from '@gitlab/ui';
import { GlDropdownItem, GlIcon } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import {
DURATION_STAGE_TIME_DESCRIPTION,
DURATION_TOTAL_TIME_DESCRIPTION,
DURATION_STAGE_TIME_NO_DATA,
DURATION_TOTAL_TIME_NO_DATA,
} from 'ee/analytics/cycle_analytics/constants';
import DurationChart from 'ee/analytics/cycle_analytics/components/duration_chart.vue';
import StageDropdownFilter from 'ee/analytics/cycle_analytics/components/stage_dropdown_filter.vue';
import Scatterplot from 'ee/analytics/shared/components/scatterplot.vue';
......@@ -15,8 +21,15 @@ const actionSpies = {
updateSelectedDurationChartStages: jest.fn(),
};
const fakeStore = ({ initialGetters, initialState }) =>
const fakeStore = ({ initialGetters, initialState, rootGetters, rootState }) =>
new Vuex.Store({
state: {
...rootState,
},
getters: {
isOverviewStageSelected: () => true,
...rootGetters,
},
modules: {
durationChart: {
namespaced: true,
......@@ -38,10 +51,12 @@ function createComponent({
stubs = {},
initialState = {},
initialGetters = {},
rootGetters = {},
rootState = {},
props = {},
} = {}) {
return mountFn(DurationChart, {
store: fakeStore({ initialState, initialGetters }),
store: fakeStore({ initialState, initialGetters, rootGetters, rootState }),
propsData: {
stages,
...props,
......@@ -59,6 +74,7 @@ describe('DurationChart', () => {
let wrapper;
const findContainer = (_wrapper) => _wrapper.find('[data-testid="vsa-duration-chart"]');
const findChartDescription = (_wrapper) => _wrapper.findComponent(GlIcon);
const findScatterPlot = (_wrapper) => _wrapper.findComponent(Scatterplot);
const findStageDropdown = (_wrapper) => _wrapper.findComponent(StageDropdownFilter);
const findLoader = (_wrapper) => _wrapper.findComponent(ChartSkeletonLoader);
......@@ -67,31 +83,52 @@ describe('DurationChart', () => {
findStageDropdown(_wrapper).findAllComponents(GlDropdownItem).at(index).vm.$emit('click');
};
beforeEach(() => {
wrapper = createComponent({});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the duration chart', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('with the overiew stage selected', () => {
beforeEach(() => {
wrapper = createComponent({});
});
it('renders the scatter plot', () => {
expect(findScatterPlot(wrapper).exists()).toBe(true);
});
it('renders the duration chart', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the scatter plot', () => {
expect(findScatterPlot(wrapper).exists()).toBe(true);
});
it('renders the stage dropdown', () => {
expect(findStageDropdown(wrapper).exists()).toBe(true);
});
it('renders the stage dropdown', () => {
expect(findStageDropdown(wrapper).exists()).toBe(true);
it('renders the chart description', () => {
expect(findChartDescription(wrapper).attributes('title')).toBe(
DURATION_TOTAL_TIME_DESCRIPTION,
);
});
describe('with no chart data', () => {
beforeEach(() => {
wrapper = createComponent({
initialGetters: {
durationChartPlottableData: () => [],
},
});
});
it('renders the no data available message', () => {
expect(findContainer(wrapper).text()).toContain(DURATION_TOTAL_TIME_NO_DATA);
});
});
});
describe('when a stage is selected', () => {
const selectedIndex = 1;
const selectedStages = stages.filter((_, index) => index !== selectedIndex);
beforeEach(() => {
wrapper = createComponent({ stubs: { StageDropdownFilter } });
selectStage(wrapper, selectedIndex);
......@@ -105,33 +142,78 @@ describe('DurationChart', () => {
});
});
describe('with no stages', () => {
describe('with a value stream stage selected', () => {
const [selectedStage] = stages;
beforeEach(() => {
wrapper = createComponent({
mountFn: mount,
props: { stages: [] },
stubs: { StageDropdownFilter: false },
rootState: {
selectedStage,
},
rootGetters: {
isOverviewStageSelected: () => false,
},
});
});
it('renders the scatter plot', () => {
expect(findScatterPlot(wrapper).exists()).toBe(true);
});
it('does not render the stage dropdown', () => {
expect(findStageDropdown(wrapper).exists()).toBe(false);
});
it('renders the stage title', () => {
expect(wrapper.text()).toContain(`Stage time: ${selectedStage.title}`);
});
it('sets the scatter plot data', () => {
expect(findScatterPlot(wrapper).props('scatterData')).toBe(durationData);
});
it('sets the median line data', () => {
expect(findScatterPlot(wrapper).props('medianLineData')).toBe(durationData);
});
it('renders the chart description', () => {
expect(findChartDescription(wrapper).attributes('title')).toBe(
DURATION_STAGE_TIME_DESCRIPTION,
);
});
describe('with no chart data', () => {
beforeEach(() => {
wrapper = createComponent({
initialGetters: {
durationChartPlottableData: () => [],
},
rootState: {
selectedStage,
},
rootGetters: {
isOverviewStageSelected: () => false,
},
});
});
it('renders the no data available message', () => {
expect(findContainer(wrapper).text()).toContain(DURATION_STAGE_TIME_NO_DATA);
});
});
});
describe('with no chart data', () => {
describe('with no stages', () => {
beforeEach(() => {
wrapper = createComponent({
initialGetters: {
durationChartPlottableData: () => [],
},
mountFn: mount,
props: { stages: [] },
stubs: { StageDropdownFilter: false },
});
});
it('renders the no data available message', () => {
expect(findContainer(wrapper).text()).toContain(
'There is no data available. Please change your selection.',
);
it('does not render the stage dropdown', () => {
expect(findStageDropdown(wrapper).exists()).toBe(false);
});
});
......
......@@ -249,46 +249,63 @@ export const taskByTypeFilters = {
selectedLabelIds: [1, 2, 3],
};
export const rawDurationData = [
export const transformedDurationData = [
{
average_duration_in_seconds: 1234000,
date: '2019-01-01T00:00:00.000Z',
id: issueStage.id,
selected: true,
data: [
{
average_duration_in_seconds: 1134000, // ~13 days
date: '2019-01-01T00:00:00.000Z',
},
{
average_duration_in_seconds: 2321000, // ~27 days
date: '2019-01-02T00:00:00.000Z',
},
],
},
{
average_duration_in_seconds: 4321000,
date: '2019-01-02T00:00:00.000Z',
id: planStage.id,
selected: true,
data: [
{
average_duration_in_seconds: 2142000, // ~25 days
date: '2019-01-01T00:00:00.000Z',
},
{
average_duration_in_seconds: 3635000, // ~42 days
date: '2019-01-02T00:00:00.000Z',
},
],
},
{
id: codeStage.id,
selected: true,
data: [
{
average_duration_in_seconds: 1234000, // ~14 days
date: '2019-01-01T00:00:00.000Z',
},
{
average_duration_in_seconds: 4321000, // ~50 days
date: '2019-01-02T00:00:00.000Z',
},
],
},
];
export const transformedDurationData = allowedStages.map(({ id }) => ({
id,
selected: true,
data: rawDurationData,
}));
export const flattenedDurationData = [
{ average_duration_in_seconds: 1234000, date: '2019-01-01' },
{ average_duration_in_seconds: 4321000, date: '2019-01-02' },
{ average_duration_in_seconds: 1234000, date: '2019-01-01' },
{ average_duration_in_seconds: 4321000, date: '2019-01-02' },
{ average_duration_in_seconds: 1134000, date: '2019-01-01' },
{ average_duration_in_seconds: 2321000, date: '2019-01-02' },
{ average_duration_in_seconds: 2142000, date: '2019-01-01' },
{ average_duration_in_seconds: 3635000, date: '2019-01-02' },
{ average_duration_in_seconds: 1234000, date: '2019-01-01' },
{ average_duration_in_seconds: 4321000, date: '2019-01-02' },
];
export const durationChartPlottableData = [
['2019-01-01', 14, '2019-01-01'],
['2019-01-02', 50, '2019-01-02'],
];
export const rawDurationMedianData = [
{
average_duration_in_seconds: 1234000,
date: '2018-12-01T00:00:00.000Z',
},
{
average_duration_in_seconds: 4321000,
date: '2018-12-02T00:00:00.000Z',
},
['2019-01-01', 17, '2019-01-01'],
['2019-01-02', 40, '2019-01-02'],
];
export const pathNavIssueMetric = 172800;
......@@ -10,7 +10,6 @@ import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import {
allowedStages as activeStages,
rawDurationData,
transformedDurationData,
endpoints,
valueStreams,
......@@ -64,7 +63,15 @@ describe('DurationChart actions', () => {
describe('fetchDurationData', () => {
beforeEach(() => {
mock.onGet(endpoints.durationData).reply(200, [...rawDurationData]);
// The first 2 stages have different duration values
mock
.onGet(endpoints.durationData)
.replyOnce(200, transformedDurationData[0].data)
.onGet(endpoints.durationData)
.replyOnce(200, transformedDurationData[1].data);
// all subsequent requests should get the same data
mock.onGet(endpoints.durationData).reply(200, transformedDurationData[2].data);
});
it("dispatches the 'requestDurationData' and 'receiveDurationDataSuccess' actions on success", () => {
......
import * as getters from 'ee/analytics/cycle_analytics/store/modules/duration_chart/getters';
import { createdAfter, createdBefore } from 'jest/cycle_analytics/mock_data';
import { transformedDurationData, durationChartPlottableData } from '../../../mock_data';
import {
transformedDurationData,
durationChartPlottableData as mockDurationChartPlottableData,
} from '../../../mock_data';
const rootState = {
createdAfter,
......@@ -8,25 +11,65 @@ const rootState = {
};
describe('DurationChart getters', () => {
const [selectedStage] = transformedDurationData;
const rootGetters = { isOverviewStageSelected: false };
const selectedStageDurationData = [
['2019-01-01', 13, '2019-01-01'],
['2019-01-02', 27, '2019-01-02'],
];
describe('durationChartPlottableData', () => {
it('returns plottable data for selected stages', () => {
const stateWithDurationData = {
durationData: transformedDurationData,
};
describe('with a VSA stage selected', () => {
beforeEach(() => {
rootState.selectedStage = selectedStage;
});
it('returns plottable data for the currently selected stage', () => {
const stateWithDurationData = {
durationData: transformedDurationData,
};
expect(
getters.durationChartPlottableData(
stateWithDurationData,
getters,
rootState,
rootGetters,
),
).toEqual(selectedStageDurationData);
});
expect(getters.durationChartPlottableData(stateWithDurationData, getters, rootState)).toEqual(
durationChartPlottableData,
);
it('returns an empty array if there is no plottable data for the selected stages', () => {
const stateWithDurationData = {
durationData: [],
};
expect(
getters.durationChartPlottableData(
stateWithDurationData,
getters,
rootState,
rootGetters,
),
).toEqual([]);
});
});
});
it('returns an empty array if there is no plottable data for the selected stages', () => {
describe('with the overview stage selected', () => {
beforeEach(() => {
rootGetters.isOverviewStageSelected = true;
});
it('returns plottable data for all available stages', () => {
const stateWithDurationData = {
durationData: [],
durationData: transformedDurationData,
isOverviewStageSelected: true,
};
expect(getters.durationChartPlottableData(stateWithDurationData, getters, rootState)).toEqual(
[],
);
expect(
getters.durationChartPlottableData(stateWithDurationData, getters, rootState, rootGetters),
).toEqual(mockDurationChartPlottableData);
});
});
});
......@@ -10880,15 +10880,12 @@ msgstr ""
msgid "CycleAnalytics|All stages"
msgstr ""
msgid "CycleAnalytics|Average days to completion"
msgid "CycleAnalytics|Average time to completion"
msgstr ""
msgid "CycleAnalytics|Date"
msgstr ""
msgid "CycleAnalytics|Days to completion"
msgstr ""
msgid "CycleAnalytics|Display chart filters"
msgstr ""
......@@ -10926,18 +10923,33 @@ msgstr ""
msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{createdAfter} to %{createdBefore}"
msgstr ""
msgid "CycleAnalytics|Stage time: %{title}"
msgstr ""
msgid "CycleAnalytics|Stages"
msgstr ""
msgid "CycleAnalytics|Tasks by type"
msgstr ""
msgid "CycleAnalytics|The average time spent in the selected stage for the items that were completed on each date. Data limited to the last 500 items."
msgid "CycleAnalytics|The average time items spent in this stage. Data limited to items completed within this date range."
msgstr ""
msgid "CycleAnalytics|The given date range is larger than 180 days"
msgstr ""
msgid "CycleAnalytics|The total time items spent across each value stream stage. Data limited to items completed within this date range."
msgstr ""
msgid "CycleAnalytics|There is no data for 'Stage time' available. Adjust the current filters."
msgstr ""
msgid "CycleAnalytics|There is no data for 'Total time' available. Adjust the current filters."
msgstr ""
msgid "CycleAnalytics|Total time"
msgstr ""
msgid "CycleAnalytics|Type of work"
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