Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-08-16 15:09:17 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-08-16 15:09:17 +0300
commit09dff3eec735ccbe001d165293ecebf195452071 (patch)
tree03c73077d0703edb9452145e7109835da2cd4918 /app/assets/javascripts
parent78e911431fc575ff4f6c9b7e0f95c02b57a5e926 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/api/analytics_api.js25
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js36
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue110
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue107
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js39
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js3
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js7
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js47
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue20
-rw-r--r--app/assets/javascripts/design_management/graphql.js8
-rw-r--r--app/assets/javascripts/design_management/graphql/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql11
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql6
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql6
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue20
-rw-r--r--app/assets/javascripts/editor/constants.js5
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js143
-rw-r--r--app/assets/javascripts/issue_show/components/fields/type.vue4
-rw-r--r--app/assets/javascripts/issue_show/constants.js4
-rw-r--r--app/assets/javascripts/jobs/store/utils.js7
-rw-r--r--app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue42
-rw-r--r--app/assets/javascripts/projects/terraform_notification/index.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js16
25 files changed, 567 insertions, 132 deletions
diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js
index e10439f699d..c7a53288ae4 100644
--- a/app/assets/javascripts/api/analytics_api.js
+++ b/app/assets/javascripts/api/analytics_api.js
@@ -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 });
+};
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 7bfda46d71c..e068910c626 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -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', () => {
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index 3763b228470..c9ecac6829b 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -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>
- </div>
- <stage-table
- :is-loading="isLoading || isLoadingStage"
- :stage-events="selectedStageEvents"
+ <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"
- :stage-count="selectedStageCount"
- :empty-state-title="emptyStageTitle"
- :empty-state-message="emptyStageText"
- :no-data-svg-path="noDataSvgPath"
- :pagination="null"
+ @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>
</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>
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
new file mode 100644
index 00000000000..7371ffd2c7c
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_metrics.vue
@@ -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>
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index a41a9ad989f..ea8d9b76b2a 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -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,
+];
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index cce2edb2447..3827db4d9b2 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -24,6 +24,9 @@ export default () => {
requestPath,
fullPath,
},
+ features: {
+ cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
+ },
});
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index 2d49af947fa..e41de85c1fa 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -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) {
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index b1b26039d41..e6da3f609b2 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -2,6 +2,7 @@ import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
export default () => ({
id: null,
+ features: {},
endpoints: {},
daysInPast: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null,
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index c941799a2ed..fa02fdf914a 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -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 || '',
+ };
+ });
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 78ba586ce37..813f87452d8 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -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);
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
index 9a0547ee9db..fa57537f74e 100644
--- a/app/assets/javascripts/design_management/graphql.js
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -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,
diff --git a/app/assets/javascripts/design_management/graphql/fragmentTypes.json b/app/assets/javascripts/design_management/graphql/fragmentTypes.json
new file mode 100644
index 00000000000..0953231ea4c
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragmentTypes.json
@@ -0,0 +1 @@
+{"__schema":{"types":[{"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]},{"kind":"UNION","name":"NoteableType","possibleTypes":[{"name":"Design"},{"name":"Issue"},{"name":"MergeRequest"}]}]}}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
new file mode 100644
index 00000000000..3fe20705ce2
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
@@ -0,0 +1,11 @@
+fragment DesignTodoItem on Design {
+ id
+ image
+ __typename
+ currentUserTodos(state: pending) {
+ nodes {
+ id
+ __typename
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql
index 0b8400ac040..41c3f56f477 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql
@@ -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
diff --git a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
index 1157fc05d5f..124f12ef018 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql
@@ -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
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 19bfa123487..48ee7068809 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -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({
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index 849ff91841a..dfc57f4966c 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -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
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
index 997503a12f5..0d60339594c 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js
@@ -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');
diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issue_show/components/fields/type.vue
index 1ed222531f4..3eac448c637 100644
--- a/app/assets/javascripts/issue_show/components/fields/type.vue
+++ b/app/assets/javascripts/issue_show/components/fields/type.vue
@@ -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>
diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js
index d93f38c2ee1..64d39a79821 100644
--- a/app/assets/javascripts/issue_show/constants.js
+++ b/app/assets/javascripts/issue_show/constants.js
@@ -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';
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 3f6ab77e26c..b64734e29f6 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -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));
diff --git a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
index 0b398eddc9c..02e31d6fbb3 100644
--- a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
+++ b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
@@ -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>
diff --git a/app/assets/javascripts/projects/terraform_notification/index.js b/app/assets/javascripts/projects/terraform_notification/index.js
index eb04f109a8e..0a273247930 100644
--- a/app/assets/javascripts/projects/terraform_notification/index.js
+++ b/app/assets/javascripts/projects/terraform_notification/index.js
@@ -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),
});
};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue
index e6229cf0a93..cdc7422c7df 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue
@@ -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) {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js
index 59e72a2ffe3..098ab72dfb5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js
@@ -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);
+};