summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/pipelines
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/pipelines')
-rw-r--r--app/assets/javascripts/pipelines/components/dag/constants.js6
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue13
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_graph.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue25
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue166
-rw-r--r--app/assets/javascripts/pipelines/components/legacy_header_component.vue132
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js (renamed from app/assets/javascripts/pipelines/components/dag/parsing_utils.js)0
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js96
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue46
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue177
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue30
-rw-r--r--app/assets/javascripts/pipelines/constants.js11
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql30
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_component_mixin.js54
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js16
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js41
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/utils.js12
-rw-r--r--app/assets/javascripts/pipelines/utils.js51
32 files changed, 857 insertions, 207 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js
index b6a98fdc488..cd89055737f 100644
--- a/app/assets/javascripts/pipelines/components/dag/constants.js
+++ b/app/assets/javascripts/pipelines/components/dag/constants.js
@@ -1,9 +1,3 @@
-/* Error constants */
-export const PARSE_FAILURE = 'parse_failure';
-export const LOAD_FAILURE = 'load_failure';
-export const UNSUPPORTED_DATA = 'unsupported_data';
-export const DEFAULT = 'default';
-
/* Interaction handles */
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 8487da3d621..6267b63328c 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -6,16 +6,9 @@ import { fetchPolicies } from '~/lib/graphql';
import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql';
import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue';
-import {
- DEFAULT,
- PARSE_FAILURE,
- LOAD_FAILURE,
- UNSUPPORTED_DATA,
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
-} from './constants';
-import { parseData } from './parsing_utils';
+import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
+import { parseData } from '../parsing_utils';
+import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
index d12baa9617e..42d1debcddf 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
@@ -1,14 +1,7 @@
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
-import {
- LINK_SELECTOR,
- NODE_SELECTOR,
- PARSE_FAILURE,
- ADD_NOTE,
- REMOVE_NOTE,
- REPLACE_NOTES,
-} from './constants';
+import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import {
currentIsLive,
getLiveLinksAsDict,
@@ -17,8 +10,9 @@ import {
toggleLinkHighlight,
togglePathHighlights,
} from './interactions';
-import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
+import { getMaxNodes, removeOrphanNodes } from '../parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
+import { PARSE_FAILURE } from '../../constants';
export default {
viewOptions: {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index efa11580c41..a580ee11627 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -88,7 +88,7 @@ export default {
: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"
- @click="onClickAction"
+ @click.stop="onClickAction"
>
<gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
<gl-icon v-else :name="actionIcon" class="gl-mr-0!" />
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 924cdeebba1..0f5a8cb8fbf 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,7 +1,7 @@
<script>
+import { escape, capitalize } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
-import GraphMixin from '../../mixins/graph_component_mixin';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
@@ -13,7 +13,7 @@ export default {
GlLoadingIcon,
LinkedPipelinesColumn,
},
- mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin],
+ mixins: [GraphWidthMixin, GraphBundleMixin],
props: {
isLoading: {
type: Boolean,
@@ -51,6 +51,9 @@ export default {
};
},
computed: {
+ graph() {
+ return this.pipeline.details?.stages;
+ },
hasTriggeredBy() {
return (
this.type !== this.$options.downstream &&
@@ -92,6 +95,39 @@ export default {
},
},
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
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 11fb2b18e9d..49591a80752 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -1,5 +1,4 @@
<script>
-import $ from 'jquery';
import { GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import JobItem from './job_item.vue';
@@ -30,27 +29,7 @@ export default {
return `${name} - ${status.label}`;
},
},
- mounted() {
- this.stopDropdownClickPropagation();
- },
methods: {
- /**
- * When the user right clicks or cmd/ctrl + click in the group name or the action icon
- * the dropdown should not be closed so we stop propagation
- * of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(
- '.js-grouped-pipeline-dropdown button, .js-grouped-pipeline-dropdown a.mini-pipeline-graph-dropdown-item',
- this.$el,
- ).on('click', e => {
- e.stopPropagation();
- });
- },
-
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
@@ -69,7 +48,9 @@ export default {
>
<ci-icon :status="group.status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-2 d-inline-block align-bottom">
+ <span
+ class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom"
+ >
{{ group.name }}
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 0fe0b671273..7aee2573ce1 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -126,7 +126,7 @@ export default {
};
</script>
<template>
- <div class="ci-job-component">
+ <div class="ci-job-component" data-qa-selector="job_item_container">
<gl-link
v-if="status.has_details"
v-gl-tooltip="{ boundary, placement: 'bottom' }"
@@ -135,6 +135,7 @@ export default {
:class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item"
data-testid="job-with-link"
+ @click.stop
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
@@ -155,6 +156,7 @@ export default {
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
+ data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
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 30ba243077e..1b71949784a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -27,7 +27,7 @@ export default {
<template>
<span class="ci-job-name-component mw-100">
<ci-icon :status="status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-2 d-inline-block align-bottom">
+ <span class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom">
{{ name }}
</span>
</span>
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 1453c349f44..a75ec585b95 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -71,7 +71,7 @@ export default {
:action-icon="action.icon"
:tooltip-text="action.title"
:link="action.path"
- class="js-stage-action stage-action position-absolute position-top-0 rounded"
+ class="js-stage-action stage-action rounded"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index c7b72be36ad..b26f28fa6af 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,8 +1,11 @@
<script>
-import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
-import ciHeader from '~/vue_shared/components/header_ci_component.vue';
-import eventHub from '../event_hub';
+import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
+import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
+import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
@@ -10,57 +13,143 @@ export default {
name: 'PipelineHeaderSection',
components: {
ciHeader,
+ GlAlert,
+ GlButton,
GlLoadingIcon,
GlModal,
- GlButton,
},
directives: {
GlModal: GlModalDirective,
},
- props: {
- pipeline: {
- type: Object,
- required: true,
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'),
+ [POST_FAILURE]: __('An error occurred while making the request.'),
+ [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'),
+ [DEFAULT]: __('An unknown error occurred.'),
+ },
+ inject: {
+ // Receive `cancel`, `delete`, `fullProject` and `retry`
+ paths: {
+ default: {},
+ },
+ pipelineId: {
+ default: '',
},
- isLoading: {
- type: Boolean,
- required: true,
+ pipelineIid: {
+ default: '',
+ },
+ },
+ apollo: {
+ pipeline: {
+ query: getPipelineQuery,
+ variables() {
+ return {
+ fullPath: this.paths.fullProject,
+ iid: this.pipelineIid,
+ };
+ },
+ update: data => data.project.pipeline,
+ error() {
+ this.reportFailure(LOAD_FAILURE);
+ },
+ pollInterval: 10000,
+ watchLoading(isLoading) {
+ if (!isLoading) {
+ // To ensure apollo has updated the cache,
+ // we only remove the loading state in sync with GraphQL
+ this.isCanceling = false;
+ this.isRetrying = false;
+ }
+ },
},
},
data() {
return {
+ pipeline: null,
+ failureType: null,
isCanceling: false,
isRetrying: false,
isDeleting: false,
};
},
-
computed: {
- status() {
- return this.pipeline.details && this.pipeline.details.status;
- },
- shouldRenderContent() {
- return !this.isLoading && Object.keys(this.pipeline).length;
- },
deleteModalConfirmationText() {
return __(
'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
);
},
+ hasError() {
+ return this.failureType;
+ },
+ hasPipelineData() {
+ return Boolean(this.pipeline);
+ },
+ isLoadingInitialQuery() {
+ return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ },
+ status() {
+ return this.pipeline?.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoadingInitialQuery && this.hasPipelineData;
+ },
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ case POST_FAILURE:
+ return {
+ text: this.$options.errorTexts[POST_FAILURE],
+ variant: 'danger',
+ };
+ case DELETE_FAILURE:
+ return {
+ text: this.$options.errorTexts[DELETE_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ variant: 'danger',
+ };
+ }
+ },
},
-
methods: {
- cancelPipeline() {
+ reportFailure(errorType) {
+ this.failureType = errorType;
+ },
+ async postAction(path) {
+ try {
+ await axios.post(path);
+ this.$apollo.queries.pipeline.refetch();
+ } catch {
+ this.reportFailure(POST_FAILURE);
+ }
+ },
+ async cancelPipeline() {
this.isCanceling = true;
- eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
+ this.postAction(this.paths.cancel);
},
- retryPipeline() {
+ async retryPipeline() {
this.isRetrying = true;
- eventHub.$emit('headerPostAction', this.pipeline.retry_path);
+ this.postAction(this.paths.retry);
},
- deletePipeline() {
+ async deletePipeline() {
this.isDeleting = true;
- eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
+ this.$apollo.queries.pipeline.stopPolling();
+
+ try {
+ const { request } = await axios.delete(this.paths.delete);
+ redirectTo(setUrlFragment(request.responseURL, 'delete_success'));
+ } catch {
+ this.$apollo.queries.pipeline.startPolling();
+ this.reportFailure(DELETE_FAILURE);
+ this.isDeleting = false;
+ }
},
},
DELETE_MODAL_ID,
@@ -68,54 +157,53 @@ export default {
</script>
<template>
<div class="pipeline-header-container">
+ <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert>
<ci-header
v-if="shouldRenderContent"
- :status="status"
- :item-id="pipeline.id"
- :time="pipeline.created_at"
+ :status="pipeline.detailedStatus"
+ :time="pipeline.createdAt"
:user="pipeline.user"
+ :item-id="Number(pipelineId)"
item-name="Pipeline"
>
<gl-button
- v-if="pipeline.retry_path"
+ v-if="pipeline.retryable"
:loading="isRetrying"
:disabled="isRetrying"
- data-testid="retryButton"
category="secondary"
variant="info"
+ data-testid="retryPipeline"
+ class="js-retry-button"
@click="retryPipeline()"
>
{{ __('Retry') }}
</gl-button>
<gl-button
- v-if="pipeline.cancel_path"
+ v-if="pipeline.cancelable"
:loading="isCanceling"
:disabled="isCanceling"
- data-testid="cancelPipeline"
- class="gl-ml-3"
- category="primary"
variant="danger"
+ data-testid="cancelPipeline"
@click="cancelPipeline()"
>
{{ __('Cancel running') }}
</gl-button>
<gl-button
- v-if="pipeline.delete_path"
+ v-if="pipeline.userPermissions.destroyPipeline"
v-gl-modal="$options.DELETE_MODAL_ID"
:loading="isDeleting"
:disabled="isDeleting"
- data-testid="deletePipeline"
class="gl-ml-3"
- category="secondary"
variant="danger"
+ category="secondary"
+ data-testid="deletePipeline"
>
{{ __('Delete') }}
</gl-button>
</ci-header>
-
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
+ <gl-loading-icon v-if="isLoadingInitialQuery" size="lg" class="gl-mt-3 gl-mb-3" />
<gl-modal
:modal-id="$options.DELETE_MODAL_ID"
diff --git a/app/assets/javascripts/pipelines/components/legacy_header_component.vue b/app/assets/javascripts/pipelines/components/legacy_header_component.vue
new file mode 100644
index 00000000000..c7b72be36ad
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/legacy_header_component.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
+import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import eventHub from '../event_hub';
+import { __ } from '~/locale';
+
+const DELETE_MODAL_ID = 'pipeline-delete-modal';
+
+export default {
+ name: 'PipelineHeaderSection',
+ components: {
+ ciHeader,
+ GlLoadingIcon,
+ GlModal,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isCanceling: false,
+ isRetrying: false,
+ isDeleting: false,
+ };
+ },
+
+ computed: {
+ status() {
+ return this.pipeline.details && this.pipeline.details.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.pipeline).length;
+ },
+ deleteModalConfirmationText() {
+ return __(
+ 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
+ );
+ },
+ },
+
+ methods: {
+ cancelPipeline() {
+ this.isCanceling = true;
+ eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
+ },
+ retryPipeline() {
+ this.isRetrying = true;
+ eventHub.$emit('headerPostAction', this.pipeline.retry_path);
+ },
+ deletePipeline() {
+ this.isDeleting = true;
+ eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
+ },
+ },
+ DELETE_MODAL_ID,
+};
+</script>
+<template>
+ <div class="pipeline-header-container">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ :item-id="pipeline.id"
+ :time="pipeline.created_at"
+ :user="pipeline.user"
+ item-name="Pipeline"
+ >
+ <gl-button
+ v-if="pipeline.retry_path"
+ :loading="isRetrying"
+ :disabled="isRetrying"
+ data-testid="retryButton"
+ category="secondary"
+ variant="info"
+ @click="retryPipeline()"
+ >
+ {{ __('Retry') }}
+ </gl-button>
+
+ <gl-button
+ v-if="pipeline.cancel_path"
+ :loading="isCanceling"
+ :disabled="isCanceling"
+ data-testid="cancelPipeline"
+ class="gl-ml-3"
+ category="primary"
+ variant="danger"
+ @click="cancelPipeline()"
+ >
+ {{ __('Cancel running') }}
+ </gl-button>
+
+ <gl-button
+ v-if="pipeline.delete_path"
+ v-gl-modal="$options.DELETE_MODAL_ID"
+ :loading="isDeleting"
+ :disabled="isDeleting"
+ data-testid="deletePipeline"
+ class="gl-ml-3"
+ category="secondary"
+ variant="danger"
+ >
+ {{ __('Delete') }}
+ </gl-button>
+ </ci-header>
+
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" />
+
+ <gl-modal
+ :modal-id="$options.DELETE_MODAL_ID"
+ :title="__('Delete pipeline')"
+ :ok-title="__('Delete pipeline')"
+ ok-variant="danger"
+ @ok="deletePipeline()"
+ >
+ <p>
+ {{ deleteModalConfirmationText }}
+ </p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index 1ed415688f2..1ed415688f2 100644
--- a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
new file mode 100644
index 00000000000..45940d4a39c
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
@@ -0,0 +1,96 @@
+import * as d3 from 'd3';
+import { createUniqueJobId } from '../../utils';
+/**
+ * This function expects its first argument data structure
+ * to be the same shaped as the one generated by `parseData`,
+ * which contains nodes and links. For each link,
+ * we find the nodes in the graph, calculate their coordinates and
+ * trace the lines that represent the needs of each job.
+ * @param {Object} nodeDict - Resulting object of `parseData` with nodes and links
+ * @param {Object} jobs - An object where each key is the job name that contains the job data
+ * @param {ref} svg - Reference to the svg we draw in
+ * @returns {Array} Links that contain all the information about them
+ */
+
+export const generateLinksData = ({ links }, jobs, 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 sourceNodeEl = document.getElementById(sourceId);
+ const targetNodeEl = document.getElementById(targetId);
+
+ const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
+ const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
+ const containerCoordinates = containerEl.getBoundingClientRect();
+
+ // Because we add the svg dynamically and calculate the coordinates
+ // with plain JS and not D3, we need to account for the fact that
+ // the coordinates we are getting are absolutes, but we want to draw
+ // relative to the svg container, which starts at `containerCoordinates(x,y)`
+ // so we substract these from the total. We also need to remove the padding
+ // from the total to make sure it's aligned properly. We then make the line
+ // positioned in the center of the job node by adding half the height
+ // of the job pill.
+ const paddingLeft = Number(
+ window
+ .getComputedStyle(containerEl, null)
+ .getPropertyValue('padding-left')
+ .replace('px', ''),
+ );
+ const paddingTop = Number(
+ window
+ .getComputedStyle(containerEl, null)
+ .getPropertyValue('padding-top')
+ .replace('px', ''),
+ );
+
+ const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft;
+ const sourceNodeY =
+ sourceNodeCoordinates.top -
+ containerCoordinates.y -
+ paddingTop +
+ sourceNodeCoordinates.height / 2;
+ const targetNodeX = targetNodeCoordinates.x - containerCoordinates.x - paddingLeft;
+ const targetNodeY =
+ targetNodeCoordinates.y -
+ containerCoordinates.y -
+ paddingTop +
+ sourceNodeCoordinates.height / 2;
+
+ // Start point
+ path.moveTo(sourceNodeX, sourceNodeY);
+
+ // Make cross-stages lines a straight line all the way
+ // until we can safely draw the bezier to look nice.
+ const straightLineDestinationX = targetNodeX - 100;
+ const controlPointX = straightLineDestinationX + (targetNodeX - straightLineDestinationX) / 2;
+
+ if (straightLineDestinationX > 0) {
+ path.lineTo(straightLineDestinationX, sourceNodeY);
+ }
+
+ // Add bezier curve. The first 4 coordinates are the 2 control
+ // points to create the curve, and the last one is the end point (x, y).
+ // We want our control points to be in the middle of the line
+ path.bezierCurveTo(
+ controlPointX,
+ sourceNodeY,
+ controlPointX,
+ targetNodeY,
+ targetNodeX,
+ targetNodeY,
+ );
+
+ return {
+ ...link,
+ source: sourceId,
+ target: targetId,
+ ref: createUniqueJobId(sourceId, targetId),
+ path: path.toString(),
+ };
+ });
+};
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 19d41b166c3..8eec0110865 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -10,13 +10,57 @@ export default {
type: String,
required: true,
},
+ jobId: {
+ type: String,
+ required: true,
+ },
+ isHighlighted: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isFadedOut: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ handleMouseOver: {
+ type: Function,
+ required: false,
+ default: () => {},
+ },
+ handleMouseLeave: {
+ type: Function,
+ required: false,
+ default: () => {},
+ },
+ },
+ computed: {
+ jobPillClasses() {
+ return [
+ { 'gl-opacity-3': this.isFadedOut },
+ this.isHighlighted ? 'gl-shadow-blue-200-x0-y0-b4-s2' : 'gl-inset-border-2-green-400',
+ ];
+ },
+ },
+ methods: {
+ onMouseEnter() {
+ this.$emit('on-mouse-enter', this.jobId);
+ },
+ onMouseLeave() {
+ this.$emit('on-mouse-leave');
+ },
},
};
</script>
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
- class="gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-inset-border-1-green-600 gl-mb-3 gl-px-5 gl-py-2 pipeline-job-pill "
+ :id="jobId"
+ class="pipeline-job-pill 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"
+ @mouseleave="onMouseLeave"
>
{{ jobName }}
</div>
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 6a0d3cce1f3..3a2b8a20bae 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -1,8 +1,13 @@
<script>
import { isEmpty } from 'lodash';
import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
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';
export default {
components: {
@@ -10,28 +15,174 @@ export default {
JobPill,
StagePill,
},
+ CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
+ CONTAINER_ID: 'pipeline-graph-container',
+ STROKE_WIDTH: 2,
+ errorTexts: {
+ [DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
+ [DEFAULT]: __('An unknown error occurred.'),
+ },
props: {
pipelineData: {
required: true,
type: Object,
},
},
+ data() {
+ return {
+ failureType: null,
+ highlightedJob: null,
+ links: [],
+ needsObject: null,
+ height: 0,
+ width: 0,
+ };
+ },
computed: {
isPipelineDataEmpty() {
return isEmpty(this.pipelineData);
},
- emptyClass() {
- return !this.isPipelineDataEmpty ? 'gl-py-7' : '';
+ hasError() {
+ return this.failureType;
+ },
+ hasHighlightedJob() {
+ return Boolean(this.highlightedJob);
+ },
+ failure() {
+ const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
+
+ return { text, variant: 'danger' };
+ },
+ viewBox() {
+ return [0, 0, this.width, this.height];
+ },
+ highlightedJobs() {
+ // If you are hovering on a job, then the jobs we want to highlight are:
+ // The job you are currently hovering + all of its needs.
+ return this.hasHighlightedJob
+ ? [this.highlightedJob, ...this.needsObject[this.highlightedJob]]
+ : [];
+ },
+ highlightedLinks() {
+ // If you are hovering on a job, then the links we want to highlight are:
+ // All the links whose `source` and `target` are highlighted jobs.
+ if (this.hasHighlightedJob) {
+ const filteredLinks = this.links.filter(link => {
+ return (
+ this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target)
+ );
+ });
+
+ return filteredLinks.map(link => link.ref);
+ }
+
+ return [];
+ },
+ },
+ mounted() {
+ if (!this.isPipelineDataEmpty) {
+ this.getGraphDimensions();
+ this.drawJobLinks();
+ }
+ },
+ methods: {
+ drawJobLinks() {
+ const { stages, jobs } = this.pipelineData;
+ const unwrappedGroups = this.unwrapPipelineData(stages);
+
+ try {
+ const parsedData = parseData(unwrappedGroups);
+ this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID);
+ } catch {
+ this.reportFailure(DRAW_FAILURE);
+ }
+ },
+ getStageBackgroundClass(index) {
+ const { length } = this.pipelineData.stages;
+
+ if (length === 1) {
+ return 'stage-rounded';
+ } else if (index === 0) {
+ return 'stage-left-rounded';
+ } else if (index === length - 1) {
+ return 'stage-right-rounded';
+ }
+
+ return '';
+ },
+ highlightNeeds(uniqueJobId) {
+ // 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) ?? {};
+ }
+
+ this.highlightedJob = uniqueJobId;
+ },
+ 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`;
+ },
+ reportFailure(errorType) {
+ this.failureType = errorType;
+ },
+ resetFailure() {
+ this.failureType = null;
+ },
+ isJobHighlighted(jobName) {
+ return this.highlightedJobs.includes(jobName);
+ },
+ isLinkHighlighted(linkRef) {
+ return this.highlightedLinks.includes(linkRef);
+ },
+ getLinkClasses(link) {
+ return [
+ this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200',
+ { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
+ ];
},
},
};
</script>
<template>
- <div class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto" :class="emptyClass">
+ <div>
+ <gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure">
+ {{ failure.text }}
+ </gl-alert>
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
{{ __('No content to show') }}
</gl-alert>
- <template v-else>
+ <div
+ v-else
+ :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"
+ >
+ <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
+ <template>
+ <path
+ v-for="link in links"
+ :key="link.path"
+ :ref="link.ref"
+ :d="link.path"
+ class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
+ :class="getLinkClasses(link)"
+ :stroke-width="$options.STROKE_WIDTH"
+ />
+ </template>
+ </svg>
<div
v-for="(stage, index) in pipelineData.stages"
:key="`${stage.name}-${index}`"
@@ -39,19 +190,25 @@ export default {
>
<div
class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
- :class="{
- 'stage-left-rounded': index === 0,
- 'stage-right-rounded': index === pipelineData.stages.length - 1,
- }"
+ :class="getStageBackgroundClass(index)"
>
<stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
</div>
<div
class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
>
- <job-pill v-for="group in stage.groups" :key="group.name" :job-name="group.name" />
+ <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)"
+ @on-mouse-enter="highlightNeeds"
+ @on-mouse-leave="removeHighlightNeeds"
+ />
</div>
</div>
- </template>
+ </div>
</div>
</template>
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 fe8e3bd2b78..c5f30c8aef0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -48,6 +48,7 @@ export default {
variant="info"
category="primary"
class="js-get-started-pipelines"
+ data-testid="get-started-pipelines"
>
{{ s__('Pipelines|Get started with Pipelines') }}
</gl-button>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
index d7b6e033bd1..cf0849751df 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
@@ -46,6 +46,8 @@ export default {
variant="success"
category="primary"
class="js-run-pipeline"
+ data-testid="run-pipeline-button"
+ data-qa-selector="run_pipeline_button"
>
{{ s__('Pipelines|Run Pipeline') }}
</gl-button>
@@ -54,12 +56,13 @@ export default {
v-if="resetCachePath"
:loading="isResetCacheButtonLoading"
class="js-clear-cache"
+ data-testid="clear-cache-button"
@click="onClickResetCache"
>
{{ s__('Pipelines|Clear Runner Caches') }}
</gl-button>
- <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint">
+ <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button">
{{ s__('Pipelines|CI Lint') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index 43a54090e18..1569b326b31 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -1,10 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
import { isEmpty } from 'lodash';
-import { GlLink } from '@gitlab/ui';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
+import { GlLink, GlModal } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
/**
* Pipeline Stop Modal.
@@ -13,7 +12,7 @@ import { s__, sprintf } from '~/locale';
*/
export default {
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
GlLink,
CiIcon,
},
@@ -46,6 +45,17 @@ export default {
hasRef() {
return !isEmpty(this.pipeline.ref);
},
+ primaryProps() {
+ return {
+ text: s__('Pipeline|Stop pipeline'),
+ attributes: [{ variant: 'danger' }],
+ };
+ },
+ cancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
},
methods: {
emitSubmit(event) {
@@ -56,11 +66,11 @@ export default {
</script>
<template>
<gl-modal
- id="confirmation-modal"
- :header-title-text="modalTitle"
- :footer-primary-button-text="s__('Pipeline|Stop pipeline')"
- footer-primary-button-variant="danger"
- @submit="emitSubmit($event)"
+ modal-id="confirmation-modal"
+ :title="modalTitle"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="emitSubmit($event)"
>
<p v-html="modalText"></p>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index 35fd9837b3e..6ac60727f23 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -19,7 +19,7 @@ export default {
};
</script>
<template>
- <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-triggerer">
+ <div class="table-section section-10 d-none d-md-block pipeline-triggerer">
<user-avatar-link
v-if="user"
:link-href="user.path"
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 f0614298bd3..63262cc79fd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags">
+ <div class="table-section section-10 d-none d-md-block pipeline-tags">
<gl-link
:href="pipeline.path"
class="js-pipeline-url-link js-onboarding-pipeline-item"
@@ -98,7 +98,7 @@ export default {
placement="top"
>
<template #title>
- <div class="autodevops-title">
+ <div class="gl-font-weight-normal gl-line-height-normal">
<gl-sprintf
:message="
__(
@@ -112,12 +112,7 @@ export default {
</gl-sprintf>
</div>
</template>
- <gl-link
- class="autodevops-link"
- :href="autoDevopsHelpPath"
- target="_blank"
- rel="noopener noreferrer nofollow"
- >
+ <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">
{{ __('Learn more about Auto DevOps') }}
</gl-link>
</gl-popover>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index b8112149778..6c60594efca 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -91,6 +91,10 @@ export default {
<div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
{{ s__('Pipeline|Stages') }}
</div>
+ <div class="table-section section-15" role="rowheader"></div>
+ <div class="table-section section-20" role="rowheader">
+ <slot name="table-header-actions"></slot>
+ </div>
</div>
<pipelines-table-row-component
v-for="model in pipelines"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
index 1bdb7d18f04..7224ec455f6 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
@@ -1,6 +1,7 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import eventHub from '../../event_hub';
+import { __ } from '~/locale';
import PipelinesActionsComponent from './pipelines_actions.vue';
import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
@@ -17,6 +18,14 @@ import { PIPELINES_TABLE } from '../../constants';
* Given the received object renders a table row in the pipelines' table.
*/
export default {
+ i18n: {
+ cancelTitle: __('Cancel'),
+ redeployTitle: __('Retry'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModalDirective,
+ },
components: {
PipelinesActionsComponent,
PipelinesArtifactsComponent,
@@ -321,7 +330,11 @@ export default {
</div>
</div>
- <pipelines-timeago :duration="pipelineDuration" :finished-time="pipelineFinishedAt" />
+ <pipelines-timeago
+ class="gl-text-right"
+ :duration="pipelineDuration"
+ :finished-time="pipelineFinishedAt"
+ />
<div
v-if="displayPipelineActions"
@@ -338,8 +351,11 @@ export default {
<gl-button
v-if="pipeline.flags.retryable"
- :loading="isRetrying"
+ v-gl-tooltip.hover
+ :aria-label="$options.i18n.redeployTitle"
+ :title="$options.i18n.redeployTitle"
:disabled="isRetrying"
+ :loading="isRetrying"
class="js-pipelines-retry-button btn-retry"
data-qa-selector="pipeline_retry_button"
icon="repeat"
@@ -350,10 +366,12 @@ export default {
<gl-button
v-if="pipeline.flags.cancelable"
+ v-gl-tooltip.hover
+ v-gl-modal-directive="'confirmation-modal'"
+ :aria-label="$options.i18n.cancelTitle"
+ :title="$options.i18n.cancelTitle"
:loading="isCancelling"
:disabled="isCancelling"
- data-toggle="modal"
- data-target="#confirmation-modal"
icon="close"
variant="danger"
category="primary"
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 7d13ee582c6..dd09247337c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -1,12 +1,11 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import '~/lib/utils/datetime_utility';
-import tooltip from '~/vue_shared/directives/tooltip';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
components: { GlIcon },
mixins: [timeagoMixin],
@@ -51,7 +50,7 @@ export default {
};
</script>
<template>
- <div class="table-section section-15 pipelines-time-ago">
+ <div class="table-section section-15">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
<div class="table-mobile-content">
<p v-if="hasDuration" class="duration">
@@ -59,11 +58,11 @@ export default {
{{ durationFormatted }}
</p>
- <p v-if="hasFinishedTime" class="finished-at d-none d-sm-none d-md-block">
+ <p v-if="hasFinishedTime" class="finished-at d-none d-md-block">
<gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" />
<time
- v-tooltip
+ v-gl-tooltip
:title="tooltipTitle(finishedTime)"
data-placement="top"
data-container="body"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
index dfa6d8c13a5..ae5758233bc 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue
@@ -3,7 +3,7 @@ import {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
- GlDeprecatedDropdownDivider,
+ GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
@@ -21,7 +21,7 @@ export default {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
- GlDeprecatedDropdownDivider,
+ GlDropdownDivider,
GlLoadingIcon,
},
props: {
@@ -94,7 +94,7 @@ export default {
<gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{
$options.anyTriggerAuthor
}}</gl-filtered-search-suggestion>
- <gl-deprecated-dropdown-divider />
+ <gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
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 aa53c5040e8..2b92ffc3f26 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,6 @@
<script>
import { mapGetters } from 'vuex';
-import { GlTooltipDirective, GlFriendlyWrap, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
@@ -8,6 +8,7 @@ export default {
components: {
GlIcon,
GlFriendlyWrap,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -45,6 +46,9 @@ export default {
<div role="rowheader" class="table-section section-20">
{{ __('Name') }}
</div>
+ <div role="rowheader" class="table-section section-10">
+ {{ __('Filename') }}
+ </div>
<div role="rowheader" class="table-section section-10 text-center">
{{ __('Status') }}
</div>
@@ -63,18 +67,30 @@ export default {
>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div>
- <div class="table-mobile-content pr-md-1 gl-overflow-wrap-break">
+ <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
<gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.classname" />
</div>
</div>
<div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div>
- <div class="table-mobile-content pr-md-1 gl-overflow-wrap-break">
- <gl-friendly-wrap
- data-testid="caseName"
- :symbols="$options.wrapSymbols"
- :text="testCase.name"
+ <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
+ <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.name" />
+ </div>
+ </div>
+
+ <div class="table-section section-10 section-wrap">
+ <div role="rowheader" class="table-mobile-header">{{ __('Filename') }}</div>
+ <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
+ <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.file" />
+ <gl-button
+ v-gl-tooltip
+ size="small"
+ category="tertiary"
+ icon="copy-to-clipboard"
+ :title="__('Copy to clipboard')"
+ :data-clipboard-text="testCase.file"
+ :aria-label="__('Copy to clipboard')"
/>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index abe5e1060c8..607e7a66f44 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -13,6 +13,8 @@ export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
SUCCESS: 'success',
+ ERROR: 'error',
+ UNKNOWN: 'unknown',
};
export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
@@ -21,3 +23,12 @@ export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
+
+/* Error constants shared across graphs */
+export const DEFAULT = 'default';
+export const DELETE_FAILURE = 'delete_pipeline_failure';
+export const DRAW_FAILURE = 'draw_failure';
+export const LOAD_FAILURE = 'load_failure';
+export const PARSE_FAILURE = 'parse_failure';
+export const POST_FAILURE = 'post_failure';
+export const UNSUPPORTED_DATA = 'unsupported_data';
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
new file mode 100644
index 00000000000..06083daeca0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
@@ -0,0 +1,30 @@
+query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
+ project(fullPath: $fullPath) {
+ pipeline(iid: $iid) {
+ id
+ status
+ retryable
+ cancelable
+ userPermissions {
+ destroyPipeline
+ }
+ detailedStatus {
+ detailsPath
+ icon
+ group
+ text
+ }
+ createdAt
+ user {
+ name
+ webPath
+ email
+ avatarUrl
+ status {
+ message
+ emoji
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
deleted file mode 100644
index 53b7a174517..00000000000
--- a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { escape } from 'lodash';
-
-export default {
- props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
- },
- computed: {
- graph() {
- return this.pipeline.details && this.pipeline.details.stages;
- },
- },
- methods: {
- capitalizeStageName(name) {
- const escapedName = escape(name);
- return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
- },
- isFirstColumn(index) {
- return index === 0;
- },
- stageConnectorClass(index, stage) {
- let className;
-
- // If it's the first stage column and only has one job
- if (index === 0 && 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);
- },
- },
-};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 745f5b886a5..67aec12655a 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -7,10 +7,11 @@ import pipelineGraph from './components/graph/graph_component.vue';
import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
-import pipelineHeader from './components/header_component.vue';
+import legacyPipelineHeader from './components/legacy_header_component.vue';
import eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue';
import createTestReportsStore from './stores/test_reports';
+import { createPipelineHeaderApp } from './pipeline_details_header';
Vue.use(Translate);
@@ -56,7 +57,7 @@ const createPipelinesDetailApp = mediator => {
});
};
-const createPipelineHeaderApp = mediator => {
+const createLegacyPipelineHeaderApp = mediator => {
if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) {
return;
}
@@ -64,7 +65,7 @@ const createPipelineHeaderApp = mediator => {
new Vue({
el: SELECTORS.PIPELINE_HEADER,
components: {
- pipelineHeader,
+ legacyPipelineHeader,
},
data() {
return {
@@ -95,7 +96,7 @@ const createPipelineHeaderApp = mediator => {
},
},
render(createElement) {
- return createElement('pipeline-header', {
+ return createElement('legacy-pipeline-header', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
@@ -132,7 +133,12 @@ export default () => {
mediator.fetchPipeline();
createPipelinesDetailApp(mediator);
- createPipelineHeaderApp(mediator);
+
+ if (gon.features.graphqlPipelineHeader) {
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
+ } else {
+ createLegacyPipelineHeaderApp(mediator);
+ }
createTestDetails();
createDagApp();
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
new file mode 100644
index 00000000000..27fe9ba3f19
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import pipelineHeader from './components/header_component.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const createPipelineHeaderApp = elSelector => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ pipelineHeader,
+ },
+ apolloProvider,
+ provide: {
+ paths: {
+ cancel: cancelPath,
+ delete: deletePath,
+ fullProject: fullPath,
+ retry: retryPath,
+ },
+ pipelineId,
+ pipelineIid,
+ },
+ render(createElement) {
+ return createElement('pipeline-header', {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
index 8f1ac305cda..42406e5a67a 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
@@ -1,13 +1,19 @@
import { __, sprintf } from '../../../locale';
+import { TestStatus } from '../../constants';
export function iconForTestStatus(status) {
switch (status) {
- case 'success':
+ case TestStatus.SUCCESS:
return 'status_success_borderless';
- case 'failed':
+ case TestStatus.FAILED:
return 'status_failed_borderless';
- default:
+ case TestStatus.ERROR:
+ return 'status_warning_borderless';
+ case TestStatus.SKIPPED:
return 'status_skipped_borderless';
+ case TestStatus.UNKNOWN:
+ default:
+ return 'status_notfound_borderless';
}
}
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index bd53b22784c..7d1a1762e0d 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -5,6 +5,8 @@ export const validateParams = params => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
+export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`;
+
/**
* This function takes a json payload that comes from a yml
* file converted to json through `jsyaml` library. Because we
@@ -18,6 +20,16 @@ export const validateParams = params => {
export const preparePipelineGraphData = jsonData => {
const jsonKeys = Object.keys(jsonData);
const jobNames = jsonKeys.filter(job => jsonData[job]?.stage);
+ // Creates an object with only the valid jobs
+ const jobs = jsonKeys.reduce((acc, val) => {
+ if (jobNames.includes(val)) {
+ return {
+ ...acc,
+ [val]: { ...jsonData[val], id: createUniqueJobId(jsonData[val].stage, val) },
+ };
+ }
+ return { ...acc };
+ }, {});
// We merge both the stages from the "stages" key in the yaml and the stage associated
// with each job to show the user both the stages they explicitly defined, and those
@@ -40,10 +52,45 @@ export const preparePipelineGraphData = jsonData => {
return {
name: stage,
groups: stageJobs.map(job => {
- return { name: job, jobs: [{ ...jsonData[job] }] };
+ return {
+ name: job,
+ jobs: [{ ...jsonData[job] }],
+ id: createUniqueJobId(stage, job),
+ };
}),
};
});
- return { stages: pipelineData };
+ return { stages: pipelineData, jobs };
+};
+
+export const generateJobNeedsDict = ({ jobs }) => {
+ const arrOfJobNames = Object.keys(jobs);
+
+ return arrOfJobNames.reduce((acc, value) => {
+ const recursiveNeeds = jobName => {
+ if (!jobs[jobName]?.needs) {
+ return [];
+ }
+
+ return jobs[jobName].needs
+ .map(job => {
+ const { id } = jobs[job];
+ // If we already have the needs of a job in the accumulator,
+ // then we use the memoized data instead of the recursive call
+ // to save some performance.
+ const newNeeds = acc[id] ?? recursiveNeeds(job);
+
+ return [id, ...newNeeds];
+ })
+ .flat(Infinity);
+ };
+
+ // To ensure we don't have duplicates job relationship when 2 jobs
+ // needed by another both depends on the same jobs, we remove any
+ // duplicates from the array.
+ const uniqueValues = Array.from(new Set(recursiveNeeds(value)));
+
+ return { ...acc, [jobs[value].id]: uniqueValues };
+ }, {});
};