diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 10:08:36 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 10:08:36 +0300 |
commit | 48aff82709769b098321c738f3444b9bdaa694c6 (patch) | |
tree | e00c7c43e2d9b603a5a6af576b1685e400410dee /app/assets/javascripts/pipelines/components | |
parent | 879f5329ee916a948223f8f43d77fba4da6cd028 (diff) |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/pipelines/components')
25 files changed, 706 insertions, 143 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> |