summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/pipelines/components
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-12-17 11:59:07 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-12-17 11:59:07 +0000
commit8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch)
tree544930fb309b30317ae9797a9683768705d664c4 /app/assets/javascripts/pipelines/components
parent4b1de649d0168371549608993deac953eb692019 (diff)
downloadgitlab-ce-8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca.tar.gz
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/pipelines/components')
-rw-r--r--app/assets/javascripts/pipelines/components/graph/accessors.js25
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue270
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue265
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue106
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue139
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue87
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue118
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue108
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js57
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js10
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue76
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue109
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue13
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue43
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/unwrapping_utils.js53
29 files changed, 1208 insertions, 495 deletions
diff --git a/app/assets/javascripts/pipelines/components/graph/accessors.js b/app/assets/javascripts/pipelines/components/graph/accessors.js
new file mode 100644
index 00000000000..6ece855bcd8
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/accessors.js
@@ -0,0 +1,25 @@
+import { get } from 'lodash';
+import { REST, GRAPHQL } from './constants';
+
+const accessors = {
+ [REST]: {
+ detailsPath: 'details_path',
+ groupId: 'id',
+ hasDetails: 'has_details',
+ pipelineStatus: ['details', 'status'],
+ sourceJob: ['source_job', 'name'],
+ },
+ [GRAPHQL]: {
+ detailsPath: 'detailsPath',
+ groupId: 'name',
+ hasDetails: 'hasDetails',
+ pipelineStatus: 'status',
+ sourceJob: ['sourceJob', 'name'],
+ },
+};
+
+const accessValue = (dataMethod, prop, item) => {
+ return get(item, accessors[dataMethod][prop]);
+};
+
+export { accessors, accessValue };
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index a580ee11627..4e9b21a5c55 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -87,10 +87,10 @@ export default {
:title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
- class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
+ class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
@click.stop="onClickAction"
>
<gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
- <gl-icon v-else :name="actionIcon" class="gl-mr-0!" />
+ <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
index ba1922b6dae..6f0deccfef6 100644
--- a/app/assets/javascripts/pipelines/components/graph/constants.js
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -1,3 +1,6 @@
export const DOWNSTREAM = 'downstream';
export const MAIN = 'main';
export const UPSTREAM = 'upstream';
+
+export const REST = 'rest';
+export const GRAPHQL = 'graphql';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 16ce279a591..67b2ed3b596 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,35 +1,23 @@
<script>
-import { escape, capitalize } from 'lodash';
-import { GlLoadingIcon } from '@gitlab/ui';
-import StageColumnComponent from './stage_column_component.vue';
-import GraphWidthMixin from '../../mixins/graph_width_mixin';
+import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
-import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
-import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
+import StageColumnComponent from './stage_column_component.vue';
+import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
export default {
name: 'PipelineGraph',
components: {
- StageColumnComponent,
- GlLoadingIcon,
+ LinkedGraphWrapper,
LinkedPipelinesColumn,
+ StageColumnComponent,
},
- mixins: [GraphWidthMixin, GraphBundleMixin],
props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
isLinkedPipeline: {
type: Boolean,
required: false,
default: false,
},
- mediator: {
+ pipeline: {
type: Object,
required: true,
},
@@ -39,12 +27,13 @@ export default {
default: MAIN,
},
},
- upstream: UPSTREAM,
- downstream: DOWNSTREAM,
+ pipelineTypeConstants: {
+ DOWNSTREAM,
+ UPSTREAM,
+ },
data() {
return {
- downstreamMarginTop: null,
- jobName: null,
+ hoveredJobName: '',
pipelineExpanded: {
jobName: '',
expanded: false,
@@ -52,219 +41,86 @@ export default {
};
},
computed: {
+ downstreamPipelines() {
+ return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
+ },
graph() {
- return this.pipeline.details?.stages;
+ return this.pipeline.stages;
},
- hasUpstream() {
- return (
- this.type !== this.$options.downstream &&
- this.upstreamPipelines &&
- this.pipeline.triggered_by !== null
- );
+ hasDownstreamPipelines() {
+ return Boolean(this.pipeline?.downstream?.length > 0);
},
- upstreamPipelines() {
- return this.pipeline.triggered_by;
+ hasUpstreamPipelines() {
+ return Boolean(this.pipeline?.upstream?.length > 0);
},
- hasDownstream() {
+ // The two show checks prevent upstream / downstream from showing redundant linked columns
+ showDownstreamPipelines() {
return (
- this.type !== this.$options.upstream &&
- this.downstreamPipelines &&
- this.pipeline.triggered.length > 0
+ this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
);
},
- downstreamPipelines() {
- return this.pipeline.triggered;
- },
- expandedUpstream() {
+ showUpstreamPipelines() {
return (
- this.pipeline.triggered_by &&
- Array.isArray(this.pipeline.triggered_by) &&
- this.pipeline.triggered_by.find(el => el.isExpanded)
+ this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
);
},
- expandedDownstream() {
- return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
- },
- pipelineTypeUpstream() {
- return this.type !== this.$options.downstream && this.expandedUpstream;
- },
- pipelineTypeDownstream() {
- return this.type !== this.$options.upstream && this.expandedDownstream;
- },
- pipelineProjectId() {
- return this.pipeline.project.id;
+ upstreamPipelines() {
+ return this.hasUpstreamPipelines ? this.pipeline.upstream : [];
},
},
methods: {
- capitalizeStageName(name) {
- const escapedName = escape(name);
- return capitalize(escapedName);
- },
- isFirstColumn(index) {
- return index === 0;
- },
- stageConnectorClass(index, stage) {
- let className;
-
- // If it's the first stage column and only has one job
- if (this.isFirstColumn(index) && stage.groups.length === 1) {
- className = 'no-margin';
- } else if (index > 0) {
- // If it is not the first column
- className = 'left-margin';
- }
-
- return className;
- },
- refreshPipelineGraph() {
- this.$emit('refreshPipelineGraph');
- },
- /**
- * CSS class is applied:
- * - if pipeline graph contains only one stage column component
- *
- * @param {number} index
- * @returns {boolean}
- */
- shouldAddRightMargin(index) {
- return !(index === this.graph.length - 1);
- },
- handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
- /**
- * Calculates the margin top of the clicked downstream pipeline by
- * subtracting the clicked downstream pipelines offsetTop by it's parent's
- * offsetTop and then subtracting 15
- */
- this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15);
-
- /**
- * If the expanded trigger is defined and the id is different than the
- * pipeline we clicked, then it means we clicked on a sibling downstream link
- * and we want to reset the pipeline store. Triggering the reset without
- * this condition would mean not allowing downstreams of downstreams to expand
- */
- if (this.expandedDownstream?.id !== pipeline.id) {
- this.$emit('onResetDownstream', this.pipeline, pipeline);
- }
-
- this.$emit('onClickDownstreamPipeline', pipeline);
- },
- calculateMarginTop(downstreamNode, pixelDiff) {
- return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
- },
- hasOnlyOneJob(stage) {
- return stage.groups.length === 1;
- },
- hasUpstreamColumn(index) {
- return index === 0 && this.hasUpstream;
- },
setJob(jobName) {
- this.jobName = jobName;
+ this.hoveredJobName = jobName;
},
- setPipelineExpanded(jobName, expanded) {
- if (expanded) {
- this.pipelineExpanded = {
- jobName,
- expanded,
- };
- } else {
- this.pipelineExpanded = {
- expanded,
- jobName: '',
- };
- }
+ togglePipelineExpanded(jobName, expanded) {
+ this.pipelineExpanded = {
+ expanded,
+ jobName: expanded ? jobName : '',
+ };
},
},
};
</script>
<template>
- <div class="build-content middle-block js-pipeline-graph">
+ <div class="js-pipeline-graph">
<div
- class="pipeline-visualization pipeline-graph"
- :class="{ 'pipeline-tab-content': !isLinkedPipeline }"
+ class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap"
+ :class="{ 'gl-py-5': !isLinkedPipeline }"
>
- <div
- :style="{
- paddingLeft: `${graphLeftPadding}px`,
- paddingRight: `${graphRightPadding}px`,
- }"
- >
- <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
-
- <pipeline-graph
- v-if="pipelineTypeUpstream"
- :type="$options.upstream"
- class="d-inline-block upstream-pipeline"
- :class="`js-upstream-pipeline-${expandedUpstream.id}`"
- :is-loading="false"
- :pipeline="expandedUpstream"
- :is-linked-pipeline="true"
- :mediator="mediator"
- @onClickUpstreamPipeline="clickUpstreamPipeline"
- @refreshPipelineGraph="requestRefreshPipelineGraph"
- />
-
- <linked-pipelines-column
- v-if="hasUpstream"
- :type="$options.upstream"
- :linked-pipelines="upstreamPipelines"
- :column-title="__('Upstream')"
- :project-id="pipelineProjectId"
- @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
- />
-
- <ul
- v-if="!isLoading"
- :class="{
- 'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
- }"
- class="stage-column-list align-top"
- >
+ <linked-graph-wrapper>
+ <template #upstream>
+ <linked-pipelines-column
+ v-if="showUpstreamPipelines"
+ :linked-pipelines="upstreamPipelines"
+ :column-title="__('Upstream')"
+ :type="$options.pipelineTypeConstants.UPSTREAM"
+ @error="emit('error', errorType)"
+ />
+ </template>
+ <template #main>
<stage-column-component
- v-for="(stage, index) in graph"
+ v-for="stage in graph"
:key="stage.name"
- :class="{
- 'has-upstream gl-ml-11': hasUpstreamColumn(index),
- 'has-only-one-job': hasOnlyOneJob(stage),
- 'gl-mr-26': shouldAddRightMargin(index),
- }"
- :title="capitalizeStageName(stage.name)"
+ :title="stage.name"
:groups="stage.groups"
- :stage-connector-class="stageConnectorClass(index, stage)"
- :is-first-column="isFirstColumn(index)"
- :has-upstream="hasUpstream"
:action="stage.status.action"
- :job-hovered="jobName"
+ :job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
- @refreshPipelineGraph="refreshPipelineGraph"
+ @refreshPipelineGraph="$emit('refreshPipelineGraph')"
/>
- </ul>
-
- <linked-pipelines-column
- v-if="hasDownstream"
- :type="$options.downstream"
- :linked-pipelines="downstreamPipelines"
- :column-title="__('Downstream')"
- :project-id="pipelineProjectId"
- @linkedPipelineClick="handleClickedDownstream"
- @downstreamHovered="setJob"
- @pipelineExpandToggle="setPipelineExpanded"
- />
-
- <pipeline-graph
- v-if="pipelineTypeDownstream"
- :type="$options.downstream"
- class="d-inline-block"
- :class="`js-downstream-pipeline-${expandedDownstream.id}`"
- :is-loading="false"
- :pipeline="expandedDownstream"
- :is-linked-pipeline="true"
- :style="{ 'margin-top': downstreamMarginTop }"
- :mediator="mediator"
- @onClickDownstreamPipeline="clickDownstreamPipeline"
- @refreshPipelineGraph="requestRefreshPipelineGraph"
- />
- </div>
+ </template>
+ <template #downstream>
+ <linked-pipelines-column
+ v-if="showDownstreamPipelines"
+ :linked-pipelines="downstreamPipelines"
+ :column-title="__('Downstream')"
+ :type="$options.pipelineTypeConstants.DOWNSTREAM"
+ @downstreamHovered="setJob"
+ @pipelineExpandToggle="togglePipelineExpanded"
+ @error="emit('error', errorType)"
+ />
+ </template>
+ </linked-graph-wrapper>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
new file mode 100644
index 00000000000..9ca4dc1e27a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
@@ -0,0 +1,265 @@
+<script>
+import { escape, capitalize } from 'lodash';
+import { GlLoadingIcon } from '@gitlab/ui';
+import StageColumnComponentLegacy from './stage_column_component_legacy.vue';
+import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue';
+import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
+import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
+
+export default {
+ name: 'PipelineGraphLegacy',
+ components: {
+ GlLoadingIcon,
+ LinkedPipelinesColumnLegacy,
+ StageColumnComponentLegacy,
+ },
+ mixins: [GraphBundleMixin],
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLinkedPipeline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: false,
+ default: MAIN,
+ },
+ },
+ upstream: UPSTREAM,
+ downstream: DOWNSTREAM,
+ data() {
+ return {
+ downstreamMarginTop: null,
+ jobName: null,
+ pipelineExpanded: {
+ jobName: '',
+ expanded: false,
+ },
+ };
+ },
+ computed: {
+ graph() {
+ return this.pipeline.details?.stages;
+ },
+ hasUpstream() {
+ return (
+ this.type !== this.$options.downstream &&
+ this.upstreamPipelines &&
+ this.pipeline.triggered_by !== null
+ );
+ },
+ upstreamPipelines() {
+ return this.pipeline.triggered_by;
+ },
+ hasDownstream() {
+ return (
+ this.type !== this.$options.upstream &&
+ this.downstreamPipelines &&
+ this.pipeline.triggered.length > 0
+ );
+ },
+ downstreamPipelines() {
+ return this.pipeline.triggered;
+ },
+ expandedUpstream() {
+ return (
+ this.pipeline.triggered_by &&
+ Array.isArray(this.pipeline.triggered_by) &&
+ this.pipeline.triggered_by.find(el => el.isExpanded)
+ );
+ },
+ expandedDownstream() {
+ return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
+ },
+ pipelineTypeUpstream() {
+ return this.type !== this.$options.downstream && this.expandedUpstream;
+ },
+ pipelineTypeDownstream() {
+ return this.type !== this.$options.upstream && this.expandedDownstream;
+ },
+ pipelineProjectId() {
+ return this.pipeline.project.id;
+ },
+ },
+ methods: {
+ capitalizeStageName(name) {
+ const escapedName = escape(name);
+ return capitalize(escapedName);
+ },
+ isFirstColumn(index) {
+ return index === 0;
+ },
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (this.isFirstColumn(index) && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
+ },
+ refreshPipelineGraph() {
+ this.$emit('refreshPipelineGraph');
+ },
+ /**
+ * CSS class is applied:
+ * - if pipeline graph contains only one stage column component
+ *
+ * @param {number} index
+ * @returns {boolean}
+ */
+ shouldAddRightMargin(index) {
+ return !(index === this.graph.length - 1);
+ },
+ handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
+ /**
+ * Calculates the margin top of the clicked downstream pipeline by
+ * subtracting the clicked downstream pipelines offsetTop by it's parent's
+ * offsetTop and then subtracting 15
+ */
+ this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15);
+
+ /**
+ * If the expanded trigger is defined and the id is different than the
+ * pipeline we clicked, then it means we clicked on a sibling downstream link
+ * and we want to reset the pipeline store. Triggering the reset without
+ * this condition would mean not allowing downstreams of downstreams to expand
+ */
+ if (this.expandedDownstream?.id !== pipeline.id) {
+ this.$emit('onResetDownstream', this.pipeline, pipeline);
+ }
+
+ this.$emit('onClickDownstreamPipeline', pipeline);
+ },
+ calculateMarginTop(downstreamNode, pixelDiff) {
+ return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
+ },
+ hasOnlyOneJob(stage) {
+ return stage.groups.length === 1;
+ },
+ hasUpstreamColumn(index) {
+ return index === 0 && this.hasUpstream;
+ },
+ setJob(jobName) {
+ this.jobName = jobName;
+ },
+ setPipelineExpanded(jobName, expanded) {
+ if (expanded) {
+ this.pipelineExpanded = {
+ jobName,
+ expanded,
+ };
+ } else {
+ this.pipelineExpanded = {
+ expanded,
+ jobName: '',
+ };
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div class="build-content middle-block js-pipeline-graph">
+ <div
+ class="pipeline-visualization pipeline-graph"
+ :class="{ 'pipeline-tab-content': !isLinkedPipeline }"
+ >
+ <div class="gl-w-full">
+ <div class="container-fluid container-limited">
+ <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
+ <pipeline-graph-legacy
+ v-if="pipelineTypeUpstream"
+ :type="$options.upstream"
+ class="d-inline-block upstream-pipeline"
+ :class="`js-upstream-pipeline-${expandedUpstream.id}`"
+ :is-loading="false"
+ :pipeline="expandedUpstream"
+ :is-linked-pipeline="true"
+ :mediator="mediator"
+ @onClickUpstreamPipeline="clickUpstreamPipeline"
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
+
+ <linked-pipelines-column-legacy
+ v-if="hasUpstream"
+ :type="$options.upstream"
+ :linked-pipelines="upstreamPipelines"
+ :column-title="__('Upstream')"
+ :project-id="pipelineProjectId"
+ @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
+ />
+
+ <ul
+ v-if="!isLoading"
+ :class="{
+ 'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
+ }"
+ class="stage-column-list align-top"
+ >
+ <stage-column-component-legacy
+ v-for="(stage, index) in graph"
+ :key="stage.name"
+ :class="{
+ 'has-upstream gl-ml-11': hasUpstreamColumn(index),
+ 'has-only-one-job': hasOnlyOneJob(stage),
+ 'gl-mr-26': shouldAddRightMargin(index),
+ }"
+ :title="capitalizeStageName(stage.name)"
+ :groups="stage.groups"
+ :stage-connector-class="stageConnectorClass(index, stage)"
+ :is-first-column="isFirstColumn(index)"
+ :has-upstream="hasUpstream"
+ :action="stage.status.action"
+ :job-hovered="jobName"
+ :pipeline-expanded="pipelineExpanded"
+ @refreshPipelineGraph="refreshPipelineGraph"
+ />
+ </ul>
+
+ <linked-pipelines-column-legacy
+ v-if="hasDownstream"
+ :type="$options.downstream"
+ :linked-pipelines="downstreamPipelines"
+ :column-title="__('Downstream')"
+ :project-id="pipelineProjectId"
+ @linkedPipelineClick="handleClickedDownstream"
+ @downstreamHovered="setJob"
+ @pipelineExpandToggle="setPipelineExpanded"
+ />
+
+ <pipeline-graph-legacy
+ v-if="pipelineTypeDownstream"
+ :type="$options.downstream"
+ class="d-inline-block"
+ :class="`js-downstream-pipeline-${expandedDownstream.id}`"
+ :is-loading="false"
+ :pipeline="expandedDownstream"
+ :is-linked-pipeline="true"
+ :style="{ 'margin-top': downstreamMarginTop }"
+ :mediator="mediator"
+ @onClickDownstreamPipeline="clickDownstreamPipeline"
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
new file mode 100644
index 00000000000..d98e3aad054
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { DEFAULT, LOAD_FAILURE } from '../../constants';
+import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
+import PipelineGraph from './graph_component.vue';
+import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils';
+
+export default {
+ name: 'PipelineGraphWrapper',
+ components: {
+ GlAlert,
+ GlLoadingIcon,
+ PipelineGraph,
+ },
+ inject: {
+ pipelineIid: {
+ default: '',
+ },
+ pipelineProjectPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ pipeline: null,
+ alertType: null,
+ showAlert: false,
+ };
+ },
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'),
+ [DEFAULT]: __('An unknown error occurred while loading this graph.'),
+ },
+ apollo: {
+ pipeline: {
+ query: getPipelineDetails,
+ pollInterval: 10000,
+ variables() {
+ return {
+ projectPath: this.pipelineProjectPath,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ return unwrapPipelineData(this.pipelineProjectPath, data);
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE);
+ },
+ },
+ },
+ computed: {
+ alert() {
+ switch (this.alertType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ variant: 'danger',
+ };
+ }
+ },
+ showLoadingIcon() {
+ /*
+ Shows the icon only when the graph is empty, not when it is is
+ being refetched, for instance, on action completion
+ */
+ return this.$apollo.queries.pipeline.loading && !this.pipeline;
+ },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
+ },
+ methods: {
+ hideAlert() {
+ this.showAlert = false;
+ },
+ refreshPipelineGraph() {
+ this.$apollo.queries.pipeline.refetch();
+ },
+ reportFailure(type) {
+ this.showAlert = true;
+ this.failureType = type;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
+ {{ alert.text }}
+ </gl-alert>
+ <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
+ <pipeline-graph
+ v-if="pipeline"
+ :pipeline="pipeline"
+ @error="reportFailure"
+ @refreshPipelineGraph="refreshPipelineGraph"
+ />
+ </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 49591a80752..203d6a12edd 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -44,17 +44,18 @@ export default {
type="button"
data-toggle="dropdown"
data-display="static"
- class="dropdown-menu-toggle build-content"
+ class="dropdown-menu-toggle build-content gl-build-content"
>
- <ci-icon :status="group.status" />
+ <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
+ <span class="gl-display-flex gl-align-items-center gl-min-w-0">
+ <ci-icon :status="group.status" :size="24" />
+ <span class="gl-text-truncate mw-70p gl-pl-3">
+ {{ group.name }}
+ </span>
+ </span>
- <span
- class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom"
- >
- {{ group.name }}
- </span>
-
- <span class="dropdown-counter-badge"> {{ group.size }} </span>
+ <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span>
+ </div>
</button>
<ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 4ed0aae0d1e..93ebe02d4e8 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -4,6 +4,8 @@ import ActionComponent from './action_component.vue';
import JobNameComponent from './job_name_component.vue';
import { sprintf } from '~/locale';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import { accessValue } from './accessors';
+import { REST } from './constants';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
@@ -41,6 +43,11 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [delayedJobMixin],
+ inject: {
+ dataMethod: {
+ default: REST,
+ },
+ },
props: {
job: {
type: Object,
@@ -71,10 +78,15 @@ export default {
boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
},
+ detailsPath() {
+ return accessValue(this.dataMethod, 'detailsPath', this.status);
+ },
+ hasDetails() {
+ return accessValue(this.dataMethod, 'hasDetails', this.status);
+ },
status() {
return this.job && this.job.status ? this.job.status : {};
},
-
tooltipText() {
const textBuilder = [];
const { name: jobName } = this.job;
@@ -129,19 +141,23 @@ export default {
};
</script>
<template>
- <div class="ci-job-component" data-qa-selector="job_item_container">
+ <div
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ data-qa-selector="job_item_container"
+ >
<gl-link
- v-if="status.has_details"
+ v-if="hasDetails"
v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
- :href="status.details_path"
+ :href="detailsPath"
:title="tooltipText"
:class="jobClasses"
- class="js-pipeline-graph-job-link qa-job-link menu-item"
+ class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none
+ gl-focus-text-decoration-none gl-hover-text-decoration-none"
data-testid="job-with-link"
@click.stop="hideTooltips"
@mouseout="hideTooltips"
>
- <job-name-component :name="job.name" :status="job.status" />
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
</gl-link>
<div
@@ -149,11 +165,11 @@ export default {
v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
:title="tooltipText"
:class="jobClasses"
- class="js-job-component-tooltip non-details-job-component"
+ class="js-job-component-tooltip non-details-job-component menu-item"
data-testid="job-without-link"
@mouseout="hideTooltips"
>
- <job-name-component :name="job.name" :status="job.status" />
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
</div>
<action-component
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 1b71949784a..23a38fc053e 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -16,18 +16,22 @@ export default {
type: String,
required: true,
},
-
status: {
type: Object,
required: true,
},
+ iconSize: {
+ type: Number,
+ required: false,
+ default: 16,
+ },
},
};
</script>
<template>
- <span class="ci-job-name-component mw-100">
- <ci-icon :status="status" />
- <span class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom">
+ <span class="ci-job-name-component mw-100 gl-display-flex gl-align-items-center">
+ <ci-icon :size="iconSize" :status="status" />
+ <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block">
{{ name }}
</span>
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 11f06a25984..1a179de64cd 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -2,7 +2,8 @@
import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import { __, sprintf } from '~/locale';
-import { UPSTREAM, DOWNSTREAM } from './constants';
+import { accessValue } from './accessors';
+import { DOWNSTREAM, REST, UPSTREAM } from './constants';
export default {
directives: {
@@ -14,28 +15,43 @@ export default {
GlLink,
GlLoadingIcon,
},
+ inject: {
+ dataMethod: {
+ default: REST,
+ },
+ },
props: {
columnTitle: {
type: String,
required: true,
},
- pipeline: {
- type: Object,
+ expanded: {
+ type: Boolean,
required: true,
},
- projectId: {
- type: Number,
+ pipeline: {
+ type: Object,
required: true,
},
type: {
type: String,
required: true,
},
- },
- data() {
- return {
- expanded: false,
- };
+ /*
+ The next two props will be removed or required
+ once the graph transition is done.
+ See: https://gitlab.com/gitlab-org/gitlab/-/issues/291043
+ */
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ projectId: {
+ type: Number,
+ required: false,
+ default: -1,
+ },
},
computed: {
tooltipText() {
@@ -46,7 +62,7 @@ export default {
return `js-linked-pipeline-${this.pipeline.id}`;
},
pipelineStatus() {
- return this.pipeline.details.status;
+ return accessValue(this.dataMethod, 'pipelineStatus', this.pipeline);
},
projectName() {
return this.pipeline.project.name;
@@ -68,6 +84,9 @@ export default {
}
return __('Multi-project');
},
+ pipelineIsLoading() {
+ return Boolean(this.isLoading || this.pipeline.isLoading);
+ },
isDownstream() {
return this.type === DOWNSTREAM;
},
@@ -75,12 +94,15 @@ export default {
return this.type === UPSTREAM;
},
isSameProject() {
- return this.projectId === this.pipeline.project.id;
+ return this.projectId > -1
+ ? this.projectId === this.pipeline.project.id
+ : !this.pipeline.multiproject;
+ },
+ sourceJobName() {
+ return accessValue(this.dataMethod, 'sourceJob', this.pipeline);
},
sourceJobInfo() {
- return this.isDownstream
- ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name })
- : '';
+ return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : '';
},
expandedIcon() {
if (this.isUpstream) {
@@ -94,16 +116,15 @@ export default {
},
methods: {
onClickLinkedPipeline() {
- this.$root.$emit('bv::hide::tooltip', this.buttonId);
- this.expanded = !this.expanded;
+ this.hideTooltips();
this.$emit('pipelineClicked', this.$refs.linkedPipeline);
- this.$emit('pipelineExpandToggle', this.pipeline.source_job.name, this.expanded);
+ this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded);
},
hideTooltips() {
this.$root.$emit('bv::hide::tooltip');
},
onDownstreamHovered() {
- this.$emit('downstreamHovered', this.pipeline.source_job.name);
+ this.$emit('downstreamHovered', this.sourceJobName);
},
onDownstreamHoverLeave() {
this.$emit('downstreamHovered', '');
@@ -113,10 +134,10 @@ export default {
</script>
<template>
- <li
+ <div
ref="linkedPipeline"
v-gl-tooltip
- class="linked-pipeline build"
+ class="linked-pipeline build gl-pipeline-job-width"
:title="tooltipText"
:class="{ 'downstream-pipeline': isDownstream }"
data-qa-selector="child_pipeline"
@@ -129,8 +150,9 @@ export default {
>
<div class="gl-display-flex">
<ci-status
- v-if="!pipeline.isLoading"
+ v-if="!pipelineIsLoading"
:status="pipelineStatus"
+ :size="24"
css-classes="gl-top-0 gl-pr-2"
/>
<div v-else class="gl-pr-2"><gl-loading-icon inline /></div>
@@ -153,10 +175,10 @@ export default {
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon"
- data-testid="expandPipelineButton"
+ data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline"
/>
</div>
- </li>
+ </div>
</template>
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 2ca33e6d33e..7d333087874 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -1,10 +1,14 @@
<script>
+import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
import LinkedPipeline from './linked_pipeline.vue';
+import { LOAD_FAILURE } from '../../constants';
import { UPSTREAM } from './constants';
+import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils';
export default {
components: {
LinkedPipeline,
+ PipelineGraph: () => import('./graph_component.vue'),
},
props: {
columnTitle: {
@@ -19,11 +23,22 @@ export default {
type: String,
required: true,
},
- projectId: {
- type: Number,
- required: true,
- },
},
+ data() {
+ return {
+ currentPipeline: null,
+ loadingPipelineId: null,
+ pipelineExpanded: false,
+ };
+ },
+ titleClasses: [
+ 'gl-font-weight-bold',
+ 'gl-pipeline-job-width',
+ 'gl-text-truncate',
+ 'gl-line-height-36',
+ 'gl-pl-3',
+ 'gl-mb-5',
+ ],
computed: {
columnClass() {
const positionValues = {
@@ -35,14 +50,69 @@ export default {
graphPosition() {
return this.isUpstream ? 'left' : 'right';
},
- // Refactor string match when BE returns Upstream/Downstream indicators
isUpstream() {
return this.type === UPSTREAM;
},
+ computedTitleClasses() {
+ const positionalClasses = this.isUpstream
+ ? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding']
+ : [];
+
+ return [...this.$options.titleClasses, ...positionalClasses];
+ },
},
methods: {
- onPipelineClick(downstreamNode, pipeline, index) {
- this.$emit('linkedPipelineClick', pipeline, index, downstreamNode);
+ getPipelineData(pipeline) {
+ const projectPath = pipeline.project.fullPath;
+
+ this.$apollo.addSmartQuery('currentPipeline', {
+ query: getPipelineDetails,
+ pollInterval: 10000,
+ variables() {
+ return {
+ projectPath,
+ iid: pipeline.iid,
+ };
+ },
+ update(data) {
+ return unwrapPipelineData(projectPath, data);
+ },
+ result() {
+ this.loadingPipelineId = null;
+ },
+ error() {
+ this.$emit('error', LOAD_FAILURE);
+ },
+ });
+
+ toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline);
+ },
+ isExpanded(id) {
+ return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id);
+ },
+ isLoadingPipeline(id) {
+ return this.loadingPipelineId === id;
+ },
+ onPipelineClick(pipeline) {
+ /* If the clicked pipeline has been expanded already, close it, clear, exit */
+ if (this.currentPipeline?.id === pipeline.id) {
+ this.pipelineExpanded = false;
+ this.currentPipeline = null;
+ return;
+ }
+
+ /* Set the loading id */
+ this.loadingPipelineId = pipeline.id;
+
+ /*
+ Expand the pipeline.
+ If this was not a toggle close action, and
+ it was already showing a different pipeline, then
+ this will be a no-op, but that doesn't matter.
+ */
+ this.pipelineExpanded = true;
+
+ this.getPipelineData(pipeline);
},
onDownstreamHovered(jobName) {
this.$emit('downstreamHovered', jobName);
@@ -60,25 +130,40 @@ export default {
</script>
<template>
- <div :class="columnClass" class="stage-column linked-pipelines-column">
- <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
- <div v-if="isUpstream" class="cross-project-triangle"></div>
- <ul>
- <linked-pipeline
- v-for="(pipeline, index) in linkedPipelines"
- :key="pipeline.id"
- :class="{
- active: pipeline.isExpanded,
- 'left-connector': pipeline.isExpanded && graphPosition === 'left',
- }"
- :pipeline="pipeline"
- :column-title="columnTitle"
- :project-id="projectId"
- :type="type"
- @pipelineClicked="onPipelineClick($event, pipeline, index)"
- @downstreamHovered="onDownstreamHovered"
- @pipelineExpandToggle="onPipelineExpandToggle"
- />
- </ul>
+ <div class="gl-display-flex">
+ <div :class="columnClass" class="linked-pipelines-column">
+ <div data-testid="linked-column-title" class="stage-name" :class="computedTitleClasses">
+ {{ columnTitle }}
+ </div>
+ <ul class="gl-pl-0">
+ <li
+ v-for="pipeline in linkedPipelines"
+ :key="pipeline.id"
+ class="gl-display-flex gl-mb-4"
+ :class="{ 'gl-flex-direction-row-reverse': isUpstream }"
+ >
+ <linked-pipeline
+ class="gl-display-inline-block"
+ :is-loading="isLoadingPipeline(pipeline.id)"
+ :pipeline="pipeline"
+ :column-title="columnTitle"
+ :type="type"
+ :expanded="isExpanded(pipeline.id)"
+ @downstreamHovered="onDownstreamHovered"
+ @pipelineClicked="onPipelineClick(pipeline)"
+ @pipelineExpandToggle="onPipelineExpandToggle"
+ />
+ <div v-if="isExpanded(pipeline.id)" class="gl-display-inline-block">
+ <pipeline-graph
+ v-if="currentPipeline"
+ :type="type"
+ class="d-inline-block gl-mt-n2"
+ :pipeline="currentPipeline"
+ :is-linked-pipeline="true"
+ />
+ </div>
+ </li>
+ </ul>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
new file mode 100644
index 00000000000..7d371b33220
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
@@ -0,0 +1,87 @@
+<script>
+import LinkedPipeline from './linked_pipeline.vue';
+import { UPSTREAM } from './constants';
+
+export default {
+ components: {
+ LinkedPipeline,
+ },
+ props: {
+ columnTitle: {
+ type: String,
+ required: true,
+ },
+ linkedPipelines: {
+ type: Array,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ columnClass() {
+ const positionValues = {
+ right: 'gl-ml-11',
+ left: 'gl-mr-7',
+ };
+ return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
+ },
+ graphPosition() {
+ return this.isUpstream ? 'left' : 'right';
+ },
+ isExpanded() {
+ return this.pipeline?.isExpanded || false;
+ },
+ isUpstream() {
+ return this.type === UPSTREAM;
+ },
+ },
+ methods: {
+ onPipelineClick(downstreamNode, pipeline, index) {
+ this.$emit('linkedPipelineClick', pipeline, index, downstreamNode);
+ },
+ onDownstreamHovered(jobName) {
+ this.$emit('downstreamHovered', jobName);
+ },
+ onPipelineExpandToggle(jobName, expanded) {
+ // Highlighting only applies to downstream pipelines
+ if (this.isUpstream) {
+ return;
+ }
+
+ this.$emit('pipelineExpandToggle', jobName, expanded);
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="columnClass" class="stage-column linked-pipelines-column">
+ <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
+ <div v-if="isUpstream" class="cross-project-triangle"></div>
+ <ul>
+ <li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id">
+ <linked-pipeline
+ :class="{
+ active: pipeline.isExpanded,
+ 'left-connector': pipeline.isExpanded && graphPosition === 'left',
+ }"
+ :pipeline="pipeline"
+ :column-title="columnTitle"
+ :project-id="projectId"
+ :type="type"
+ :expanded="isExpanded"
+ @pipelineClicked="onPipelineClick($event, pipeline, index)"
+ @downstreamHovered="onDownstreamHovered"
+ @pipelineExpandToggle="onPipelineExpandToggle"
+ />
+ </li>
+ </ul>
+ </div>
+</template>
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 a75ec585b95..b9bddc94ce4 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,17 +1,19 @@
<script>
-import { isEmpty, escape } from 'lodash';
-import stageColumnMixin from '../../mixins/stage_column_mixin';
+import { capitalize, escape, isEmpty } from 'lodash';
+import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import ActionComponent from './action_component.vue';
+import { GRAPHQL } from './constants';
+import { accessValue } from './accessors';
export default {
components: {
- JobItem,
- JobGroupDropdown,
ActionComponent,
+ JobGroupDropdown,
+ JobItem,
+ MainGraphWrapper,
},
- mixins: [stageColumnMixin],
props: {
title: {
type: String,
@@ -21,16 +23,6 @@ export default {
type: Array,
required: true,
},
- isFirstColumn: {
- type: Boolean,
- required: false,
- default: false,
- },
- stageConnectorClass: {
- type: String,
- required: false,
- default: '',
- },
action: {
type: Object,
required: false,
@@ -47,62 +39,68 @@ export default {
default: () => ({}),
},
},
+ titleClasses: [
+ 'gl-font-weight-bold',
+ 'gl-pipeline-job-width',
+ 'gl-text-truncate',
+ 'gl-line-height-36',
+ 'gl-pl-3',
+ ],
computed: {
+ formattedTitle() {
+ return capitalize(escape(this.title));
+ },
hasAction() {
return !isEmpty(this.action);
},
},
methods: {
+ getGroupId(group) {
+ return accessValue(GRAPHQL, 'groupId', group);
+ },
groupId(group) {
return `ci-badge-${escape(group.name)}`;
},
- pipelineActionRequestComplete() {
- this.$emit('refreshPipelineGraph');
- },
},
};
</script>
<template>
- <li :class="stageConnectorClass" class="stage-column">
- <div class="stage-name position-relative">
- {{ title }}
- <action-component
- v-if="hasAction"
- :action-icon="action.icon"
- :tooltip-text="action.title"
- :link="action.path"
- class="js-stage-action stage-action rounded"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </div>
-
- <div class="builds-container">
- <ul>
- <li
- v-for="(group, index) in groups"
- :id="groupId(group)"
- :key="group.id"
- :class="buildConnnectorClass(index)"
- class="build"
- >
- <div class="curve"></div>
-
- <job-item
- v-if="group.size === 1"
- :job="group.jobs[0]"
- :job-hovered="jobHovered"
- :pipeline-expanded="pipelineExpanded"
- css-class-job-name="build-content"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
-
- <job-group-dropdown
- v-if="group.size > 1"
- :group="group"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </li>
- </ul>
- </div>
- </li>
+ <main-graph-wrapper>
+ <template #stages>
+ <div
+ data-testid="stage-column-title"
+ class="gl-display-flex gl-justify-content-space-between gl-relative"
+ :class="$options.titleClasses"
+ >
+ <div>{{ formattedTitle }}</div>
+ <action-component
+ v-if="hasAction"
+ :action-icon="action.icon"
+ :tooltip-text="action.title"
+ :link="action.path"
+ class="js-stage-action stage-action rounded"
+ @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
+ />
+ </div>
+ </template>
+ <template #jobs>
+ <div
+ v-for="group in groups"
+ :id="groupId(group)"
+ :key="getGroupId(group)"
+ data-testid="stage-column-group"
+ class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
+ >
+ <job-item
+ v-if="group.size === 1"
+ :job="group.jobs[0]"
+ :job-hovered="jobHovered"
+ :pipeline-expanded="pipelineExpanded"
+ css-class-job-name="gl-build-content"
+ @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
+ />
+ <job-group-dropdown v-else :group="group" />
+ </div>
+ </template>
+ </main-graph-wrapper>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
new file mode 100644
index 00000000000..258b6bf6b6d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
@@ -0,0 +1,108 @@
+<script>
+import { isEmpty, escape } from 'lodash';
+import stageColumnMixin from '../../mixins/stage_column_mixin';
+import JobItem from './job_item.vue';
+import JobGroupDropdown from './job_group_dropdown.vue';
+import ActionComponent from './action_component.vue';
+
+export default {
+ components: {
+ JobItem,
+ JobGroupDropdown,
+ ActionComponent,
+ },
+ mixins: [stageColumnMixin],
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ groups: {
+ type: Array,
+ required: true,
+ },
+ isFirstColumn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ stageConnectorClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ action: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ jobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ pipelineExpanded: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ hasAction() {
+ return !isEmpty(this.action);
+ },
+ },
+ methods: {
+ groupId(group) {
+ return `ci-badge-${escape(group.name)}`;
+ },
+ pipelineActionRequestComplete() {
+ this.$emit('refreshPipelineGraph');
+ },
+ },
+};
+</script>
+<template>
+ <li :class="stageConnectorClass" class="stage-column">
+ <div class="stage-name position-relative" data-testid="stage-column-title">
+ {{ title }}
+ <action-component
+ v-if="hasAction"
+ :action-icon="action.icon"
+ :tooltip-text="action.title"
+ :link="action.path"
+ class="js-stage-action stage-action rounded"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+
+ <div class="builds-container">
+ <ul>
+ <li
+ v-for="(group, index) in groups"
+ :id="groupId(group)"
+ :key="group.id"
+ :class="buildConnnectorClass(index)"
+ class="build"
+ >
+ <div class="curve"></div>
+
+ <job-item
+ v-if="group.size === 1"
+ :job="group.jobs[0]"
+ :job-hovered="jobHovered"
+ :pipeline-expanded="pipelineExpanded"
+ css-class-job-name="build-content"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+
+ <job-group-dropdown
+ v-if="group.size > 1"
+ :group="group"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </li>
+ </ul>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
new file mode 100644
index 00000000000..32588feb426
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -0,0 +1,57 @@
+import Visibility from 'visibilityjs';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { unwrapStagesWithNeeds } from '../unwrapping_utils';
+
+const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
+ return {
+ ...linkedPipeline,
+ multiproject: mainPipelineProjectPath !== linkedPipeline.project.fullPath,
+ };
+};
+
+const transformId = linkedPipeline => {
+ return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
+};
+
+const unwrapPipelineData = (mainPipelineProjectPath, data) => {
+ if (!data?.project?.pipeline) {
+ return null;
+ }
+
+ const { pipeline } = data.project;
+
+ const {
+ upstream,
+ downstream,
+ stages: { nodes: stages },
+ } = pipeline;
+
+ const nodes = unwrapStagesWithNeeds(stages);
+
+ return {
+ ...pipeline,
+ id: getIdFromGraphQLId(pipeline.id),
+ stages: nodes,
+ upstream: upstream
+ ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
+ : [],
+ downstream: downstream
+ ? downstream.nodes.map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
+ : [],
+ };
+};
+
+const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
+ const stopStartQuery = query => {
+ if (!Visibility.hidden()) {
+ query.startPolling(interval);
+ } else {
+ query.stopPolling();
+ }
+ };
+
+ stopStartQuery(queryRef);
+ Visibility.change(stopStartQuery.bind(null, queryRef));
+};
+
+export { unwrapPipelineData, toggleQueryPollingByVisibility };
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue b/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue
new file mode 100644
index 00000000000..fb2280d971a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue
@@ -0,0 +1,7 @@
+<template>
+ <div class="gl-display-flex">
+ <slot name="upstream"></slot>
+ <slot name="main"></slot>
+ <slot name="downstream"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue
new file mode 100644
index 00000000000..1c9e3236d56
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ props: {
+ stageClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ jobClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-mb-5"
+ :class="stageClasses"
+ >
+ <slot name="stages"> </slot>
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ :class="jobClasses"
+ >
+ <slot name="jobs"> </slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 741609c908a..af7c0d0ec3f 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -229,6 +229,7 @@ export default {
v-if="pipeline.cancelable"
:loading="isCanceling"
:disabled="isCanceling"
+ class="gl-ml-3"
variant="danger"
data-testid="cancelPipeline"
@click="cancelPipeline()"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
index 45940d4a39c..35230e1511b 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
@@ -1,5 +1,5 @@
import * as d3 from 'd3';
-import { createUniqueJobId } from '../../utils';
+import { createUniqueLinkId } from '../../utils';
/**
* This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`,
@@ -12,13 +12,13 @@ import { createUniqueJobId } from '../../utils';
* @returns {Array} Links that contain all the information about them
*/
-export const generateLinksData = ({ links }, jobs, containerID) => {
+export const generateLinksData = ({ links }, containerID) => {
const containerEl = document.getElementById(containerID);
return links.map(link => {
const path = d3.path();
- const sourceId = jobs[link.source].id;
- const targetId = jobs[link.target].id;
+ const sourceId = link.source;
+ const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
@@ -89,7 +89,7 @@ export const generateLinksData = ({ links }, jobs, containerID) => {
...link,
source: sourceId,
target: targetId,
- ref: createUniqueJobId(sourceId, targetId),
+ ref: createUniqueLinkId(sourceId, targetId),
path: path.toString(),
};
});
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue
deleted file mode 100644
index 3cc76425e2a..00000000000
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-<script>
-import { GlTab, GlTabs } from '@gitlab/ui';
-import jsYaml from 'js-yaml';
-import PipelineGraph from './pipeline_graph.vue';
-import { preparePipelineGraphData } from '../../utils';
-
-export default {
- FILE_CONTENT_SELECTOR: '#blob-content',
- EMPTY_FILE_SELECTOR: '.nothing-here-block',
-
- components: {
- GlTab,
- GlTabs,
- PipelineGraph,
- },
- props: {
- blobData: {
- required: true,
- type: String,
- },
- },
- data() {
- return {
- selectedTabIndex: 0,
- pipelineData: {},
- };
- },
- computed: {
- isVisualizationTab() {
- return this.selectedTabIndex === 1;
- },
- },
- async created() {
- if (this.blobData) {
- // The blobData in this case represents the gitlab-ci.yml data
- const json = await jsYaml.load(this.blobData);
- this.pipelineData = preparePipelineGraphData(json);
- }
- },
- methods: {
- // This is used because the blob page still uses haml, and we can't make
- // our haml hide the unused section so we resort to a standard query here.
- toggleFileContent({ isFileTab }) {
- const el = document.querySelector(this.$options.FILE_CONTENT_SELECTOR);
- const emptySection = document.querySelector(this.$options.EMPTY_FILE_SELECTOR);
-
- const elementToHide = el || emptySection;
-
- if (!elementToHide) {
- return;
- }
-
- // Checking for the current style display prevents user
- // from toggling visiblity on and off when clicking on the tab
- if (!isFileTab && elementToHide.style.display !== 'none') {
- elementToHide.style.display = 'none';
- }
-
- if (isFileTab && elementToHide.style.display === 'none') {
- elementToHide.style.display = 'block';
- }
- },
- },
-};
-</script>
-<template>
- <div>
- <div>
- <gl-tabs v-model="selectedTabIndex">
- <gl-tab :title="__('File')" @click="toggleFileContent({ isFileTab: true })" />
- <gl-tab :title="__('Visualization')" @click="toggleFileContent({ isFileTab: false })" />
- </gl-tabs>
- </div>
- <pipeline-graph v-if="isVisualizationTab" :pipeline-data="pipelineData" />
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index a0c35f54c0e..51a95612d3f 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -10,10 +10,6 @@ export default {
type: String,
required: true,
},
- jobId: {
- type: String,
- required: true,
- },
isHighlighted: {
type: Boolean,
required: false,
@@ -45,7 +41,7 @@ export default {
},
methods: {
onMouseEnter() {
- this.$emit('on-mouse-enter', this.jobId);
+ this.$emit('on-mouse-enter', this.jobName);
},
onMouseLeave() {
this.$emit('on-mouse-leave');
@@ -56,7 +52,7 @@ export default {
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
- :id="jobId"
+ :id="jobName"
class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
:class="jobPillClasses"
@mouseover="onMouseEnter"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 11ad2f2a3b6..73e5f2542fb 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -6,8 +6,10 @@ import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils';
-import { DRAW_FAILURE, DEFAULT } from '../../constants';
-import { generateJobNeedsDict } from '../../utils';
+import { unwrapArrayOfJobs } from '../unwrapping_utils';
+import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
+import { createJobsHash, generateJobNeedsDict } from '../../utils';
+import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
export default {
components: {
@@ -22,6 +24,12 @@ export default {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
},
+ warningTexts: {
+ [EMPTY_PIPELINE_DATA]: __(
+ 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
+ ),
+ [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
+ },
props: {
pipelineData: {
required: true,
@@ -40,18 +48,51 @@ export default {
},
computed: {
isPipelineDataEmpty() {
- return isEmpty(this.pipelineData);
+ return !this.isInvalidCiConfig && isEmpty(this.pipelineData?.stages);
+ },
+ isInvalidCiConfig() {
+ return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
+ },
+ showAlert() {
+ return this.hasError || this.hasWarning;
},
hasError() {
return this.failureType;
},
+ hasWarning() {
+ return this.warning;
+ },
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
+ alert() {
+ if (this.hasError) {
+ return this.failure;
+ }
+
+ return this.warning;
+ },
failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
- return { text, variant: 'danger' };
+ return { text, variant: 'danger', dismissible: true };
+ },
+ warning() {
+ if (this.isPipelineDataEmpty) {
+ return {
+ text: this.$options.warningTexts[EMPTY_PIPELINE_DATA],
+ variant: 'tip',
+ dismissible: false,
+ };
+ } else if (this.isInvalidCiConfig) {
+ return {
+ text: this.$options.warningTexts[INVALID_CI_CONFIG],
+ variant: 'danger',
+ dismissible: false,
+ };
+ }
+
+ return null;
},
viewBox() {
return [0, 0, this.width, this.height];
@@ -80,19 +121,21 @@ export default {
},
},
mounted() {
- if (!this.isPipelineDataEmpty) {
- this.getGraphDimensions();
- this.drawJobLinks();
+ if (!this.isPipelineDataEmpty && !this.isInvalidCiConfig) {
+ // This guarantee that all sub-elements are rendered
+ // https://v3.vuejs.org/api/options-lifecycle-hooks.html#mounted
+ this.$nextTick(() => {
+ this.getGraphDimensions();
+ this.prepareLinkData();
+ });
}
},
methods: {
- drawJobLinks() {
- const { stages, jobs } = this.pipelineData;
- const unwrappedGroups = this.unwrapPipelineData(stages);
-
+ prepareLinkData() {
try {
- const parsedData = parseData(unwrappedGroups);
- this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID);
+ const arrayOfJobs = unwrapArrayOfJobs(this.pipelineData);
+ const parsedData = parseData(arrayOfJobs);
+ this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID);
} catch {
this.reportFailure(DRAW_FAILURE);
}
@@ -119,7 +162,8 @@ export default {
// The first time we hover, we create the object where
// we store all the data to properly highlight the needs.
if (!this.needsObject) {
- this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {};
+ const jobs = createJobsHash(this.pipelineData);
+ this.needsObject = generateJobNeedsDict(jobs) ?? {};
}
this.highlightedJob = uniqueJobId;
@@ -127,18 +171,9 @@ export default {
removeHighlightNeeds() {
this.highlightedJob = null;
},
- unwrapPipelineData(stages) {
- return stages
- .map(({ name, groups }) => {
- return groups.map(group => {
- return { category: name, ...group };
- });
- })
- .flat(2);
- },
getGraphDimensions() {
- this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`;
- this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`;
+ this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`;
+ this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
},
reportFailure(errorType) {
this.failureType = errorType;
@@ -163,21 +198,20 @@ export default {
</script>
<template>
<div>
- <gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure">
- {{ failure.text }}
- </gl-alert>
- <gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
- {{
- __(
- 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
- )
- }}
+ <gl-alert
+ v-if="showAlert"
+ :variant="alert.variant"
+ :dismissible="alert.dismissible"
+ @dismiss="alert.dismissible ? resetFailure : null"
+ >
+ {{ alert.text }}
</gl-alert>
<div
- v-else
+ v-if="!hasWarning"
:id="$options.CONTAINER_ID"
:ref="$options.CONTAINER_REF"
class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
+ data-testid="graph-container"
>
<svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
<template>
@@ -210,10 +244,9 @@ export default {
<job-pill
v-for="group in stage.groups"
:key="group.name"
- :job-id="group.id"
:job-name="group.name"
- :is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)"
- :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)"
+ :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
+ :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
@on-mouse-enter="highlightNeeds"
@on-mouse-leave="removeHighlightNeeds"
/>
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 c5f30c8aef0..78b69073cd3 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -29,11 +29,13 @@ export default {
</div>
<div class="col-12">
- <div class="text-content">
+ <div class="gl-text-content">
<template v-if="canSetCi">
- <h4 class="text-center">{{ s__('Pipelines|Build with confidence') }}</h4>
+ <h4 class="gl-text-center" data-testid="header-text">
+ {{ s__('Pipelines|Build with confidence') }}
+ </h4>
- <p>
+ <p data-testid="info-text">
{{
s__(`Pipelines|Continuous Integration can help
catch bugs by running your tests automatically,
@@ -42,12 +44,11 @@ export default {
}}
</p>
- <div class="text-center">
+ <div class="gl-text-center">
<gl-button
:href="helpPagePath"
variant="info"
category="primary"
- class="js-get-started-pipelines"
data-testid="get-started-pipelines"
>
{{ s__('Pipelines|Get started with Pipelines') }}
@@ -55,7 +56,7 @@ export default {
</div>
</template>
- <p v-else class="text-center">
+ <p v-else class="gl-text-center">
{{ s__('Pipelines|This project is not currently set up to run pipelines.') }}
</p>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 63262cc79fd..bde0dd53aac 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -25,6 +25,11 @@ export default {
required: true,
},
},
+ inject: {
+ targetProjectFullPath: {
+ default: '',
+ },
+ },
computed: {
user() {
return this.pipeline.user;
@@ -32,6 +37,12 @@ export default {
isScheduled() {
return this.pipeline.source === SCHEDULE_ORIGIN;
},
+ isInFork() {
+ return Boolean(
+ this.targetProjectFullPath &&
+ this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
+ );
+ },
},
};
</script>
@@ -52,9 +63,8 @@ export default {
:title="__('This pipeline was triggered by a schedule.')"
class="badge badge-info"
data-testid="pipeline-url-scheduled"
+ >{{ __('Scheduled') }}</span
>
- {{ __('Scheduled') }}
- </span>
</gl-link>
<span
v-if="pipeline.flags.latest"
@@ -62,27 +72,24 @@ export default {
:title="__('Latest pipeline for the most recent commit on this branch')"
class="js-pipeline-url-latest badge badge-success"
data-testid="pipeline-url-latest"
+ >{{ __('latest') }}</span
>
- {{ __('latest') }}
- </span>
<span
v-if="pipeline.flags.yaml_errors"
v-gl-tooltip
:title="pipeline.yaml_errors"
class="js-pipeline-url-yaml badge badge-danger"
data-testid="pipeline-url-yaml"
+ >{{ __('yaml invalid') }}</span
>
- {{ __('yaml invalid') }}
- </span>
<span
v-if="pipeline.flags.failure_reason"
v-gl-tooltip
:title="pipeline.failure_reason"
class="js-pipeline-url-failure badge badge-danger"
data-testid="pipeline-url-failure"
+ >{{ __('error') }}</span
>
- {{ __('error') }}
- </span>
<gl-link
v-if="pipeline.flags.auto_devops"
:id="`pipeline-url-autodevops-${pipeline.id}`"
@@ -112,17 +119,16 @@ export default {
</gl-sprintf>
</div>
</template>
- <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">
- {{ __('Learn more about Auto DevOps') }}
- </gl-link>
+ <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">{{
+ __('Learn more about Auto DevOps')
+ }}</gl-link>
</gl-popover>
<span
v-if="pipeline.flags.stuck"
class="js-pipeline-url-stuck badge badge-warning"
data-testid="pipeline-url-stuck"
+ >{{ __('stuck') }}</span
>
- {{ __('stuck') }}
- </span>
<span
v-if="pipeline.flags.detached_merge_request_pipeline"
v-gl-tooltip
@@ -133,9 +139,16 @@ export default {
"
class="js-pipeline-url-detached badge badge-info"
data-testid="pipeline-url-detached"
+ >{{ __('detached') }}</span
+ >
+ <span
+ v-if="isInFork"
+ v-gl-tooltip
+ :title="__('Pipeline ran in fork of project')"
+ class="badge badge-info"
+ data-testid="pipeline-url-fork"
+ >{{ __('fork') }}</span
>
- {{ __('detached') }}
- </span>
</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 9ee427d01fd..ff27226b408 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -62,7 +62,7 @@ export default {
type: String,
required: true,
},
- autoDevopsPath: {
+ autoDevopsHelpPath: {
type: String,
required: true,
},
@@ -342,7 +342,7 @@ export default {
:pipelines="state.pipelines"
:pipeline-schedule-url="pipelineScheduleUrl"
:update-graph-dropdown="updateGraphDropdown"
- :auto-devops-help-path="autoDevopsPath"
+ :auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
index e52afe08336..1ea71610897 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
@@ -32,7 +32,7 @@ export default {
if (action.scheduled_at) {
const confirmationMessage = sprintf(
s__(
- "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.",
+ 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.',
),
{ jobName: action.name },
);
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 1d117cfe34a..5548a1021f5 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -53,12 +53,12 @@ export default {
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
<div class="table-mobile-content">
<p v-if="hasDuration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" aria-hidden="true" />
+ <gl-icon name="timer" class="gl-vertical-align-baseline!" />
{{ durationFormatted }}
</p>
<p v-if="hasFinishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" />
+ <gl-icon name="calendar" class="gl-vertical-align-baseline!" />
<time
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 7afbb59cbd6..4b4fb6082c6 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -1,6 +1,13 @@
<script>
-import { mapGetters } from 'vuex';
-import { GlModalDirective, GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui';
+import { mapState, mapGetters, mapActions } from 'vuex';
+import {
+ GlModalDirective,
+ GlTooltipDirective,
+ GlFriendlyWrap,
+ GlIcon,
+ GlButton,
+ GlPagination,
+} from '@gitlab/ui';
import { __ } from '~/locale';
import TestCaseDetails from './test_case_details.vue';
@@ -10,6 +17,7 @@ export default {
GlIcon,
GlFriendlyWrap,
GlButton,
+ GlPagination,
TestCaseDetails,
},
directives: {
@@ -24,11 +32,15 @@ export default {
},
},
computed: {
- ...mapGetters(['getSuiteTests']),
+ ...mapState(['pageInfo']),
+ ...mapGetters(['getSuiteTests', 'getSuiteTestCount']),
hasSuites() {
return this.getSuiteTests.length > 0;
},
},
+ methods: {
+ ...mapActions(['setPage']),
+ },
wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'],
};
</script>
@@ -129,6 +141,14 @@ export default {
</div>
</div>
</div>
+
+ <gl-pagination
+ v-model="pageInfo.page"
+ class="gl-display-flex gl-justify-content-center"
+ :per-page="pageInfo.perPage"
+ :total-items="getSuiteTestCount"
+ @input="setPage"
+ />
</div>
<div v-else>
diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
new file mode 100644
index 00000000000..aa33f622ce6
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
@@ -0,0 +1,53 @@
+/**
+ * This function takes the stages and add the stage name
+ * at the group level as `category` to have an easier
+ * implementation while constructions nodes with D3
+ * @param {Array} stages
+ * @returns {Array} - Array of stages with stage name at the group level as `category`
+ */
+export const unwrapArrayOfJobs = (stages = []) => {
+ return stages
+ .map(({ name, groups }) => {
+ return groups.map(group => {
+ return { category: name, ...group };
+ });
+ })
+ .flat(2);
+};
+
+const unwrapGroups = stages => {
+ return stages.map(stage => {
+ const {
+ groups: { nodes: groups },
+ } = stage;
+ return { ...stage, groups };
+ });
+};
+
+const unwrapNodesWithName = (jobArray, prop, field = 'name') => {
+ return jobArray.map(job => {
+ return { ...job, [prop]: job[prop].nodes.map(item => item[field]) };
+ });
+};
+
+const unwrapJobWithNeeds = denodedJobArray => {
+ return unwrapNodesWithName(denodedJobArray, 'needs');
+};
+
+const unwrapStagesWithNeeds = denodedStages => {
+ const unwrappedNestedGroups = unwrapGroups(denodedStages);
+
+ const nodes = unwrappedNestedGroups.map(node => {
+ const { groups } = node;
+ const groupsWithJobs = groups.map(group => {
+ const jobs = unwrapJobWithNeeds(group.jobs.nodes);
+ return { ...group, jobs };
+ });
+
+ return { ...node, groups: groupsWithJobs };
+ });
+
+ return nodes;
+};
+
+export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds };