Commit 4ab1ad04 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '346554-vsa-add-total-time-chart-each-stage' into 'master'

Add the total time chart to each VSA stage

See merge request gitlab-org/gitlab!80255
parents c6c31507 9c048ea7
......@@ -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