Commit 70135cce authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Transform chart data from key values

Transforms the chart data received from
the api ready for display in the ui.

Minor prettier and gitlab pot regenerate
parent 21b1e874
<script> <script>
import { GlEmptyState, GlDaterangePicker,GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState, GlDaterangePicker, GlLoadingIcon } from '@gitlab/ui';
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts'; import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale'; import dateFormat from 'dateformat';
import createFlash from '~/flash'; import { s__, sprintf } from '~/locale';
import { getDateInPast, getDayDifference } from '~/lib/utils/datetime_utility'; import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { prepareLabelDatasetForChart, generateDatesInRange } from '../utils';
import { PROJECTS_PER_PAGE, DEFAULT_DAYS_IN_PAST } from '../constants'; import { PROJECTS_PER_PAGE, DEFAULT_DAYS_IN_PAST } from '../constants';
import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue'; import GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
...@@ -14,40 +15,14 @@ import Scatterplot from '../../shared/components/scatterplot.vue'; ...@@ -14,40 +15,14 @@ import Scatterplot from '../../shared/components/scatterplot.vue';
import StageDropdownFilter from './stage_dropdown_filter.vue'; import StageDropdownFilter from './stage_dropdown_filter.vue';
import SummaryTable from './summary_table.vue'; import SummaryTable from './summary_table.vue';
import StageTable from './stage_table.vue'; import StageTable from './stage_table.vue';
import { LAST_ACTIVITY_AT } from '../../shared/constants'; import { LAST_ACTIVITY_AT, dateFormats } from '../../shared/constants';
import { toYmd } from '../../shared/utils';
const generateDatesBetweenStartAndEnd = (start, end) => {
const dayDifference = getDayDifference(start, end);
return [...Array(dayDifference).keys()].map(i => {
const d = getDateInPast(end, i);
return toYmd(new Date(d));
});
};
const prepareDataset = ({ dataset, range }) =>
dataset.reduce(
(acc, curr) => {
const {
label: { title },
series: [datapoints],
} = curr;
acc.seriesNames = [...acc.seriesNames, title];
acc.data = [...acc.data, range.map(index => (datapoints[index] ? datapoints[index] : 0))];
return acc;
},
{ data: [], seriesNames: [] },
);
export default { export default {
name: 'CycleAnalytics', name: 'CycleAnalytics',
components: { components: {
GlLoadingIcon, GlLoadingIcon,
<<<<<<< HEAD
GlEmptyState, GlEmptyState,
=======
GlStackedColumnChart, GlStackedColumnChart,
>>>>>>> Scaffold ui for type of work chart
GroupsDropdownFilter, GroupsDropdownFilter,
ProjectsDropdownFilter, ProjectsDropdownFilter,
SummaryTable, SummaryTable,
...@@ -90,7 +65,7 @@ export default { ...@@ -90,7 +65,7 @@ export default {
'featureFlags', 'featureFlags',
'isLoading', 'isLoading',
'isLoadingStage', 'isLoadingStage',
'isLoadingChartData', 'isLoadingTasksByTypeChart',
'isLoadingDurationChart', 'isLoadingDurationChart',
'isEmptyStage', 'isEmptyStage',
'isSavingCustomStage', 'isSavingCustomStage',
...@@ -109,7 +84,7 @@ export default { ...@@ -109,7 +84,7 @@ export default {
'tasksByType', 'tasksByType',
'medians', 'medians',
]), ]),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData','tasksByTypeData']), ...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']),
shouldRenderEmptyState() { shouldRenderEmptyState() {
return !this.selectedGroup; return !this.selectedGroup;
}, },
...@@ -137,18 +112,54 @@ export default { ...@@ -137,18 +112,54 @@ export default {
return this.startDate && this.endDate; return this.startDate && this.endDate;
}, },
typeOfWork() { typeOfWork() {
if (!this.hasDateRangeSet) { // generate settings for the tasksByType chart
return { option: { legend: false }, datatset: [], range: [] }; // if (!this.hasDateRangeSet) {
} // return { option: { legend: false }, datatset: [], range: [] };
const range = generateDatesBetweenStartAndEnd(this.startDate, this.endDate).reverse(); // }
// const range = generateDatesInRange(this.startDate, this.endDate).reverse();
// // TODO: diff and data should be replaced with the tasksByTypeData getter
// const diff = range.length + 1;
// const rawData = typeOfWork(diff);
// const { data, seriesNames } = prepareLabelDatasetForChart({
// dataset: Object.values(rawData),
// range,
// });
return { return {
option: { legend: false }, option: { legend: false },
range, range: [],
...prepareDataset({ dataset: this.tasksByTypeData, range }), data: [],
seriesNames: [],
}; };
}, },
chartDataDescription() {
if (this.selectedGroup) {
const selectedProjectCount = this.setSelectedProjects.length;
const { startDate, endDate } = this;
const { name: groupName } = this.selectedGroup;
const str =
selectedProjectCount > 0
? s__(
"CycleAnalyticsCharts|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}",
)
: s__(
"CycleAnalyticsCharts|Showing data for group '%{groupName}' from %{startDate} to %{endDate}",
);
return sprintf(str, {
startDate: dateFormat(startDate, dateFormats.defaultDate),
endDate: dateFormat(endDate, dateFormats.defaultDate),
groupName,
selectedProjectCount,
});
}
return null;
},
}, },
mounted() { mounted() {
// console.log('this.tasksByType', this.tasksByType);
this.initDateRange(); this.initDateRange();
this.setFeatureFlags({ this.setFeatureFlags({
hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled, hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled,
...@@ -342,6 +353,7 @@ export default { ...@@ -342,6 +353,7 @@ export default {
</template> </template>
</div> </div>
<div v-if="hasDateRangeSet"> <div v-if="hasDateRangeSet">
<!-- TODO: move into component file -->
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<h2>{{ __('Type of work') }}</h2> <h2>{{ __('Type of work') }}</h2>
...@@ -349,7 +361,7 @@ export default { ...@@ -349,7 +361,7 @@ export default {
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-12">
<header> <header>
<h3>{{ __('Tasks by type') }}</h3> <h3>{{ __('Tasks by type') }}</h3>
</header> </header>
......
...@@ -279,7 +279,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => { ...@@ -279,7 +279,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
dispatch('requestTasksByTypeData'); dispatch('requestTasksByTypeData');
return Api.cycleAnalyticsTasksByType(params) return Api.cycleAnalyticsTasksByType(params)
.then(data => dispatch('receiveTasksByTypeDataSuccess', data)) .then(({ data }) => dispatch('receiveTasksByTypeDataSuccess', data))
.catch(error => dispatch('receiveTasksByTypeDataError', error)); .catch(error => dispatch('receiveTasksByTypeDataError', error));
} }
return Promise.resolve(); return Promise.resolve();
......
...@@ -6,12 +6,13 @@ import { getDateInPast } from '~/lib/utils/datetime_utility'; ...@@ -6,12 +6,13 @@ import { getDateInPast } from '~/lib/utils/datetime_utility';
import { toYmd } from '../../shared/utils'; import { toYmd } from '../../shared/utils';
const today = new Date(); const today = new Date();
const dataRange = [...Array(30).keys()] const generateRange = (limit = 30) =>
.map(i => { [...Array(limit).keys()]
const d = getDateInPast(today, i); .map(i => {
return toYmd(new Date(d)); const d = getDateInPast(today, i);
}) return toYmd(new Date(d));
.reverse(); })
.reverse();
function randomInt(range) { function randomInt(range) {
return Math.floor(Math.random() * Math.floor(range)); return Math.floor(Math.random() * Math.floor(range));
...@@ -24,41 +25,58 @@ function arrayToObject(arr) { ...@@ -24,41 +25,58 @@ function arrayToObject(arr) {
}, {}); }, {});
} }
const genSeries = () => arrayToObject(dataRange.map(key => [key, randomInt(100)])); const genSeries = dayRange =>
arrayToObject(generateRange(dayRange).map(key => [key, randomInt(100)]));
const fakeApiResponse = convertObjectPropsToCamelCase( const generateApiResponse = dayRange =>
[ convertObjectPropsToCamelCase(
{ [
label: { {
id: 1, label: {
title: __('Bug'), id: 1,
color: '#428BCA', title: __('Bug'),
text_color: '#FFFFFF', color: '#428BCA',
text_color: '#FFFFFF',
},
series: [genSeries(dayRange)],
}, },
series: [genSeries()], {
}, label: {
{ id: 3,
label: { title: __('Backstage'),
id: 3, color: '#327BCA',
title: __('Backstage'), text_color: '#FFFFFF',
color: '#327BCA', },
text_color: '#FFFFFF', series: [genSeries(dayRange)],
}, },
series: [genSeries()], {
}, label: {
{ id: 2,
label: { title: __('Feature'),
id: 2, color: '#428BCA',
title: __('Feature'), text_color: '#FFFFFF',
color: '#428BCA', },
text_color: '#FFFFFF', series: [genSeries(dayRange)],
}, },
series: [genSeries()], ],
}, { deep: true },
], );
{ deep: true },
);
const transformResponseToLabelHash = data => {}; const transformResponseToLabelHash = data =>
data.reduce(
(acc, { label: { id, ...labelRest }, series }) => ({
...acc,
[id]: {
label: { id, ...labelRest },
series,
},
}),
{},
);
export const typeOfWork = dayRange =>
transformResponseToLabelHash(
convertObjectPropsToCamelCase(generateApiResponse(dayRange), { deep: true }),
);
export const typeOfWork = convertObjectPropsToCamelCase(fakeApiResponse, { deep: true }); export default {};
...@@ -26,4 +26,4 @@ export const durationChartPlottableData = state => { ...@@ -26,4 +26,4 @@ export const durationChartPlottableData = state => {
return plottableData.length ? plottableData : null; return plottableData.length ? plottableData : null;
}; };
export const tasksByTypeData = state => export const tasksByTypeData = state =>
state.tasksByType && state.tasksByType.data ? state.tasksByType.data : []; state.tasksByType && state.tasksByType.data ? state.tasksByType.data : {};
...@@ -133,16 +133,16 @@ export default { ...@@ -133,16 +133,16 @@ export default {
); );
}, },
[types.REQUEST_TASKS_BY_TYPE_DATA](state) { [types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true; state.isLoadingTasksByTypeChart = true;
}, },
[types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) { [types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) {
state.isLoadingChartData = false; state.isLoadingTasksByTypeChart = false;
}, },
[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) { [types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) {
state.isLoadingChartData = false; state.isLoadingTasksByTypeChart = false;
state.tasksByType = { state.tasksByType = {
...state.tasksByType, ...state.tasksByType,
data, data: convertObjectPropsToCamelCase(data, { deep: true }),
}; };
}, },
[types.REQUEST_CREATE_CUSTOM_STAGE](state) { [types.REQUEST_CREATE_CUSTOM_STAGE](state) {
...@@ -176,5 +176,3 @@ export default { ...@@ -176,5 +176,3 @@ export default {
state.isLoadingDurationChart = false; state.isLoadingDurationChart = false;
}, },
}; };
...@@ -8,7 +8,7 @@ export default () => ({ ...@@ -8,7 +8,7 @@ export default () => ({
isLoading: false, isLoading: false,
isLoadingStage: false, isLoadingStage: false,
isLoadingChartData: false, isLoadingTasksByTypeChart: false,
isLoadingDurationChart: false, isLoadingDurationChart: false,
isEmptyStage: false, isEmptyStage: false,
......
...@@ -2,9 +2,10 @@ import { isString, isNumber } from 'underscore'; ...@@ -2,9 +2,10 @@ import { isString, isNumber } from 'underscore';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { newDate, dayAfter, secondsToDays } from '~/lib/utils/datetime_utility'; import { newDate, dayAfter, secondsToDays, getDatesInRange } from '~/lib/utils/datetime_utility';
import { dateFormats } from '../shared/constants'; import { dateFormats } from '../shared/constants';
import { STAGE_NAME } from './constants'; import { STAGE_NAME } from './constants';
import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label'; const EVENT_TYPE_LABEL = 'label';
...@@ -189,5 +190,58 @@ export const getDurationChartData = (data, startDate, endDate) => { ...@@ -189,5 +190,58 @@ export const getDurationChartData = (data, startDate, endDate) => {
return eventData; return eventData;
}; };
// takes the type of work data and converts to a k:v structure // takes the type of work data and converts to a k:v structure
export const transformRawTypeOfWorkData = raw => {}; export const transformRawTypeOfWorkData = raw => {};
export const prepareLabelDatasetForChart = ({ dataset, range }) =>
dataset.reduce(
(acc, curr) => {
const {
label: { title },
series: [datapoints],
} = curr;
acc.seriesNames = [...acc.seriesNames, title];
acc.data = [...acc.data, range.map(index => (datapoints[index] ? datapoints[index] : 0))];
return acc;
},
{ data: [], seriesNames: [] },
);
export const flattenTaskByTypeSeries = series =>
series.map(dataSet => {
// ignore the date, just return the value
return dataSet[1];
});
// TODO: docblocks
export const getTasksByTypeData = ({ data, startDate, endDate }) => {
// TODO: check that the date range and datapoint values are in the same order
const range = getDatesInRange(startDate, endDate, toYmd).reverse();
// TODO: handle zero's?
// TODO: fixup seeded data so it falls in the correct date range
const transformed = data.reduce(
(acc, curr) => {
const {
label: { title },
series,
} = curr;
// TODO: double check if BE fills in all the dates and adds zeros
acc.seriesNames = [...acc.seriesNames, title];
// TODO: maybe flatmap
acc.data = [...acc.data, flattenTaskByTypeSeries(series)];
return acc;
},
{
data: [],
seriesNames: [],
},
);
return {
...transformed,
range,
};
};
...@@ -4,8 +4,9 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations'; ...@@ -4,8 +4,9 @@ import mutations from 'ee/analytics/cycle_analytics/store/mutations';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types'; import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants'; import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime_utility'; import { getDateInPast, getDatesInRange } from '~/lib/utils/datetime_utility';
import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data'; import { mockLabels } from '../../../../../spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data';
import { toYmd } from 'ee/analytics/shared/utils';
const endpoints = { const endpoints = {
customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint
...@@ -69,7 +70,8 @@ export const stageMedians = defaultStages.reduce((acc, stage) => { ...@@ -69,7 +70,8 @@ export const stageMedians = defaultStages.reduce((acc, stage) => {
}, {}); }, {});
export const endDate = new Date(2019, 0, 14); export const endDate = new Date(2019, 0, 14);
export const startDate = getDateInPast(endDate, DEFAULT_DAYS_IN_PAST); // Limit to just 5 days data for testing
export const startDate = getDateInPast(endDate, 4);
export const rawIssueEvents = stageFixtures.issue; export const rawIssueEvents = stageFixtures.issue;
export const issueEvents = deepCamelCase(stageFixtures.issue); export const issueEvents = deepCamelCase(stageFixtures.issue);
...@@ -121,7 +123,22 @@ export const customStageEvents = [ ...@@ -121,7 +123,22 @@ export const customStageEvents = [
labelStopEvent, labelStopEvent,
]; ];
export const tasksByTypeData = getJSONFixture('analytics/type_of_work/tasks_by_type.json'); const dateRange = getDatesInRange(startDate, endDate, toYmd);
export const tasksByTypeData = convertObjectPropsToCamelCase(
getJSONFixture('analytics/type_of_work/tasks_by_type.json').map(labelData => {
// add data points for our mock date range
const maxValue = 10;
const series = dateRange.map(date => [date, Math.floor(Math.random() * Math.floor(maxValue))]);
return {
...labelData,
series,
};
}),
{
deep: true,
},
);
export const rawDurationData = [ export const rawDurationData = [
{ {
......
...@@ -10,6 +10,8 @@ import { ...@@ -10,6 +10,8 @@ import {
getDurationChartData, getDurationChartData,
transformRawStages, transformRawStages,
isPersistedStage, isPersistedStage,
getTasksByTypeData,
flattenTaskByTypeSeries,
} from 'ee/analytics/cycle_analytics/utils'; } from 'ee/analytics/cycle_analytics/utils';
import { import {
customStageEvents as events, customStageEvents as events,
...@@ -23,6 +25,7 @@ import { ...@@ -23,6 +25,7 @@ import {
endDate, endDate,
issueStage, issueStage,
rawCustomStage, rawCustomStage,
tasksByTypeData,
} from './mock_data'; } from './mock_data';
const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier); const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier);
...@@ -199,4 +202,70 @@ describe('Cycle analytics utils', () => { ...@@ -199,4 +202,70 @@ describe('Cycle analytics utils', () => {
expect(isPersistedStage({ custom, id })).toEqual(expected); expect(isPersistedStage({ custom, id })).toEqual(expected);
}); });
}); });
describe.skip('flattenTaskByTypeSeries', () => {});
describe.only('getTasksByTypeData', () => {
let transformed = {};
const rawData = tasksByTypeData;
const labels = rawData.map(d => {
const { label } = d;
return label.title;
});
const data = rawData.map(d => {
const { series } = d;
return flattenTaskByTypeSeries(series);
});
const range = [];
console.log('rawData', rawData);
// console.log('labels', labels);
console.log('data', data);
beforeEach(() => {
transformed = getTasksByTypeData({ data: rawData, startDate, endDate });
});
it('will return an object with the properties needed for the chart', () => {
['seriesNames', 'data', 'range'].forEach(key => {
expect(transformed).toHaveProperty(key);
});
});
describe('seriesNames', () => {
it('returns the names of all the labels in the dataset', () => {
expect(transformed.seriesNames).toEqual(labels);
});
});
describe('range', () => {
it('returns the date range as an array', () => {
expect(transformed.range).toEqual(range);
});
it('includes each day between the start date and end date', () => {
expect(transformed.range).toEqual(range);
});
it('includes the start date and end date', () => {
expect(transformed.range).toContain(startDate);
expect(transformed.range).toContain(endDate);
});
});
describe('data', () => {
it('returns an array of data points', () => {
expect(transformed.data).toEqual(data);
});
it('contains an array of data for each label', () => {
expect(transformed.data.length).toEqual(labels.length);
});
it('contains a value for each day in the range', () => {
transformed.data.forEach(d => {
expect(d.length).toEqual(transformed.range.length);
});
});
});
});
}); });
...@@ -219,7 +219,7 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do ...@@ -219,7 +219,7 @@ describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
sign_in(user) sign_in(user)
end end
it 'analytics/type_of_work/tasks_by_type.json' do it 'analytics/type_of_work/tasks_by_type?created_before=":created_before"' do
params = { group_id: group.full_path, label_ids: [label.id, label2.id, label3.id], created_after: 10.days.ago, subject: 'Issue' } params = { group_id: group.full_path, label_ids: [label.id, label2.id, label3.id], created_after: 10.days.ago, subject: 'Issue' }
get(:show, params: params, format: :json) get(:show, params: params, format: :json)
......
...@@ -2480,6 +2480,9 @@ msgstr "" ...@@ -2480,6 +2480,9 @@ msgstr ""
msgid "Background color" msgid "Background color"
msgstr "" msgstr ""
msgid "Backstage"
msgstr ""
msgid "Badges" msgid "Badges"
msgstr "" msgstr ""
...@@ -2897,6 +2900,9 @@ msgstr "" ...@@ -2897,6 +2900,9 @@ msgstr ""
msgid "Browse files" msgid "Browse files"
msgstr "" msgstr ""
msgid "Bug"
msgstr ""
msgid "BuildArtifacts|An error occurred while fetching the artifacts" msgid "BuildArtifacts|An error occurred while fetching the artifacts"
msgstr "" msgstr ""
...@@ -5541,6 +5547,12 @@ msgstr "" ...@@ -5541,6 +5547,12 @@ msgstr ""
msgid "CycleAnalyticsEvent|Issue closed" msgid "CycleAnalyticsEvent|Issue closed"
msgstr "" msgstr ""
msgid "CycleAnalyticsCharts|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}"
msgstr ""
msgid "CycleAnalyticsCharts|Showing data for group '%{groupName}' from %{startDate} to %{endDate}"
msgstr ""
msgid "CycleAnalyticsEvent|Issue created" msgid "CycleAnalyticsEvent|Issue created"
msgstr "" msgstr ""
...@@ -7770,6 +7782,9 @@ msgstr "" ...@@ -7770,6 +7782,9 @@ msgstr ""
msgid "Favicon was successfully removed." msgid "Favicon was successfully removed."
msgstr "" msgstr ""
msgid "Feature"
msgstr ""
msgid "Feature Flags" msgid "Feature Flags"
msgstr "" msgstr ""
...@@ -17941,6 +17956,9 @@ msgstr "" ...@@ -17941,6 +17956,9 @@ msgstr ""
msgid "Target-Branch" msgid "Target-Branch"
msgstr "" msgstr ""
msgid "Tasks by type"
msgstr ""
msgid "Team" msgid "Team"
msgstr "" msgstr ""
...@@ -19533,6 +19551,9 @@ msgstr "" ...@@ -19533,6 +19551,9 @@ msgstr ""
msgid "Type/State" msgid "Type/State"
msgstr "" msgstr ""
msgid "Type of work"
msgstr ""
msgid "U2F Devices (%{length})" msgid "U2F Devices (%{length})"
msgstr "" 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