Commit 3504a360 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Kushal Pandya

Display toggle for aggregation

Adds a GlToggle component to allow
users to enable / disable the VSA aggregated
backend.
parent c33c5d65
<script>
import { GlIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import DateRange from '~/analytics/shared/components/daterange.vue';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { DATE_RANGE_LIMIT, PROJECTS_PER_PAGE } from '~/analytics/shared/constants';
import FilterBar from './filter_bar.vue';
export const AGGREGATION_TOGGLE_LABEL = s__('CycleAnalytics|Filter by stop date');
export const AGGREGATION_DESCRIPTION = s__(
'CycleAnalytics|When enabled, the results show items with a stop event within the date range. When disabled, the results show items with a start event within the date range.',
);
export default {
name: 'ValueStreamFilters',
components: {
GlIcon,
GlToggle,
DateRange,
ProjectsDropdownFilter,
FilterBar,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
selectedProjects: {
type: Array,
......@@ -45,6 +57,21 @@ export default {
required: false,
default: null,
},
canToggleAggregation: {
type: Boolean,
required: false,
default: false,
},
isAggregationEnabled: {
type: Boolean,
required: false,
default: false,
},
isUpdatingAggregationData: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
projectsQueryParams() {
......@@ -54,8 +81,19 @@ export default {
};
},
},
methods: {
onUpdateAggregation(ev) {
if (!this.isUpdatingAggregationData) {
this.$emit('toggleAggregation', ev);
}
},
},
multiProjectSelect: true,
maxDateRange: DATE_RANGE_LIMIT,
i18n: {
AGGREGATION_TOGGLE_LABEL,
AGGREGATION_DESCRIPTION,
},
};
</script>
<template>
......@@ -84,7 +122,28 @@ export default {
@selected="$emit('selectProject', $event)"
/>
</div>
<div>
<div class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row">
<div
v-if="canToggleAggregation"
class="gl-display-flex gl-text-align-center gl-my-2 gl-lg-mt-0 gl-lg-mb-0 gl-mr-5"
>
<gl-toggle
class="gl-flex-direction-row"
:value="isAggregationEnabled"
:label="$options.i18n.AGGREGATION_TOGGLE_LABEL"
:disabled="isUpdatingAggregationData"
label-position="left"
@change="onUpdateAggregation"
>
<template #label>
{{ $options.i18n.AGGREGATION_TOGGLE_LABEL }}&nbsp;<gl-icon
v-gl-tooltip.hover
:title="$options.i18n.AGGREGATION_DESCRIPTION"
name="information-o"
/>
</template>
</gl-toggle>
</div>
<date-range
v-if="hasDateRangeFilter"
:start-date="startDate"
......
<script>
import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
......@@ -27,6 +31,7 @@ export default {
ValueStreamSelect,
UrlSync,
},
mixins: [glFeatureFlagsMixin()],
props: {
emptyStateSvgPath: {
type: String,
......@@ -58,6 +63,7 @@ export default {
'selectedValueStream',
'pagination',
'aggregation',
'isUpdatingAggregation',
]),
...mapGetters([
'hasNoAccessError',
......@@ -84,8 +90,11 @@ export default {
hasDateRangeSet() {
return this.createdAfter && this.createdBefore;
},
canToggleAggregation() {
return this.glFeatures.useVsaAggregatedTables;
},
isAggregationEnabled() {
return this.aggregation?.enabled;
return this.canToggleAggregation && this.aggregation?.enabled;
},
query() {
const { project_ids, created_after, created_before } = this.cycleAnalyticsRequestParams;
......@@ -123,6 +132,7 @@ export default {
'setDefaultSelectedStage',
'setDateRange',
'updateStageTablePagination',
'updateAggregation',
]),
onProjectsSelect(projects) {
this.setSelectedProjects(projects);
......@@ -144,6 +154,37 @@ export default {
onHandleUpdatePagination(data) {
this.updateStageTablePagination(data);
},
onToggleAggregation(value) {
this.updateAggregation(value)
.then(() => {
this.$toast.show(
value
? s__('CycleAnalytics|Aggregation enabled')
: s__('CycleAnalytics|Aggregation disabled'),
);
/*
* NOTE: We have opted for a hard page refresh here as the cleanest way to
* ensure users will be seeing accurate information when this request succeeds, or correctly
* prompted to create a value stream.
*
* With https://gitlab.com/groups/gitlab-org/-/epics/6046 we are changing how we calculate
* data for value stream analytics. One of the side effects will be removing the "in memory"
* default value stream that has currently been available when there are no custom value streams.
*
* All the API requests require at least 1 value stream to exist in the group, if there are no
* value streams available we will instead display an empty state with next steps on how
* to set up your first custom value stream: https://gitlab.com/gitlab-org/gitlab/-/issues/351853.
*/
refreshCurrentPage();
})
.catch(() => {
createFlash({
message: s__(
'CycleAnalytics|There was an error updating the aggregation status, please try again.',
),
});
});
},
},
METRICS_REQUESTS,
aggregationPopoverOptions: {
......@@ -187,6 +228,10 @@ export default {
:selected-projects="selectedProjects"
:start-date="createdAfter"
:end-date="createdBefore"
:can-toggle-aggregation="canToggleAggregation"
:is-aggregation-enabled="isAggregationEnabled"
:is-updating-aggregation-data="isLoading || isUpdatingAggregation"
@toggleAggregation="onToggleAggregation"
@selectProject="onProjectsSelect"
@setDateRange="onSetDateRange"
/>
......
......@@ -2,6 +2,18 @@ import Api from 'ee/api';
import { FETCH_VALUE_STREAM_DATA } from '../../constants';
import * as types from '../mutation_types';
export const updateAggregation = ({ commit, getters }, status) => {
const { currentGroupPath } = getters;
commit(types.REQUEST_UPDATE_AGGREGATION);
return Api.cycleAnalyticsUpdateAggregation(currentGroupPath, { enabled: status })
.then(() => commit(types.RECEIVE_UPDATE_AGGREGATION_SUCCESS))
.catch((err) => {
commit(types.RECEIVE_UPDATE_AGGREGATION_ERROR);
throw err;
});
};
export const receiveCreateValueStreamSuccess = ({ commit, dispatch }, valueStream = {}) => {
commit(types.RECEIVE_CREATE_VALUE_STREAM_SUCCESS, valueStream);
return dispatch('fetchCycleAnalyticsData');
......
......@@ -49,3 +49,7 @@ export const RECEIVE_DELETE_VALUE_STREAM_ERROR = 'RECEIVE_DELETE_VALUE_STREAM_ER
export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS';
export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS';
export const RECEIVE_VALUE_STREAMS_ERROR = 'RECEIVE_VALUE_STREAMS_ERROR';
export const REQUEST_UPDATE_AGGREGATION = 'REQUEST_UPDATE_AGGREGATION';
export const RECEIVE_UPDATE_AGGREGATION_SUCCESS = 'RECEIVE_UPDATE_AGGREGATION_SUCCESS';
export const RECEIVE_UPDATE_AGGREGATION_ERROR = 'RECEIVE_UPDATE_AGGREGATION_ERROR';
......@@ -200,4 +200,13 @@ export default {
direction: direction || PAGINATION_SORT_DIRECTION_DESC,
});
},
[types.REQUEST_UPDATE_AGGREGATION](state) {
state.isUpdatingAggregation = true;
},
[types.RECEIVE_UPDATE_AGGREGATION_SUCCESS](state) {
state.isUpdatingAggregation = true;
},
[types.RECEIVE_UPDATE_AGGREGATION_ERROR](state) {
state.isUpdatingAggregation = false;
},
};
......@@ -28,6 +28,7 @@ export default () => ({
isEditingValueStream: false,
isDeletingValueStream: false,
isFetchingGroupLabels: false,
isUpdatingAggregation: false,
createValueStreamErrors: {},
deleteValueStreamError: null,
......
......@@ -23,6 +23,8 @@ export default {
cycleAnalyticsStagePath:
'/groups/:id/-/analytics/value_stream_analytics/value_streams/:value_stream_id/stages/:stage_id',
cycleAnalyticsGroupLabelsPath: '/groups/:namespace_path/-/labels.json',
cycleAnalyticsAggregationPath:
'/groups/:namespace_path/-/analytics/value_stream_analytics/use_aggregated_backend',
codeReviewAnalyticsPath: '/api/:version/analytics/code_review',
groupActivityIssuesPath: '/api/:version/analytics/group_activity/issues_count',
groupActivityMergeRequestsPath: '/api/:version/analytics/group_activity/merge_requests_count',
......@@ -209,6 +211,14 @@ export default {
});
},
cycleAnalyticsUpdateAggregation(groupId, data) {
const url = Api.buildUrl(this.cycleAnalyticsAggregationPath).replace(
':namespace_path',
groupId,
);
return axios.put(url, data);
},
codeReviewAnalytics(params = {}) {
const url = Api.buildUrl(this.codeReviewAnalyticsPath);
return axios.get(url, { params });
......
......@@ -16,6 +16,10 @@ class Groups::Analytics::CycleAnalyticsController < Groups::Analytics::Applicati
render_403 unless can?(current_user, :read_group_cycle_analytics, @group)
end
before_action do
push_frontend_feature_flag(:use_vsa_aggregated_tables, @group, default_enabled: :yaml)
end
layout 'group'
track_redis_hll_event :show, name: 'g_analytics_valuestream'
......
......@@ -141,6 +141,12 @@ describe('EE Value Stream Analytics component', () => {
noAccessSvgPath,
...props,
},
provide: {
glFeatures: {
useVsaAggregatedTables: true,
...featureFlags,
},
},
mocks,
...opts,
});
......@@ -374,6 +380,26 @@ describe('EE Value Stream Analytics component', () => {
expect(findAggregationStatus().exists()).toBe(false);
});
});
describe('useVsaAggregatedTables = false', () => {
beforeEach(async () => {
wrapper = await createComponent({
initialState: {
...initialCycleAnalyticsState,
aggregation: {
...aggregationData,
},
},
featureFlags: {
useVsaAggregatedTables: false,
},
});
});
it('does not render the aggregation status', () => {
expect(findAggregationStatus().exists()).toBe(false);
});
});
});
describe('with failed requests while loading', () => {
......
......@@ -53,6 +53,7 @@ export const endpoints = {
tasksByTypeData: /analytics\/type_of_work\/tasks_by_type/,
tasksByTypeTopLabelsData: /analytics\/type_of_work\/tasks_by_type\/top_labels/,
valueStreamData: /analytics\/value_stream_analytics\/value_streams/,
valueStreamAggregationData: /analytics\/value_stream_analytics\/use_aggregated_backend/,
};
export const valueStreams = [
......
......@@ -382,4 +382,48 @@ describe('Value Stream Analytics actions / value streams', () => {
);
});
});
describe('updateAggregation', () => {
beforeEach(() => {
state = { currentGroup, aggregation: { enabled: false } };
});
describe('with no errors', () => {
beforeEach(() => {
mock
.onPut(endpoints.valueStreamAggregationData)
.replyOnce(httpStatusCodes.OK, { enabled: true });
});
it(`commits the ${types.REQUEST_UPDATE_AGGREGATION} and ${types.RECEIVE_UPDATE_AGGREGATION_SUCCESS} actions`, () => {
return testAction(actions.updateAggregation, true, state, [
{ type: types.REQUEST_UPDATE_AGGREGATION },
{ type: types.RECEIVE_UPDATE_AGGREGATION_SUCCESS },
]);
});
});
describe('with a failing request', () => {
let mockCommit;
beforeEach(() => {
mockCommit = jest.fn();
mock.onGet(endpoints.valueStreamAggregationData).reply(httpStatusCodes.NOT_FOUND);
});
it(`will commit ${types.RECEIVE_VALUE_STREAMS_ERROR}`, () => {
return actions.updateAggregation({ state, getters, commit: mockCommit }).catch(() => {
expect(mockCommit.mock.calls).toEqual([
['REQUEST_UPDATE_AGGREGATION'],
['RECEIVE_UPDATE_AGGREGATION_ERROR'],
]);
});
});
it('throws an error', () => {
return expect(
actions.updateAggregation({ state, getters, commit: mockCommit }),
).rejects.toThrow('Request failed with status code 404');
});
});
});
});
......@@ -32,6 +32,9 @@ describe('Value Stream Analytics mutations', () => {
it.each`
mutation | stateKey | value
${types.REQUEST_UPDATE_AGGREGATION} | ${'isUpdatingAggregation'} | ${true}
${types.RECEIVE_UPDATE_AGGREGATION_ERROR} | ${'isUpdatingAggregation'} | ${false}
${types.RECEIVE_UPDATE_AGGREGATION_SUCCESS} | ${'isUpdatingAggregation'} | ${true}
${types.REQUEST_VALUE_STREAMS} | ${'valueStreams'} | ${[]}
${types.RECEIVE_VALUE_STREAMS_ERROR} | ${'valueStreams'} | ${[]}
${types.REQUEST_VALUE_STREAMS} | ${'isLoadingValueStreams'} | ${true}
......
......@@ -447,6 +447,23 @@ describe('Api', () => {
.catch(done.fail);
});
});
describe('cycleAnalyticsUpdateAggregation', () => {
it('updates the aggregation enabled status', (done) => {
const reqdata = { enabled: true };
const expectedUrl = `${dummyValueStreamAnalyticsUrlRoot}/use_aggregated_backend`;
mock.onPut(expectedUrl).reply(httpStatus.OK, reqdata);
Api.cycleAnalyticsUpdateAggregation(groupId, reqdata)
.then(({ data, config: { url } }) => {
expect(data).toEqual(reqdata);
expect(url).toEqual(expectedUrl);
})
.then(done)
.catch(done.fail);
});
});
});
describe('GroupActivityAnalytics', () => {
......
......@@ -10923,6 +10923,12 @@ msgstr ""
msgid "CycleAnalytics|%{selectedLabelsCount} selected (%{maxLabels} max)"
msgstr ""
msgid "CycleAnalytics|Aggregation disabled"
msgstr ""
msgid "CycleAnalytics|Aggregation enabled"
msgstr ""
msgid "CycleAnalytics|Average time to completion"
msgstr ""
......@@ -10941,6 +10947,9 @@ msgstr ""
msgid "CycleAnalytics|Display chart filters"
msgstr ""
msgid "CycleAnalytics|Filter by stop date"
msgstr ""
msgid "CycleAnalytics|Lead Time for Changes"
msgstr ""
......@@ -10993,12 +11002,18 @@ msgstr ""
msgid "CycleAnalytics|There is no data for 'Total time' available. Adjust the current filters."
msgstr ""
msgid "CycleAnalytics|There was an error updating the aggregation status, please try again."
msgstr ""
msgid "CycleAnalytics|Total time"
msgstr ""
msgid "CycleAnalytics|Type of work"
msgstr ""
msgid "CycleAnalytics|When enabled, the results show items with a stop event within the date range. When disabled, the results show items with a start event within the date range."
msgstr ""
msgid "CycleAnalytics|group dropdown filter"
msgstr ""
......
......@@ -143,9 +143,12 @@ describe('Value stream analytics component', () => {
expect(findFilters().props()).toEqual({
groupId,
groupPath,
canToggleAggregation: false,
endDate: createdBefore,
hasDateRangeFilter: true,
hasProjectFilter: false,
isAggregationEnabled: false,
isUpdatingAggregationData: false,
selectedProjects: [],
startDate: createdAfter,
});
......
import { shallowMount } from '@vue/test-utils';
import { GlToggle } from '@gitlab/ui';
import Daterange from '~/analytics/shared/components/daterange.vue';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import FilterBar from '~/cycle_analytics/components/filter_bar.vue';
......@@ -29,6 +30,7 @@ describe('ValueStreamFilters', () => {
const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter);
const findDateRangePicker = () => wrapper.findComponent(Daterange);
const findFilterBar = () => wrapper.findComponent(FilterBar);
const findAggregationToggle = () => wrapper.findComponent(GlToggle);
beforeEach(() => {
wrapper = createComponent();
......@@ -57,6 +59,10 @@ describe('ValueStreamFilters', () => {
expect(findDateRangePicker().exists()).toBe(true);
});
it('will not render the aggregation toggle', () => {
expect(findAggregationToggle().exists()).toBe(false);
});
it('will emit `selectProject` when a project is selected', () => {
findProjectsDropdown().vm.$emit('selected');
......@@ -88,4 +94,52 @@ describe('ValueStreamFilters', () => {
expect(findProjectsDropdown().exists()).toBe(false);
});
});
describe('canToggleAggregation = true', () => {
beforeEach(() => {
wrapper = createComponent({ isAggregationEnabled: false, canToggleAggregation: true });
});
it('will render the aggregation toggle', () => {
expect(findAggregationToggle().exists()).toBe(true);
});
it('will set the aggregation toggle to the `isAggregationEnabled` value', () => {
expect(findAggregationToggle().props('value')).toBe(false);
wrapper = createComponent({
isAggregationEnabled: true,
canToggleAggregation: true,
});
expect(findAggregationToggle().props('value')).toBe(true);
});
it('will emit `toggleAggregation` when the toggle is changed', async () => {
expect(wrapper.emitted('toggleAggregation')).toBeUndefined();
await findAggregationToggle().vm.$emit('change', true);
expect(wrapper.emitted('toggleAggregation')).toHaveLength(1);
expect(wrapper.emitted('toggleAggregation')).toEqual([[true]]);
});
});
describe('isUpdatingAggregationData = true', () => {
beforeEach(() => {
wrapper = createComponent({ canToggleAggregation: true, isUpdatingAggregationData: true });
});
it('will disable the aggregation toggle', () => {
expect(findAggregationToggle().props('disabled')).toBe(true);
});
it('will not emit `toggleAggregation` when the toggle is changed', async () => {
expect(wrapper.emitted('toggleAggregation')).toBeUndefined();
await findAggregationToggle().vm.$emit('change', true);
expect(wrapper.emitted('toggleAggregation')).toBeUndefined();
});
});
});
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