diff options
Diffstat (limited to 'app/assets/javascripts/pipelines')
44 files changed, 1479 insertions, 610 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 }; diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 607e7a66f44..757d285ef19 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -28,6 +28,8 @@ export const RAW_TEXT_WARNING = s__( export const DEFAULT = 'default'; export const DELETE_FAILURE = 'delete_pipeline_failure'; export const DRAW_FAILURE = 'draw_failure'; +export const EMPTY_PIPELINE_DATA = 'empty_data'; +export const INVALID_CI_CONFIG = 'invalid_ci_config'; export const LOAD_FAILURE = 'load_failure'; export const PARSE_FAILURE = 'parse_failure'; export const POST_FAILURE = 'post_failure'; diff --git a/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql b/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql new file mode 100644 index 00000000000..3bf6d8dc9d8 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql @@ -0,0 +1,17 @@ +fragment LinkedPipelineData on Pipeline { + id + iid + path + status: detailedStatus { + group + label + icon + } + sourceJob { + name + } + project { + name + fullPath + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql new file mode 100644 index 00000000000..25aede49631 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql @@ -0,0 +1,65 @@ +#import "../fragments/linked_pipelines.fragment.graphql" + +query getPipelineDetails($projectPath: ID!, $iid: ID!) { + project(fullPath: $projectPath) { + pipeline(iid: $iid) { + id + iid + downstream { + nodes { + ...LinkedPipelineData + } + } + upstream { + ...LinkedPipelineData + } + stages { + nodes { + name + status: detailedStatus { + action { + icon + path + title + } + } + groups { + nodes { + status: detailedStatus { + label + group + icon + } + name + size + jobs { + nodes { + name + scheduledAt + needs { + nodes { + name + } + } + status: detailedStatus { + icon + tooltip + hasDetails + detailsPath + group + action { + buttonTitle + icon + path + title + } + } + } + } + } + } + } + } + } + } +} 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 index 06083daeca0..1b3f80b1f18 100644 --- 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 @@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { project(fullPath: $fullPath) { pipeline(iid: $iid) { id + iid status retryable cancelable diff --git a/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql b/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql new file mode 100644 index 00000000000..1da4fa0a72b --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql @@ -0,0 +1,20 @@ +fragment PipelineStagesConnection on CiConfigStageConnection { + nodes { + name + groups { + nodes { + name + jobs { + nodes { + name + needs { + nodes { + name + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js deleted file mode 100644 index 2dbaa5a5c9a..00000000000 --- a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js +++ /dev/null @@ -1,50 +0,0 @@ -import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; -import { LAYOUT_CHANGE_DELAY } from '~/pipelines/constants'; - -export default { - debouncedResize: null, - sidebarMutationObserver: null, - data() { - return { - graphLeftPadding: 0, - graphRightPadding: 0, - }; - }, - beforeDestroy() { - window.removeEventListener('resize', this.$options.debouncedResize); - - if (this.$options.sidebarMutationObserver) { - this.$options.sidebarMutationObserver.disconnect(); - } - }, - created() { - this.$options.debouncedResize = debounceByAnimationFrame(this.setGraphPadding); - window.addEventListener('resize', this.$options.debouncedResize); - }, - mounted() { - this.setGraphPadding(); - - this.$options.sidebarMutationObserver = new MutationObserver(this.handleLayoutChange); - this.$options.sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); - }, - methods: { - setGraphPadding() { - // only add padding to main graph (not inline upstream/downstream graphs) - if (this.type && this.type !== 'main') return; - - const container = document.querySelector('.js-pipeline-container'); - if (!container) return; - - this.graphLeftPadding = container.offsetLeft; - this.graphRightPadding = window.innerWidth - container.offsetLeft - container.offsetWidth; - }, - handleLayoutChange() { - // wait until animations finish, then recalculate padding - window.setTimeout(this.setGraphPadding, LAYOUT_CHANGE_DELAY); - }, - }, -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 29dec2309a7..27f71d2b878 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,7 +3,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; -import pipelineGraph from './components/graph/graph_component.vue'; +import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import legacyPipelineHeader from './components/legacy_header_component.vue'; @@ -28,7 +28,7 @@ const createLegacyPipelinesDetailApp = mediator => { new Vue({ el: SELECTORS.PIPELINE_GRAPH, components: { - pipelineGraph, + PipelineGraphLegacy, }, mixins: [GraphBundleMixin], data() { @@ -37,7 +37,7 @@ const createLegacyPipelinesDetailApp = mediator => { }; }, render(createElement) { - return createElement('pipeline-graph', { + return createElement('pipeline-graph-legacy', { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, @@ -149,7 +149,9 @@ export default async function() { const { createPipelinesDetailApp } = await import( /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' ); - createPipelinesDetailApp(); + + const { pipelineProjectPath, pipelineIid } = dataset; + createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid); } catch { Flash(__('An error occurred while loading the pipeline.')); } diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js index 880855cf21d..1b296c305cb 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -1,7 +1,37 @@ -const createPipelinesDetailApp = () => { - // Placeholder. See: https://gitlab.com/gitlab-org/gitlab/-/issues/223262 - // eslint-disable-next-line no-useless-return - return; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; +import { GRAPHQL } from './components/graph/constants'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + batchMax: 2, + }, + ), +}); + +const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => { + // eslint-disable-next-line no-new + new Vue({ + el: selector, + components: { + PipelineGraphWrapper, + }, + apolloProvider, + provide: { + pipelineProjectPath, + pipelineIid, + dataMethod: GRAPHQL, + }, + render(createElement) { + return createElement(PipelineGraphWrapper); + }, + }); }; export { createPipelinesDetailApp }; diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js new file mode 100644 index 00000000000..4575a99f60f --- /dev/null +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -0,0 +1,75 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { + parseBoolean, + historyReplaceState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import Translate from '~/vue_shared/translate'; +import Pipelines from './components/pipelines_list/pipelines.vue'; +import PipelinesStore from './stores/pipelines_store'; + +Vue.use(Translate); +Vue.use(GlToast); + +export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { + const el = document.querySelector(selector); + if (!el) { + return null; + } + + const { + endpoint, + pipelineScheduleUrl, + helpPagePath, + emptyStateSvgPath, + errorStateSvgPath, + noPipelinesSvgPath, + autoDevopsHelpPath, + newPipelinePath, + canCreatePipeline, + hasGitlabCi, + ciLintPath, + resetCachePath, + projectId, + params, + } = el.dataset; + + return new Vue({ + el, + data() { + return { + store: new PipelinesStore(), + }; + }, + created() { + if (doesHashExistInUrl('delete_success')) { + this.$toast.show(__('The pipeline has been deleted')); + historyReplaceState(buildUrlWithCurrentLocation()); + } + }, + render(createElement) { + return createElement(Pipelines, { + props: { + store: this.store, + endpoint, + pipelineScheduleUrl, + helpPagePath, + emptyStateSvgPath, + errorStateSvgPath, + noPipelinesSvgPath, + autoDevopsHelpPath, + newPipelinePath, + canCreatePipeline: parseBoolean(canCreatePipeline), + hasGitlabCi: parseBoolean(hasGitlabCi), + ciLintPath, + resetCachePath, + projectId, + params: JSON.parse(params), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index f10bbeec77c..3c664457756 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -47,6 +47,7 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { }); }; +export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page); export const setSelectedSuiteIndex = ({ commit }, data) => commit(types.SET_SELECTED_SUITE_INDEX, data); export const removeSelectedSuiteIndex = ({ commit }) => diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index c123014756d..56f769c00fa 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -14,5 +14,10 @@ export const getSelectedSuite = state => export const getSuiteTests = state => { const { test_cases: testCases = [] } = getSelectedSuite(state); - return testCases.map(addIconStatus); + const { page, perPage } = state.pageInfo; + const start = (page - 1) * perPage; + + return testCases.map(addIconStatus).slice(start, start + perPage); }; + +export const getSuiteTestCount = state => getSelectedSuite(state)?.test_cases?.length || 0; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js index 52345888cb0..803f6bf60b1 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -1,3 +1,4 @@ +export const SET_PAGE = 'SET_PAGE'; export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; export const SET_SUMMARY = 'SET_SUMMARY'; export const SET_SUITE = 'SET_SUITE'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 3652a12a6ba..cf0bf8483dd 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,6 +1,14 @@ import * as types from './mutation_types'; export default { + [types.SET_PAGE](state, page) { + Object.assign(state, { + pageInfo: Object.assign(state.pageInfo, { + page, + }), + }); + }, + [types.SET_SUITE](state, { suite = {}, index = null }) { state.testReports.test_suites[index] = { ...suite, hasFullSuite: true }; }, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index af79521d68a..7f5da549a9d 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -4,4 +4,8 @@ export default ({ summaryEndpoint = '', suiteEndpoint = '' }) => ({ testReports: {}, selectedSuiteIndex: null, isLoading: false, + pageInfo: { + page: 1, + perPage: 20, + }, }); diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 7d1a1762e0d..28d6c0edb0f 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -5,66 +5,42 @@ export const validateParams = params => { return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); }; -export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`; +export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`; /** - * This function takes a json payload that comes from a yml - * file converted to json through `jsyaml` library. Because we - * naively convert the entire yaml to json, some keys (like `includes`) - * are irrelevant to rendering the graph and must be removed. We also - * restructure the data to have the structure from an API response for the - * pipeline data. - * @param {Object} jsonData - * @returns {Array} - Array of stages containing all jobs + * This function takes the stages array and transform it + * into a hash where each key is a job name and the job data + * is associated to that key. + * @param {Array} stages + * @returns {Object} - Hash of jobs */ -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 - // that they added under jobs. We also remove duplicates. - const jobStages = jobNames.map(job => jsonData[job].stage); - const userDefinedStages = jsonData?.stages ?? []; - - // The order is important here. We always show the stages in order they were - // defined in the `stages` key first, and then stages that are under the jobs. - const stages = Array.from(new Set([...userDefinedStages, ...jobStages])); - - const arrayOfJobsByStage = stages.map(val => { - return jobNames.filter(job => { - return jsonData[job].stage === val; - }); - }); +export const createJobsHash = (stages = []) => { + const jobsHash = {}; - const pipelineData = stages.map((stage, index) => { - const stageJobs = arrayOfJobsByStage[index]; - return { - name: stage, - groups: stageJobs.map(job => { - return { - name: job, - jobs: [{ ...jsonData[job] }], - id: createUniqueJobId(stage, job), - }; - }), - }; + stages.forEach(stage => { + if (stage.groups.length > 0) { + stage.groups.forEach(group => { + group.jobs.forEach(job => { + jobsHash[job.name] = job; + }); + }); + } }); - return { stages: pipelineData, jobs }; + return jobsHash; }; -export const generateJobNeedsDict = ({ jobs }) => { +/** + * This function takes the jobs hash generated by + * `createJobsHash` function and returns an easier + * structure to work with for needs relationship + * where the key is the job name and the value is an + * array of all the needs this job has recursively + * (includes the needs of the needs) + * @param {Object} jobs + * @returns {Object} - Hash of jobs and array of needs + */ +export const generateJobNeedsDict = (jobs = {}) => { const arrOfJobNames = Object.keys(jobs); return arrOfJobNames.reduce((acc, value) => { @@ -75,13 +51,12 @@ export const generateJobNeedsDict = ({ jobs }) => { 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); + const newNeeds = acc[job] ?? recursiveNeeds(job); - return [id, ...newNeeds]; + return [job, ...newNeeds]; }) .flat(Infinity); }; @@ -91,6 +66,6 @@ export const generateJobNeedsDict = ({ jobs }) => { // duplicates from the array. const uniqueValues = Array.from(new Set(recursiveNeeds(value))); - return { ...acc, [jobs[value].id]: uniqueValues }; + return { ...acc, [value]: uniqueValues }; }, {}); }; |