Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
78e911431f
commit
09dff3eec7
74 changed files with 1676 additions and 511 deletions
|
@ -2,10 +2,17 @@ import axios from '~/lib/utils/axios_utils';
|
|||
import { joinPaths } from '~/lib/utils/url_utility';
|
||||
import { buildApiUrl } from './api_utils';
|
||||
|
||||
const PROJECT_VSA_METRICS_BASE = '/:request_path/-/analytics/value_stream_analytics';
|
||||
const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams';
|
||||
const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`;
|
||||
const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`;
|
||||
|
||||
export const METRIC_TYPE_SUMMARY = 'summary';
|
||||
export const METRIC_TYPE_TIME_SUMMARY = 'time_summary';
|
||||
|
||||
const buildProjectMetricsPath = (requestPath) =>
|
||||
buildApiUrl(PROJECT_VSA_METRICS_BASE).replace(':request_path', requestPath);
|
||||
|
||||
const buildProjectValueStreamPath = (requestPath, valueStreamId = null) => {
|
||||
if (valueStreamId) {
|
||||
return buildApiUrl(PROJECT_VSA_STAGES_PATH)
|
||||
|
@ -40,9 +47,7 @@ export const getProjectValueStreamMetrics = (requestPath, params) =>
|
|||
axios.get(requestPath, { params });
|
||||
|
||||
/**
|
||||
* Shared group VSA paths
|
||||
* We share some endpoints across and group and project level VSA
|
||||
* When used for project level VSA, requests should include the `project_id` in the params object
|
||||
* Dedicated project VSA paths
|
||||
*/
|
||||
|
||||
export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
|
||||
|
@ -62,3 +67,17 @@ export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId
|
|||
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
|
||||
return axios.get(joinPaths(stageBase, 'count'), { params });
|
||||
};
|
||||
|
||||
export const getValueStreamMetrics = ({
|
||||
endpoint = METRIC_TYPE_SUMMARY,
|
||||
requestPath,
|
||||
params = {},
|
||||
}) => {
|
||||
const metricBase = buildProjectMetricsPath(requestPath);
|
||||
return axios.get(joinPaths(metricBase, endpoint), { params });
|
||||
};
|
||||
|
||||
export const getValueStreamSummaryMetrics = (requestPath, params = {}) => {
|
||||
const metricBase = buildProjectMetricsPath(requestPath);
|
||||
return axios.get(joinPaths(metricBase, 'summary'), { params });
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import $ from 'jquery';
|
||||
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
|
||||
import SourceEditor from '~/editor/source_editor';
|
||||
import { getBlobLanguage } from '~/editor/utils';
|
||||
import createFlash from '~/flash';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
|
||||
|
@ -16,16 +17,7 @@ export default class EditBlob {
|
|||
this.configureMonacoEditor();
|
||||
|
||||
if (this.options.isMarkdown) {
|
||||
import('~/editor/extensions/source_editor_markdown_ext')
|
||||
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
|
||||
this.editor.use(new MarkdownExtension());
|
||||
addEditorMarkdownListeners(this.editor);
|
||||
})
|
||||
.catch((e) =>
|
||||
createFlash({
|
||||
message: `${BLOB_EDITOR_ERROR}: ${e}`,
|
||||
}),
|
||||
);
|
||||
this.fetchMarkdownExtension();
|
||||
}
|
||||
|
||||
this.initModePanesAndLinks();
|
||||
|
@ -34,12 +26,30 @@ export default class EditBlob {
|
|||
this.editor.focus();
|
||||
}
|
||||
|
||||
fetchMarkdownExtension() {
|
||||
import('~/editor/extensions/source_editor_markdown_ext')
|
||||
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
|
||||
this.editor.use(
|
||||
new MarkdownExtension({ instance: this.editor, projectPath: this.options.projectPath }),
|
||||
);
|
||||
this.hasMarkdownExtension = true;
|
||||
addEditorMarkdownListeners(this.editor);
|
||||
})
|
||||
.catch((e) =>
|
||||
createFlash({
|
||||
message: `${BLOB_EDITOR_ERROR}: ${e}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
configureMonacoEditor() {
|
||||
const editorEl = document.getElementById('editor');
|
||||
const fileNameEl = document.getElementById('file_path') || document.getElementById('file_name');
|
||||
const fileContentEl = document.getElementById('file-content');
|
||||
const form = document.querySelector('.js-edit-blob-form');
|
||||
|
||||
this.hasMarkdownExtension = false;
|
||||
|
||||
const rootEditor = new SourceEditor();
|
||||
|
||||
this.editor = rootEditor.createInstance({
|
||||
|
@ -51,6 +61,12 @@ export default class EditBlob {
|
|||
|
||||
fileNameEl.addEventListener('change', () => {
|
||||
this.editor.updateModelLanguage(fileNameEl.value);
|
||||
const newLang = getBlobLanguage(fileNameEl.value);
|
||||
if (newLang === 'markdown') {
|
||||
if (!this.hasMarkdownExtension) {
|
||||
this.fetchMarkdownExtension();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
|
|
|
@ -4,7 +4,9 @@ import Cookies from 'js-cookie';
|
|||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
|
||||
import StageTable from '~/cycle_analytics/components/stage_table.vue';
|
||||
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
|
||||
import { __ } from '~/locale';
|
||||
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
|
||||
|
||||
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
|
||||
|
||||
|
@ -16,6 +18,7 @@ export default {
|
|||
GlSprintf,
|
||||
PathNavigation,
|
||||
StageTable,
|
||||
ValueStreamMetrics,
|
||||
},
|
||||
props: {
|
||||
noDataSvgPath: {
|
||||
|
@ -45,8 +48,10 @@ export default {
|
|||
'daysInPast',
|
||||
'permissions',
|
||||
'stageCounts',
|
||||
'endpoints',
|
||||
'features',
|
||||
]),
|
||||
...mapGetters(['pathNavigationData']),
|
||||
...mapGetters(['pathNavigationData', 'filterParams']),
|
||||
displayStageEvents() {
|
||||
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
|
||||
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
|
||||
|
@ -88,6 +93,9 @@ export default {
|
|||
}
|
||||
return 0;
|
||||
},
|
||||
metricsRequests() {
|
||||
return this.features?.cycleAnalyticsForGroups ? METRICS_REQUESTS : SUMMARY_METRICS_REQUEST;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions([
|
||||
|
@ -122,62 +130,54 @@ export default {
|
|||
<template>
|
||||
<div class="cycle-analytics">
|
||||
<h3>{{ $options.i18n.pageTitle }}</h3>
|
||||
<path-navigation
|
||||
v-if="displayPathNavigation"
|
||||
class="js-path-navigation gl-w-full gl-pb-2"
|
||||
:loading="isLoading || isLoadingStage"
|
||||
:stages="pathNavigationData"
|
||||
:selected-stage="selectedStage"
|
||||
@selected="onSelectStage"
|
||||
/>
|
||||
<gl-loading-icon v-if="isLoading" size="lg" />
|
||||
<div v-else class="wrapper">
|
||||
<!--
|
||||
We wont have access to the stage counts until we move to a default value stream
|
||||
For now we can use the `withStageCounts` flag to ensure we don't display empty stage counts
|
||||
Related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/326705
|
||||
-->
|
||||
<div class="card" data-testid="vsa-stage-overview-metrics">
|
||||
<div class="card-header">{{ __('Recent Project Activity') }}</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center">
|
||||
<h3 class="header">{{ item.value }}</h3>
|
||||
<p class="text">{{ item.title }}</p>
|
||||
</div>
|
||||
<div class="flex-grow align-self-center text-center">
|
||||
<div class="js-ca-dropdown dropdown inline">
|
||||
<!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
|
||||
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
|
||||
<span class="dropdown-label">
|
||||
<gl-sprintf :message="$options.i18n.dropdownText">
|
||||
<template #days>{{ daysInPast }}</template>
|
||||
</gl-sprintf>
|
||||
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
|
||||
<a href="#" @click.prevent="handleDateSelect(days)">
|
||||
<gl-sprintf :message="$options.i18n.dropdownText">
|
||||
<template #days>{{ days }}</template>
|
||||
</gl-sprintf>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
|
||||
<path-navigation
|
||||
v-if="displayPathNavigation"
|
||||
class="js-path-navigation gl-w-full gl-pb-2"
|
||||
:loading="isLoading || isLoadingStage"
|
||||
:stages="pathNavigationData"
|
||||
:selected-stage="selectedStage"
|
||||
@selected="onSelectStage"
|
||||
/>
|
||||
<div class="gl-flex-grow gl-align-self-end">
|
||||
<div class="js-ca-dropdown dropdown inline">
|
||||
<!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
|
||||
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
|
||||
<span class="dropdown-label">
|
||||
<gl-sprintf :message="$options.i18n.dropdownText">
|
||||
<template #days>{{ daysInPast }}</template>
|
||||
</gl-sprintf>
|
||||
<gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
|
||||
<a href="#" @click.prevent="handleDateSelect(days)">
|
||||
<gl-sprintf :message="$options.i18n.dropdownText">
|
||||
<template #days>{{ days }}</template>
|
||||
</gl-sprintf>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<stage-table
|
||||
:is-loading="isLoading || isLoadingStage"
|
||||
:stage-events="selectedStageEvents"
|
||||
:selected-stage="selectedStage"
|
||||
:stage-count="selectedStageCount"
|
||||
:empty-state-title="emptyStageTitle"
|
||||
:empty-state-message="emptyStageText"
|
||||
:no-data-svg-path="noDataSvgPath"
|
||||
:pagination="null"
|
||||
/>
|
||||
</div>
|
||||
<value-stream-metrics
|
||||
:request-path="endpoints.fullPath"
|
||||
:request-params="filterParams"
|
||||
:requests="metricsRequests"
|
||||
/>
|
||||
<gl-loading-icon v-if="isLoading" size="lg" />
|
||||
<stage-table
|
||||
v-else
|
||||
:is-loading="isLoading || isLoadingStage"
|
||||
:stage-events="selectedStageEvents"
|
||||
:selected-stage="selectedStage"
|
||||
:stage-count="selectedStageCount"
|
||||
:empty-state-title="emptyStageTitle"
|
||||
:empty-state-message="emptyStageText"
|
||||
:no-data-svg-path="noDataSvgPath"
|
||||
:pagination="null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
<script>
|
||||
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlPopover } from '@gitlab/ui';
|
||||
import { GlSingleStat } from '@gitlab/ui/dist/charts';
|
||||
import { flatten } from 'lodash';
|
||||
import createFlash from '~/flash';
|
||||
import { sprintf, s__ } from '~/locale';
|
||||
import { METRICS_POPOVER_CONTENT } from '../constants';
|
||||
import { removeFlash, prepareTimeMetricsData } from '../utils';
|
||||
|
||||
const requestData = ({ request, endpoint, path, params, name }) => {
|
||||
return request({ endpoint, params, requestPath: path })
|
||||
.then(({ data }) => data)
|
||||
.catch(() => {
|
||||
const message = sprintf(
|
||||
s__(
|
||||
'ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data.',
|
||||
),
|
||||
{ requestTypeName: name },
|
||||
);
|
||||
createFlash({ message });
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMetricsData = (reqs = [], path, params) => {
|
||||
const promises = reqs.map((r) => requestData({ ...r, path, params }));
|
||||
return Promise.all(promises).then((responses) =>
|
||||
prepareTimeMetricsData(flatten(responses), METRICS_POPOVER_CONTENT),
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'ValueStreamMetrics',
|
||||
components: {
|
||||
GlPopover,
|
||||
GlSingleStat,
|
||||
GlSkeletonLoading,
|
||||
},
|
||||
props: {
|
||||
requestPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
requestParams: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
requests: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
metrics: [],
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
requestParams() {
|
||||
this.fetchData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchData();
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
removeFlash();
|
||||
this.isLoading = true;
|
||||
return fetchMetricsData(this.requests, this.requestPath, this.requestParams)
|
||||
.then((data) => {
|
||||
this.metrics = data;
|
||||
this.isLoading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="gl-display-flex gl-flex-wrap" data-testid="vsa-time-metrics">
|
||||
<div v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6">
|
||||
<gl-skeleton-loading />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="metric in metrics" :key="metric.key" class="gl-my-6 gl-pr-9">
|
||||
<gl-single-stat
|
||||
:id="metric.key"
|
||||
:value="`${metric.value}`"
|
||||
:title="metric.label"
|
||||
:unit="metric.unit || ''"
|
||||
:should-animate="true"
|
||||
:animation-decimal-places="1"
|
||||
tabindex="0"
|
||||
/>
|
||||
<gl-popover :target="metric.key" placement="bottom">
|
||||
<template #title>
|
||||
<span class="gl-display-block gl-text-left">{{ metric.label }}</span>
|
||||
</template>
|
||||
<span v-if="metric.description">{{ metric.description }}</span>
|
||||
</gl-popover>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
getValueStreamMetrics,
|
||||
METRIC_TYPE_SUMMARY,
|
||||
METRIC_TYPE_TIME_SUMMARY,
|
||||
} from '~/api/analytics_api';
|
||||
import { __, s__ } from '~/locale';
|
||||
|
||||
export const DEFAULT_DAYS_IN_PAST = 30;
|
||||
|
@ -30,3 +35,37 @@ export const I18N_VSA_ERROR_STAGE_MEDIAN = __('There was an error fetching media
|
|||
export const I18N_VSA_ERROR_SELECTED_STAGE = __(
|
||||
'There was an error fetching data for the selected stage',
|
||||
);
|
||||
|
||||
export const OVERVIEW_METRICS = {
|
||||
TIME_SUMMARY: 'TIME_SUMMARY',
|
||||
RECENT_ACTIVITY: 'RECENT_ACTIVITY',
|
||||
};
|
||||
|
||||
export const METRICS_POPOVER_CONTENT = {
|
||||
'lead-time': {
|
||||
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
|
||||
},
|
||||
'cycle-time': {
|
||||
description: s__(
|
||||
'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
|
||||
),
|
||||
},
|
||||
'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
|
||||
'new-issues': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
|
||||
deploys: { description: s__('ValueStreamAnalytics|Total number of deploys to production.') },
|
||||
'deployment-frequency': {
|
||||
description: s__('ValueStreamAnalytics|Average number of deployments to production per day.'),
|
||||
},
|
||||
commits: {
|
||||
description: s__('ValueStreamAnalytics|Number of commits pushed to the default branch'),
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
];
|
||||
|
|
|
@ -24,6 +24,9 @@ export default () => {
|
|||
requestPath,
|
||||
fullPath,
|
||||
},
|
||||
features: {
|
||||
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
|
||||
import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
|
||||
import { decorateData, formatMedianValues, calculateFormattedDayInPast } from '../utils';
|
||||
import { formatMedianValues, calculateFormattedDayInPast } from '../utils';
|
||||
import * as types from './mutation_types';
|
||||
|
||||
export default {
|
||||
[types.INITIALIZE_VSA](state, { endpoints }) {
|
||||
[types.INITIALIZE_VSA](state, { endpoints, features }) {
|
||||
state.endpoints = endpoints;
|
||||
const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
|
||||
state.createdBefore = now;
|
||||
state.createdAfter = past;
|
||||
state.features = features;
|
||||
},
|
||||
[types.SET_LOADING](state, loadingState) {
|
||||
state.isLoading = loadingState;
|
||||
|
@ -48,9 +49,7 @@ export default {
|
|||
state.hasError = false;
|
||||
},
|
||||
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
|
||||
const { summary } = decorateData(data);
|
||||
state.permissions = data?.permissions || {};
|
||||
state.summary = summary;
|
||||
state.hasError = false;
|
||||
},
|
||||
[types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
|
|||
|
||||
export default () => ({
|
||||
id: null,
|
||||
features: {},
|
||||
endpoints: {},
|
||||
daysInPast: DEFAULT_DAYS_TO_DISPLAY,
|
||||
createdAfter: null,
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import dateFormat from 'dateformat';
|
||||
import { unescape } from 'lodash';
|
||||
import { dateFormats } from '~/analytics/shared/constants';
|
||||
import { hideFlash } from '~/flash';
|
||||
import { sanitize } from '~/lib/dompurify';
|
||||
import { roundToNearestHalf } from '~/lib/utils/common_utils';
|
||||
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
|
||||
import { parseSeconds } from '~/lib/utils/datetime_utility';
|
||||
import { slugify } from '~/lib/utils/text_utility';
|
||||
import { s__, sprintf } from '../locale';
|
||||
|
||||
const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
|
||||
|
||||
export const decorateData = (data = {}) => {
|
||||
const { summary } = data;
|
||||
return {
|
||||
summary: summary?.map((item) => mapToSummary(item)) || [],
|
||||
};
|
||||
export const removeFlash = (type = 'alert') => {
|
||||
const flashEl = document.querySelector(`.flash-${type}`);
|
||||
if (flashEl) {
|
||||
hideFlash(flashEl);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -116,3 +116,36 @@ export const calculateFormattedDayInPast = (daysInPast, today = new Date()) => {
|
|||
past: toIsoFormat(getDateInPast(today, daysInPast)),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} MetricData
|
||||
* @property {String} title - Title of the metric measured
|
||||
* @property {String} value - String representing the decimal point value, e.g '1.5'
|
||||
* @property {String} [unit] - String representing the decimal point value, e.g '1.5'
|
||||
*
|
||||
* @typedef {Object} TransformedMetricData
|
||||
* @property {String} label - Title of the metric measured
|
||||
* @property {String} value - String representing the decimal point value, e.g '1.5'
|
||||
* @property {String} key - Slugified string based on the 'title'
|
||||
* @property {String} description - String to display for a description
|
||||
* @property {String} unit - String representing the decimal point value, e.g '1.5'
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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, ...rest }) => {
|
||||
const key = slugify(label);
|
||||
return {
|
||||
...rest,
|
||||
label,
|
||||
key,
|
||||
description: popoverContent[key]?.description || '',
|
||||
};
|
||||
});
|
||||
|
|
|
@ -4,13 +4,16 @@ import { ApolloMutation } from 'vue-apollo';
|
|||
import createFlash from '~/flash';
|
||||
import { s__ } from '~/locale';
|
||||
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
|
||||
import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
|
||||
import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql';
|
||||
import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql';
|
||||
import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
|
||||
import getDesignQuery from '../../graphql/queries/get_design.query.graphql';
|
||||
import allVersionsMixin from '../../mixins/all_versions';
|
||||
import { hasErrors } from '../../utils/cache_update';
|
||||
import { extractDesign } from '../../utils/design_management_utils';
|
||||
import { ADD_DISCUSSION_COMMENT_ERROR } from '../../utils/error_messages';
|
||||
import DesignNote from './design_note.vue';
|
||||
import DesignReplyForm from './design_reply_form.vue';
|
||||
|
@ -161,6 +164,19 @@ export default {
|
|||
},
|
||||
toggleResolvedStatus() {
|
||||
this.isResolving = true;
|
||||
|
||||
/**
|
||||
* Get previous todo count
|
||||
*/
|
||||
const { defaultClient: client } = this.$apollo.provider.clients;
|
||||
const sourceData = client.readQuery({
|
||||
query: getDesignQuery,
|
||||
variables: this.designVariables,
|
||||
});
|
||||
|
||||
const design = extractDesign(sourceData);
|
||||
const prevTodoCount = design.currentUserTodos?.nodes?.length || 0;
|
||||
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: toggleResolveDiscussionMutation,
|
||||
|
@ -170,6 +186,10 @@ export default {
|
|||
if (data.errors?.length > 0) {
|
||||
this.$emit('resolve-discussion-error', data.errors[0]);
|
||||
}
|
||||
const newTodoCount =
|
||||
data?.discussionToggleResolve?.discussion?.noteable?.currentUserTodos?.nodes?.length ||
|
||||
0;
|
||||
updateGlobalTodoCount(newTodoCount - prevTodoCount);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.$emit('resolve-discussion-error', err);
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
|
||||
import { defaultDataIdFromObject, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
|
||||
import produce from 'immer';
|
||||
import { uniqueId } from 'lodash';
|
||||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import introspectionQueryResultData from './graphql/fragmentTypes.json';
|
||||
import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
|
||||
import getDesignQuery from './graphql/queries/get_design.query.graphql';
|
||||
import typeDefs from './graphql/typedefs.graphql';
|
||||
|
@ -12,6 +13,10 @@ import { addPendingTodoToStore } from './utils/cache_update';
|
|||
import { extractTodoIdFromDeletePath, createPendingTodo } from './utils/design_management_utils';
|
||||
import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages';
|
||||
|
||||
const fragmentMatcher = new IntrospectionFragmentMatcher({
|
||||
introspectionQueryResultData,
|
||||
});
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const resolvers = {
|
||||
|
@ -80,6 +85,7 @@ const defaultClient = createDefaultClient(
|
|||
}
|
||||
return defaultDataIdFromObject(object);
|
||||
},
|
||||
fragmentMatcher,
|
||||
},
|
||||
typeDefs,
|
||||
assumeImmutableResults: true,
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{"__schema":{"types":[{"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]},{"kind":"UNION","name":"NoteableType","possibleTypes":[{"name":"Design"},{"name":"Issue"},{"name":"MergeRequest"}]}]}}
|
|
@ -0,0 +1,11 @@
|
|||
fragment DesignTodoItem on Design {
|
||||
id
|
||||
image
|
||||
__typename
|
||||
currentUserTodos(state: pending) {
|
||||
nodes {
|
||||
id
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
#import "../fragments/design_note.fragment.graphql"
|
||||
#import "../fragments/design_todo_item.fragment.graphql"
|
||||
|
||||
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
|
||||
createImageDiffNote(input: $input) {
|
||||
|
@ -7,6 +8,11 @@ mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
|
|||
discussion {
|
||||
id
|
||||
replyId
|
||||
noteable {
|
||||
... on Design {
|
||||
...DesignTodoItem
|
||||
}
|
||||
}
|
||||
notes {
|
||||
nodes {
|
||||
...DesignNote
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
#import "../fragments/design_note.fragment.graphql"
|
||||
#import "../fragments/discussion_resolved_status.fragment.graphql"
|
||||
#import "../fragments/design_todo_item.fragment.graphql"
|
||||
|
||||
mutation toggleResolveDiscussion($id: ID!, $resolve: Boolean!) {
|
||||
discussionToggleResolve(input: { id: $id, resolve: $resolve }) {
|
||||
discussion {
|
||||
id
|
||||
...ResolvedStatus
|
||||
noteable {
|
||||
... on Design {
|
||||
...DesignTodoItem
|
||||
}
|
||||
}
|
||||
notes {
|
||||
nodes {
|
||||
...DesignNote
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script>
|
||||
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
|
||||
import { isNull } from 'lodash';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { ApolloMutation } from 'vue-apollo';
|
||||
import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings';
|
||||
import createFlash from '~/flash';
|
||||
import { fetchPolicies } from '~/lib/graphql';
|
||||
import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import DesignDestroyer from '../../components/design_destroyer.vue';
|
||||
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
|
||||
|
@ -93,6 +95,7 @@ export default {
|
|||
errorMessage: '',
|
||||
scale: DEFAULT_SCALE,
|
||||
resolvedDiscussionsExpanded: false,
|
||||
prevCurrentUserTodos: null,
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
|
@ -163,6 +166,13 @@ export default {
|
|||
resolvedDiscussions() {
|
||||
return this.discussions.filter((discussion) => discussion.resolved);
|
||||
},
|
||||
currentUserTodos() {
|
||||
if (!this.design || !this.design.currentUserTodos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.design.currentUserTodos?.nodes?.length;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
resolvedDiscussions(val) {
|
||||
|
@ -170,6 +180,9 @@ export default {
|
|||
this.resolvedDiscussionsExpanded = false;
|
||||
}
|
||||
},
|
||||
currentUserTodos(_, prevCurrentUserTodos) {
|
||||
this.prevCurrentUserTodos = prevCurrentUserTodos;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
Mousetrap.bind(keysFor(ISSUE_CLOSE_DESIGN), this.closeDesign);
|
||||
|
@ -272,9 +285,14 @@ export default {
|
|||
this.$refs.newDiscussionForm.focusInput();
|
||||
}
|
||||
},
|
||||
closeCommentForm() {
|
||||
closeCommentForm(data) {
|
||||
this.comment = '';
|
||||
this.annotationCoordinates = null;
|
||||
|
||||
if (data?.data && !isNull(this.prevCurrentUserTodos)) {
|
||||
updateGlobalTodoCount(this.currentUserTodos - this.prevCurrentUserTodos);
|
||||
this.prevCurrentUserTodos = this.currentUserTodos;
|
||||
}
|
||||
},
|
||||
closeDesign() {
|
||||
this.$router.push({
|
||||
|
|
|
@ -28,3 +28,8 @@ export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
|
|||
// '*.gitlab-ci.yml' regardless of project configuration.
|
||||
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641
|
||||
export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml';
|
||||
|
||||
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md';
|
||||
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview';
|
||||
export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview';
|
||||
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width
|
||||
|
|
|
@ -1,6 +1,149 @@
|
|||
import { debounce } from 'lodash';
|
||||
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
|
||||
import createFlash from '~/flash';
|
||||
import { sanitize } from '~/lib/dompurify';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { __ } from '~/locale';
|
||||
import syntaxHighlight from '~/syntax_highlight';
|
||||
import {
|
||||
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
|
||||
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
|
||||
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
|
||||
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
|
||||
} from '../constants';
|
||||
import { SourceEditorExtension } from './source_editor_extension_base';
|
||||
|
||||
const getPreview = (text, projectPath = '') => {
|
||||
let url;
|
||||
|
||||
if (projectPath) {
|
||||
url = `/${projectPath}/preview_markdown`;
|
||||
} else {
|
||||
const { group, project } = document.body.dataset;
|
||||
url = `/${group}/${project}/preview_markdown`;
|
||||
}
|
||||
return axios
|
||||
.post(url, {
|
||||
text,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
return data.body;
|
||||
});
|
||||
};
|
||||
|
||||
const setupDomElement = ({ injectToEl = null } = {}) => {
|
||||
const previewEl = document.createElement('div');
|
||||
previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS);
|
||||
previewEl.style.display = 'none';
|
||||
if (injectToEl) {
|
||||
injectToEl.appendChild(previewEl);
|
||||
}
|
||||
return previewEl;
|
||||
};
|
||||
|
||||
export class EditorMarkdownExtension extends SourceEditorExtension {
|
||||
constructor({ instance, projectPath, ...args } = {}) {
|
||||
super({ instance, ...args });
|
||||
Object.assign(instance, {
|
||||
projectPath,
|
||||
preview: {
|
||||
el: undefined,
|
||||
action: undefined,
|
||||
shown: false,
|
||||
},
|
||||
});
|
||||
this.setupPreviewAction.call(instance);
|
||||
}
|
||||
|
||||
static togglePreviewLayout() {
|
||||
const { width, height } = this.getLayoutInfo();
|
||||
const newWidth = this.preview.shown
|
||||
? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
|
||||
: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
|
||||
this.layout({ width: newWidth, height });
|
||||
}
|
||||
|
||||
static togglePreviewPanel() {
|
||||
const parentEl = this.getDomNode().parentElement;
|
||||
const { el: previewEl } = this.preview;
|
||||
parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS);
|
||||
|
||||
if (previewEl.style.display === 'none') {
|
||||
// Show the preview panel
|
||||
this.fetchPreview();
|
||||
} else {
|
||||
// Hide the preview panel
|
||||
previewEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.preview.action.dispose();
|
||||
if (this.preview.shown) {
|
||||
EditorMarkdownExtension.togglePreviewPanel.call(this);
|
||||
EditorMarkdownExtension.togglePreviewLayout.call(this);
|
||||
}
|
||||
this.preview.shown = false;
|
||||
}
|
||||
|
||||
fetchPreview() {
|
||||
const { el: previewEl } = this.preview;
|
||||
getPreview(this.getValue(), this.projectPath)
|
||||
.then((data) => {
|
||||
previewEl.innerHTML = sanitize(data);
|
||||
syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight'));
|
||||
previewEl.style.display = 'block';
|
||||
})
|
||||
.catch(() => createFlash(BLOB_PREVIEW_ERROR));
|
||||
}
|
||||
|
||||
setupPreviewAction() {
|
||||
if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
|
||||
|
||||
this.preview.action = this.addAction({
|
||||
id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
|
||||
label: __('Preview Markdown'),
|
||||
keybindings: [
|
||||
// eslint-disable-next-line no-bitwise,no-undef
|
||||
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
|
||||
],
|
||||
contextMenuGroupId: 'navigation',
|
||||
contextMenuOrder: 1.5,
|
||||
|
||||
// Method that will be executed when the action is triggered.
|
||||
// @param ed The editor instance is passed in as a convenience
|
||||
run(instance) {
|
||||
instance.togglePreview();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
togglePreview() {
|
||||
if (!this.preview?.el) {
|
||||
this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement });
|
||||
}
|
||||
EditorMarkdownExtension.togglePreviewLayout.call(this);
|
||||
EditorMarkdownExtension.togglePreviewPanel.call(this);
|
||||
|
||||
if (!this.preview?.shown) {
|
||||
this.modelChangeListener = this.onDidChangeModelContent(
|
||||
debounce(this.fetchPreview.bind(this), 250),
|
||||
);
|
||||
} else {
|
||||
this.modelChangeListener.dispose();
|
||||
}
|
||||
|
||||
this.preview.shown = !this.preview?.shown;
|
||||
|
||||
this.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
|
||||
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
|
||||
this.setupPreviewAction();
|
||||
} else {
|
||||
this.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSelectedText(selection = this.getSelection()) {
|
||||
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
|
||||
const valArray = this.getValue().split('\n');
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
|
||||
import { capitalize } from 'lodash';
|
||||
import { __ } from '~/locale';
|
||||
import { IssuableTypes } from '../../constants';
|
||||
|
@ -15,6 +15,7 @@ export default {
|
|||
IssuableTypes,
|
||||
components: {
|
||||
GlFormGroup,
|
||||
GlIcon,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
},
|
||||
|
@ -72,6 +73,7 @@ export default {
|
|||
is-check-item
|
||||
@click="updateIssueType(type.value)"
|
||||
>
|
||||
<gl-icon :name="type.icon" />
|
||||
{{ type.text }}
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
|
|
|
@ -28,8 +28,8 @@ export const STATUS_PAGE_PUBLISHED = __('Published on status page');
|
|||
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
|
||||
|
||||
export const IssuableTypes = [
|
||||
{ value: 'issue', text: __('Issue') },
|
||||
{ value: 'incident', text: __('Incident') },
|
||||
{ value: 'issue', text: __('Issue'), icon: 'issue-type-issue' },
|
||||
{ value: 'incident', text: __('Incident'), icon: 'issue-type-incident' },
|
||||
];
|
||||
|
||||
export const IssueTypePath = 'issues';
|
||||
|
|
|
@ -174,7 +174,7 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi
|
|||
parsedLines[currentHeader.index].line.section_duration = line.section_duration;
|
||||
isPreviousLineHeader = false;
|
||||
currentHeader = null;
|
||||
} else {
|
||||
} else if (currentHeader?.isHeader) {
|
||||
currentHeader.line.section_duration = line.section_duration;
|
||||
|
||||
if (previousSection && previousSection?.index) {
|
||||
|
@ -185,6 +185,11 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi
|
|||
}
|
||||
|
||||
currentHeader = previousSection;
|
||||
} else {
|
||||
// On older job logs, there's no `section_header: true` response, it's just an object
|
||||
// with the `section_duration` and `section` props, so we just parse it
|
||||
// as a standard line
|
||||
parsedLines.push(parseLine(line, currentLineCount));
|
||||
}
|
||||
} else {
|
||||
parsedLines.push(parseLine(line, currentLineCount));
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { GlBanner } from '@gitlab/ui';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils';
|
||||
import { setCookie } from '~/lib/utils/common_utils';
|
||||
import { s__ } from '~/locale';
|
||||
|
||||
export default {
|
||||
|
@ -16,50 +16,36 @@ export default {
|
|||
components: {
|
||||
GlBanner,
|
||||
},
|
||||
props: {
|
||||
projectId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
inject: ['terraformImagePath', 'bannerDismissedKey'],
|
||||
data() {
|
||||
return {
|
||||
isVisible: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
bannerDissmisedKey() {
|
||||
return `terraform_notification_dismissed_for_project_${this.projectId}`;
|
||||
},
|
||||
docsUrl() {
|
||||
return helpPagePath('user/infrastructure/terraform_state');
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (parseBoolean(getCookie(this.bannerDissmisedKey))) {
|
||||
this.isVisible = false;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
setCookie(this.bannerDissmisedKey, true);
|
||||
setCookie(this.bannerDismissedKey, true);
|
||||
this.isVisible = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="isVisible">
|
||||
<div class="gl-py-5">
|
||||
<gl-banner
|
||||
:title="$options.i18n.title"
|
||||
:button-text="$options.i18n.buttonText"
|
||||
:button-link="docsUrl"
|
||||
variant="introduction"
|
||||
@close="handleClose"
|
||||
>
|
||||
<p>{{ $options.i18n.description }}</p>
|
||||
</gl-banner>
|
||||
</div>
|
||||
<div v-if="isVisible" class="gl-py-5">
|
||||
<gl-banner
|
||||
:title="$options.i18n.title"
|
||||
:button-text="$options.i18n.buttonText"
|
||||
:button-link="docsUrl"
|
||||
:svg-path="terraformImagePath"
|
||||
variant="promotion"
|
||||
@close="handleClose"
|
||||
>
|
||||
<p>{{ $options.i18n.description }}</p>
|
||||
</gl-banner>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
import Vue from 'vue';
|
||||
import { parseBoolean, getCookie } from '~/lib/utils/common_utils';
|
||||
import TerraformNotification from './components/terraform_notification.vue';
|
||||
|
||||
export default () => {
|
||||
const el = document.querySelector('.js-terraform-notification');
|
||||
const bannerDismissedKey = 'terraform_notification_dismissed';
|
||||
|
||||
if (!el) {
|
||||
if (!el || parseBoolean(getCookie(bannerDismissedKey))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { projectId } = el.dataset;
|
||||
const { terraformImagePath } = el.dataset;
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
render: (createElement) =>
|
||||
createElement(TerraformNotification, { props: { projectId: Number(projectId) } }),
|
||||
provide: {
|
||||
terraformImagePath,
|
||||
bannerDismissedKey,
|
||||
},
|
||||
render: (createElement) => createElement(TerraformNotification),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { todoLabel } from './utils';
|
||||
import { todoLabel, updateGlobalTodoCount } from './utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -19,23 +19,11 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
updateGlobalTodoCount(additionalTodoCount) {
|
||||
const countContainer = document.querySelector('.js-todos-count');
|
||||
if (countContainer === null) return;
|
||||
const currentCount = parseInt(countContainer.innerText, 10);
|
||||
const todoToggleEvent = new CustomEvent('todo:toggle', {
|
||||
detail: {
|
||||
count: Math.max(currentCount + additionalTodoCount, 0),
|
||||
},
|
||||
});
|
||||
|
||||
document.dispatchEvent(todoToggleEvent);
|
||||
},
|
||||
incrementGlobalTodoCount() {
|
||||
this.updateGlobalTodoCount(1);
|
||||
updateGlobalTodoCount(1);
|
||||
},
|
||||
decrementGlobalTodoCount() {
|
||||
this.updateGlobalTodoCount(-1);
|
||||
updateGlobalTodoCount(-1);
|
||||
},
|
||||
onToggle(event) {
|
||||
if (this.isTodo) {
|
||||
|
|
|
@ -3,3 +3,19 @@ import { __ } from '~/locale';
|
|||
export const todoLabel = (hasTodo) => {
|
||||
return hasTodo ? __('Mark as done') : __('Add a to do');
|
||||
};
|
||||
|
||||
export const updateGlobalTodoCount = (additionalTodoCount) => {
|
||||
const countContainer = document.querySelector('.js-todos-count');
|
||||
|
||||
if (countContainer === null) return;
|
||||
|
||||
const currentCount = parseInt(countContainer.innerText, 10);
|
||||
|
||||
const todoToggleEvent = new CustomEvent('todo:toggle', {
|
||||
detail: {
|
||||
count: Math.max(currentCount + additionalTodoCount, 0),
|
||||
},
|
||||
});
|
||||
|
||||
document.dispatchEvent(todoToggleEvent);
|
||||
};
|
||||
|
|
|
@ -25,6 +25,17 @@
|
|||
height: 500px;
|
||||
}
|
||||
|
||||
.source-editor-preview {
|
||||
@include gl-display-flex;
|
||||
|
||||
.md {
|
||||
@include gl-overflow-scroll;
|
||||
@include gl-px-6;
|
||||
@include gl-py-4;
|
||||
@include gl-w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-editor.gl-source-editor {
|
||||
.margin-view-overlays {
|
||||
.line-numbers {
|
||||
|
|
|
@ -48,6 +48,14 @@ module IssuesHelper
|
|||
end
|
||||
end
|
||||
|
||||
def work_item_type_icon(issue_type)
|
||||
if WorkItem::Type.base_types.include?(issue_type)
|
||||
"issue-type-#{issue_type.to_s.dasherize}"
|
||||
else
|
||||
'issue-type-issue'
|
||||
end
|
||||
end
|
||||
|
||||
def confidential_icon(issue)
|
||||
sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
|
||||
end
|
||||
|
|
|
@ -128,8 +128,7 @@ module CounterAttribute
|
|||
end
|
||||
|
||||
def counter_attribute_enabled?(attribute)
|
||||
Feature.enabled?(:efficient_counter_attribute, project) &&
|
||||
self.class.counter_attributes.include?(attribute)
|
||||
self.class.counter_attributes.include?(attribute)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -14,6 +14,7 @@ class InstanceConfiguration
|
|||
host: host,
|
||||
gitlab_pages: gitlab_pages,
|
||||
gitlab_ci: gitlab_ci,
|
||||
package_file_size_limits: package_file_size_limits,
|
||||
rate_limits: rate_limits }.deep_symbolize_keys
|
||||
end
|
||||
end
|
||||
|
@ -44,6 +45,22 @@ class InstanceConfiguration
|
|||
default: 100.megabytes })
|
||||
end
|
||||
|
||||
def package_file_size_limits
|
||||
Plan.all.to_h { |plan| [plan.name.capitalize, plan_file_size_limits(plan)] }
|
||||
end
|
||||
|
||||
def plan_file_size_limits(plan)
|
||||
{
|
||||
conan: plan.actual_limits[:conan_max_file_size],
|
||||
maven: plan.actual_limits[:maven_max_file_size],
|
||||
npm: plan.actual_limits[:npm_max_file_size],
|
||||
nuget: plan.actual_limits[:nuget_max_file_size],
|
||||
pypi: plan.actual_limits[:pypi_max_file_size],
|
||||
terraform_module: plan.actual_limits[:terraform_module_max_file_size],
|
||||
generic: plan.actual_limits[:generic_packages_max_file_size]
|
||||
}
|
||||
end
|
||||
|
||||
def rate_limits
|
||||
{
|
||||
unauthenticated: {
|
||||
|
|
|
@ -49,10 +49,7 @@ class ProjectFeature < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
# Default scopes force us to unscope here since a service may need to check
|
||||
# permissions for a project in pending_delete
|
||||
# http://stackoverflow.com/questions/1540645/how-to-disable-default-scope-for-a-belongs-to
|
||||
belongs_to :project, -> { unscope(where: :pending_delete) }
|
||||
belongs_to :project
|
||||
|
||||
validates :project, presence: true
|
||||
|
||||
|
|
|
@ -294,8 +294,6 @@ module Projects
|
|||
end
|
||||
|
||||
def pages_file_entries_limit
|
||||
return 0 unless Feature.enabled?(:pages_limit_entries_count, project, default_enabled: :yaml)
|
||||
|
||||
project.actual_limits.pages_file_entries
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
= render 'help/instance_configuration/ssh_info'
|
||||
= render 'help/instance_configuration/gitlab_pages'
|
||||
= render 'help/instance_configuration/gitlab_ci'
|
||||
= render 'help/instance_configuration/package_registry'
|
||||
= render 'help/instance_configuration/rate_limits'
|
||||
%p
|
||||
%strong= _("Table of contents")
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
- package_file_size_limits = @instance_configuration.settings[:package_file_size_limits]
|
||||
- content_for :table_content do
|
||||
- if package_file_size_limits.present?
|
||||
%li= link_to _('Package Registry'), '#package-registry'
|
||||
|
||||
- content_for :settings_content do
|
||||
- if package_file_size_limits.present?
|
||||
%h2#package-registry
|
||||
= _('Package Registry')
|
||||
|
||||
%p
|
||||
= _('There are several file size limits in place for the Package Registry.')
|
||||
.table-responsive
|
||||
%table
|
||||
%thead
|
||||
%tr
|
||||
%th= _('Package type')
|
||||
- package_file_size_limits.each_key do |title|
|
||||
%th= title
|
||||
%tbody
|
||||
%tr
|
||||
%td= 'Conan'
|
||||
- package_file_size_limits.each_value do |limits|
|
||||
%td= instance_configuration_human_size_cell(limits[:conan])
|
||||
%tr
|
||||
%td= 'Maven'
|
||||
- package_file_size_limits.each_value do |limits|
|
||||
%td= instance_configuration_human_size_cell(limits[:maven])
|
||||
%tr
|
||||
%td= 'npm'
|
||||
- package_file_size_limits.each_value do |limits|
|
||||
%td= instance_configuration_human_size_cell(limits[:npm])
|
||||
%tr
|
||||
%td= 'NuGet'
|
||||
- package_file_size_limits.each_value do |limits|
|
||||
%td= instance_configuration_human_size_cell(limits[:nuget])
|
||||
%tr
|
||||
%td= 'PyPI'
|
||||
- package_file_size_limits.each_value do |limits|
|
||||
%td= instance_configuration_human_size_cell(limits[:pypi])
|
||||
%tr
|
||||
%td= 'Terraform Module'
|
||||
- package_file_size_limits.each_value do |limits|
|
||||
%td= instance_configuration_human_size_cell(limits[:terraform_module])
|
||||
%tr
|
||||
%td= _('Generic')
|
||||
- package_file_size_limits.each_value do |limits|
|
||||
%td= instance_configuration_human_size_cell(limits[:generic])
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
- if show_terraform_banner?(project)
|
||||
.container-fluid{ class: @content_class }
|
||||
.js-terraform-notification{ data: { project_id: project.id } }
|
||||
.js-terraform-notification{ data: { terraform_image_path: image_path('illustrations/third-party-logos/ci_cd-template-logos/terraform.svg') } }
|
||||
|
|
|
@ -16,14 +16,14 @@
|
|||
= _("Select type")
|
||||
%button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ type: 'button', "aria-label" => _('Close') }
|
||||
= sprite_icon('close', size: 16, css_class: 'dropdown-menu-close-icon')
|
||||
.dropdown-content
|
||||
.dropdown-content{ data: { testid: 'issue-type-select-dropdown' } }
|
||||
%ul
|
||||
%li.js-filter-issuable-type
|
||||
= link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
|
||||
= _("Issue")
|
||||
#{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_("Issue")}
|
||||
%li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
|
||||
= link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
|
||||
= _("Incident")
|
||||
#{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_("Incident")}
|
||||
|
||||
#js-type-popover
|
||||
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: efficient_counter_attribute
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35878
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238535
|
||||
milestone: '13.3'
|
||||
type: development
|
||||
group: group::pipeline execution
|
||||
default_enabled: false
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: pages_limit_entries_count
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64925/diffs
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334765
|
||||
milestone: '14.1'
|
||||
type: development
|
||||
group: group::release
|
||||
default_enabled: false
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
data_category: optional
|
||||
key_path: redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly
|
||||
description: Number of users performing actions on Jira issues by month
|
||||
description: Number of users for Jira and Slack by month
|
||||
product_section: dev
|
||||
product_stage: ecosystem
|
||||
product_group: group::integrations
|
||||
|
|
|
@ -131,3 +131,32 @@ To workaround this issue, make sure to apply one of the following conditions:
|
|||
1. The `terraform-user` creates all subgroup resources.
|
||||
1. Grant Maintainer or Owner role to the `terraform-user` user on `subgroup-B`.
|
||||
1. The `terraform-user` inherited access to `subgroup-B` and `subgroup-B` contains at least one project.
|
||||
|
||||
### Invalid CI/CD syntax error when using the "latest" base template
|
||||
|
||||
On GitLab 14.2 and later, you might get a CI/CD syntax error when using the
|
||||
`latest` Base Terraform template:
|
||||
|
||||
```yaml
|
||||
include:
|
||||
- template: Terraform/Base.latest.gitlab-ci.yml
|
||||
|
||||
my-Terraform-job:
|
||||
extends: .init
|
||||
```
|
||||
|
||||
The base template's [jobs were renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67719/)
|
||||
with better Terraform-specific names. To resolve the syntax error, you can:
|
||||
|
||||
- Use the stable `Terraform/Base.gitlab-ci.yml` template, which has not changed.
|
||||
- Update your pipeline configuration to use the new job names in
|
||||
`https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml`.
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
include:
|
||||
- template: Terraform/Base.latest.gitlab-ci.yml
|
||||
|
||||
my-Terraform-job:
|
||||
extends: .terraform:init # The updated name.
|
||||
```
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Terraform.gitlab-ci.yml
|
||||
|
||||
include:
|
||||
- template: Terraform/Base.latest.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml
|
||||
- template: Terraform/Base.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml
|
||||
|
||||
stages:
|
||||
- init
|
||||
|
|
|
@ -14,20 +14,22 @@ stages:
|
|||
- cleanup
|
||||
|
||||
init:
|
||||
extends: .init
|
||||
extends: .terraform:init
|
||||
|
||||
validate:
|
||||
extends: .validate
|
||||
extends: .terraform:validate
|
||||
|
||||
build:
|
||||
extends: .build
|
||||
extends: .terraform:build
|
||||
|
||||
deploy:
|
||||
extends: .deploy
|
||||
extends: .terraform:deploy
|
||||
dependencies:
|
||||
- build
|
||||
environment:
|
||||
name: $TF_STATE_NAME
|
||||
|
||||
cleanup:
|
||||
extends: .destroy
|
||||
extends: .terraform:destroy
|
||||
dependencies:
|
||||
- deploy
|
||||
|
|
64
lib/gitlab/ci/templates/Terraform/Base.gitlab-ci.yml
Normal file
64
lib/gitlab/ci/templates/Terraform/Base.gitlab-ci.yml
Normal file
|
@ -0,0 +1,64 @@
|
|||
# Terraform/Base.latest
|
||||
#
|
||||
# The purpose of this template is to provide flexibility to the user so
|
||||
# they are able to only include the jobs that they find interesting.
|
||||
#
|
||||
# Therefore, this template is not supposed to run any jobs. The idea is to only
|
||||
# create hidden jobs. See: https://docs.gitlab.com/ee/ci/yaml/#hide-jobs
|
||||
#
|
||||
# There is a more opinionated template which we suggest the users to abide,
|
||||
# which is the lib/gitlab/ci/templates/Terraform.latest.gitlab-ci.yml
|
||||
|
||||
image:
|
||||
name: registry.gitlab.com/gitlab-org/terraform-images/releases/terraform:1.0.3
|
||||
|
||||
variables:
|
||||
TF_ROOT: ${CI_PROJECT_DIR} # The relative path to the root directory of the Terraform project
|
||||
TF_STATE_NAME: ${TF_STATE_NAME:-default} # The name of the state file used by the GitLab Managed Terraform state backend
|
||||
|
||||
cache:
|
||||
key: "${TF_ROOT}"
|
||||
paths:
|
||||
- ${TF_ROOT}/.terraform/
|
||||
- ${TF_ROOT}/.terraform.lock.hcl
|
||||
|
||||
.init: &init
|
||||
stage: init
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform init
|
||||
|
||||
.validate: &validate
|
||||
stage: validate
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform validate
|
||||
|
||||
.build: &build
|
||||
stage: build
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform plan
|
||||
- gitlab-terraform plan-json
|
||||
artifacts:
|
||||
paths:
|
||||
- ${TF_ROOT}/plan.cache
|
||||
reports:
|
||||
terraform: ${TF_ROOT}/plan.json
|
||||
|
||||
.deploy: &deploy
|
||||
stage: deploy
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform apply
|
||||
when: manual
|
||||
only:
|
||||
variables:
|
||||
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
.destroy: &destroy
|
||||
stage: cleanup
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform destroy
|
||||
when: manual
|
|
@ -13,7 +13,8 @@ image:
|
|||
name: registry.gitlab.com/gitlab-org/terraform-images/stable:latest
|
||||
|
||||
variables:
|
||||
TF_ROOT: ${CI_PROJECT_DIR}
|
||||
TF_ROOT: ${CI_PROJECT_DIR} # The relative path to the root directory of the Terraform project
|
||||
TF_STATE_NAME: ${TF_STATE_NAME:-default} # The name of the state file used by the GitLab Managed Terraform state backend
|
||||
|
||||
cache:
|
||||
key: "${TF_ROOT}"
|
||||
|
@ -21,43 +22,46 @@ cache:
|
|||
- ${TF_ROOT}/.terraform/
|
||||
- ${TF_ROOT}/.terraform.lock.hcl
|
||||
|
||||
.init: &init
|
||||
.terraform:init: &terraform_init
|
||||
stage: init
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform init
|
||||
|
||||
.validate: &validate
|
||||
.terraform:validate: &terraform_validate
|
||||
stage: validate
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform validate
|
||||
|
||||
.build: &build
|
||||
.terraform:build: &terraform_build
|
||||
stage: build
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform plan
|
||||
- gitlab-terraform plan-json
|
||||
resource_group: ${TF_STATE_NAME}
|
||||
artifacts:
|
||||
paths:
|
||||
- ${TF_ROOT}/plan.cache
|
||||
reports:
|
||||
terraform: ${TF_ROOT}/plan.json
|
||||
|
||||
.deploy: &deploy
|
||||
.terraform:deploy: &terraform_deploy
|
||||
stage: deploy
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform apply
|
||||
resource_group: ${TF_STATE_NAME}
|
||||
when: manual
|
||||
only:
|
||||
variables:
|
||||
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
.destroy: &destroy
|
||||
.terraform:destroy: &terraform_destroy
|
||||
stage: cleanup
|
||||
script:
|
||||
- cd ${TF_ROOT}
|
||||
- gitlab-terraform destroy
|
||||
resource_group: ${TF_STATE_NAME}
|
||||
when: manual
|
||||
|
|
|
@ -57,68 +57,5 @@ namespace :gitlab do
|
|||
post.puts "<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->"
|
||||
end
|
||||
end
|
||||
|
||||
desc 'GitLab | Docs | Clean up old redirects'
|
||||
task :clean_redirects do
|
||||
#
|
||||
# Calculate new path from the redirect URL.
|
||||
#
|
||||
# If the redirect is not a full URL:
|
||||
# 1. Create a new Pathname of the file
|
||||
# 2. Use dirname to get all but the last component of the path
|
||||
# 3. Join with the redirect_to entry
|
||||
# 4. Substitute:
|
||||
# - '.md' => '.html'
|
||||
# - 'doc/' => '/ee/'
|
||||
#
|
||||
# If the redirect URL is a full URL pointing to the Docs site
|
||||
# (cross-linking among the 4 products), remove the FQDN prefix:
|
||||
#
|
||||
# From : https://docs.gitlab.com/ee/install/requirements.html
|
||||
# To : /ee/install/requirements.html
|
||||
#
|
||||
def new_path(redirect, filename)
|
||||
if !redirect.start_with?('http')
|
||||
Pathname.new(filename).dirname.join(redirect).to_s.gsub(%r(\.md), '.html').gsub(%r(doc/), '/ee/')
|
||||
elsif redirect.start_with?('https://docs.gitlab.com')
|
||||
redirect.gsub('https://docs.gitlab.com', '')
|
||||
else
|
||||
redirect
|
||||
end
|
||||
end
|
||||
|
||||
today = Time.now.utc.to_date
|
||||
|
||||
#
|
||||
# Find the files to be deleted.
|
||||
# Exclude 'doc/development/documentation/index.md' because it
|
||||
# contains an example of the YAML front matter.
|
||||
#
|
||||
files_to_be_deleted = `grep -Ir 'remove_date:' doc | grep -v doc/development/documentation/index.md | cut -d ":" -f 1`.split("\n")
|
||||
|
||||
#
|
||||
# Iterate over the files to be deleted and print the needed
|
||||
# YAML entries for the Docs site redirects.
|
||||
#
|
||||
files_to_be_deleted.each do |filename|
|
||||
frontmatter = YAML.safe_load(File.read(filename))
|
||||
remove_date = Date.parse(frontmatter['remove_date'])
|
||||
old_path = filename.gsub(%r(\.md), '.html').gsub(%r(doc/), '/ee/')
|
||||
|
||||
#
|
||||
# Check if the removal date is before today, and delete the file and
|
||||
# print the content to be pasted in
|
||||
# https://gitlab.com/gitlab-org/gitlab-docs/-/blob/master/content/_data/redirects.yaml.
|
||||
# The remove_date of redirects.yaml should be nine months in the future.
|
||||
# To not be confused with the remove_date of the Markdown page.
|
||||
#
|
||||
next unless remove_date < today
|
||||
|
||||
File.delete(filename) if File.exist?(filename)
|
||||
puts " - from: #{old_path}"
|
||||
puts " to: #{new_path(frontmatter['redirect_to'], filename)}"
|
||||
puts " remove_date: #{remove_date >> 9}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14705,6 +14705,9 @@ msgstr ""
|
|||
msgid "Generate site and private keys at"
|
||||
msgstr ""
|
||||
|
||||
msgid "Generic"
|
||||
msgstr ""
|
||||
|
||||
msgid "Generic package file size in bytes"
|
||||
msgstr ""
|
||||
|
||||
|
@ -23652,6 +23655,9 @@ msgstr ""
|
|||
msgid "Package recipe already exists"
|
||||
msgstr ""
|
||||
|
||||
msgid "Package type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Package type must be Conan"
|
||||
msgstr ""
|
||||
|
||||
|
@ -33562,6 +33568,9 @@ msgstr ""
|
|||
msgid "There are running deployments on the environment. Please retry later."
|
||||
msgstr ""
|
||||
|
||||
msgid "There are several file size limits in place for the Package Registry."
|
||||
msgstr ""
|
||||
|
||||
msgid "There are several rate limits in place to protect the system."
|
||||
msgstr ""
|
||||
|
||||
|
@ -33793,9 +33802,6 @@ msgstr ""
|
|||
msgid "There was an error while fetching the table data. Please refresh the page to try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "There was an error while fetching value stream analytics %{requestTypeName} data."
|
||||
msgstr ""
|
||||
|
||||
msgid "There was an error while fetching value stream analytics data."
|
||||
msgstr ""
|
||||
|
||||
|
@ -36631,9 +36637,15 @@ msgstr ""
|
|||
msgid "ValueStreamAnalytics|Median time from issue first merge request created to issue closed."
|
||||
msgstr ""
|
||||
|
||||
msgid "ValueStreamAnalytics|Number of commits pushed to the default branch"
|
||||
msgstr ""
|
||||
|
||||
msgid "ValueStreamAnalytics|Number of new issues created."
|
||||
msgstr ""
|
||||
|
||||
msgid "ValueStreamAnalytics|There was an error while fetching value stream analytics %{requestTypeName} data."
|
||||
msgstr ""
|
||||
|
||||
msgid "ValueStreamAnalytics|Total number of deploys to production."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -101,3 +101,5 @@ module QA
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
QA::Resource::GroupBase.prepend_mod_with('Resource::GroupBase', namespace: QA)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
module QA
|
||||
RSpec.describe 'Manage', :requires_admin do
|
||||
describe 'Bulk group import via api' do
|
||||
describe 'Bulk group import' do
|
||||
let!(:staging?) { Runtime::Scenario.gitlab_address.include?('staging.gitlab.com') }
|
||||
|
||||
let(:admin_api_client) { Runtime::API::Client.as_admin }
|
||||
|
|
|
@ -57,10 +57,7 @@ module QA
|
|||
# Non blocking issues:
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/331252
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/333678 <- can cause 500 when creating user and group back to back
|
||||
it(
|
||||
'imports group with subgroups and labels',
|
||||
testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1785'
|
||||
) do
|
||||
it 'imports group from UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1785' do
|
||||
Page::Group::BulkImport.perform do |import_page|
|
||||
import_page.import_group(imported_group.path, imported_group.sandbox.path)
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
let_it_be(:guest) { create(:user) }
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' }
|
||||
let_it_be(:metrics_selector) { "[data-testid='vsa-time-metrics']" }
|
||||
|
||||
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
|
||||
let(:milestone) { create(:milestone, project: project) }
|
||||
|
@ -26,11 +27,13 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'shows pipeline summary' do
|
||||
expect(new_issues_counter).to have_content('-')
|
||||
expect(commits_counter).to have_content('-')
|
||||
expect(deploys_counter).to have_content('-')
|
||||
expect(deployment_frequency_counter).to have_content('-')
|
||||
it 'displays metrics' do
|
||||
aggregate_failures 'with relevant values' do
|
||||
expect(new_issues_counter).to have_content('-')
|
||||
expect(commits_counter).to have_content('-')
|
||||
expect(deploys_counter).to have_content('-')
|
||||
expect(deployment_frequency_counter).to have_content('-')
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows active stage with empty message' do
|
||||
|
@ -60,11 +63,15 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
visit project_cycle_analytics_path(project)
|
||||
end
|
||||
|
||||
it 'shows pipeline summary' do
|
||||
expect(new_issues_counter).to have_content('1')
|
||||
expect(commits_counter).to have_content('2')
|
||||
expect(deploys_counter).to have_content('1')
|
||||
expect(deployment_frequency_counter).to have_content('0')
|
||||
it 'displays metrics' do
|
||||
metrics_tiles = page.find(metrics_selector)
|
||||
|
||||
aggregate_failures 'with relevant values' do
|
||||
expect(metrics_tiles).to have_content('Commit')
|
||||
expect(metrics_tiles).to have_content('Deploy')
|
||||
expect(metrics_tiles).to have_content('Deployment Frequency')
|
||||
expect(metrics_tiles).to have_content('New Issue')
|
||||
end
|
||||
end
|
||||
|
||||
it 'shows data on each stage', :sidekiq_might_not_need_inline do
|
||||
|
@ -96,7 +103,7 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
end
|
||||
|
||||
it 'shows only relevant data' do
|
||||
expect(new_issues_counter).to have_content('1')
|
||||
expect(new_issue_counter).to have_content('1')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -116,7 +123,7 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
end
|
||||
|
||||
it 'does not show the commit stats' do
|
||||
expect(page).to have_no_selector(:xpath, commits_counter_selector)
|
||||
expect(page.find(metrics_selector)).not_to have_selector("#commits")
|
||||
end
|
||||
|
||||
it 'needs permissions to see restricted stages' do
|
||||
|
@ -130,28 +137,29 @@ RSpec.describe 'Value Stream Analytics', :js do
|
|||
end
|
||||
end
|
||||
|
||||
def new_issues_counter
|
||||
find(:xpath, "//p[contains(text(),'New Issue')]/preceding-sibling::h3")
|
||||
def find_metric_tile(sel)
|
||||
page.find("#{metrics_selector} #{sel}")
|
||||
end
|
||||
|
||||
def commits_counter_selector
|
||||
"//p[contains(text(),'Commits')]/preceding-sibling::h3"
|
||||
# When now use proper pluralization for the metric names, which affects the id
|
||||
def new_issue_counter
|
||||
find_metric_tile("#new-issue")
|
||||
end
|
||||
|
||||
def new_issues_counter
|
||||
find_metric_tile("#new-issues")
|
||||
end
|
||||
|
||||
def commits_counter
|
||||
find(:xpath, commits_counter_selector)
|
||||
find_metric_tile("#commits")
|
||||
end
|
||||
|
||||
def deploys_counter
|
||||
find(:xpath, "//p[contains(text(),'Deploy')]/preceding-sibling::h3", match: :first)
|
||||
end
|
||||
|
||||
def deployment_frequency_counter_selector
|
||||
"//p[contains(text(),'Deployment Frequency')]/preceding-sibling::h3"
|
||||
find_metric_tile("#deploys")
|
||||
end
|
||||
|
||||
def deployment_frequency_counter
|
||||
find(:xpath, deployment_frequency_counter_selector)
|
||||
find_metric_tile("#deployment-frequency")
|
||||
end
|
||||
|
||||
def expect_issue_to_be_present
|
||||
|
|
|
@ -6,13 +6,13 @@ RSpec.describe 'New/edit issue', :js do
|
|||
include ActionView::Helpers::JavaScriptHelper
|
||||
include FormHelper
|
||||
|
||||
let!(:project) { create(:project) }
|
||||
let!(:user) { create(:user)}
|
||||
let!(:user2) { create(:user)}
|
||||
let!(:milestone) { create(:milestone, project: project) }
|
||||
let!(:label) { create(:label, project: project) }
|
||||
let!(:label2) { create(:label, project: project) }
|
||||
let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) }
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:user) { create(:user)}
|
||||
let_it_be(:user2) { create(:user)}
|
||||
let_it_be(:milestone) { create(:milestone, project: project) }
|
||||
let_it_be(:label) { create(:label, project: project) }
|
||||
let_it_be(:label2) { create(:label, project: project) }
|
||||
let_it_be(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) }
|
||||
|
||||
before do
|
||||
stub_licensed_features(multiple_issue_assignees: false, issue_weights: false)
|
||||
|
@ -234,6 +234,28 @@ RSpec.describe 'New/edit issue', :js do
|
|||
expect(page).to have_selector('.atwho-view')
|
||||
end
|
||||
|
||||
describe 'displays issue type options in the dropdown' do
|
||||
before do
|
||||
page.within('.issue-form') do
|
||||
click_button 'Issue'
|
||||
end
|
||||
end
|
||||
|
||||
it 'correctly displays the Issue type option with an icon', :aggregate_failures do
|
||||
page.within('[data-testid="issue-type-select-dropdown"]') do
|
||||
expect(page).to have_selector('[data-testid="issue-type-issue-icon"]')
|
||||
expect(page).to have_content('Issue')
|
||||
end
|
||||
end
|
||||
|
||||
it 'correctly displays the Incident type option with an icon', :aggregate_failures do
|
||||
page.within('[data-testid="issue-type-select-dropdown"]') do
|
||||
expect(page).to have_selector('[data-testid="issue-type-incident-icon"]')
|
||||
expect(page).to have_content('Incident')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'milestone' do
|
||||
let!(:milestone) { create(:milestone, title: '"><img src=x onerror=alert(document.domain)>', project: project) }
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
|||
import BaseComponent from '~/cycle_analytics/components/base.vue';
|
||||
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
|
||||
import StageTable from '~/cycle_analytics/components/stage_table.vue';
|
||||
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
|
||||
import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
|
||||
import initState from '~/cycle_analytics/store/state';
|
||||
import {
|
||||
|
@ -23,6 +24,7 @@ const selectedStageEvents = issueEvents.events;
|
|||
const noDataSvgPath = 'path/to/no/data';
|
||||
const noAccessSvgPath = 'path/to/no/access';
|
||||
const selectedStageCount = stageCounts[selectedStage.id];
|
||||
const fullPath = 'full/path/to/foo';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
|
@ -34,6 +36,7 @@ const defaultState = {
|
|||
createdBefore,
|
||||
createdAfter,
|
||||
stageCounts,
|
||||
endpoints: { fullPath },
|
||||
};
|
||||
|
||||
function createStore({ initialState = {}, initialGetters = {} }) {
|
||||
|
@ -45,6 +48,10 @@ function createStore({ initialState = {}, initialGetters = {} }) {
|
|||
},
|
||||
getters: {
|
||||
pathNavigationData: () => transformedProjectStagePathData,
|
||||
filterParams: () => ({
|
||||
created_after: createdAfter,
|
||||
created_before: createdBefore,
|
||||
}),
|
||||
...initialGetters,
|
||||
},
|
||||
});
|
||||
|
@ -67,11 +74,17 @@ function createComponent({ initialState, initialGetters } = {}) {
|
|||
|
||||
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
|
||||
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
|
||||
const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics');
|
||||
const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics);
|
||||
const findStageTable = () => wrapper.findComponent(StageTable);
|
||||
const findStageEvents = () => findStageTable().props('stageEvents');
|
||||
const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title');
|
||||
|
||||
const hasMetricsRequests = (reqs) => {
|
||||
const foundReqs = findOverviewMetrics().props('requests');
|
||||
expect(foundReqs.length).toEqual(reqs.length);
|
||||
expect(foundReqs.map(({ name }) => name)).toEqual(reqs);
|
||||
};
|
||||
|
||||
describe('Value stream analytics component', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } });
|
||||
|
@ -94,6 +107,10 @@ describe('Value stream analytics component', () => {
|
|||
expect(findOverviewMetrics().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('passes requests prop to the metrics component', () => {
|
||||
hasMetricsRequests(['recent activity']);
|
||||
});
|
||||
|
||||
it('renders the stage table', () => {
|
||||
expect(findStageTable().exists()).toBe(true);
|
||||
});
|
||||
|
@ -110,6 +127,16 @@ describe('Value stream analytics component', () => {
|
|||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('with `cycleAnalyticsForGroups=true` license', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } });
|
||||
});
|
||||
|
||||
it('passes requests prop to the metrics component', () => {
|
||||
hasMetricsRequests(['time summary', 'recent activity']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLoading = true', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
|
@ -121,14 +148,14 @@ describe('Value stream analytics component', () => {
|
|||
expect(findPathNavigation().props('loading')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render the overview metrics', () => {
|
||||
expect(findOverviewMetrics().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render the stage table', () => {
|
||||
expect(findStageTable().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the overview metrics', () => {
|
||||
expect(findOverviewMetrics().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
|
|
|
@ -15,8 +15,11 @@ export const getStageByTitle = (stages, title) =>
|
|||
const fixtureEndpoints = {
|
||||
customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages',
|
||||
stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`,
|
||||
metricsData: 'projects/analytics/value_stream_analytics/summary',
|
||||
};
|
||||
|
||||
export const metricsData = getJSONFixture(fixtureEndpoints.metricsData);
|
||||
|
||||
export const customizableStagesAndEvents = getJSONFixture(
|
||||
fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents,
|
||||
);
|
||||
|
|
|
@ -6,8 +6,6 @@ import {
|
|||
selectedStage,
|
||||
rawIssueEvents,
|
||||
issueEvents,
|
||||
rawData,
|
||||
convertedData,
|
||||
selectedValueStream,
|
||||
rawValueStreamStages,
|
||||
valueStreamStages,
|
||||
|
@ -90,18 +88,17 @@ describe('Project Value Stream Analytics mutations', () => {
|
|||
});
|
||||
|
||||
it.each`
|
||||
mutation | payload | stateKey | value
|
||||
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
|
||||
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
|
||||
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
|
||||
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
|
||||
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
|
||||
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
|
||||
${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary}
|
||||
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
|
||||
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
|
||||
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
|
||||
${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
|
||||
mutation | payload | stateKey | value
|
||||
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
|
||||
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
|
||||
${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
|
||||
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
|
||||
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
|
||||
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
|
||||
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
|
||||
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
|
||||
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
|
||||
${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
|
||||
`(
|
||||
'$mutation with $payload will set $stateKey to $value',
|
||||
({ mutation, payload, stateKey, value }) => {
|
||||
|
|
|
@ -1,40 +1,24 @@
|
|||
import { useFakeDate } from 'helpers/fake_date';
|
||||
import {
|
||||
decorateData,
|
||||
transformStagesForPathNavigation,
|
||||
timeSummaryForPathNavigation,
|
||||
medianTimeToParsedSeconds,
|
||||
formatMedianValues,
|
||||
filterStagesByHiddenStatus,
|
||||
calculateFormattedDayInPast,
|
||||
prepareTimeMetricsData,
|
||||
} from '~/cycle_analytics/utils';
|
||||
import { slugify } from '~/lib/utils/text_utility';
|
||||
import {
|
||||
selectedStage,
|
||||
rawData,
|
||||
convertedData,
|
||||
allowedStages,
|
||||
stageMedians,
|
||||
pathNavIssueMetric,
|
||||
rawStageMedians,
|
||||
metricsData,
|
||||
} from './mock_data';
|
||||
|
||||
describe('Value stream analytics utils', () => {
|
||||
describe('decorateData', () => {
|
||||
const result = decorateData(rawData);
|
||||
it('returns the summary data', () => {
|
||||
expect(result.summary).toEqual(convertedData.summary);
|
||||
});
|
||||
|
||||
it('returns `-` for summary data that has no value', () => {
|
||||
const singleSummaryResult = decorateData({
|
||||
stats: [],
|
||||
permissions: { issue: true },
|
||||
summary: [{ value: null, title: 'Commits' }],
|
||||
});
|
||||
expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformStagesForPathNavigation', () => {
|
||||
const stages = allowedStages;
|
||||
const response = transformStagesForPathNavigation({
|
||||
|
@ -129,4 +113,32 @@ describe('Value stream analytics utils', () => {
|
|||
expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareTimeMetricsData', () => {
|
||||
let prepared;
|
||||
const [first, second] = metricsData;
|
||||
const firstKey = slugify(first.title);
|
||||
const secondKey = slugify(second.title);
|
||||
|
||||
beforeEach(() => {
|
||||
prepared = prepareTimeMetricsData([first, second], {
|
||||
[firstKey]: { description: 'Is a value that is good' },
|
||||
});
|
||||
});
|
||||
|
||||
it('will add a `key` based on the title', () => {
|
||||
expect(prepared).toMatchObject([{ key: firstKey }, { key: secondKey }]);
|
||||
});
|
||||
|
||||
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: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
128
spec/frontend/cycle_analytics/value_stream_metrics_spec.js
Normal file
128
spec/frontend/cycle_analytics/value_stream_metrics_spec.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
|
||||
import { GlSingleStat } from '@gitlab/ui/dist/charts';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api';
|
||||
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
|
||||
import createFlash from '~/flash';
|
||||
import { group, metricsData } from './mock_data';
|
||||
|
||||
jest.mock('~/flash');
|
||||
|
||||
describe('ValueStreamMetrics', () => {
|
||||
let wrapper;
|
||||
let mockGetValueStreamSummaryMetrics;
|
||||
|
||||
const { full_path: requestPath } = group;
|
||||
const fakeReqName = 'Mock metrics';
|
||||
const metricsRequestFactory = () => ({
|
||||
request: mockGetValueStreamSummaryMetrics,
|
||||
endpoint: METRIC_TYPE_SUMMARY,
|
||||
name: fakeReqName,
|
||||
});
|
||||
|
||||
const createComponent = ({ requestParams = {} } = {}) => {
|
||||
return shallowMount(ValueStreamMetrics, {
|
||||
propsData: {
|
||||
requestPath,
|
||||
requestParams,
|
||||
requests: [metricsRequestFactory()],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findMetrics = () => wrapper.findAllComponents(GlSingleStat);
|
||||
|
||||
const expectToHaveRequest = (fields) => {
|
||||
expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({
|
||||
endpoint: METRIC_TYPE_SUMMARY,
|
||||
requestPath,
|
||||
...fields,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
describe('with successful requests', () => {
|
||||
beforeEach(() => {
|
||||
mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
|
||||
wrapper = createComponent();
|
||||
});
|
||||
|
||||
it('will display a loader with pending requests', async () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
|
||||
});
|
||||
|
||||
describe('with data loaded', () => {
|
||||
beforeEach(async () => {
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('fetches data from the value stream analytics endpoint', () => {
|
||||
expectToHaveRequest({ params: {} });
|
||||
});
|
||||
|
||||
it.each`
|
||||
index | value | title | unit
|
||||
${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit}
|
||||
${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit}
|
||||
${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit}
|
||||
${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit}
|
||||
`(
|
||||
'renders a single stat component for the $title with value and unit',
|
||||
({ index, value, title, unit }) => {
|
||||
const metric = findMetrics().at(index);
|
||||
expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' });
|
||||
},
|
||||
);
|
||||
|
||||
it('will not display a loading icon', () => {
|
||||
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
|
||||
});
|
||||
|
||||
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('with a request failing', () => {
|
||||
beforeEach(async () => {
|
||||
mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue();
|
||||
wrapper = createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('it should render an error message', () => {
|
||||
expect(createFlash).toHaveBeenCalledWith({
|
||||
message: `There was an error while fetching value stream analytics ${fakeReqName} data.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -17,6 +17,8 @@ const defaultMockDiscussion = {
|
|||
notes,
|
||||
};
|
||||
|
||||
const DEFAULT_TODO_COUNT = 2;
|
||||
|
||||
describe('Design discussions component', () => {
|
||||
let wrapper;
|
||||
|
||||
|
@ -41,8 +43,14 @@ describe('Design discussions component', () => {
|
|||
},
|
||||
};
|
||||
const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } });
|
||||
const readQuery = jest.fn().mockReturnValue({
|
||||
project: {
|
||||
issue: { designCollection: { designs: { nodes: [{ currentUserTodos: { nodes: [] } }] } } },
|
||||
},
|
||||
});
|
||||
const $apollo = {
|
||||
mutate,
|
||||
provider: { clients: { defaultClient: { readQuery } } },
|
||||
};
|
||||
|
||||
function createComponent(props = {}, data = {}) {
|
||||
|
@ -69,6 +77,12 @@ describe('Design discussions component', () => {
|
|||
$apollo,
|
||||
$route: {
|
||||
hash: '#note_1',
|
||||
params: {
|
||||
id: 1,
|
||||
},
|
||||
query: {
|
||||
version: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -138,7 +152,13 @@ describe('Design discussions component', () => {
|
|||
});
|
||||
|
||||
describe('when discussion is resolved', () => {
|
||||
let dispatchEventSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
|
||||
jest.spyOn(document, 'querySelector').mockReturnValue({
|
||||
innerText: DEFAULT_TODO_COUNT,
|
||||
});
|
||||
createComponent({
|
||||
discussion: {
|
||||
...defaultMockDiscussion,
|
||||
|
@ -174,6 +194,24 @@ describe('Design discussions component', () => {
|
|||
expect(findResolveIcon().props('name')).toBe('check-circle-filled');
|
||||
});
|
||||
|
||||
it('emit todo:toggle when discussion is resolved', async () => {
|
||||
createComponent(
|
||||
{ discussionWithOpenForm: defaultMockDiscussion.id },
|
||||
{ discussionComment: 'test', isFormRendered: true },
|
||||
);
|
||||
findResolveButton().trigger('click');
|
||||
findReplyForm().vm.$emit('submitForm');
|
||||
|
||||
await mutate();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const dispatchedEvent = dispatchEventSpy.mock.calls[0][0];
|
||||
|
||||
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchedEvent.detail).toEqual({ count: DEFAULT_TODO_COUNT });
|
||||
expect(dispatchedEvent.type).toBe('todo:toggle');
|
||||
});
|
||||
|
||||
describe('when replies are expanded', () => {
|
||||
beforeEach(() => {
|
||||
findRepliesWidget().vm.$emit('toggle');
|
||||
|
|
|
@ -172,3 +172,40 @@ export const moveDesignMutationResponseWithErrors = {
|
|||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const resolveCommentMutationResponse = {
|
||||
discussionToggleResolve: {
|
||||
discussion: {
|
||||
noteable: {
|
||||
id: 'gid://gitlab/DesignManagement::Design/1',
|
||||
currentUserTodos: {
|
||||
nodes: [],
|
||||
__typename: 'TodoConnection',
|
||||
},
|
||||
__typename: 'Design',
|
||||
},
|
||||
__typename: 'Discussion',
|
||||
},
|
||||
errors: [],
|
||||
__typename: 'DiscussionToggleResolvePayload',
|
||||
},
|
||||
};
|
||||
|
||||
export const getDesignQueryResponse = {
|
||||
project: {
|
||||
issue: {
|
||||
designCollection: {
|
||||
designs: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'gid://gitlab/DesignManagement::Design/1',
|
||||
currentUserTodos: {
|
||||
nodes: [{ id: 'gid://gitlab/Todo::1' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,16 +1,34 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Range, Position } from 'monaco-editor';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import {
|
||||
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
|
||||
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
|
||||
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
|
||||
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
|
||||
} from '~/editor/constants';
|
||||
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
|
||||
import SourceEditor from '~/editor/source_editor';
|
||||
import createFlash from '~/flash';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import syntaxHighlight from '~/syntax_highlight';
|
||||
|
||||
jest.mock('~/syntax_highlight');
|
||||
jest.mock('~/flash');
|
||||
|
||||
describe('Markdown Extension for Source Editor', () => {
|
||||
let editor;
|
||||
let instance;
|
||||
let editorEl;
|
||||
let panelSpy;
|
||||
let mockAxios;
|
||||
const projectPath = 'fooGroup/barProj';
|
||||
const firstLine = 'This is a';
|
||||
const secondLine = 'multiline';
|
||||
const thirdLine = 'string with some **markup**';
|
||||
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
|
||||
const filePath = 'foo.md';
|
||||
const responseData = '<div>FooBar</div>';
|
||||
|
||||
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
|
||||
const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn);
|
||||
|
@ -22,7 +40,13 @@ describe('Markdown Extension for Source Editor', () => {
|
|||
const selectionToString = () => instance.getSelection().toString();
|
||||
const positionToString = () => instance.getPosition().toString();
|
||||
|
||||
const togglePreview = async () => {
|
||||
instance.togglePreview();
|
||||
await waitForPromises();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxios = new MockAdapter(axios);
|
||||
setFixtures('<div id="editor" data-editor-loading></div>');
|
||||
editorEl = document.getElementById('editor');
|
||||
editor = new SourceEditor();
|
||||
|
@ -31,12 +55,313 @@ describe('Markdown Extension for Source Editor', () => {
|
|||
blobPath: filePath,
|
||||
blobContent: text,
|
||||
});
|
||||
editor.use(new EditorMarkdownExtension());
|
||||
editor.use(new EditorMarkdownExtension({ instance, projectPath }));
|
||||
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
instance.dispose();
|
||||
editorEl.remove();
|
||||
mockAxios.restore();
|
||||
});
|
||||
|
||||
it('sets up the instance', () => {
|
||||
expect(instance.preview).toEqual({
|
||||
el: undefined,
|
||||
action: expect.any(Object),
|
||||
shown: false,
|
||||
});
|
||||
expect(instance.projectPath).toBe(projectPath);
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
beforeEach(async () => {
|
||||
mockAxios.onPost().reply(200, { body: responseData });
|
||||
await togglePreview();
|
||||
});
|
||||
|
||||
it('removes the contextual menu action', () => {
|
||||
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
|
||||
|
||||
instance.cleanup();
|
||||
|
||||
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null);
|
||||
});
|
||||
|
||||
it('toggles the `shown` flag', () => {
|
||||
expect(instance.preview.shown).toBe(true);
|
||||
instance.cleanup();
|
||||
expect(instance.preview.shown).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles the panel only if the preview is visible', () => {
|
||||
const { el: previewEl } = instance.preview;
|
||||
const parentEl = previewEl.parentElement;
|
||||
|
||||
expect(previewEl).toBeVisible();
|
||||
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true);
|
||||
|
||||
instance.cleanup();
|
||||
expect(previewEl).toBeHidden();
|
||||
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
instance.cleanup();
|
||||
expect(previewEl).toBeHidden();
|
||||
expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles the layout only if the preview is visible', () => {
|
||||
const { width } = instance.getLayoutInfo();
|
||||
|
||||
expect(instance.preview.shown).toBe(true);
|
||||
|
||||
instance.cleanup();
|
||||
|
||||
const { width: newWidth } = instance.getLayoutInfo();
|
||||
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
|
||||
|
||||
instance.cleanup();
|
||||
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPreview', () => {
|
||||
const group = 'foo';
|
||||
const project = 'bar';
|
||||
const setData = (path, g, p) => {
|
||||
instance.projectPath = path;
|
||||
document.body.setAttribute('data-group', g);
|
||||
document.body.setAttribute('data-project', p);
|
||||
};
|
||||
const fetchPreview = async () => {
|
||||
instance.fetchPreview();
|
||||
await waitForPromises();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxios.onPost().reply(200, { body: responseData });
|
||||
});
|
||||
|
||||
it('correctly fetches preview based on projectPath', async () => {
|
||||
setData(projectPath, group, project);
|
||||
await fetchPreview();
|
||||
expect(mockAxios.history.post[0].url).toBe(`/${projectPath}/preview_markdown`);
|
||||
expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
|
||||
});
|
||||
|
||||
it('correctly fetches preview based on group and project data attributes', async () => {
|
||||
setData(undefined, group, project);
|
||||
await fetchPreview();
|
||||
expect(mockAxios.history.post[0].url).toBe(`/${group}/${project}/preview_markdown`);
|
||||
expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text }));
|
||||
});
|
||||
|
||||
it('puts the fetched content into the preview DOM element', async () => {
|
||||
instance.preview.el = editorEl.parentElement;
|
||||
await fetchPreview();
|
||||
expect(instance.preview.el.innerHTML).toEqual(responseData);
|
||||
});
|
||||
|
||||
it('applies syntax highlighting to the preview content', async () => {
|
||||
instance.preview.el = editorEl.parentElement;
|
||||
await fetchPreview();
|
||||
expect(syntaxHighlight).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('catches the errors when fetching the preview', async () => {
|
||||
mockAxios.onPost().reply(500);
|
||||
|
||||
await fetchPreview();
|
||||
expect(createFlash).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPreviewAction', () => {
|
||||
it('adds the contextual menu action', () => {
|
||||
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not set up action if one already exists', () => {
|
||||
jest.spyOn(instance, 'addAction').mockImplementation();
|
||||
|
||||
instance.setupPreviewAction();
|
||||
expect(instance.addAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles preview when the action is triggered', () => {
|
||||
jest.spyOn(instance, 'togglePreview').mockImplementation();
|
||||
|
||||
expect(instance.togglePreview).not.toHaveBeenCalled();
|
||||
|
||||
const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
|
||||
action.run();
|
||||
|
||||
expect(instance.togglePreview).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePreview', () => {
|
||||
beforeEach(() => {
|
||||
mockAxios.onPost().reply(200, { body: responseData });
|
||||
});
|
||||
|
||||
it('toggles preview flag on instance', () => {
|
||||
expect(instance.preview.shown).toBe(false);
|
||||
|
||||
instance.togglePreview();
|
||||
expect(instance.preview.shown).toBe(true);
|
||||
|
||||
instance.togglePreview();
|
||||
expect(instance.preview.shown).toBe(false);
|
||||
});
|
||||
|
||||
describe('model language changes', () => {
|
||||
const plaintextPath = 'foo.txt';
|
||||
const markdownPath = 'foo.md';
|
||||
let cleanupSpy;
|
||||
let actionSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanupSpy = jest.spyOn(instance, 'cleanup');
|
||||
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
|
||||
instance.togglePreview();
|
||||
});
|
||||
|
||||
it('cleans up when switching away from markdown', async () => {
|
||||
expect(instance.cleanup).not.toHaveBeenCalled();
|
||||
expect(instance.setupPreviewAction).not.toHaveBeenCalled();
|
||||
|
||||
instance.updateModelLanguage(plaintextPath);
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalled();
|
||||
expect(actionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-enables the action when switching back to markdown', () => {
|
||||
instance.updateModelLanguage(plaintextPath);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
instance.updateModelLanguage(markdownPath);
|
||||
|
||||
expect(cleanupSpy).not.toHaveBeenCalled();
|
||||
expect(actionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-enable the action if we do not change the language', () => {
|
||||
instance.updateModelLanguage(markdownPath);
|
||||
|
||||
expect(cleanupSpy).not.toHaveBeenCalled();
|
||||
expect(actionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel DOM element set up', () => {
|
||||
it('sets up an element to contain the preview and stores it on instance', () => {
|
||||
expect(instance.preview.el).toBeUndefined();
|
||||
|
||||
instance.togglePreview();
|
||||
|
||||
expect(instance.preview.el).toBeDefined();
|
||||
expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('re-uses existing preview DOM element on repeated calls', () => {
|
||||
instance.togglePreview();
|
||||
const origPreviewEl = instance.preview.el;
|
||||
instance.togglePreview();
|
||||
|
||||
expect(instance.preview.el).toBe(origPreviewEl);
|
||||
});
|
||||
|
||||
it('hides the preview DOM element by default', () => {
|
||||
panelSpy.mockImplementation();
|
||||
instance.togglePreview();
|
||||
expect(instance.preview.el.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview layout setup', () => {
|
||||
it('sets correct preview layout', () => {
|
||||
jest.spyOn(instance, 'layout');
|
||||
const { width, height } = instance.getLayoutInfo();
|
||||
|
||||
instance.togglePreview();
|
||||
|
||||
expect(instance.layout).toHaveBeenCalledWith({
|
||||
width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
|
||||
height,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview panel', () => {
|
||||
it('toggles preview CSS class on the editor', () => {
|
||||
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
|
||||
false,
|
||||
);
|
||||
instance.togglePreview();
|
||||
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
|
||||
true,
|
||||
);
|
||||
instance.togglePreview();
|
||||
expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles visibility of the preview DOM element', async () => {
|
||||
await togglePreview();
|
||||
expect(instance.preview.el.style.display).toBe('block');
|
||||
await togglePreview();
|
||||
expect(instance.preview.el.style.display).toBe('none');
|
||||
});
|
||||
|
||||
describe('hidden preview DOM element', () => {
|
||||
it('listens to model changes and re-fetches preview', async () => {
|
||||
expect(mockAxios.history.post).toHaveLength(0);
|
||||
await togglePreview();
|
||||
expect(mockAxios.history.post).toHaveLength(1);
|
||||
|
||||
instance.setValue('New Value');
|
||||
await waitForPromises();
|
||||
expect(mockAxios.history.post).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('stores disposable listener for model changes', async () => {
|
||||
expect(instance.modelChangeListener).toBeUndefined();
|
||||
await togglePreview();
|
||||
expect(instance.modelChangeListener).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('already visible preview', () => {
|
||||
beforeEach(async () => {
|
||||
await togglePreview();
|
||||
mockAxios.resetHistory();
|
||||
});
|
||||
|
||||
it('does not re-fetch the preview', () => {
|
||||
instance.togglePreview();
|
||||
expect(mockAxios.history.post).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disposes the model change event listener', () => {
|
||||
const disposeSpy = jest.fn();
|
||||
instance.modelChangeListener = {
|
||||
dispose: disposeSpy,
|
||||
};
|
||||
instance.togglePreview();
|
||||
expect(disposeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSelectedText', () => {
|
||||
|
|
|
@ -51,4 +51,21 @@ 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" do
|
||||
get(:show, params: params, format: :json)
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
|
@ -35,6 +35,9 @@ describe('Issue type field component', () => {
|
|||
const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup);
|
||||
const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown);
|
||||
const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem);
|
||||
const findTypeFromDropDownItemAt = (at) => findTypeFromDropDownItems().at(at);
|
||||
const findTypeFromDropDownItemIconAt = (at) =>
|
||||
findTypeFromDropDownItems().at(at).findComponent(GlIcon);
|
||||
|
||||
const createComponent = ({ data } = {}) => {
|
||||
fakeApollo = createMockApollo([], mockResolvers);
|
||||
|
@ -60,6 +63,15 @@ describe('Issue type field component', () => {
|
|||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it.each`
|
||||
at | text | icon
|
||||
${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon}
|
||||
${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon}
|
||||
`(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => {
|
||||
expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon);
|
||||
expect(findTypeFromDropDownItemAt(at).text()).toBe(text);
|
||||
});
|
||||
|
||||
it('renders a form group with the correct label', () => {
|
||||
expect(findTypeFromGroup().attributes('label')).toBe(i18n.label);
|
||||
});
|
||||
|
|
|
@ -123,6 +123,15 @@ export const multipleCollapsibleSectionsMockData = [
|
|||
},
|
||||
];
|
||||
|
||||
export const backwardsCompatibilityTrace = [
|
||||
{
|
||||
offset: 2365,
|
||||
content: [],
|
||||
section: 'download-artifacts',
|
||||
section_duration: '00:01',
|
||||
},
|
||||
];
|
||||
|
||||
export const originalTrace = [
|
||||
{
|
||||
offset: 1,
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
collapsibleTrace,
|
||||
collapsibleTraceIncremental,
|
||||
multipleCollapsibleSectionsMockData,
|
||||
backwardsCompatibilityTrace,
|
||||
} from '../components/log/mock_data';
|
||||
|
||||
describe('Jobs Store Utils', () => {
|
||||
|
@ -297,6 +298,21 @@ describe('Jobs Store Utils', () => {
|
|||
expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backwards compatibility', () => {
|
||||
beforeEach(() => {
|
||||
result = logLinesParser(backwardsCompatibilityTrace);
|
||||
});
|
||||
|
||||
it('should return an object with a parsedLines prop', () => {
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
parsedLines: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
expect(result.parsedLines).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOffsetAndRemove', () => {
|
||||
|
|
|
@ -5,19 +5,21 @@ import TerraformNotification from '~/projects/terraform_notification/components/
|
|||
|
||||
jest.mock('~/lib/utils/common_utils');
|
||||
|
||||
const bannerDissmisedKey = 'terraform_notification_dismissed_for_project_1';
|
||||
const terraformImagePath = '/path/to/image';
|
||||
const bannerDismissedKey = 'terraform_notification_dismissed';
|
||||
|
||||
describe('TerraformNotificationBanner', () => {
|
||||
let wrapper;
|
||||
|
||||
const propsData = {
|
||||
projectId: 1,
|
||||
const provideData = {
|
||||
terraformImagePath,
|
||||
bannerDismissedKey,
|
||||
};
|
||||
const findBanner = () => wrapper.findComponent(GlBanner);
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallowMount(TerraformNotification, {
|
||||
propsData,
|
||||
provide: provideData,
|
||||
stubs: { GlBanner },
|
||||
});
|
||||
});
|
||||
|
@ -27,19 +29,6 @@ describe('TerraformNotificationBanner', () => {
|
|||
parseBoolean.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('when the dismiss cookie is set', () => {
|
||||
beforeEach(() => {
|
||||
parseBoolean.mockReturnValue(true);
|
||||
wrapper = shallowMount(TerraformNotification, {
|
||||
propsData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render the banner', () => {
|
||||
expect(findBanner().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the dismiss cookie is not set', () => {
|
||||
it('should render the banner', () => {
|
||||
expect(findBanner().exists()).toBe(true);
|
||||
|
@ -51,8 +40,8 @@ describe('TerraformNotificationBanner', () => {
|
|||
await findBanner().vm.$emit('close');
|
||||
});
|
||||
|
||||
it('should set the cookie with the bannerDissmisedKey', () => {
|
||||
expect(setCookie).toHaveBeenCalledWith(bannerDissmisedKey, true);
|
||||
it('should set the cookie with the bannerDismissedKey', () => {
|
||||
expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true);
|
||||
});
|
||||
|
||||
it('should remove the banner', () => {
|
||||
|
|
|
@ -1,14 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "spec_helper"
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe IssuesHelper do
|
||||
let(:project) { create(:project) }
|
||||
let(:issue) { create :issue, project: project }
|
||||
let(:ext_project) { create :redmine_project }
|
||||
|
||||
describe '#work_item_type_icon' do
|
||||
it 'returns icon of all standard base types' do
|
||||
WorkItem::Type.base_types.each do |type|
|
||||
expect(work_item_type_icon(type[0])).to eq "issue-type-#{type[0].to_s.dasherize}"
|
||||
end
|
||||
end
|
||||
|
||||
it 'defaults to issue icon if type is unknown' do
|
||||
expect(work_item_type_icon('invalid')).to eq 'issue-type-issue'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#award_user_list' do
|
||||
it "returns a comma-separated list of the first X users" do
|
||||
it 'returns a comma-separated list of the first X users' do
|
||||
user = build_stubbed(:user, name: 'Joe')
|
||||
awards = Array.new(3, build_stubbed(:award_emoji, user: user))
|
||||
|
||||
|
@ -24,7 +36,7 @@ RSpec.describe IssuesHelper do
|
|||
expect(award_user_list([award], nil)).to eq 'Joe'
|
||||
end
|
||||
|
||||
it "truncates lists" do
|
||||
it 'truncates lists' do
|
||||
user = build_stubbed(:user, name: 'Jane')
|
||||
awards = Array.new(5, build_stubbed(:award_emoji, user: user))
|
||||
|
||||
|
@ -32,14 +44,14 @@ RSpec.describe IssuesHelper do
|
|||
.to eq('Jane, Jane, Jane, and 2 more.')
|
||||
end
|
||||
|
||||
it "displays the current user in front of other users" do
|
||||
it 'displays the current user in front of other users' do
|
||||
current_user = build_stubbed(:user)
|
||||
my_award = build_stubbed(:award_emoji, user: current_user)
|
||||
award = build_stubbed(:award_emoji, user: build_stubbed(:user, name: 'Jane'))
|
||||
awards = Array.new(5, award).push(my_award)
|
||||
|
||||
expect(award_user_list(awards, current_user, limit: 2))
|
||||
.to eq("You, Jane, and 4 more.")
|
||||
.to eq('You, Jane, and 4 more.')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -54,19 +66,19 @@ RSpec.describe IssuesHelper do
|
|||
end
|
||||
end
|
||||
|
||||
it "returns disabled string for unauthenticated user" do
|
||||
expect(helper.award_state_class(awardable, AwardEmoji.all, nil)).to eq("disabled")
|
||||
it 'returns disabled string for unauthenticated user' do
|
||||
expect(helper.award_state_class(awardable, AwardEmoji.all, nil)).to eq('disabled')
|
||||
end
|
||||
|
||||
it "returns disabled for a user that does not have access to the awardable" do
|
||||
expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq("disabled")
|
||||
it 'returns disabled for a user that does not have access to the awardable' do
|
||||
expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq('disabled')
|
||||
end
|
||||
|
||||
it "returns active string for author" do
|
||||
expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq("active")
|
||||
it 'returns active string for author' do
|
||||
expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('active')
|
||||
end
|
||||
|
||||
it "is blank for a user that has access to the awardable" do
|
||||
it 'is blank for a user that has access to the awardable' do
|
||||
user = build(:user)
|
||||
expect(helper).to receive(:can?).with(user, :award_emoji, awardable).and_return(true)
|
||||
|
||||
|
@ -74,40 +86,40 @@ RSpec.describe IssuesHelper do
|
|||
end
|
||||
end
|
||||
|
||||
describe "awards_sort" do
|
||||
it "sorts a hash so thumbsup and thumbsdown are always on top" do
|
||||
data = { "thumbsdown" => "some value", "lifter" => "some value", "thumbsup" => "some value" }
|
||||
describe 'awards_sort' do
|
||||
it 'sorts a hash so thumbsup and thumbsdown are always on top' do
|
||||
data = { 'thumbsdown' => 'some value', 'lifter' => 'some value', 'thumbsup' => 'some value' }
|
||||
expect(awards_sort(data).keys).to eq(%w(thumbsup thumbsdown lifter))
|
||||
end
|
||||
end
|
||||
|
||||
describe "#link_to_discussions_to_resolve" do
|
||||
describe "passing only a merge request" do
|
||||
describe '#link_to_discussions_to_resolve' do
|
||||
describe 'passing only a merge request' do
|
||||
let(:merge_request) { create(:merge_request) }
|
||||
|
||||
it "links just the merge request" do
|
||||
it 'links just the merge request' do
|
||||
expected_path = project_merge_request_path(merge_request.project, merge_request)
|
||||
|
||||
expect(link_to_discussions_to_resolve(merge_request, nil)).to include(expected_path)
|
||||
end
|
||||
|
||||
it "contains the reference to the merge request" do
|
||||
it 'contains the reference to the merge request' do
|
||||
expect(link_to_discussions_to_resolve(merge_request, nil)).to include(merge_request.to_reference)
|
||||
end
|
||||
end
|
||||
|
||||
describe "when passing a discussion" do
|
||||
describe 'when passing a discussion' do
|
||||
let(:diff_note) { create(:diff_note_on_merge_request) }
|
||||
let(:merge_request) { diff_note.noteable }
|
||||
let(:discussion) { diff_note.to_discussion }
|
||||
|
||||
it "links to the merge request with first note if a single discussion was passed" do
|
||||
it 'links to the merge request with first note if a single discussion was passed' do
|
||||
expected_path = Gitlab::UrlBuilder.build(diff_note)
|
||||
|
||||
expect(link_to_discussions_to_resolve(merge_request, discussion)).to include(expected_path)
|
||||
end
|
||||
|
||||
it "contains both the reference to the merge request and a mention of the discussion" do
|
||||
it 'contains both the reference to the merge request and a mention of the discussion' do
|
||||
expect(link_to_discussions_to_resolve(merge_request, discussion)).to include("#{merge_request.to_reference} (discussion #{diff_note.id})")
|
||||
end
|
||||
end
|
||||
|
@ -235,13 +247,13 @@ RSpec.describe IssuesHelper do
|
|||
end
|
||||
|
||||
describe '#use_startup_call' do
|
||||
it "returns false when a query param is present" do
|
||||
it 'returns false when a query param is present' do
|
||||
allow(controller.request).to receive(:query_parameters).and_return({ foo: 'bar' })
|
||||
|
||||
expect(helper.use_startup_call?).to eq(false)
|
||||
end
|
||||
|
||||
it "returns false when user has stored sort preference" do
|
||||
it 'returns false when user has stored sort preference' do
|
||||
controller.instance_variable_set(:@sort, 'updated_asc')
|
||||
|
||||
expect(helper.use_startup_call?).to eq(false)
|
||||
|
@ -265,13 +277,13 @@ RSpec.describe IssuesHelper do
|
|||
|
||||
it 'returns expected result' do
|
||||
expected = {
|
||||
can_create_issue: "true",
|
||||
can_reopen_issue: "true",
|
||||
can_report_spam: "false",
|
||||
can_update_issue: "true",
|
||||
can_create_issue: 'true',
|
||||
can_reopen_issue: 'true',
|
||||
can_report_spam: 'false',
|
||||
can_update_issue: 'true',
|
||||
iid: issue.iid,
|
||||
is_issue_author: "false",
|
||||
issue_type: "issue",
|
||||
is_issue_author: 'false',
|
||||
issue_type: 'issue',
|
||||
new_issue_path: new_project_issue_path(project),
|
||||
project_path: project.full_path,
|
||||
report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)),
|
||||
|
@ -345,7 +357,7 @@ RSpec.describe IssuesHelper do
|
|||
end
|
||||
|
||||
it 'returns manual ordering class' do
|
||||
expect(helper.issue_manual_ordering_class).to eq("manual-ordering")
|
||||
expect(helper.issue_manual_ordering_class).to eq('manual-ordering')
|
||||
end
|
||||
|
||||
context 'when manual sorting disabled' do
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Terraform/Base.latest.gitlab-ci.yml' do
|
||||
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base.latest') }
|
||||
RSpec.describe 'Terraform/Base.gitlab-ci.yml' do
|
||||
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base') }
|
||||
|
||||
describe 'the created pipeline' do
|
||||
let(:default_branch) { 'master' }
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Terraform/Base.latest.gitlab-ci.yml' do
|
||||
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base.latest') }
|
||||
|
||||
describe 'the created pipeline' do
|
||||
let(:default_branch) { 'master' }
|
||||
let(:pipeline_branch) { default_branch }
|
||||
let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) }
|
||||
let(:user) { project.owner }
|
||||
let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
|
||||
let(:pipeline) { service.execute!(:push).payload }
|
||||
let(:build_names) { pipeline.builds.pluck(:name) }
|
||||
|
||||
before do
|
||||
stub_ci_pipeline_yaml_file(template.content)
|
||||
allow(project).to receive(:default_branch).and_return(default_branch)
|
||||
end
|
||||
|
||||
it 'does not create any jobs' do
|
||||
expect(build_names).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Terraform.gitlab-ci.yml' do
|
||||
before do
|
||||
allow(Gitlab::Template::GitlabCiYmlTemplate).to receive(:excluded_patterns).and_return([])
|
||||
end
|
||||
|
||||
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform') }
|
||||
|
||||
describe 'the created pipeline' do
|
||||
let(:default_branch) { project.default_branch_or_main }
|
||||
let(:pipeline_branch) { default_branch }
|
||||
let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) }
|
||||
let(:user) { project.owner }
|
||||
let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
|
||||
let(:pipeline) { service.execute!(:push).payload }
|
||||
let(:build_names) { pipeline.builds.pluck(:name) }
|
||||
|
||||
before do
|
||||
stub_ci_pipeline_yaml_file(template.content)
|
||||
allow(project).to receive(:default_branch).and_return(default_branch)
|
||||
end
|
||||
|
||||
context 'on master branch' do
|
||||
it 'creates init, validate and build jobs', :aggregate_failures do
|
||||
expect(pipeline.errors).to be_empty
|
||||
expect(build_names).to include('init', 'validate', 'build', 'deploy')
|
||||
end
|
||||
end
|
||||
|
||||
context 'outside the master branch' do
|
||||
let(:pipeline_branch) { 'patch-1' }
|
||||
|
||||
before do
|
||||
project.repository.create_branch(pipeline_branch, default_branch)
|
||||
end
|
||||
|
||||
it 'does not creates a deploy and a test job', :aggregate_failures do
|
||||
expect(pipeline.errors).to be_empty
|
||||
expect(build_names).not_to include('deploy')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -25,7 +25,8 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do
|
|||
end
|
||||
|
||||
context 'on master branch' do
|
||||
it 'creates init, validate and build jobs' do
|
||||
it 'creates init, validate and build jobs', :aggregate_failures do
|
||||
expect(pipeline.errors).to be_empty
|
||||
expect(build_names).to include('init', 'validate', 'build', 'deploy')
|
||||
end
|
||||
end
|
||||
|
@ -37,7 +38,8 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do
|
|||
project.repository.create_branch(pipeline_branch, default_branch)
|
||||
end
|
||||
|
||||
it 'does not creates a deploy and a test job' do
|
||||
it 'does not creates a deploy and a test job', :aggregate_failures do
|
||||
expect(pipeline.errors).to be_empty
|
||||
expect(build_names).not_to include('deploy')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -97,6 +97,41 @@ RSpec.describe InstanceConfiguration do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#package_file_size_limits' do
|
||||
let_it_be(:plan1) { create(:plan, name: 'plan1', title: 'Plan 1') }
|
||||
let_it_be(:plan2) { create(:plan, name: 'plan2', title: 'Plan 2') }
|
||||
|
||||
before do
|
||||
create(:plan_limits,
|
||||
plan: plan1,
|
||||
conan_max_file_size: 1001,
|
||||
maven_max_file_size: 1002,
|
||||
npm_max_file_size: 1003,
|
||||
nuget_max_file_size: 1004,
|
||||
pypi_max_file_size: 1005,
|
||||
terraform_module_max_file_size: 1006,
|
||||
generic_packages_max_file_size: 1007
|
||||
)
|
||||
create(:plan_limits,
|
||||
plan: plan2,
|
||||
conan_max_file_size: 1101,
|
||||
maven_max_file_size: 1102,
|
||||
npm_max_file_size: 1103,
|
||||
nuget_max_file_size: 1104,
|
||||
pypi_max_file_size: 1105,
|
||||
terraform_module_max_file_size: 1106,
|
||||
generic_packages_max_file_size: 1107
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns package file size limits' do
|
||||
file_size_limits = subject.settings[:package_file_size_limits]
|
||||
|
||||
expect(file_size_limits[:Plan1]).to eq({ conan: 1001, maven: 1002, npm: 1003, nuget: 1004, pypi: 1005, terraform_module: 1006, generic: 1007 })
|
||||
expect(file_size_limits[:Plan2]).to eq({ conan: 1101, maven: 1102, npm: 1103, nuget: 1104, pypi: 1105, terraform_module: 1106, generic: 1107 })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#rate_limits' do
|
||||
before do
|
||||
Gitlab::CurrentSettings.current_application_settings.update!(
|
||||
|
|
|
@ -8,6 +8,8 @@ RSpec.describe ProjectFeature do
|
|||
let(:project) { create(:project) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it { is_expected.to belong_to(:project) }
|
||||
|
||||
describe 'PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT' do
|
||||
it 'has higher level than that of PRIVATE_FEATURES_MIN_ACCESS_LEVEL' do
|
||||
described_class::PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT.each do |feature, level|
|
||||
|
|
|
@ -401,12 +401,6 @@ RSpec.describe ProjectStatistics do
|
|||
let(:stat) { :build_artifacts_size }
|
||||
|
||||
it_behaves_like 'a statistic that increases storage_size asynchronously'
|
||||
|
||||
it_behaves_like 'a statistic that increases storage_size' do
|
||||
before do
|
||||
stub_feature_flags(efficient_counter_attribute: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when adjusting :pipeline_artifacts_size' do
|
||||
|
|
|
@ -156,13 +156,6 @@ RSpec.describe Projects::UpdatePagesService do
|
|||
expect(GenericCommitStatus.last.description).to eq("pages site contains 3 file entries, while limit is set to 2")
|
||||
end
|
||||
|
||||
it 'does not limit pages file count if feature is disabled' do
|
||||
stub_feature_flags(pages_limit_entries_count: false)
|
||||
create(:plan_limits, :default_plan, pages_file_entries: 2)
|
||||
|
||||
expect(execute).to eq(:success)
|
||||
end
|
||||
|
||||
it 'removes pages after destroy' do
|
||||
expect(PagesWorker).to receive(:perform_in)
|
||||
expect(project.pages_deployed?).to be_falsey
|
||||
|
|
|
@ -62,26 +62,6 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes|
|
|||
.to raise_error(ActiveModel::MissingAttributeError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(efficient_counter_attribute: false)
|
||||
end
|
||||
|
||||
it 'delegates to ActiveRecord update!' do
|
||||
expect { subject }
|
||||
.to change { model.reset.read_attribute(attribute) }.by(increment)
|
||||
end
|
||||
|
||||
it 'does not increment the counter in Redis' do
|
||||
subject
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
counter = redis.get(model.counter_key(attribute))
|
||||
expect(counter).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,116 +22,6 @@ RSpec.shared_examples 'UpdateProjectStatistics' do |with_counter_attribute|
|
|||
|
||||
it { is_expected.to be_new_record }
|
||||
|
||||
context 'when feature flag efficient_counter_attribute is disabled' do
|
||||
before do
|
||||
stub_feature_flags(efficient_counter_attribute: false)
|
||||
end
|
||||
|
||||
context 'when creating' do
|
||||
it 'updates the project statistics' do
|
||||
delta0 = reload_stat
|
||||
|
||||
subject.save!
|
||||
|
||||
delta1 = reload_stat
|
||||
|
||||
expect(delta1).to eq(delta0 + read_attribute)
|
||||
expect(delta1).to be > delta0
|
||||
end
|
||||
|
||||
it 'schedules a namespace statistics worker' do
|
||||
expect(Namespaces::ScheduleAggregationWorker)
|
||||
.to receive(:perform_async).once
|
||||
|
||||
subject.save!
|
||||
end
|
||||
end
|
||||
|
||||
context 'when updating' do
|
||||
let(:delta) { 42 }
|
||||
|
||||
before do
|
||||
subject.save!
|
||||
end
|
||||
|
||||
it 'updates project statistics' do
|
||||
expect(ProjectStatistics)
|
||||
.to receive(:increment_statistic)
|
||||
.and_call_original
|
||||
|
||||
subject.write_attribute(statistic_attribute, read_attribute + delta)
|
||||
|
||||
expect { subject.save! }
|
||||
.to change { reload_stat }
|
||||
.by(delta)
|
||||
end
|
||||
|
||||
it 'schedules a namespace statistics worker' do
|
||||
expect(Namespaces::ScheduleAggregationWorker)
|
||||
.to receive(:perform_async).once
|
||||
|
||||
subject.write_attribute(statistic_attribute, read_attribute + delta)
|
||||
subject.save!
|
||||
end
|
||||
|
||||
it 'avoids N + 1 queries' do
|
||||
subject.write_attribute(statistic_attribute, read_attribute + delta)
|
||||
|
||||
control_count = ActiveRecord::QueryRecorder.new do
|
||||
subject.save!
|
||||
end
|
||||
|
||||
subject.write_attribute(statistic_attribute, read_attribute + delta)
|
||||
|
||||
expect do
|
||||
subject.save!
|
||||
end.not_to exceed_query_limit(control_count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when destroying' do
|
||||
before do
|
||||
subject.save!
|
||||
end
|
||||
|
||||
it 'updates the project statistics' do
|
||||
delta0 = reload_stat
|
||||
|
||||
subject.destroy!
|
||||
|
||||
delta1 = reload_stat
|
||||
|
||||
expect(delta1).to eq(delta0 - read_attribute)
|
||||
expect(delta1).to be < delta0
|
||||
end
|
||||
|
||||
it 'schedules a namespace statistics worker' do
|
||||
expect(Namespaces::ScheduleAggregationWorker)
|
||||
.to receive(:perform_async).once
|
||||
|
||||
subject.destroy!
|
||||
end
|
||||
|
||||
context 'when it is destroyed from the project level' do
|
||||
it 'does not update the project statistics' do
|
||||
expect(ProjectStatistics)
|
||||
.not_to receive(:increment_statistic)
|
||||
|
||||
project.update!(pending_delete: true)
|
||||
project.destroy!
|
||||
end
|
||||
|
||||
it 'does not schedule a namespace statistics worker' do
|
||||
expect(Namespaces::ScheduleAggregationWorker)
|
||||
.not_to receive(:perform_async)
|
||||
|
||||
project.update!(pending_delete: true)
|
||||
project.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def expect_flush_counter_increments_worker_performed
|
||||
expect(FlushCounterIncrementsWorker)
|
||||
.to receive(:perform_in)
|
||||
|
|
Loading…
Reference in a new issue