summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/pipelines
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
commit4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch)
tree5423a1c7516cffe36384133ade12572cf709398d /app/assets/javascripts/pipelines
parente570267f2f6b326480d284e0164a6464ba4081bc (diff)
downloadgitlab-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')
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue63
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue159
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js7
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/api.js5
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js1
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue89
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue61
-rw-r--r--app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js25
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue76
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue115
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue21
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue89
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/empty_state.vue60
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue10
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js8
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js6
-rw-r--r--app/assets/javascripts/pipelines/utils.js9
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);
+ });
+};