Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-01-15 03:29:08 +00:00
parent 330ecbc47d
commit afaae32aba
25 changed files with 188 additions and 552 deletions

View File

@ -203,7 +203,6 @@ Layout/EmptyLineAfterMagicComment:
- 'ee/spec/features/projects/settings/merge_request_approvals_settings_spec.rb'
- 'ee/spec/features/projects/settings/merge_requests_settings_spec.rb'
- 'ee/spec/frontend/fixtures/analytics/charts.rb'
- 'ee/spec/frontend/fixtures/analytics/metrics.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams_code_stage.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams_issue_stage.rb'

View File

@ -60,7 +60,6 @@ RSpec/FeatureCategory:
- 'ee/spec/finders/work_items/widgets/filters/status_spec.rb'
- 'ee/spec/frontend/fixtures/analytics/charts.rb'
- 'ee/spec/frontend/fixtures/analytics/devops_reports/devops_adoption/enabled_namespaces.rb'
- 'ee/spec/frontend/fixtures/analytics/metrics.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams_code_stage.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams_issue_stage.rb'

View File

@ -3,7 +3,6 @@ RSpec/SpecFilePathSuffix:
Exclude:
- 'ee/spec/frontend/fixtures/analytics/charts.rb'
- 'ee/spec/frontend/fixtures/analytics/devops_reports/devops_adoption/enabled_namespaces.rb'
- 'ee/spec/frontend/fixtures/analytics/metrics.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams_code_stage.rb'
- 'ee/spec/frontend/fixtures/analytics/value_streams_issue_stage.rb'

View File

@ -1 +1 @@
9170d747c9dd37ca7434095f89d11f000bf7226c
88843a6cba6fd37e13fe56faf1dd640382195314

View File

@ -1 +1 @@
9f6b60a00d85240eec76d6a6f9a4511686f80e78
c9ad6326777be8ab69ddb1cf4b58e91a0fa38c81

View File

