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>
import { GlEmptyState, GlDaterangePicker,GlLoadingIcon } from '@gitlab/ui';
import { GlEmptyState, GlDaterangePicker, GlLoadingIcon } from '@gitlab/ui';
import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { getDateInPast, getDayDifference } from '~/lib/utils/datetime_utility';
import dateFormat from 'dateformat';
import { s__, sprintf } from '~/locale';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
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 GroupsDropdownFilter from '../../shared/components/groups_dropdown_filter.vue';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
......@@ -14,40 +15,14 @@ import Scatterplot from '../../shared/components/scatterplot.vue';
import StageDropdownFilter from './stage_dropdown_filter.vue';
import SummaryTable from './summary_table.vue';
import StageTable from './stage_table.vue';
import { LAST_ACTIVITY_AT } 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: [] },
);
import { LAST_ACTIVITY_AT, dateFormats } from '../../shared/constants';
export default {
name: 'CycleAnalytics',
components: {
GlLoadingIcon,
<<<<<<< HEAD
GlEmptyState,
=======
GlStackedColumnChart,
>>>>>>> Scaffold ui for type of work chart
GroupsDropdownFilter,
ProjectsDropdownFilter,
SummaryTable,
......@@ -90,7 +65,7 @@ export default {
'featureFlags',
'isLoading',
'isLoadingStage',
'isLoadingChartData',
'isLoadingTasksByTypeChart',
'isLoadingDurationChart',
'isEmptyStage',
'isSavingCustomStage',
......@@ -109,7 +84,7 @@ export default {
'tasksByType',
'medians',
]),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData','tasksByTypeData']),
...mapGetters(['hasNoAccessError', 'currentGroupPath', 'durationChartPlottableData']),
shouldRenderEmptyState() {
return !this.selectedGroup;
},
......@@ -137,18 +112,54 @@ export default {
return this.startDate && this.endDate;
},
typeOfWork() {
if (!this.hasDateRangeSet) {
return { option: { legend: false }, datatset: [], range: [] };
}
const range = generateDatesBetweenStartAndEnd(this.startDate, this.endDate).reverse();
// generate settings for the tasksByType chart
// if (!this.hasDateRangeSet) {
// return { option: { legend: false }, datatset: [], range: [] };
// }
// 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 {
option: { legend: false },
range,
...prepareDataset({ dataset: this.tasksByTypeData, range }),
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() {
// console.log('this.tasksByType', this.tasksByType);
this.initDateRange();
this.setFeatureFlags({
hasDurationChart: this.glFeatures.cycleAnalyticsScatterplotEnabled,
......@@ -342,6 +353,7 @@ export default {
</template>
</div>
<div v-if="hasDateRangeSet">
<!-- TODO: move into component file -->
<div class="row">
<div class="col-12">
<h2>{{ __('Type of work') }}</h2>
......@@ -349,7 +361,7 @@ export default {
</div>
</div>
<div class="row">
<div class="col-6">
<div class="col-12">
<header>
<h3>{{ __('Tasks by type') }}</h3>
</header>
......
......@@ -279,7 +279,7 @@ export const fetchTasksByTypeData = ({ dispatch, state, getters }) => {
dispatch('requestTasksByTypeData');
return Api.cycleAnalyticsTasksByType(params)
.then(data => dispatch('receiveTasksByTypeDataSuccess', data))
.then(({ data }) => dispatch('receiveTasksByTypeDataSuccess', data))
.catch(error => dispatch('receiveTasksByTypeDataError', error));
}
return Promise.resolve();
......
......@@ -6,7 +6,8 @@ import { getDateInPast } from '~/lib/utils/datetime_utility';
import { toYmd } from '../../shared/utils';
const today = new Date();
const dataRange = [...Array(30).keys()]
const generateRange = (limit = 30) =>
[...Array(limit).keys()]
.map(i => {
const d = getDateInPast(today, i);
return toYmd(new Date(d));
......@@ -24,9 +25,11 @@ 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: {
......@@ -35,7 +38,7 @@ const fakeApiResponse = convertObjectPropsToCamelCase(
color: '#428BCA',
text_color: '#FFFFFF',
},
series: [genSeries()],
series: [genSeries(dayRange)],
},
{
label: {
......@@ -44,7 +47,7 @@ const fakeApiResponse = convertObjectPropsToCamelCase(
color: '#327BCA',
text_color: '#FFFFFF',
},
series: [genSeries()],
series: [genSeries(dayRange)],
},
{
label: {
......@@ -53,12 +56,27 @@ const fakeApiResponse = convertObjectPropsToCamelCase(
color: '#428BCA',
text_color: '#FFFFFF',
},
series: [genSeries()],
series: [genSeries(dayRange)],
},
],
{ 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 => {
return plottableData.length ? plottableData : null;
};
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 {
);
},
[types.REQUEST_TASKS_BY_TYPE_DATA](state) {
state.isLoadingChartData = true;
state.isLoadingTasksByTypeChart = true;
},
[types.RECEIVE_TASKS_BY_TYPE_DATA_ERROR](state) {
state.isLoadingChartData = false;
state.isLoadingTasksByTypeChart = false;
},
[types.RECEIVE_TASKS_BY_TYPE_DATA_SUCCESS](state, data) {
state.isLoadingChartData = false;
state.isLoadingTasksByTypeChart = false;
state.tasksByType = {
...state.tasksByType,
data,
data: convertObjectPropsToCamelCase(data, { deep: true }),
};
},
[types.REQUEST_CREATE_CUSTOM_STAGE](state) {
......@@ -176,5 +176,3 @@ export default {
state.isLoadingDurationChart = false;
},
};
......@@ -8,7 +8,7 @@ export default () => ({
isLoading: false,
isLoadingStage: false,
isLoadingChartData: false,
isLoadingTasksByTypeChart: false,
isLoadingDurationChart: false,
isEmptyStage: false,
......
......@@ -2,9 +2,10 @@ import { isString, isNumber } from 'underscore';
import dateFormat from 'dateformat';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
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 { STAGE_NAME } from './constants';
import { toYmd } from '../shared/utils';
const EVENT_TYPE_LABEL = 'label';
......@@ -189,5 +190,58 @@ export const getDurationChartData = (data, startDate, endDate) => {
return eventData;
};
// takes the type of work data and converts to a k:v structure
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';
import * as types from 'ee/analytics/cycle_analytics/store/mutation_types';
import { DEFAULT_DAYS_IN_PAST } from 'ee/analytics/cycle_analytics/constants';
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 { toYmd } from 'ee/analytics/shared/utils';
const endpoints = {
customizableCycleAnalyticsStagesAndEvents: 'analytics/cycle_analytics/stages.json', // customizable stages and events endpoint
......@@ -69,7 +70,8 @@ export const stageMedians = defaultStages.reduce((acc, stage) => {
}, {});
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 issueEvents = deepCamelCase(stageFixtures.issue);
......@@ -121,7 +123,22 @@ export const customStageEvents = [
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 = [
{
......
......@@ -10,6 +10,8 @@ import {
getDurationChartData,
transformRawStages,
isPersistedStage,
getTasksByTypeData,
flattenTaskByTypeSeries,
} from 'ee/analytics/cycle_analytics/utils';
import {
customStageEvents as events,
......@@ -23,6 +25,7 @@ import {
endDate,
issueStage,
rawCustomStage,
tasksByTypeData,
} from './mock_data';
const labelEvents = [labelStartEvent, labelStopEvent].map(i => i.identifier);
......@@ -199,4 +202,70 @@ describe('Cycle analytics utils', () => {
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
sign_in(user)
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' }
get(:show, params: params, format: :json)
......
......@@ -2480,6 +2480,9 @@ msgstr ""
msgid "Background color"
msgstr ""
msgid "Backstage"
msgstr ""
msgid "Badges"
msgstr ""
......@@ -2897,6 +2900,9 @@ msgstr ""
msgid "Browse files"
msgstr ""
msgid "Bug"
msgstr ""
msgid "BuildArtifacts|An error occurred while fetching the artifacts"
msgstr ""
......@@ -5541,6 +5547,12 @@ msgstr ""
msgid "CycleAnalyticsEvent|Issue closed"
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"
msgstr ""
......@@ -7770,6 +7782,9 @@ msgstr ""
msgid "Favicon was successfully removed."
msgstr ""
msgid "Feature"
msgstr ""
msgid "Feature Flags"
msgstr ""
......@@ -17941,6 +17956,9 @@ msgstr ""
msgid "Target-Branch"
msgstr ""
msgid "Tasks by type"
msgstr ""
msgid "Team"
msgstr ""
......@@ -19533,6 +19551,9 @@ msgstr ""
msgid "Type/State"
msgstr ""
msgid "Type of work"
msgstr ""
msgid "U2F Devices (%{length})"
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