diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
commit | 4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch) | |
tree | 5423a1c7516cffe36384133ade12572cf709398d /app/assets/javascripts/pipelines | |
parent | e570267f2f6b326480d284e0164a6464ba4081bc (diff) | |
download | gitlab-ce-4555e1b21c365ed8303ffb7a3325d773c9b8bf31.tar.gz |
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'app/assets/javascripts/pipelines')
27 files changed, 671 insertions, 270 deletions
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 63048777724..71ec81b8969 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -2,6 +2,7 @@ import { reportToSentry } from '../../utils'; import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinksLayer from '../graph_shared/links_layer.vue'; +import { generateColumnsFromLayersListMemoized } from '../parsing_utils'; import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; @@ -25,6 +26,10 @@ export default { type: Object, required: true, }, + showLinks: { + type: Boolean, + required: true, + }, viewType: { type: String, required: true, @@ -74,7 +79,9 @@ export default { return this.hasDownstreamPipelines ? this.pipeline.downstream : []; }, layout() { - return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList(); + return this.isStageView + ? this.pipeline.stages + : generateColumnsFromLayersListMemoized(this.pipeline, this.pipelineLayers); }, hasDownstreamPipelines() { return Boolean(this.pipeline?.downstream?.length > 0); @@ -91,8 +98,8 @@ export default { collectMetrics: true, }; }, - shouldHideLinks() { - return this.isStageView; + showJobLinks() { + return !this.isStageView && this.showLinks; }, shouldShowStageName() { return !this.isStageView; @@ -120,26 +127,6 @@ export default { this.getMeasurements(); }, methods: { - generateColumnsFromLayersList() { - return this.pipelineLayers.map((layers, idx) => { - /* - look up the groups in each layer, - then add each set of layer groups to a stage-like object - */ - - const groups = layers.map((id) => { - const { stageIdx, groupIdx } = this.pipeline.stagesLookup[id]; - return this.pipeline.stages?.[stageIdx]?.groups?.[groupIdx]; - }); - - return { - name: '', - id: `layer-${idx}`, - status: { action: null }, - groups: groups.filter(Boolean), - }; - }); - }, getMeasurements() { this.measurements = { width: this.$refs[this.containerId].scrollWidth, @@ -178,7 +165,7 @@ export default { <div class="js-pipeline-graph"> <div ref="mainPipelineContainer" - class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap" + class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap gl-border-t-solid gl-border-t-1 gl-border-gray-100" :class="{ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline }" > <linked-graph-wrapper> @@ -188,6 +175,7 @@ export default { :config-paths="configPaths" :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" + :show-links="showJobLinks" :type="$options.pipelineTypeConstants.UPSTREAM" :view-type="viewType" @error="onError" @@ -202,9 +190,8 @@ export default { :container-measurements="measurements" :highlighted-job="hoveredJobName" :metrics-config="metricsConfig" - :never-show-links="shouldHideLinks" + :show-links="showJobLinks" :view-type="viewType" - default-link-color="gl-stroke-transparent" @error="onError" @highlightedJobsChange="updateHighlightedJobs" > @@ -234,6 +221,7 @@ export default { :config-paths="configPaths" :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" + :show-links="showJobLinks" :type="$options.pipelineTypeConstants.DOWNSTREAM" :view-type="viewType" @downstreamHovered="setSourceJob" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 0bc6d883245..9329a35ba99 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -5,7 +5,9 @@ import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; -import { reportToSentry } from '../../utils'; +import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; +import { reportToSentry, reportMessageToSentry } from '../../utils'; import { listByLayers } from '../parsing_utils'; import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; import PipelineGraph from './graph_component.vue'; @@ -17,6 +19,9 @@ import { unwrapPipelineData, } from './utils'; +const featureName = 'pipeline_needs_hover_tip'; +const enumFeatureName = featureName.toUpperCase(); + export default { name: 'PipelineGraphWrapper', components: { @@ -44,10 +49,12 @@ export default { data() { return { alertType: null, + callouts: [], currentViewType: STAGE_VIEW, pipeline: null, pipelineLayers: null, showAlert: false, + showLinks: false, }; }, errorTexts: { @@ -59,6 +66,18 @@ export default { [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, apollo: { + callouts: { + query: getUserCallouts, + update(data) { + return data?.currentUser?.callouts?.nodes.map((callout) => callout.featureName) || []; + }, + error(err) { + reportToSentry( + this.$options.name, + `type: callout_load_failure, info: ${serializeLoadErrors(err)}`, + ); + }, + }, pipeline: { context() { return getQueryHeaders(this.graphqlResourceEtag); @@ -90,9 +109,16 @@ export default { }, error(err) { this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); - reportToSentry( + + reportMessageToSentry( this.$options.name, - `type: ${LOAD_FAILURE}, info: ${serializeLoadErrors(err)}`, + `| type: ${LOAD_FAILURE} , info: ${serializeLoadErrors(err)}`, + { + projectPath: this.projectPath, + pipelineIid: this.pipelineIid, + pipelineStages: this.pipeline?.stages?.length || 0, + nbOfDownstreams: this.pipeline?.downstream?.length || 0, + }, ); }, result({ error }) { @@ -137,6 +163,13 @@ export default { metricsPath: this.metricsPath, }; }, + graphViewType() { + /* This prevents reading view type off the localStorage value if it does not apply. */ + return this.showGraphViewSelector ? this.currentViewType : STAGE_VIEW; + }, + hoverTipPreviouslyDismissed() { + return this.callouts.includes(enumFeatureName); + }, showLoadingIcon() { /* Shows the icon only when the graph is empty, not when it is is @@ -166,6 +199,18 @@ export default { return this.pipelineLayers; }, + handleTipDismissal() { + try { + this.$apollo.mutate({ + mutation: DismissPipelineGraphCallout, + variables: { + featureName, + }, + }); + } catch (err) { + reportToSentry(this.$options.name, `type: callout_dismiss_failure, info: ${err}`); + } + }, hideAlert() { this.showAlert = false; this.alertType = null; @@ -182,6 +227,9 @@ export default { } }, /* eslint-enable @gitlab/require-i18n-strings */ + updateShowLinksState(val) { + this.showLinks = val; + }, updateViewType(type) { this.currentViewType = type; }, @@ -201,8 +249,12 @@ export default { > <graph-view-selector v-if="showGraphViewSelector" - :type="currentViewType" + :type="graphViewType" + :show-links="showLinks" + :tip-previously-dismissed="hoverTipPreviouslyDismissed" + @dismissHoverTip="handleTipDismissal" @updateViewType="updateViewType" + @updateShowLinksState="updateShowLinksState" /> </local-storage-sync> <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> @@ -211,7 +263,8 @@ export default { :config-paths="configPaths" :pipeline="pipeline" :pipeline-layers="getPipelineLayers()" - :view-type="currentViewType" + :show-links="showLinks" + :view-type="graphViewType" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue index f33e6290e37..1435276edd3 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -1,17 +1,25 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui'; import { __ } from '~/locale'; import { STAGE_VIEW, LAYER_VIEW } from './constants'; export default { name: 'GraphViewSelector', components: { - GlDropdown, - GlDropdownItem, - GlIcon, - GlSprintf, + GlAlert, + GlLoadingIcon, + GlSegmentedControl, + GlToggle, }, props: { + showLinks: { + type: Boolean, + required: true, + }, + tipPreviouslyDismissed: { + type: Boolean, + required: true, + }, type: { type: String, required: true, @@ -19,67 +27,138 @@ export default { }, data() { return { - currentViewType: STAGE_VIEW, + hoverTipDismissed: false, + isToggleLoading: false, + isSwitcherLoading: false, + segmentSelectedType: this.type, + showLinksActive: false, }; }, i18n: { - labelText: __('Order jobs by'), + hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'), + linksLabelText: __('Show dependencies'), + viewLabelText: __('Group jobs by'), }, views: { [STAGE_VIEW]: { type: STAGE_VIEW, text: { primary: __('Stage'), - secondary: __('View the jobs grouped into stages'), }, }, [LAYER_VIEW]: { type: LAYER_VIEW, text: { - primary: __('%{codeStart}needs:%{codeEnd} relationships'), - secondary: __('View what jobs are needed for a job to run'), + primary: __('Job dependencies'), }, }, }, computed: { - currentDropdownText() { - return this.$options.views[this.type].text.primary; + showLinksToggle() { + return this.segmentSelectedType === LAYER_VIEW; + }, + showTip() { + return ( + this.showLinks && + this.showLinksActive && + !this.tipPreviouslyDismissed && + !this.hoverTipDismissed + ); + }, + viewTypesList() { + return Object.keys(this.$options.views).map((key) => { + return { + value: key, + text: this.$options.views[key].text.primary, + }; + }); + }, + }, + watch: { + /* + How does this reset the loading? As we note in the methods comment below, + the loader is set to on before the update work is undertaken (in the parent). + Once the work is complete, one of these values will change, since that's the + point of the work. When that happens, the related value will update and we are done. + + The bonus for this approach is that it works the same whichever "direction" + the work goes in. + */ + showLinks() { + this.isToggleLoading = false; + }, + type() { + this.isSwitcherLoading = false; }, }, methods: { - itemClick(type) { - this.$emit('updateViewType', type); + dismissTip() { + this.hoverTipDismissed = true; + this.$emit('dismissHoverTip'); + }, + /* + In both toggle methods, we use setTimeout so that the loading indicator displays, + then the work is done to update the DOM. The process is: + → user clicks + → call stack: set loading to true + → render: the loading icon appears on the screen + → callback queue: now do the work to calculate the new view / links + (note: this work is done in the parent after the event is emitted) + + setTimeout is how we move the work to the callback queue. + We can't use nextTick because that is called before the render loop. + + See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details. + */ + toggleView(type) { + this.isSwitcherLoading = true; + setTimeout(() => { + this.$emit('updateViewType', type); + }); + }, + toggleShowLinksActive(val) { + this.isToggleLoading = true; + setTimeout(() => { + this.$emit('updateShowLinksState', val); + }); }, }, }; </script> <template> - <div class="gl-display-flex gl-align-items-center gl-my-4"> - <span>{{ $options.i18n.labelText }}</span> - <gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4"> - <template #button-content> - <gl-sprintf :message="currentDropdownText"> - <template #code="{ content }"> - <code> {{ content }} </code> - </template> - </gl-sprintf> - <gl-icon class="gl-px-2" name="angle-down" :size="16" /> - </template> - <gl-dropdown-item - v-for="view in $options.views" - :key="view.type" - :secondary-text="view.text.secondary" - @click="itemClick(view.type)" - > - <b> - <gl-sprintf :message="view.text.primary"> - <template #code="{ content }"> - <code> {{ content }} </code> - </template> - </gl-sprintf> - </b> - </gl-dropdown-item> - </gl-dropdown> + <div> + <div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4"> + <gl-loading-icon + v-if="isSwitcherLoading" + data-testid="switcher-loading-state" + class="gl-absolute gl-w-full gl-bg-white gl-opacity-5 gl-z-index-2" + size="lg" + /> + <span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span> + <gl-segmented-control + v-model="segmentSelectedType" + :options="viewTypesList" + :disabled="isSwitcherLoading" + data-testid="pipeline-view-selector" + class="gl-mx-4" + @input="toggleView" + /> + + <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center"> + <gl-toggle + v-model="showLinksActive" + data-testid="show-links-toggle" + class="gl-mx-4" + :label="$options.i18n.linksLabelText" + :is-loading="isToggleLoading" + label-position="left" + @change="toggleShowLinksActive" + /> + </div> + </div> + <gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip"> + {{ $options.i18n.hoverTipText }} + </gl-alert> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 6451605a222..b2a3f27e079 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -53,6 +53,7 @@ export default { }; </script> <template> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <button type="button" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 7f772e35e55..45113ecff41 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -3,7 +3,7 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu import { LOAD_FAILURE } from '../../constants'; import { reportToSentry } from '../../utils'; import { listByLayers } from '../parsing_utils'; -import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants'; +import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; import { getQueryHeaders, @@ -32,6 +32,10 @@ export default { type: Array, required: true, }, + showLinks: { + type: Boolean, + required: true, + }, type: { type: String, required: true, @@ -76,6 +80,9 @@ export default { graphPosition() { return this.isUpstream ? 'left' : 'right'; }, + graphViewType() { + return this.currentPipeline?.usesNeeds ? this.viewType : STAGE_VIEW; + }, isUpstream() { return this.type === UPSTREAM; }, @@ -217,8 +224,9 @@ export default { :config-paths="configPaths" :pipeline="currentPipeline" :pipeline-layers="getPipelineLayers(pipeline.id)" + :show-links="showLinks" :is-linked-pipeline="true" - :view-type="viewType" + :view-type="graphViewType" /> </div> </li> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index fa2f381c8a4..81d59f1ef65 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -160,7 +160,10 @@ export default { :pipeline-id="pipelineId" :stage-name="showStageName ? group.stageName : ''" css-class-job-name="gl-build-content" - :class="{ 'gl-opacity-3': isFadedOut(group.name) }" + :class="[ + { 'gl-opacity-3': isFadedOut(group.name) }, + 'gl-transition-duration-slow gl-transition-timing-function-ease', + ]" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" /> <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 373aa6bf9a1..163b3898c28 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils'; @@ -39,15 +40,15 @@ const serializeGqlErr = (gqlError) => { const serializeLoadErrors = (errors) => { const { gqlError, graphQLErrors, networkError, message } = errors; - if (graphQLErrors) { + if (!isEmpty(graphQLErrors)) { return graphQLErrors.map((err) => serializeGqlErr(err)).join('; '); } - if (gqlError) { + if (!isEmpty(gqlError)) { return serializeGqlErr(gqlError); } - if (networkError) { + if (!isEmpty(networkError)) { return `Network error: ${networkError.message}`; } diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js index 49cd04d11e9..0fe7d9ffda3 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/api.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js @@ -2,6 +2,11 @@ import axios from '~/lib/utils/axios_utils'; import { reportToSentry } from '../../utils'; export const reportPerformance = (path, stats) => { + // FIXME: https://gitlab.com/gitlab-org/gitlab/-/issues/330245 + if (!path) { + return; + } + axios.post(path, stats).catch((err) => { reportToSentry('links_inner_perf', `error: ${err}`); }); diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 202498fb188..7c306683305 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -15,6 +15,7 @@ export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobNam export const generateLinksData = ({ links }, containerID, modifier = '') => { const containerEl = document.getElementById(containerID); + return links.map((link) => { const path = d3.path(); diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index 0ed5b8a5f09..5c775df7b48 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -1,19 +1,8 @@ <script> import { isEmpty } from 'lodash'; -import { - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - PIPELINES_DETAIL_LINK_DURATION, - PIPELINES_DETAIL_LINKS_TOTAL, - PIPELINES_DETAIL_LINKS_JOB_RATIO, -} from '~/performance/constants'; -import { performanceMarkAndMeasure } from '~/performance/utils'; import { DRAW_FAILURE } from '../../constants'; import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils'; import { STAGE_VIEW } from '../graph/constants'; -import { parseData } from '../parsing_utils'; -import { reportPerformance } from './api'; import { generateLinksData } from './drawing_utils'; export default { @@ -28,6 +17,10 @@ export default { type: Object, required: true, }, + parsedData: { + type: Object, + required: true, + }, pipelineId: { type: Number, required: true, @@ -36,15 +29,6 @@ export default { type: Array, required: true, }, - totalGroups: { - type: Number, - required: true, - }, - metricsConfig: { - type: Object, - required: false, - default: () => ({}), - }, defaultLinkColor: { type: String, required: false, @@ -65,13 +49,9 @@ export default { return { links: [], needsObject: null, - parsedData: {}, }; }, computed: { - shouldCollectMetrics() { - return this.metricsConfig.collectMetrics && this.metricsConfig.path; - }, hasHighlightedJob() { return Boolean(this.highlightedJob); }, @@ -115,13 +95,16 @@ export default { highlightedJobs(jobs) { this.$emit('highlightedJobsChange', jobs); }, + parsedData() { + this.calculateLinkData(); + }, viewType() { /* We need to wait a tick so that the layout reflows before the links refresh. */ this.$nextTick(() => { - this.refreshLinks(); + this.calculateLinkData(); }); }, }, @@ -129,69 +112,21 @@ export default { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, mounted() { - if (!isEmpty(this.pipelineData)) { - this.prepareLinkData(); + if (!isEmpty(this.parsedData)) { + this.calculateLinkData(); } }, methods: { - beginPerfMeasure() { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); - } - }, - finishPerfMeasureAndSend() { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ - mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - measures: [ - { - name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - }, - ], - }); - } - - window.requestAnimationFrame(() => { - const duration = window.performance.getEntriesByName( - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - )[0]?.duration; - - if (!duration) { - return; - } - - const data = { - histograms: [ - { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, - { name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length }, - { - name: PIPELINES_DETAIL_LINKS_JOB_RATIO, - value: this.links.length / this.totalGroups, - }, - ], - }; - - reportPerformance(this.metricsConfig.path, data); - }); - }, isLinkHighlighted(linkRef) { return this.highlightedLinks.includes(linkRef); }, - prepareLinkData() { - this.beginPerfMeasure(); + calculateLinkData() { try { - const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - this.parsedData = parseData(arrayOfJobs); - this.refreshLinks(); + this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); } catch (err) { this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false }); reportToSentry(this.$options.name, err); } - this.finishPerfMeasureAndSend(); - }, - refreshLinks() { - this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); }, getLinkClasses(link) { return [ diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 8dbab245f44..81409752621 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -1,5 +1,4 @@ <script> -import { GlAlert } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { __ } from '~/locale'; import { @@ -19,10 +18,8 @@ import LinksInner from './links_inner.vue'; export default { name: 'LinksLayer', components: { - GlAlert, LinksInner, }, - MAX_GROUPS: 200, props: { containerMeasurements: { type: Object, @@ -37,15 +34,16 @@ export default { required: false, default: () => ({}), }, - neverShowLinks: { + showLinks: { type: Boolean, required: false, - default: false, + default: true, }, }, data() { return { alertDismissed: false, + parsedData: {}, showLinksOverride: false, }; }, @@ -67,43 +65,15 @@ export default { shouldCollectMetrics() { return this.metricsConfig.collectMetrics && this.metricsConfig.path; }, - showAlert() { - /* - This is a hard override that allows us to turn off the links without - needing to remove the component entirely for iteration or based on graph type. - */ - if (this.neverShowLinks) { - return false; - } - - return !this.containerZero && !this.showLinkedLayers && !this.alertDismissed; - }, showLinkedLayers() { - /* - This is a hard override that allows us to turn off the links without - needing to remove the component entirely for iteration or based on graph type. - */ - if (this.neverShowLinks) { - return false; - } - - return ( - !this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS) - ); + return this.showLinks && !this.containerZero; }, }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, mounted() { - /* - This is code to get metrics for the graph (to observe links performance). - It is currently here because we want values for links without drawing them. - It can be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/298930 - is closed and functionality is enabled by default. - */ - - if (this.neverShowLinks && !isEmpty(this.pipelineData)) { + if (!isEmpty(this.pipelineData)) { window.requestAnimationFrame(() => { this.prepareLinkData(); }); @@ -151,19 +121,13 @@ export default { reportPerformance(this.metricsConfig.path, data); }); }, - dismissAlert() { - this.alertDismissed = true; - }, - overrideShowLinks() { - this.dismissAlert(); - this.showLinksOverride = true; - }, prepareLinkData() { this.beginPerfMeasure(); let numLinks; try { const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - numLinks = parseData(arrayOfJobs).links.length; + this.parsedData = parseData(arrayOfJobs); + numLinks = this.parsedData.links.length; } catch (err) { reportToSentry(this.$options.name, err); } @@ -176,24 +140,15 @@ export default { <links-inner v-if="showLinkedLayers" :container-measurements="containerMeasurements" + :parsed-data="parsedData" :pipeline-data="pipelineData" :total-groups="numGroups" - :metrics-config="metricsConfig" v-bind="$attrs" v-on="$listeners" > <slot></slot> </links-inner> <div v-else> - <gl-alert - v-if="showAlert" - class="gl-ml-4 gl-mb-4" - :primary-button-text="$options.i18n.showLinksAnyways" - @primaryAction="overrideShowLinks" - @dismiss="dismissAlert" - > - {{ $options.i18n.tooManyJobs }} - </gl-alert> <div class="gl-display-flex gl-relative"> <slot></slot> </div> diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue index 6982586ab12..6dff3828a34 100644 --- a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue +++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue @@ -2,7 +2,7 @@ import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; const featureName = 'pipeline_needs_banner'; @@ -55,7 +55,7 @@ export default { this.dismissedAlert = true; try { this.$apollo.mutate({ - mutation: DismissPipelineNotification, + mutation: DismissPipelineGraphCallout, variables: { featureName, }, diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index f5ab869633b..9d886e0e379 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -1,4 +1,4 @@ -import { uniqWith, isEqual } from 'lodash'; +import { isEqual, memoize, uniqWith } from 'lodash'; import { createSankey } from './dag/drawing_utils'; /* @@ -170,3 +170,26 @@ export const listByLayers = ({ stages }) => { return acc; }, []); }; + +export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipelineLayers) => { + return pipelineLayers.map((layers, idx) => { + /* + Look up the groups in each layer, + then add each set of layer groups to a stage-like object. + */ + + const groups = layers.map((id) => { + const { stageIdx, groupIdx } = stagesLookup[id]; + return stages[stageIdx]?.groups?.[groupIdx]; + }); + + return { + name: '', + id: `layer-${idx}`, + status: { action: null }, + groups: groups.filter(Boolean), + }; + }); +}; + +export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index c3bcfcb18fb..e9773f055a7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,6 +1,8 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; -import Experiment from '~/experimentation/components/experiment.vue'; +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { startCodeQualityWalkthrough, track } from '~/code_quality_walkthrough/utils'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import { getExperimentData } from '~/experimentation/utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import PipelinesCiTemplates from './pipelines_ci_templates.vue'; @@ -12,12 +14,18 @@ export default { test, and deploy your code. Let GitLab take care of time consuming tasks, so you can spend more time creating.`), btnText: s__('Pipelines|Get started with CI/CD'), + codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'), + codeQualityDescription: s__(`Pipelines|To keep your codebase simple, + readable, and accessible to contributors, use GitLab CI/CD + to analyze your code quality with every push to your project.`), + codeQualityBtnText: s__('Pipelines|Add a code quality job'), noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'), }, name: 'PipelinesEmptyState', components: { GlEmptyState, - Experiment, + GlButton, + GitlabExperiment, PipelinesCiTemplates, }, props: { @@ -29,36 +37,82 @@ export default { type: Boolean, required: true, }, + codeQualityPagePath: { + type: String, + required: false, + default: null, + }, }, computed: { ciHelpPagePath() { return helpPagePath('ci/quick_start/index.md'); }, + isPipelineEmptyStateTemplatesExperimentActive() { + return this.canSetCi && Boolean(getExperimentData('pipeline_empty_state_templates')); + }, + }, + mounted() { + startCodeQualityWalkthrough(); + }, + methods: { + trackClick() { + track('cta_clicked'); + }, }, }; </script> <template> <div> - <experiment name="pipeline_empty_state_templates"> + <gitlab-experiment + v-if="isPipelineEmptyStateTemplatesExperimentActive" + name="pipeline_empty_state_templates" + > <template #control> <gl-empty-state - v-if="canSetCi" :title="$options.i18n.title" :svg-path="emptyStateSvgPath" :description="$options.i18n.description" :primary-button-text="$options.i18n.btnText" :primary-button-link="ciHelpPagePath" /> + </template> + <template #candidate> + <pipelines-ci-templates /> + </template> + </gitlab-experiment> + <gitlab-experiment v-else-if="canSetCi" name="code_quality_walkthrough"> + <template #control> <gl-empty-state - v-else - title="" + :title="$options.i18n.title" :svg-path="emptyStateSvgPath" - :description="$options.i18n.noCiDescription" - /> + :description="$options.i18n.description" + > + <template #actions> + <gl-button :href="ciHelpPagePath" variant="confirm" @click="trackClick()"> + {{ $options.i18n.btnText }} + </gl-button> + </template> + </gl-empty-state> </template> <template #candidate> - <pipelines-ci-templates /> + <gl-empty-state + :title="$options.i18n.codeQualityTitle" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.codeQualityDescription" + > + <template #actions> + <gl-button :href="codeQualityPagePath" variant="confirm" @click="trackClick()"> + {{ $options.i18n.codeQualityBtnText }} + </gl-button> + </template> + </gl-empty-state> </template> - </experiment> + </gitlab-experiment> + <gl-empty-state + v-else + title="" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.noCiDescription" + /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue new file mode 100644 index 00000000000..d7bd2d731b1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -0,0 +1,115 @@ +<script> +import { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, s__ } from '~/locale'; + +export const i18n = { + artifacts: __('Artifacts'), + downloadArtifact: __('Download %{name} artifact'), + artifactSectionHeader: __('Download artifacts'), + artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), +}; + +export default { + i18n, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSprintf, + }, + inject: { + artifactsEndpoint: { + default: '', + }, + artifactsEndpointPlaceholder: { + default: '', + }, + }, + props: { + pipelineId: { + type: Number, + required: true, + }, + }, + data() { + return { + artifacts: [], + hasError: false, + isLoading: false, + }; + }, + methods: { + fetchArtifacts() { + this.isLoading = true; + // Replace the placeholder with the ID of the pipeline we are viewing + const endpoint = this.artifactsEndpoint.replace( + this.artifactsEndpointPlaceholder, + this.pipelineId, + ); + return axios + .get(endpoint) + .then(({ data }) => { + this.artifacts = data.artifacts; + }) + .catch(() => { + this.hasError = true; + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> +<template> + <gl-dropdown + v-gl-tooltip + :title="$options.i18n.artifacts" + :text="$options.i18n.artifacts" + :aria-label="$options.i18n.artifacts" + icon="ellipsis_v" + data-testid="pipeline-multi-actions-dropdown" + right + lazy + text-sr-only + no-caret + @show.once="fetchArtifacts" + > + <gl-dropdown-section-header>{{ + $options.i18n.artifactSectionHeader + }}</gl-dropdown-section-header> + + <gl-alert v-if="hasError" variant="danger" :dismissible="false"> + {{ $options.i18n.artifactsFetchErrorMessage }} + </gl-alert> + + <gl-loading-icon v-if="isLoading" /> + + <gl-dropdown-item + v-for="(artifact, i) in artifacts" + :key="i" + :href="artifact.path" + rel="nofollow" + download + data-testid="artifact-item" + > + <gl-sprintf :message="$options.i18n.downloadArtifact"> + <template #name>{{ artifact.name }}</template> + </gl-sprintf> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index 81eeead2171..85ee44f427d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -2,7 +2,7 @@ import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import eventHub from '../../event_hub'; -import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; +import PipelineMultiActions from './pipeline_multi_actions.vue'; import PipelinesManualActions from './pipelines_manual_actions.vue'; export default { @@ -16,8 +16,8 @@ export default { }, components: { GlButton, + PipelineMultiActions, PipelinesManualActions, - PipelinesArtifactsComponent, }, props: { pipeline: { @@ -36,14 +36,6 @@ export default { }; }, computed: { - displayPipelineActions() { - return ( - this.pipeline.flags.retryable || - this.pipeline.flags.cancelable || - this.pipeline.details.manual_actions.length || - this.pipeline.details.artifacts.length - ); - }, actions() { if (!this.pipeline || !this.pipeline.details) { return []; @@ -76,15 +68,10 @@ export default { </script> <template> - <div v-if="displayPipelineActions" class="gl-text-right"> + <div class="gl-text-right"> <div class="btn-group"> <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" /> - <pipelines-artifacts-component - v-if="pipeline.details.artifacts.length" - :artifacts="pipeline.details.artifacts" - /> - <gl-button v-if="pipeline.flags.retryable" v-gl-tooltip.hover @@ -114,6 +101,8 @@ export default { class="js-pipelines-cancel-button" @click="handleCancelClick" /> + + <pipeline-multi-actions :pipeline-id="pipeline.id" /> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index f14a582d731..0218cb2e1b8 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -94,6 +94,11 @@ export default { type: Object, required: true, }, + codeQualityPagePath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -331,6 +336,7 @@ export default { v-else-if="stateToRender === $options.stateMap.emptyState" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" + :code-quality-page-path="codeQualityPagePath" /> <gl-empty-state diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 9c3990f82df..147fff52101 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -1,40 +1,107 @@ <script> -import { GlDropdown, GlDropdownItem, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { + GlAlert, + GlDropdown, + GlDropdownItem, + GlLoadingIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, s__ } from '~/locale'; + +export const i18n = { + artifacts: __('Artifacts'), + downloadArtifact: __('Download %{name} artifact'), + artifactSectionHeader: __('Download artifacts'), + artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), + noArtifacts: s__('Pipelines|No artifacts available'), +}; export default { + i18n, directives: { GlTooltip: GlTooltipDirective, }, components: { + GlAlert, GlDropdown, GlDropdownItem, + GlLoadingIcon, GlSprintf, }, - translations: { - artifacts: __('Artifacts'), - downloadArtifact: __('Download %{name} artifact'), + inject: { + artifactsEndpoint: { + default: '', + }, + artifactsEndpointPlaceholder: { + default: '', + }, }, props: { - artifacts: { - type: Array, + pipelineId: { + type: Number, required: true, }, }, + data() { + return { + artifacts: [], + hasError: false, + isLoading: false, + }; + }, + computed: { + hasArtifacts() { + return Boolean(this.artifacts.length); + }, + }, + methods: { + fetchArtifacts() { + this.isLoading = true; + // Replace the placeholder with the ID of the pipeline we are viewing + const endpoint = this.artifactsEndpoint.replace( + this.artifactsEndpointPlaceholder, + this.pipelineId, + ); + return axios + .get(endpoint) + .then(({ data }) => { + this.artifacts = data.artifacts; + }) + .catch(() => { + this.hasError = true; + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, }; </script> <template> <gl-dropdown v-gl-tooltip class="build-artifacts js-pipeline-dropdown-download" - :title="$options.translations.artifacts" - :text="$options.translations.artifacts" - :aria-label="$options.translations.artifacts" + :title="$options.i18n.artifacts" + :text="$options.i18n.artifacts" + :aria-label="$options.i18n.artifacts" icon="download" right lazy text-sr-only + @show.once="fetchArtifacts" > + <gl-alert v-if="hasError" variant="danger" :dismissible="false"> + {{ $options.i18n.artifactsFetchErrorMessage }} + </gl-alert> + + <gl-loading-icon v-if="isLoading" /> + + <gl-alert v-else-if="!hasArtifacts" variant="info" :dismissible="false"> + {{ $options.i18n.noArtifacts }} + </gl-alert> + <gl-dropdown-item v-for="(artifact, i) in artifacts" :key="i" @@ -42,7 +109,7 @@ export default { rel="nofollow" download > - <gl-sprintf :message="$options.translations.downloadArtifact"> + <gl-sprintf :message="$options.i18n.downloadArtifact"> <template #name>{{ artifact.name }}</template> </gl-sprintf> </gl-dropdown-item> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 492c562ec5c..de3f783ac84 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -1,7 +1,8 @@ <script> import { GlFilteredSearch } from '@gitlab/ui'; import { map } from 'lodash'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; import PipelineStatusToken from './tokens/pipeline_status_token.vue'; import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue'; @@ -43,7 +44,7 @@ export default { title: s__('Pipeline|Trigger author'), unique: true, token: PipelineTriggerAuthorToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, projectId: this.projectId, }, { @@ -52,7 +53,7 @@ export default { title: s__('Pipeline|Branch name'), unique: true, token: PipelineBranchNameToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, projectId: this.projectId, disabled: this.selectedTypes.includes(this.$options.tagType), }, @@ -62,7 +63,7 @@ export default { title: s__('Pipeline|Tag name'), unique: true, token: PipelineTagNameToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, projectId: this.projectId, disabled: this.selectedTypes.includes(this.$options.branchType), }, @@ -72,7 +73,7 @@ export default { title: s__('Pipeline|Status'), unique: true, token: PipelineStatusToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, }, ]; }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue index cc3c8d522b3..f56457a4162 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -1,9 +1,12 @@ <script> +import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue'; +import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants'; import { CHILD_VIEW } from '~/pipelines/constants'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; export default { components: { + CodeQualityWalkthrough, CiBadge, }, props: { @@ -23,15 +26,37 @@ export default { isChildView() { return this.viewType === CHILD_VIEW; }, + shouldRenderCodeQualityWalkthrough() { + return Object.values(PIPELINE_STATUSES).includes(this.pipelineStatus.group); + }, + codeQualityStep() { + const prefix = [PIPELINE_STATUSES.successWithWarnings, PIPELINE_STATUSES.failed].includes( + this.pipelineStatus.group, + ) + ? 'failed' + : this.pipelineStatus.group; + return `${prefix}_pipeline`; + }, + codeQualityBuildPath() { + return this.pipeline?.details?.code_quality_build_path; + }, }, }; </script> <template> - <ci-badge - :status="pipelineStatus" - :show-text="!isChildView" - :icon-classes="'gl-vertical-align-middle!'" - data-qa-selector="pipeline_commit_status" - /> + <div> + <ci-badge + id="js-code-quality-walkthrough" + :status="pipelineStatus" + :show-text="!isChildView" + :icon-classes="'gl-vertical-align-middle!'" + data-qa-selector="pipeline_commit_status" + /> + <code-quality-walkthrough + v-if="shouldRenderCodeQualityWalkthrough" + :step="codeQualityStep" + :link="codeQualityBuildPath" + /> + </div> </template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue new file mode 100644 index 00000000000..e9f7874d3e4 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue @@ -0,0 +1,60 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export const i18n = { + noTestsButton: s__('TestReports|Learn more about pipeline test reports'), + noTestsDescription: s__('TestReports|No test cases were found in the test report.'), + noTestsTitle: s__('TestReports|There are no tests to display'), + noReportsButton: s__('TestReports|Learn how to upload pipeline test reports'), + noReportsDescription: s__( + 'TestReports|You can configure your job to use unit test reports, and GitLab displays a report here and in the related merge request.', + ), + noReportsTitle: s__('TestReports|There are no test reports for this pipeline'), +}; + +export default { + i18n, + components: { + GlEmptyState, + }, + inject: { + emptyStateImagePath: { + default: '', + }, + hasTestReport: { + default: false, + }, + }, + computed: { + emptyStateText() { + if (this.hasTestReport) { + return { + button: this.$options.i18n.noTestsButton, + description: this.$options.i18n.noTestsDescription, + title: this.$options.i18n.noTestsTitle, + }; + } + return { + button: this.$options.i18n.noReportsButton, + description: this.$options.i18n.noReportsDescription, + title: this.$options.i18n.noReportsTitle, + }; + }, + testReportDocPath() { + return helpPagePath('ci/unit_test_reports'); + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="emptyStateText.title" + :description="emptyStateText.description" + :svg-path="emptyStateImagePath" + :primary-button-link="testReportDocPath" + :primary-button-text="emptyStateText.button" + /> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue index 2edc84e62cb..47e5bb0bde8 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue @@ -1,6 +1,6 @@ <script> -import { GlBadge, GlModal } from '@gitlab/ui'; -import { __, n__, sprintf } from '~/locale'; +import { GlBadge, GlFriendlyWrap, GlLink, GlModal } from '@gitlab/ui'; +import { __, n__, s__, sprintf } from '~/locale'; import CodeBlock from '~/vue_shared/components/code_block.vue'; export default { @@ -8,6 +8,8 @@ export default { components: { CodeBlock, GlBadge, + GlFriendlyWrap, + GlLink, GlModal, }, props: { @@ -50,6 +52,7 @@ export default { duration: __('Execution time'), history: __('History'), trace: __('System output'), + attachment: s__('TestReports|Attachment'), }, modalCloseButton: { text: __('Close'), @@ -85,6 +88,18 @@ export default { </div> </div> + <div v-if="testCase.attachment_url" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.attachment }}</strong> + <gl-link + class="col-sm-9" + :href="testCase.attachment_url" + target="_blank" + data-testid="test-case-attachment-url" + > + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.attachment_url" /> + </gl-link> + </div> + <div v-if="testCase.system_output" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 58d60e2a185..58d072b0005 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; import TestSummaryTable from './test_summary_table.vue'; @@ -8,6 +9,7 @@ import TestSummaryTable from './test_summary_table.vue'; export default { name: 'TestReports', components: { + EmptyState, GlLoadingIcon, TestSuiteTable, TestSummary, @@ -83,11 +85,5 @@ export default { </transition> </div> - <div v-else> - <div class="row gl-mt-3"> - <div class="col-12"> - <p data-testid="no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> - </div> - </div> - </div> + <empty-state v-else /> </template> diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql index e4fd55a28be..e8af1db9592 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql +++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql @@ -1,4 +1,4 @@ -mutation DismissPipelineNotification($featureName: String!) { +mutation DismissPipelineGraphCallout($featureName: String!) { userCalloutCreate(input: { featureName: $featureName }) { errors } diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index a2bc049c3c7..911f40f4db3 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; @@ -63,7 +64,8 @@ const createLegacyPipelinesDetailApp = (mediator) => { const createTestDetails = () => { const el = document.querySelector(SELECTORS.PIPELINE_TESTS); - const { blobPath, summaryEndpoint, suiteEndpoint } = el?.dataset || {}; + const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } = + el?.dataset || {}; const testReportsStore = createTestReportsStore({ blobPath, summaryEndpoint, @@ -76,6 +78,10 @@ const createTestDetails = () => { components: { TestReports, }, + provide: { + emptyStateImagePath, + hasTestReport: parseBoolean(hasTestReport), + }, store: testReportsStore, render(createElement) { return createElement('test-reports'); diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 9ed4365ad75..c892311782c 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -22,6 +22,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { const { endpoint, + artifactsEndpoint, + artifactsEndpointPlaceholder, pipelineScheduleUrl, emptyStateSvgPath, errorStateSvgPath, @@ -35,12 +37,15 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { resetCachePath, projectId, params, + codeQualityPagePath, } = el.dataset; return new Vue({ el, provide: { addCiYmlPath, + artifactsEndpoint, + artifactsEndpointPlaceholder, suggestedCiTemplates: JSON.parse(suggestedCiTemplates), }, data() { @@ -70,6 +75,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { resetCachePath, projectId, params: JSON.parse(params), + codeQualityPagePath, }, }); }, diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 0a6c326fa3d..800a363cada 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -73,3 +73,12 @@ export const reportToSentry = (component, failureType) => { Sentry.captureException(failureType); }); }; + +export const reportMessageToSentry = (component, message, context) => { + Sentry.withScope((scope) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + scope.setContext('Vue data', context); + scope.setTag('component', component); + Sentry.captureMessage(message); + }); +}; |