@ -5,7 +5,11 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue';
import { VSA_METRICS_GROUPS, FLOW_METRICS_QUERY_TYPE } from '~/analytics/shared/constants';
import { toYmd, generateValueStreamsDashboardLink } from '~/analytics/shared/utils';
import {
toYmd,
generateValueStreamsDashboardLink,
overviewMetricsRequestParams,
} from '~/analytics/shared/utils';
import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
@ -121,6 +125,9 @@ export default {
filterBarNamespacePath() {
return this.groupPath || this.namespace.restApiRequestPath;
},
overviewRequestParams() {
return overviewMetricsRequestParams(this.filterParams);
},
},
methods: {
...mapActions([
@ -183,8 +190,8 @@ export default {
/>
</div>
<value-stream-metrics
:request-path="namespace.restApiRequestPath"
:request-params="filterParams"
:request-path="namespace.path"
:request-params="overviewRequestParams"
:group-by="$options.VSA_METRICS_GROUPS"
:dashboards-path="dashboardsPath"
:query-type="$options.FLOW_METRICS_QUERY_TYPE"

View File

@ -1,8 +1,3 @@
import {
getValueStreamMetrics,
METRIC_TYPE_SUMMARY,
METRIC_TYPE_TIME_SUMMARY,
} from '~/api/analytics_api';
import { __, s__ } from '~/locale';
export const OVERVIEW_STAGE_ID = 'overview';
@ -32,15 +27,6 @@ export const I18N_VSA_ERROR_SELECTED_STAGE = __(
'There was an error fetching data for the selected stage',
);
export const SUMMARY_METRICS_REQUEST = [
{ endpoint: METRIC_TYPE_SUMMARY, name: __('recent activity'), request: getValueStreamMetrics },
];
export const METRICS_REQUESTS = [
{ endpoint: METRIC_TYPE_TIME_SUMMARY, name: __('time summary'), request: getValueStreamMetrics },
...SUMMARY_METRICS_REQUEST,
];
export const MILESTONES_ENDPOINT = '/-/milestones.json';
export const LABELS_ENDPOINT = '/-/labels.json';
export const MAX_LABELS = 100;

View File

@ -77,6 +77,7 @@ export const buildCycleAnalyticsInitialData = ({
createdAfter,
createdBefore,
namespaceName,
namespacePath,
namespaceRestApiRequestPath,
} = {}) => {
return {
@ -84,6 +85,7 @@ export const buildCycleAnalyticsInitialData = ({
groupPath,
namespace: {
name: namespaceName,
path: namespacePath,
restApiRequestPath: namespaceRestApiRequestPath,
},
createdAfter: newDate(createdAfter),

View File

@ -1,157 +0,0 @@
<script>
// NOTE: the API requests for this component are being migrated to graphql
// related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/498179
import { GlSkeletonLoader } from '@gitlab/ui';
import { isEqual, keyBy } from 'lodash';
import { createAlert } from '~/alert';
import { sprintf, s__ } from '~/locale';
import { fetchMetricsData, removeFlash } from '../utils';
import ValueStreamsDashboardLink from './value_streams_dashboard_link.vue';
import MetricTile from './metric_tile.vue';
const extractMetricsGroupData = (keyList = [], data = []) => {
if (!keyList.length || !data.length) return [];
const kv = keyBy(data, 'identifier');
return keyList.map((id) => kv[id] || null).filter((obj) => Boolean(obj));
};
const groupRawMetrics = (groups = [], rawData = []) => {
return groups.map((curr) => {
const { keys, ...rest } = curr;
return {
data: extractMetricsGroupData(keys, rawData),
keys,
...rest,
};
});
};
export default {
name: 'LegacyValueStreamMetrics',
components: {
GlSkeletonLoader,
MetricTile,
ValueStreamsDashboardLink,
},
props: {
requestPath: {
type: String,
required: true,
},
requestParams: {
type: Object,
required: true,
},
requests: {
type: Array,
required: true,
},
filterFn: {
type: Function,
required: false,
default: null,
},
groupBy: {
type: Array,
required: false,
default: () => [],
},
dashboardsPath: {
type: String,
required: false,
default: null,
},
},
data() {
return {
metrics: [],
groupedMetrics: [],
isLoading: false,
};
},
computed: {
hasGroupedMetrics() {
return Boolean(this.groupBy.length);
},
},
watch: {
requestParams(newVal, oldVal) {
if (!isEqual(newVal, oldVal)) {
this.fetchData();
}
},
},
mounted() {
this.fetchData();
},
methods: {
shouldDisplayDashboardLink(index) {
// When we have groups of metrics, we should only display the link for the first group
return index === 0 && this.dashboardsPath;
},
fetchData() {
removeFlash();
this.isLoading = true;
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
.then((data) => {
this.metrics = this.filterFn ? this.filterFn(data) : data;
if (this.hasGroupedMetrics) {
this.groupedMetrics = groupRawMetrics(this.groupBy, this.metrics);
}
this.isLoading = false;
})
.catch((err) => {
const message = sprintf(
s__(
'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.',
),
{ requestTypeName: err.message },
);
createAlert({ message });
this.isLoading = false;
});
},
},
};
</script>
<template>
<div class="gl-flex" data-testid="vsa-metrics" :class="isLoading ? 'gl-my-6' : 'gl-mt-6'">
<gl-skeleton-loader v-if="isLoading" />
<template v-else>
<div v-if="hasGroupedMetrics" class="gl-flex-col">
<div
v-for="(group, groupIndex) in groupedMetrics"
:key="group.key"
class="gl-mb-7"
data-testid="vsa-metrics-group"
>
<h4 class="gl-my-0">{{ group.title }}</h4>
<div class="gl-flex gl-flex-wrap">
<metric-tile
v-for="metric in group.data"
:key="metric.identifier"
:metric="metric"
class="gl-mt-5 gl-pr-10"
/>
<value-streams-dashboard-link
v-if="shouldDisplayDashboardLink(groupIndex)"
class="gl-mt-5"
:request-path="dashboardsPath"
/>
</div>
</div>
</div>
<div v-else class="gl-mb-7 gl-flex gl-flex-wrap">
<metric-tile
v-for="metric in metrics"
:key="metric.identifier"
:metric="metric"
class="gl-mt-5 gl-pr-10"
/>
</div>
</template>
</div>
</template>

View File

@ -19,9 +19,6 @@ import ValueStreamsDashboardLink from './value_streams_dashboard_link.vue';
import MetricTile from './metric_tile.vue';
const extractMetricsGroupData = (keyList = [], data = []) => {
const dataKeys = data.map(({ identifier }) => identifier);
if (!keyList.length || !dataKeys.some((key) => keyList.includes(key))) return [];
return keyList.reduce((acc, curr) => {
const metric = data.find((item) => item.identifier === curr);
return metric ? [...acc, metric] : acc;
@ -95,14 +92,13 @@ export default {
},
computed: {
queryDateRange() {
const { created_after: startDate, created_before: endDate } = this.requestParams;
const { startDate, endDate } = this.requestParams;
return { startDate: toYmd(startDate), endDate: toYmd(endDate) };
},
flowMetricsVariables() {
const additionalParams = Object.keys(FLOW_METRICS_QUERY_FILTERS).reduce((acc, key) => {
const additionalParams = FLOW_METRICS_QUERY_FILTERS.reduce((acc, key) => {
if (this.requestParams[key]) {
const graphqlField = FLOW_METRICS_QUERY_FILTERS[key];
return { ...acc, [graphqlField]: this.requestParams[key] };
return { ...acc, [key]: this.requestParams[key] };
}
return acc;
}, {});

View File

@ -5,10 +5,10 @@ export const BUCKETING_INTERVAL_MONTHLY = 'MONTHLY';
* Available filters for the flow metrics query, along with date range filters
* NOTE: these additional do not apply to the `deploymentCount` field
*/
export const FLOW_METRICS_QUERY_FILTERS = {
label_name: 'labelNames',
project_ids: 'projectIds',
assignee_username: 'assigneeUsernames',
milestone_title: 'milestoneTitle',
author_username: 'authorUsername',
};
export const FLOW_METRICS_QUERY_FILTERS = [
'labelNames',
'projectIds',
'assigneeUsernames',
'milestoneTitle',
'authorUsername',
];

View File

@ -1,7 +1,6 @@
import { flatten } from 'lodash';
import dateFormat from '~/lib/dateformat';
import { SECONDS_IN_DAY } from '~/lib/utils/datetime_utility';
import { slugify } from '~/lib/utils/text_utility';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
@ -86,49 +85,6 @@ export const removeFlash = (type = 'alert') => {
document.querySelector(`.flash-${type} .js-close`)?.click();
};
/**
* Prepares metric data to be rendered in the metric_card component
*
* @param {MetricData[]} data - The metric data to be rendered
* @param {Object} popoverContent - Key value pair of data to display in the popover
* @returns {TransformedMetricData[]} An array of metrics ready to render in the metric_card
*/
export const prepareTimeMetricsData = (data = [], popoverContent = {}) =>
data.map(({ title: label, identifier, ...rest }) => {
const metricIdentifier = identifier || slugify(label);
return {
...rest,
label,
identifier: metricIdentifier,
description: popoverContent[metricIdentifier]?.description || '',
};
});
const requestData = ({ request, endpoint, requestPath, params, name }) => {
return request({ endpoint, params, requestPath })
.then(({ data }) => data)
.catch(() => {
throw new Error(name);
});
};
/**
* Takes a configuration array of metrics requests (key metrics and DORA) and returns
* a flat array of all the responses. Different metrics are retrieved from different endpoints
* additionally we only support certain metrics for FOSS users.
*
* @param {Array} requests - array of metric api requests to be made
* @param {String} requestPath - path for the group / project we are requesting
* @param {Object} params - optional parameters to filter, including `created_after` and `created_before` dates
* @returns a flat array of metrics
*/
export const fetchMetricsData = (requests = [], requestPath, params) => {
const promises = requests.map((r) => requestData({ ...r, requestPath, params }));
return Promise.all(promises).then((responses) =>
prepareTimeMetricsData(flatten(responses), VALUE_STREAM_METRIC_TILE_METADATA),
);
};
/**
* Formats any valid number as percentage
*
@ -249,3 +205,33 @@ export const extractQueryResponseFromNamespace = ({ result, resultKey }) => {
}
return {};
};
/**
* Takes the raw snake_case query parameters and extracts + converts the relevant values
* for the overview metrics component
* @param {Object} params - Object containing the supported query parameters
* @param {Date} params.created_before
* @param {Date} params.created_after
* @param {string} params.author_username
* @param {string} params.milestone_title
* @param {Array} params.label_name
* @param {Array} params.assignee_username
*
* @returns {Object} CamelCased parameter names
*/
export const overviewMetricsRequestParams = (params = {}) => {
const {
createdAfter: startDate,
createdBefore: endDate,
labelName: labelNames,
assigneeUsername: assigneeUsernames,
...rest
} = convertObjectPropsToCamelCase(params);
return {
startDate,
endDate,
labelNames,
assigneeUsernames,
...rest,
};
};

View File

@ -12,9 +12,6 @@ export const CYCLE_TIME_METRIC_TYPE = 'cycle_time';
export const ISSUES_METRIC_TYPE = 'issues';
export const DEPLOYS_METRIC_TYPE = 'deploys';
export const METRIC_TYPE_SUMMARY = 'summary';
export const METRIC_TYPE_TIME_SUMMARY = 'time_summary';
const buildProjectMetricsPath = (namespacePath) =>
buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':namespace_path', namespacePath);
@ -76,15 +73,6 @@ export const getValueStreamStageCounts = (
return axios.get(joinPaths(stageBase, 'count'), { params });
};
export const getValueStreamMetrics = ({
endpoint = METRIC_TYPE_SUMMARY,
requestPath: namespacePath,
params = {},
}) => {
const metricBase = buildProjectMetricsPath(namespacePath);
return axios.get(joinPaths(metricBase, endpoint), { params });
};
export const getValueStreamSummaryMetrics = (namespacePath, params = {}) => {
const metricBase = buildProjectMetricsPath(namespacePath);
return axios.get(joinPaths(metricBase, 'summary'), { params });

View File

@ -12,6 +12,7 @@ import {
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
import { findWidget } from '~/issues/list/utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import glAbilitiesMixin from '~/vue_shared/mixins/gl_abilities_mixin';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
import WorkItemStateToggle from '~/work_items/components/work_item_state_toggle.vue';
@ -51,10 +52,15 @@ export default {
GlFormCheckbox,
HelpIcon,
WorkItemStateToggle,
CommentTemperature: () =>
import(
/* webpackChunkName: 'comment_temperature' */ 'ee_component/ai/components/comment_temperature.vue'
),
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [glAbilitiesMixin()],
props: {
workItemId: {
type: String,
@ -169,6 +175,7 @@ export default {
toggleResolveChecked: this.isDiscussionResolved,
emailParticipants: [],
workItem: {},
isMeasuringCommentTemperature: false,
};
},
computed: {
@ -217,6 +224,9 @@ export default {
showInternalNoteCheckbox() {
return this.canMarkNoteAsInternal && this.isNewDiscussion;
},
currentUserId() {
return window.gon.current_user_id;
},
},
apollo: {
emailParticipants: {
@ -296,7 +306,15 @@ export default {
this.$emit('cancelEditing');
clearDraft(this.autosaveKey);
},
submitForm() {
submitForm(shouldMeasureTemperature = true) {
this.isMeasuringCommentTemperature =
this.glAbilities.measureCommentTemperature && shouldMeasureTemperature;
if (this.isMeasuringCommentTemperature) {
this.$refs.commentTemperature.measureCommentTemperature();
return;
}
if (this.isSubmitting) {
return;
}
@ -334,11 +352,20 @@ export default {
supports-quick-actions
:autofocus="autofocus"
@input="setCommentText"
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keydown.meta.enter="submitForm()"
@keydown.ctrl.enter="submitForm()"
@keydown.esc.stop="cancelEditing"
/>
</comment-field-layout>
<comment-temperature
v-if="glAbilities.measureCommentTemperature"
ref="commentTemperature"
v-model="commentText"
:item-id="workItemId"
:item-type="workItemTypeKey"
:user-id="currentUserId"
@save="submitForm(false)"
/>
<div class="note-form-actions" data-testid="work-item-comment-form-actions">
<div v-if="showResolveDiscussionToggle">
<label>
@ -367,9 +394,9 @@ export default {
category="primary"
variant="confirm"
data-testid="confirm-button"
:disabled="!commentText.length"
:disabled="!commentText.length || isMeasuringCommentTemperature"
:loading="isSubmitting"
@click="submitForm"
@click="submitForm()"
>{{ commentButtonTextComputed }}
</gl-button>
<work-item-state-toggle
@ -380,9 +407,10 @@ export default {
:work-item-type="workItemType"
:full-path="fullPath"
:has-comment="Boolean(commentText.length)"
:disabled="Boolean(commentText.lengt) && isMeasuringCommentTemperature"
:parent-id="parentId"
can-update
@submit-comment="submitForm"
@submit-comment="submitForm()"
@error="$emit('error', $event)"
/>
<gl-button

View File

@ -60,6 +60,11 @@ export default {
required: false,
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
parentId: {
type: String,
required: false,
@ -291,7 +296,7 @@ export default {
</template>
</gl-disclosure-dropdown-item>
<gl-button v-else :loading="updateInProgress" @click="action">{{
<gl-button v-else :loading="updateInProgress" :disabled="disabled" @click="action">{{
toggleWorkItemStateText
}}</gl-button>

View File

@ -817,8 +817,6 @@
- 1
- - search_zoekt_task_failed_event
- 1
- - search_zoekt_update_index_used_bytes
- 1
- - secrets_management_provision_project_secrets_manager
- 1
- - security_configuration_set_group_secret_push_protection

View File

@ -14375,6 +14375,9 @@ msgstr ""
msgid "Compliance Center|Frameworks"
msgstr ""
msgid "Compliance Center|Report and manage standards adherence, violations, and compliance frameworks for the group. %{linkStart}Learn more%{linkEnd}."
msgstr ""
msgid "Compliance Center|Standards Adherence"
msgstr ""
@ -47273,9 +47276,6 @@ msgstr ""
msgid "Report abuse to administrator"
msgstr ""
msgid "Report and manage standards adherence, violations, and compliance frameworks for the group."
msgstr ""
msgid "Report couldn't be prepared."
msgstr ""
@ -62367,9 +62367,6 @@ msgstr ""
msgid "ValueStreamAnalytics|There was an error while fetching flow metrics data."
msgstr ""
msgid "ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data."
msgstr ""
msgid "ValueStreamAnalytics|Total number of deploys to production."
msgstr ""
@ -68529,9 +68526,6 @@ msgstr ""
msgid "read-only"
msgstr ""
msgid "recent activity"
msgstr ""
msgid "register"
msgstr ""
@ -68784,9 +68778,6 @@ msgstr ""
msgid "this document"
msgstr ""
msgid "time summary"
msgstr ""
msgid "to yourself"
msgstr ""

View File

@ -27,6 +27,7 @@ RSpec.describe 'Work item detail', :js, feature_category: :team_planning do
let(:linked_item) { task }
before_all do
stub_feature_flags(comment_temperature: false)
group.add_developer(user)
end

View File

@ -11,6 +11,7 @@ import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue';
import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue';
import { NOT_ENOUGH_DATA_ERROR } from '~/analytics/cycle_analytics/constants';
import initState from '~/analytics/cycle_analytics/store/state';
import { filterParams } from '~/analytics/cycle_analytics/store/getters';
import {
transformedProjectStagePathData,
selectedStage,
@ -26,7 +27,7 @@ const selectedStageEvents = issueEvents.events;
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
const selectedStageCount = stageCounts[selectedStage.id];
const namespaceRestApiRequestPath = 'full/path/to/foo';
const namespaceRestApiRequestPath = 'rest/full/path/to/foo';
Vue.use(Vuex);
@ -40,7 +41,13 @@ const defaultState = {
createdAfter,
stageCounts,
groupPath,
namespace: { restApiRequestPath: namespaceRestApiRequestPath },
namespace: { restApiRequestPath: namespaceRestApiRequestPath, path },
filters: {
authors: {},
milestones: {},
assignees: {},
labels: {},
},
};
function createStore({ initialState = {}, initialGetters = {} }) {
@ -52,10 +59,7 @@ function createStore({ initialState = {}, initialGetters = {} }) {
},
getters: {
pathNavigationData: () => transformedProjectStagePathData,
filterParams: () => ({
created_after: createdAfter,
created_before: createdBefore,
}),
filterParams,
...initialGetters,
},
});
@ -102,10 +106,24 @@ describe('Value stream analytics component', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
it('sets the request params for the metrics component', () => {
expect(findOverviewMetrics().props('requestParams')).toMatchObject({
assigneeUsernames: null,
authorUsername: null,
milestoneTitle: null,
labelNames: null,
endDate: '2019-01-14',
startDate: '2018-12-15',
});
});
it('passes relevant props to the metrics component', () => {
expect(findOverviewMetrics().props('isLicensed')).toBe(false);
expect(findOverviewMetrics().props('queryType')).toBe('FLOW_METRICS_QUERY_TYPE');
expect(findOverviewMetrics().props('isProjectNamespace')).toBe(true);
expect(findOverviewMetrics().props()).toMatchObject({
requestPath: path,
isLicensed: false,
queryType: 'FLOW_METRICS_QUERY_TYPE',
isProjectNamespace: true,
});
});
it('renders the stage table', () => {
@ -169,7 +187,7 @@ describe('Value stream analytics component', () => {
it('renders a link to the value streams dashboard using the namespace path', () => {
expect(findOverviewMetrics().props('dashboardsPath')).toBeDefined();
expect(findOverviewMetrics().props('dashboardsPath')).toBe(
'/full/path/to/foo/-/analytics/dashboards/value_streams_dashboard',
'/rest/full/path/to/foo/-/analytics/dashboards/value_streams_dashboard',
);
});
});

View File

@ -1,208 +0,0 @@
import { GlSkeletonLoader } from '@gitlab/ui';
import { nextTick } from 'vue';
import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import LegacyValueStreamMetrics from '~/analytics/shared/components/legacy_value_stream_metrics.vue';
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
import { VSA_METRICS_GROUPS, VALUE_STREAM_METRIC_METADATA } from '~/analytics/shared/constants';
import { prepareTimeMetricsData } from '~/analytics/shared/utils';
import MetricTile from '~/analytics/shared/components/metric_tile.vue';
import ValueStreamsDashboardLink from '~/analytics/shared/components/value_streams_dashboard_link.vue';
import { createAlert } from '~/alert';
import { group } from '../mock_data';
jest.mock('~/alert');
describe('LegacyValueStreamMetrics', () => {
let wrapper;
let mockGetValueStreamSummaryMetrics;
let mockFilterFn;
const { full_path: requestPath } = group;
const fakeReqName = 'Mock metrics';
const metricsRequestFactory = () => ({
request: mockGetValueStreamSummaryMetrics,
endpoint: METRIC_TYPE_SUMMARY,
name: fakeReqName,
});
const createComponent = (props = {}) => {
return shallowMountExtended(LegacyValueStreamMetrics, {
propsData: {
requestPath,
requestParams: {},
requests: [metricsRequestFactory()],
...props,
},
});
};
const findVSDLink = () => wrapper.findComponent(ValueStreamsDashboardLink);
const findMetrics = () => wrapper.findAllComponents(MetricTile);
const findMetricsGroups = () => wrapper.findAllByTestId('vsa-metrics-group');
const expectToHaveRequest = (fields) => {
expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
endpoint: METRIC_TYPE_SUMMARY,
requestPath,
...fields,
});
};
describe('with successful requests', () => {
beforeEach(() => {
mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
});
it('will display a loader with pending requests', async () => {
wrapper = createComponent();
await nextTick();
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
});
describe('with data loaded', () => {
beforeEach(async () => {
wrapper = createComponent();
await waitForPromises();
});
it('fetches data from the value stream analytics endpoint', () => {
expectToHaveRequest({ params: {} });
});
describe.each`
index | identifier | value | label
${0} | ${metricsData[0].identifier} | ${metricsData[0].value} | ${metricsData[0].title}
${1} | ${metricsData[1].identifier} | ${metricsData[1].value} | ${metricsData[1].title}
${2} | ${metricsData[2].identifier} | ${metricsData[2].value} | ${metricsData[2].title}
${3} | ${metricsData[3].identifier} | ${metricsData[3].value} | ${metricsData[3].title}
`('metric tiles', ({ identifier, index, value, label }) => {
it(`renders a metric tile component for "${label}"`, () => {
const metric = findMetrics().at(index);
expect(metric.props('metric')).toMatchObject({ identifier, value, label });
expect(metric.isVisible()).toBe(true);
});
});
it('will not display a loading icon', () => {
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
});
describe('filterFn', () => {
const transferredMetricsData = prepareTimeMetricsData(
metricsData,
VALUE_STREAM_METRIC_METADATA,
);
it('with a filter function, will call the function with the metrics data', async () => {
const filteredData = [
{ identifier: 'issues', value: '3', title: 'New issues', description: 'foo' },
];
mockFilterFn = jest.fn(() => filteredData);
wrapper = createComponent({
filterFn: mockFilterFn,
});
await waitForPromises();
expect(mockFilterFn).toHaveBeenCalledWith(transferredMetricsData);
expect(findMetrics().at(0).props('metric')).toEqual(filteredData[0]);
});
it('without a filter function, it will only update the metrics', async () => {
wrapper = createComponent();
await waitForPromises();
expect(mockFilterFn).not.toHaveBeenCalled();
expect(findMetrics().at(0).props('metric')).toEqual(transferredMetricsData[0]);
});
});
describe('with additional params', () => {
beforeEach(async () => {
wrapper = createComponent({
requestParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
await waitForPromises();
});
it('fetches data for the `getValueStreamSummaryMetrics` request', () => {
expectToHaveRequest({
params: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
},
});
});
});
describe('groupBy', () => {
beforeEach(async () => {
mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS });
await waitForPromises();
});
it('renders the metrics as separate groups', () => {
const groups = findMetricsGroups();
expect(groups).toHaveLength(VSA_METRICS_GROUPS.length);
});
it('renders titles for each group', () => {
const groups = findMetricsGroups();
groups.wrappers.forEach((g, index) => {
const { title } = VSA_METRICS_GROUPS[index];
expect(g.html()).toContain(title);
});
});
});
});
});
describe('Value Streams Dashboard Link', () => {
it('will render when a dashboardsPath is set', async () => {
wrapper = createComponent({
groupBy: VSA_METRICS_GROUPS,
dashboardsPath: 'fake-group-path',
});
await waitForPromises();
const vsdLink = findVSDLink();
expect(vsdLink.exists()).toBe(true);
expect(vsdLink.props()).toEqual({ requestPath: 'fake-group-path' });
});
it('does not render without a dashboardsPath', async () => {
wrapper = createComponent({ groupBy: VSA_METRICS_GROUPS });
await waitForPromises();
expect(findVSDLink().exists()).toBe(false);
});
});
describe('with a request failing', () => {
beforeEach(async () => {
mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
wrapper = createComponent();
await waitForPromises();
});
it('should render an error message', () => {
expect(createAlert).toHaveBeenCalledWith({
message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
});
});
});
});

View File

@ -88,16 +88,19 @@ describe('ValueStreamMetrics', () => {
const { path: requestPath } = group;
const createComponent = async ({ props = {}, apolloProvider = null } = {}) => {
const { requestParams, ...propsRest } = props;
wrapper = shallowMountExtended(ValueStreamMetrics, {
apolloProvider,
propsData: {
requestPath,
requestParams: {
created_after: mockStartDate,
created_before: mockEndDate,
startDate: mockStartDate,
endDate: mockEndDate,
...requestParams,
},
isLicensed: true,
...props,
...propsRest,
},
});
@ -129,12 +132,20 @@ describe('ValueStreamMetrics', () => {
startDate = '2018-12-15',
endDate = '2019-01-14',
labelNames,
projectIds,
assigneeUsernames,
authorUsername,
milestoneTitle,
} = {}) =>
expect(flowMetricsRequestHandler).toHaveBeenCalledWith({
fullPath,
startDate,
endDate,
labelNames,
projectIds,
assigneeUsernames,
authorUsername,
milestoneTitle,
});
afterEach(() => {
@ -228,6 +239,10 @@ describe('ValueStreamMetrics', () => {
});
describe('with additional params', () => {
const assigneeUsernames = ['Rei Ayanami', 'Asuka Shikinami', 'Mari Makinami'];
const authorUsername = 'Yui Ikari';
const milestoneTitle = 'N3i';
beforeEach(async () => {
setGraphqlQueryHandlerResponses();
@ -235,10 +250,13 @@ describe('ValueStreamMetrics', () => {
apolloProvider: createMockApolloProvider(),
props: {
requestParams: {
'project_ids[]': [1],
created_after: '2020-01-01',
created_before: '2020-02-01',
'labelNames[]': ['some', 'fake', 'label'],
startDate: new Date('2020-01-01'),
endDate: new Date('2020-02-01'),
projectIds: [1],
labelNames: ['some', 'fake', 'label'],
assigneeUsernames,
authorUsername,
milestoneTitle,
},
},
});
@ -246,15 +264,19 @@ describe('ValueStreamMetrics', () => {
it('fetches the flowMetrics data', () => {
expectFlowMetricsRequests({
'project_ids[]': [1],
labelNames: ['some', 'fake', 'label'],
projectIds: [1],
startDate: '2020-01-01',
endDate: '2020-02-01',
assigneeUsernames,
authorUsername,
milestoneTitle,
});
});
it('fetches the doraMetrics data', () => {
expectDoraMetricsRequests({
'project_ids[]': [1],
projectIds: [1],
startDate: '2020-01-01',
endDate: '2020-02-01',
});

View File

@ -1,13 +1,11 @@
import metricsData from 'test_fixtures/projects/analytics/value_stream_analytics/summary.json';
import {
extractFilterQueryParameters,
extractPaginationQueryParameters,
filterBySearchTerm,
generateValueStreamsDashboardLink,
getDataZoomOption,
prepareTimeMetricsData,
overviewMetricsRequestParams,
} from '~/analytics/shared/utils';
import { slugify } from '~/lib/utils/text_utility';
import { objectToQuery } from '~/lib/utils/url_utility';
describe('filterBySearchTerm', () => {
@ -181,39 +179,6 @@ describe('getDataZoomOption', () => {
});
});
describe('prepareTimeMetricsData', () => {
let prepared;
const [first, second] = metricsData;
delete second.identifier; // testing the case when identifier is missing
const firstIdentifier = first.identifier;
const secondIdentifier = slugify(second.title);
beforeEach(() => {
prepared = prepareTimeMetricsData([first, second], {
[firstIdentifier]: { description: 'Is a value that is good' },
});
});
it('will add a `identifier` based on the title', () => {
expect(prepared).toMatchObject([
{ identifier: firstIdentifier },
{ identifier: secondIdentifier },
]);
});
it('will add a `label` key', () => {
expect(prepared).toMatchObject([{ label: 'New issues' }, { label: 'Commits' }]);
});
it('will add a popover description using the key if it is provided', () => {
expect(prepared).toMatchObject([
{ description: 'Is a value that is good' },
{ description: '' },
]);
});
});
describe('generateValueStreamsDashboardLink', () => {
it.each`
namespacePath | isProjectNamespace | result
@ -241,3 +206,22 @@ describe('generateValueStreamsDashboardLink', () => {
});
});
});
describe('overviewMetricsRequestParams', () => {
it('returns empty object when no params provided', () => {
expect(overviewMetricsRequestParams()).toEqual({});
});
it.each`
requestParam | value | expected
${'created_after'} | ${'2024-01-01'} | ${'startDate'}
${'created_before'} | ${'2024-12-31'} | ${'endDate'}
${'label_name'} | ${['bug', 'feature']} | ${'labelNames'}
${'assignee_username'} | ${['user1', 'user2']} | ${'assigneeUsernames'}
${'author_username'} | ${'Author A'} | ${'authorUsername'}
${'milestone_title'} | ${'some new milestone'} | ${'milestoneTitle'}
`('correctly transforms the $requestParam parameter', ({ requestParam, value, expected }) => {
const result = overviewMetricsRequestParams({ [requestParam]: value });
expect(result[expected]).toBe(value);
});
});

View File

@ -47,21 +47,4 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do
end
end
end
describe Projects::Analytics::CycleAnalytics::SummaryController, type: :controller do
render_views
let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } }
before do
project.add_developer(user)
sign_in(user)
end
it "projects/analytics/value_stream_analytics/summary.json" do
get(:show, params: params, format: :json)
expect(response).to be_successful
end
end
end

View File

@ -62,6 +62,7 @@ describe('Work Item State toggle button component', () => {
workItemState = STATE_OPEN,
workItemType = 'Task',
hasComment = false,
disabled = false,
parentId = null,
} = {}) => {
wrapper = shallowMountExtended(WorkItemStateToggle, {
@ -79,11 +80,20 @@ describe('Work Item State toggle button component', () => {
workItemType,
canUpdate,
hasComment,
disabled,
parentId,
},
});
};
it('disables button when disabled prop is set', () => {
createComponent({
disabled: true,
});
expect(findStateToggleButton().props('disabled')).toBe(true);
});
describe('work item State button text', () => {
it.each`
workItemState | workItemType | buttonText

View File

@ -337,7 +337,6 @@
- './ee/spec/finders/users_finder_spec.rb'
- './ee/spec/frontend/fixtures/analytics/charts.rb'
- './ee/spec/frontend/fixtures/analytics/devops_reports/devops_adoption/enabled_namespaces.rb'
- './ee/spec/frontend/fixtures/analytics/metrics.rb'
- './ee/spec/frontend/fixtures/analytics/value_streams_code_stage.rb'
- './ee/spec/frontend/fixtures/analytics/value_streams_issue_stage.rb'
- './ee/spec/frontend/fixtures/analytics/value_streams_plan_stage.rb'