diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-16 15:09:17 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-16 15:09:17 +0300 |
commit | 09dff3eec735ccbe001d165293ecebf195452071 (patch) | |
tree | 03c73077d0703edb9452145e7109835da2cd4918 /app/assets/javascripts | |
parent | 78e911431fc575ff4f6c9b7e0f95c02b57a5e926 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
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); +}; |