diff options
Diffstat (limited to 'app/assets/javascripts/pipelines')
42 files changed, 593 insertions, 227 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js index 51b1fb4f4cc..b6a98fdc488 100644 --- a/app/assets/javascripts/pipelines/components/dag/constants.js +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -8,3 +8,8 @@ export const DEFAULT = 'default'; export const IS_HIGHLIGHTED = 'dag-highlighted'; export const LINK_SELECTOR = 'dag-link'; export const NODE_SELECTOR = 'dag-node'; + +/* Annotation types */ +export const ADD_NOTE = 'add'; +export const REMOVE_NOTE = 'remove'; +export const REPLACE_NOTES = 'replace'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 6e0d23ef87f..85163a666e2 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -1,19 +1,32 @@ <script> -import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import DagGraph from './dag_graph.vue'; -import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants'; +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'; export default { // eslint-disable-next-line @gitlab/require-i18n-strings name: 'Dag', components: { + DagAnnotations, DagGraph, GlAlert, GlLink, GlSprintf, + GlEmptyState, + GlButton, }, props: { graphUrl: { @@ -21,21 +34,43 @@ export default { required: false, default: '', }, + emptySvgPath: { + type: String, + required: true, + default: '', + }, + dagDocPath: { + type: String, + required: true, + default: '', + }, }, data() { return { - showFailureAlert: false, - showBetaInfo: true, + annotationsMap: {}, failureType: null, graphData: null, + showFailureAlert: false, + showBetaInfo: true, + hasNoDependentJobs: false, }; }, errorTexts: { [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'), [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'), - [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'), + [UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, + emptyStateTexts: { + title: __('Start using Directed Acyclic Graphs (DAG)'), + firstDescription: __( + "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.", + ), + secondDescription: __( + 'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.', + ), + button: __('Learn more about job dependencies'), + }, computed: { betaMessage() { return __( @@ -66,6 +101,9 @@ export default { }; } }, + shouldDisplayAnnotations() { + return !isEmpty(this.annotationsMap); + }, shouldDisplayGraph() { return Boolean(!this.showFailureAlert && this.graphData); }, @@ -86,6 +124,9 @@ export default { .catch(() => reportFailure(LOAD_FAILURE)); }, methods: { + addAnnotationToMap({ uid, source, target }) { + this.$set(this.annotationsMap, uid, { source, target }); + }, processGraphData(data) { let parsed; @@ -96,11 +137,18 @@ export default { return; } - if (parsed.links.length < 2) { + if (parsed.links.length === 1) { this.reportFailure(UNSUPPORTED_DATA); return; } + // If there are no links, we don't report failure + // as it simply means the user does not use job dependencies + if (parsed.links.length === 0) { + this.hasNoDependentJobs = true; + return; + } + this.graphData = parsed; }, hideAlert() { @@ -109,10 +157,28 @@ export default { hideBetaInfo() { this.showBetaInfo = false; }, + removeAnnotationFromMap({ uid }) { + this.$delete(this.annotationsMap, uid); + }, reportFailure(type) { this.showFailureAlert = true; this.failureType = type; }, + updateAnnotation({ type, data }) { + switch (type) { + case ADD_NOTE: + this.addAnnotationToMap(data); + break; + case REMOVE_NOTE: + this.removeAnnotationFromMap(data); + break; + case REPLACE_NOTES: + this.annotationsMap = data; + break; + default: + break; + } + }, }, }; </script> @@ -131,6 +197,43 @@ export default { </template> </gl-sprintf> </gl-alert> - <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" /> + <div class="gl-relative"> + <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> + <dag-graph + v-if="shouldDisplayGraph" + :graph-data="graphData" + @onFailure="reportFailure" + @update-annotation="updateAnnotation" + /> + <gl-empty-state + v-else-if="hasNoDependentJobs" + :svg-path="emptySvgPath" + :title="$options.emptyStateTexts.title" + > + <template #description> + <div class="gl-text-left"> + <p> + <gl-sprintf :message="$options.emptyStateTexts.firstDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.emptyStateTexts.secondDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </div> + </template> + <template #actions> + <gl-button :href="dagDocPath" target="__blank" variant="success"> + {{ $options.emptyStateTexts.button }} + </gl-button> + </template> + </gl-empty-state> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue new file mode 100644 index 00000000000..a1500166cdc --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue @@ -0,0 +1,73 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'DagAnnotations', + components: { + GlButton, + }, + props: { + annotations: { + type: Object, + required: true, + }, + }, + data() { + return { + showList: true, + }; + }, + computed: { + linkText() { + return this.showList ? __('Hide list') : __('Show list'); + }, + shouldShowLink() { + return Object.keys(this.annotations).length > 1; + }, + wrapperClasses() { + return [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-fixed', + 'gl-right-1', + 'gl-top-66vh', + 'gl-w-max-content', + 'gl-px-5', + 'gl-py-4', + 'gl-rounded-base', + 'gl-bg-white', + ].join(' '); + }, + }, + methods: { + toggleList() { + this.showList = !this.showList; + }, + }, +}; +</script> +<template> + <div :class="wrapperClasses"> + <div v-if="showList"> + <div + v-for="note in annotations" + :key="note.uid" + class="gl-display-flex gl-align-items-center" + > + <div + data-testid="dag-color-block" + class="gl-w-6 gl-h-5" + :style="{ + background: `linear-gradient(0.25turn, ${note.source.color} 40%, ${note.target.color} 60%)`, + }" + ></div> + <div data-testid="dag-note-text" class="gl-px-2 gl-font-base gl-align-items-center"> + {{ note.source.name }} → {{ note.target.name }} + </div> + </div> + </div> + + <gl-button v-if="shouldShowLink" variant="link" @click="toggleList">{{ linkText }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue index 063ec091e4d..d12baa9617e 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -1,8 +1,17 @@ <script> import * as d3 from 'd3'; import { uniqueId } from 'lodash'; -import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants'; import { + LINK_SELECTOR, + NODE_SELECTOR, + PARSE_FAILURE, + ADD_NOTE, + REMOVE_NOTE, + REPLACE_NOTES, +} from './constants'; +import { + currentIsLive, + getLiveLinksAsDict, highlightLinks, restoreLinks, toggleLinkHighlight, @@ -25,6 +34,11 @@ export default { containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join( ' ', ), + hoverFadeClasses: [ + 'gl-cursor-pointer', + 'gl-transition-duration-slow', + 'gl-transition-timing-function-ease', + ].join(' '), }, gitLabColorRotation: [ '#e17223', @@ -50,8 +64,8 @@ export default { data() { return { color: () => {}, - width: 0, height: 0, + width: 0, }; }, mounted() { @@ -60,7 +74,7 @@ export default { try { countedAndTransformed = this.transformData(this.graphData); } catch { - this.$emit('onFailure', PARSE_FAILURE); + this.$emit('on-failure', PARSE_FAILURE); return; } @@ -90,17 +104,33 @@ export default { }, appendLinkInteractions(link) { + const { baseOpacity } = this.$options.viewOptions; return link - .on('mouseover', highlightLinks) - .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity)) - .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity)); + .on('mouseover', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: ADD_NOTE, data: d }); + highlightLinks(d, idx, collection); + }) + .on('mouseout', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: REMOVE_NOTE, data: d }); + restoreLinks(baseOpacity); + }) + .on('click', (d, idx, collection) => { + toggleLinkHighlight(baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); }, appendNodeInteractions(node) { - return node.on( - 'click', - togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity), - ); + return node.on('click', (d, idx, collection) => { + togglePathHighlights(this.$options.viewOptions.baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); }, appendLabelAsForeignObject(d, i, n) { @@ -230,7 +260,10 @@ export default { .attr('id', d => { return this.createAndAssignId(d, 'uid', LINK_SELECTOR); }) - .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true); + .classed( + `${LINK_SELECTOR} gl-transition-property-stroke-opacity ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ); }, generateNodes(svg, nodeData) { @@ -242,7 +275,10 @@ export default { .data(nodeData) .enter() .append('line') - .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true) + .classed( + `${NODE_SELECTOR} gl-transition-property-stroke ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ) .attr('id', d => { return this.createAndAssignId(d, 'uid', NODE_SELECTOR); }) @@ -260,6 +296,11 @@ export default { .attr('y2', d => d.y1 - 4); }, + initColors() { + const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); + return ({ name }) => colorFn(name); + }, + labelNodes(svg, nodeData) { return svg .append('g') @@ -271,11 +312,6 @@ export default { .each(this.appendLabelAsForeignObject); }, - initColors() { - const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); - return ({ name }) => colorFn(name); - }, - transformData(parsed) { const baseLayout = createSankey()(parsed); const cleanedNodes = removeOrphanNodes(baseLayout.nodes); diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js index c9008730c90..e9f3e9f0e2c 100644 --- a/app/assets/javascripts/pipelines/components/dag/interactions.js +++ b/app/assets/javascripts/pipelines/components/dag/interactions.js @@ -5,10 +5,20 @@ export const highlightIn = 1; export const highlightOut = 0.2; const getCurrent = (idx, collection) => d3.select(collection[idx]); -const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED); +const getLiveLinks = () => d3.selectAll(`.${LINK_SELECTOR}.${IS_HIGHLIGHTED}`); const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`); const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`); +export const getLiveLinksAsDict = () => { + return Object.fromEntries( + getLiveLinks() + .data() + .map(d => [d.uid, d]), + ); +}; +export const currentIsLive = (idx, collection) => + getCurrent(idx, collection).classed(IS_HIGHLIGHTED); + const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut); const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2'); const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn); @@ -16,10 +26,10 @@ const foregroundNodes = selection => selection.attr('stroke', d => d.color); const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity); const renewNodes = selection => selection.attr('stroke', d => d.color); -const getAllLinkAncestors = node => { +export const getAllLinkAncestors = node => { if (node.targetLinks) { return node.targetLinks.flatMap(n => { - return [n.uid, ...getAllLinkAncestors(n.source)]; + return [n, ...getAllLinkAncestors(n.source)]; }); } @@ -59,8 +69,8 @@ const highlightPath = (parentLinks, parentNodes) => { backgroundNodes(getNodesNotLive()); /* highlight correct links */ - parentLinks.forEach(id => { - foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + parentLinks.forEach(({ uid }) => { + foregroundLinks(d3.select(`#${uid}`)).classed(IS_HIGHLIGHTED, true); }); /* highlight correct nodes */ @@ -69,9 +79,22 @@ const highlightPath = (parentLinks, parentNodes) => { }); }; +const restoreNodes = () => { + /* + When paths are unclicked, they can take down nodes that + are still in use for other paths. This checks the live paths and + rehighlights their nodes. + */ + + getLiveLinks().each(d => { + foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true); + foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true); + }); +}; + const restorePath = (parentLinks, parentNodes, baseOpacity) => { - parentLinks.forEach(id => { - renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false); + parentLinks.forEach(({ uid }) => { + renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false); }); parentNodes.forEach(id => { @@ -86,14 +109,10 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => { backgroundLinks(getOtherLinks()); backgroundNodes(getNodesNotLive()); + restoreNodes(); }; -export const restoreLinks = (baseOpacity, d, idx, collection) => { - /* in this case, it has just been clicked */ - if (currentIsLive(idx, collection)) { - return; - } - +export const restoreLinks = baseOpacity => { /* if there exist live links, reset to highlight out / pale otherwise, reset to base @@ -111,11 +130,12 @@ export const restoreLinks = (baseOpacity, d, idx, collection) => { export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => { if (currentIsLive(idx, collection)) { - restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity); + restorePath([d], [d.source.uid, d.target.uid], baseOpacity); + restoreNodes(); return; } - highlightPath([d.uid], [d.source.uid, d.target.uid]); + highlightPath([d], [d.source.uid, d.target.uid]); }; export const togglePathHighlights = (baseOpacity, d, idx, collection) => { diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 1ff5b662d18..6b890688a48 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -43,6 +43,7 @@ export default { data() { return { downstreamMarginTop: null, + jobName: null, }; }, computed: { @@ -91,13 +92,9 @@ export default { /** * 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 either 15 (if child) or 30 (if not a child) - * due to the height of node and stage name margin bottom. + * offsetTop and then subtracting 15 */ - this.downstreamMarginTop = this.calculateMarginTop( - downstreamNode, - downstreamNode.classList.contains('child-pipeline') ? 15 : 30, - ); + this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); /** * If the expanded trigger is defined and the id is different than the @@ -120,6 +117,9 @@ export default { hasUpstream(index) { return index === 0 && this.hasTriggeredBy; }, + setJob(jobName) { + this.jobName = jobName; + }, }, }; </script> @@ -172,7 +172,7 @@ export default { :class="{ 'has-upstream prepend-left-64': hasUpstream(index), 'has-only-one-job': hasOnlyOneJob(stage), - 'append-right-46': shouldAddRightMargin(index), + 'gl-mr-26': shouldAddRightMargin(index), }" :title="capitalizeStageName(stage.name)" :groups="stage.groups" @@ -180,6 +180,7 @@ export default { :is-first-column="isFirstColumn(index)" :has-triggered-by="hasTriggeredBy" :action="stage.status.action" + :job-hovered="jobName" @refreshPipelineGraph="refreshPipelineGraph" /> </ul> @@ -191,6 +192,7 @@ export default { :project-id="pipelineProjectId" graph-position="right" @linkedPipelineClick="handleClickedDownstream" + @downstreamHovered="setJob" /> <pipeline-graph diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index bfd314e0439..4d72cc55b34 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -31,6 +31,7 @@ import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; */ export default { + hoverClass: 'gl-inset-border-1-blue-500', components: { ActionComponent, JobNameComponent, @@ -55,6 +56,11 @@ export default { required: false, default: Infinity, }, + jobHovered: { + type: String, + required: false, + default: '', + }, }, computed: { boundary() { @@ -95,6 +101,11 @@ export default { hasAction() { return this.job.status && this.job.status.action && this.job.status.action.path; }, + jobClasses() { + return this.job.name === this.jobHovered + ? `${this.$options.hoverClass} ${this.cssClassJobName}` + : this.cssClassJobName; + }, }, methods: { pipelineActionRequestComplete() { @@ -120,8 +131,9 @@ export default { v-else v-gl-tooltip="{ boundary, placement: 'bottom' }" :title="tooltipText" - :class="cssClassJobName" + :class="jobClasses" class="js-job-component-tooltip non-details-job-component" + data-testid="job-without-link" > <job-name-component :name="job.name" :status="job.status" /> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 550b9daa521..733553e02c0 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; export default { directives: { @@ -28,7 +28,8 @@ export default { }, computed: { tooltipText() { - return `${this.projectName} - ${this.pipelineStatus.label}`; + return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} + ${this.sourceJobInfo}`; }, buttonId() { return `js-linked-pipeline-${this.pipeline.id}`; @@ -39,25 +40,32 @@ export default { projectName() { return this.pipeline.project.name; }, + downstreamTitle() { + return this.childPipeline ? __('child-pipeline') : this.pipeline.project.name; + }, parentPipeline() { // Refactor string match when BE returns Upstream/Downstream indicators return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream'); }, childPipeline() { // Refactor string match when BE returns Upstream/Downstream indicators - return this.projectId === this.pipeline.project.id && this.columnTitle === __('Downstream'); + return this.projectId === this.pipeline.project.id && this.isDownstream; }, label() { - return this.parentPipeline ? __('Parent') : __('Child'); - }, - childTooltipText() { - return __('This pipeline was triggered by a parent pipeline'); + if (this.parentPipeline) { + return __('Parent'); + } else if (this.childPipeline) { + return __('Child'); + } + return __('Multi-project'); }, - parentTooltipText() { - return __('This pipeline triggered a child pipeline'); + isDownstream() { + return this.columnTitle === __('Downstream'); }, - labelToolTipText() { - return this.label === __('Parent') ? this.parentTooltipText : this.childTooltipText; + sourceJobInfo() { + return this.isDownstream + ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name }) + : ''; }, }, methods: { @@ -68,6 +76,12 @@ export default { hideTooltips() { this.$root.$emit('bv::hide::tooltip'); }, + onDownstreamHovered() { + this.$emit('downstreamHovered', this.pipeline.source_job.name); + }, + onDownstreamHoverLeave() { + this.$emit('downstreamHovered', ''); + }, }, }; </script> @@ -76,7 +90,10 @@ export default { <li ref="linkedPipeline" class="linked-pipeline build" - :class="{ 'child-pipeline': childPipeline }" + :class="{ 'downstream-pipeline': isDownstream }" + data-qa-selector="child_pipeline" + @mouseover="onDownstreamHovered" + @mouseleave="onDownstreamHoverLeave" > <gl-deprecated-button :id="buttonId" @@ -94,15 +111,9 @@ export default { css-classes="position-top-0" class="js-linked-pipeline-status" /> - <span class="str-truncated align-bottom"> {{ projectName }} • #{{ pipeline.id }} </span> - <div v-if="parentPipeline || childPipeline" class="parent-child-label-container"> - <span - v-gl-tooltip.bottom - :title="labelToolTipText" - class="badge badge-primary" - @mouseover="hideTooltips" - >{{ label }}</span - > + <span class="str-truncated"> {{ downstreamTitle }} • #{{ pipeline.id }} </span> + <div class="gl-pt-2"> + <span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span> </div> </gl-deprecated-button> </li> 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 e3429184c05..c4dfd3382a2 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -28,7 +28,7 @@ export default { columnClass() { const positionValues = { right: 'prepend-left-64', - left: 'append-right-32', + left: 'gl-mr-7', }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, @@ -41,6 +41,9 @@ export default { onPipelineClick(downstreamNode, pipeline, index) { this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); }, + onDownstreamHovered(jobName) { + this.$emit('downstreamHovered', jobName); + }, }, }; </script> @@ -61,6 +64,7 @@ export default { :column-title="columnTitle" :project-id="projectId" @pipelineClicked="onPipelineClick($event, pipeline, index)" + @downstreamHovered="onDownstreamHovered" /> </ul> </div> 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 bed0ed51d5f..9de6ba819c2 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -36,6 +36,11 @@ export default { required: false, default: () => ({}), }, + jobHovered: { + type: String, + required: false, + default: '', + }, }, computed: { hasAction() { @@ -80,6 +85,7 @@ export default { <job-item v-if="group.size === 1" :job="group.jobs[0]" + :job-hovered="jobHovered" css-class-job-name="build-content" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index e7777d0d3af..dff642161db 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -108,7 +108,7 @@ export default { /> </ci-header> - <gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default append-bottom-default" /> + <gl-loading-icon v-if="isLoading" 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/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue index 6c3a4a27606..6c3a4a27606 100644 --- a/app/assets/javascripts/pipelines/components/blank_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 74ada6a4d15..74ada6a4d15 100644 --- a/app/assets/javascripts/pipelines/components/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue index 4f6c9d2bd90..a66bbb7e5ba 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue @@ -1,6 +1,6 @@ <script> import { GlDeprecatedButton } from '@gitlab/ui'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; export default { name: 'PipelineNavControls', diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue index f604edd8859..f604edd8859 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue diff --git a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue index 740b54cd8e0..35fd9837b3e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -26,9 +26,9 @@ export default { :img-src="user.avatar_url" :img-size="26" :tooltip-text="user.name" - class="prepend-left-default js-pipeline-url-user" + class="gl-ml-3 js-pipeline-url-user" /> - <span v-else class="prepend-left-default js-pipeline-url-api api"> + <span v-else class="gl-ml-3 js-pipeline-url-api api"> {{ s__('Pipelines|API') }} </span> </div> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 6c977b841af..2905b2ca26f 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { escape } from 'lodash'; +import { SCHEDULE_ORIGIN } from '../../constants'; import { __, sprintf } from '~/locale'; import popover from '~/vue_shared/directives/popover'; @@ -27,6 +28,10 @@ export default { type: Object, required: true, }, + pipelineScheduleUrl: { + type: String, + required: true, + }, autoDevopsHelpPath: { type: String, required: true, @@ -36,6 +41,9 @@ export default { user() { return this.pipeline.user; }, + isScheduled() { + return this.pipeline.source === SCHEDULE_ORIGIN; + }, popoverOptions() { return { html: true, @@ -61,16 +69,28 @@ export default { <gl-link :href="pipeline.path" class="js-pipeline-url-link js-onboarding-pipeline-item" + data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > <span class="pipeline-id">#{{ pipeline.id }}</span> </gl-link> <div class="label-container"> + <gl-link v-if="isScheduled" :href="pipelineScheduleUrl" target="__blank"> + <span + v-gl-tooltip + :title="__('This pipeline was triggered by a schedule.')" + class="badge badge-info" + data-testid="pipeline-url-scheduled" + > + {{ __('Scheduled') }} + </span> + </gl-link> <span v-if="pipeline.flags.latest" v-gl-tooltip :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> @@ -79,6 +99,7 @@ export default { v-gl-tooltip :title="pipeline.yaml_errors" class="js-pipeline-url-yaml badge badge-danger" + data-testid="pipeline-url-yaml" > {{ __('yaml invalid') }} </span> @@ -87,6 +108,7 @@ export default { v-gl-tooltip :title="pipeline.failure_reason" class="js-pipeline-url-failure badge badge-danger" + data-testid="pipeline-url-failure" > {{ __('error') }} </span> @@ -95,10 +117,15 @@ export default { v-popover="popoverOptions" tabindex="0" class="js-pipeline-url-autodevops badge badge-info autodevops-badge" + data-testid="pipeline-url-autodevops" role="button" >{{ __('Auto DevOps') }}</gl-link > - <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning"> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck badge badge-warning" + data-testid="pipeline-url-stuck" + > {{ __('stuck') }} </span> <span @@ -110,6 +137,7 @@ export default { ) " class="js-pipeline-url-detached badge badge-info" + data-testid="pipeline-url-detached" > {{ __('detached') }} </span> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index dbf29b0c29c..0c531650fd2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,17 +1,18 @@ <script> import { isEqual } from 'lodash'; -import { __, sprintf, s__ } from '../../locale'; -import createFlash from '../../flash'; -import PipelinesService from '../services/pipelines_service'; -import pipelinesMixin from '../mixins/pipelines'; -import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; -import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; +import { __, s__ } from '~/locale'; +import createFlash from '~/flash'; +import PipelinesService from '../../services/pipelines_service'; +import pipelinesMixin from '../../mixins/pipelines'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import NavigationControls from './nav_controls.vue'; -import { getParameterByName } from '../../lib/utils/common_utils'; -import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; +import Icon from '~/vue_shared/components/icon.vue'; import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; -import { validateParams } from '../utils'; -import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants'; +import { validateParams } from '../../utils'; +import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -20,6 +21,7 @@ export default { NavigationTabs, NavigationControls, PipelinesFilteredSearch, + Icon, }, mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()], props: { @@ -40,6 +42,11 @@ export default { type: String, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, helpPagePath: { type: String, required: true, @@ -115,8 +122,6 @@ export default { }, scopes: { all: 'all', - pending: 'pending', - running: 'running', finished: 'finished', branches: 'branches', tags: 'tags', @@ -169,13 +174,8 @@ export default { }, emptyTabMessage() { - const { scopes } = this.$options; - const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; - - if (possibleScopes.includes(this.scope)) { - return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { - scope: this.scope, - }); + if (this.scope === this.$options.scopes.finished) { + return s__('Pipelines|There are currently no finished pipelines.'); } return s__('Pipelines|There are currently no pipelines.'); @@ -193,21 +193,8 @@ export default { isActive: this.scope === 'all', }, { - name: __('Pending'), - scope: scopes.pending, - count: count.pending, - isActive: this.scope === 'pending', - }, - { - name: __('Running'), - scope: scopes.running, - count: count.running, - isActive: this.scope === 'running', - }, - { name: __('Finished'), scope: scopes.finished, - count: count.finished, isActive: this.scope === 'finished', }, { @@ -298,8 +285,8 @@ export default { v-if="shouldRenderTabs || shouldRenderButtons" class="top-area scrolling-tabs-container inner-page-scroll-tabs" > - <div class="fade-left"><i class="fa fa-angle-left" aria-hidden="true"> </i></div> - <div class="fade-right"><i class="fa fa-angle-right" aria-hidden="true"> </i></div> + <div class="fade-left"><icon name="chevron-lg-left" :size="12" /></div> + <div class="fade-right"><icon name="chevron-lg-right" :size="12" /></div> <navigation-tabs v-if="shouldRenderTabs" @@ -358,6 +345,7 @@ export default { <div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder"> <pipelines-table-component :pipelines="state.pipelines" + :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsPath" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue index 7d4276e8d2e..3009ca7a775 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -5,7 +5,7 @@ import flash from '~/flash'; import { s__, __, sprintf } from '~/locale'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; export default { directives: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 59c066b2683..59c066b2683 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 0505a8668d1..0505a8668d1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index d3ba0c97f6b..b8112149778 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective } from '@gitlab/ui'; import PipelinesTableRowComponent from './pipelines_table_row.vue'; import PipelineStopModal from './pipeline_stop_modal.vue'; -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; /** * Pipelines Table Component. @@ -22,6 +22,11 @@ export default { type: Array, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, updateGraphDropdown: { type: Boolean, required: false, @@ -91,6 +96,7 @@ export default { v-for="model in pipelines" :key="model.id" :pipeline="model" + :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index 981914dd046..f25994a7506 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -1,16 +1,16 @@ <script> -import eventHub from '../event_hub'; +import eventHub from '../../event_hub'; import PipelinesActionsComponent from './pipelines_actions.vue'; import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; -import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import PipelineStage from './stage.vue'; import PipelineUrl from './pipeline_url.vue'; import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelinesTimeago from './time_ago.vue'; -import CommitComponent from '../../vue_shared/components/commit.vue'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; -import Icon from '../../vue_shared/components/icon.vue'; -import { PIPELINES_TABLE } from '../constants'; +import CommitComponent from '~/vue_shared/components/commit.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { PIPELINES_TABLE } from '../../constants'; /** * Pipeline table row. @@ -35,6 +35,11 @@ export default { type: Object, required: true, }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, updateGraphDropdown: { type: Boolean, required: false, @@ -274,7 +279,11 @@ export default { </div> </div> - <pipeline-url :pipeline="pipeline" :auto-devops-help-path="autoDevopsHelpPath" /> + <pipeline-url + :pipeline="pipeline" + :pipeline-schedule-url="pipelineScheduleUrl" + :auto-devops-help-path="autoDevopsHelpPath" + /> <pipeline-triggerer :pipeline="pipeline" /> <div class="table-section section-wrap section-20"> @@ -300,7 +309,8 @@ export default { <div v-for="(stage, index) in pipeline.details.stages" :key="index" - class="stage-container dropdown js-mini-pipeline-graph" + class="stage-container dropdown" + data-testid="widget-mini-pipeline-graph" > <pipeline-stage :type="$options.pipelinesTable" diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue index 569920a4f31..99492bd8357 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue @@ -14,13 +14,13 @@ import $ from 'jquery'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '../../locale'; -import Flash from '../../flash'; -import axios from '../../lib/utils/axios_utils'; -import eventHub from '../event_hub'; -import Icon from '../../vue_shared/components/icon.vue'; -import JobItem from './graph/job_item.vue'; -import { PIPELINES_TABLE } from '../constants'; +import { __ } from '~/locale'; +import Flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import eventHub from '../../event_hub'; +import Icon from '~/vue_shared/components/icon.vue'; +import JobItem from '../graph/job_item.vue'; +import { PIPELINES_TABLE } from '../../constants'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 2a23a0f6744..8a01e1fe3f5 100644 --- a/app/assets/javascripts/pipelines/components/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,8 +1,8 @@ <script> import iconTimerSvg from 'icons/_icon_timer.svg'; -import '../../lib/utils/datetime_utility'; -import tooltip from '../../vue_shared/directives/tooltip'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; +import '~/lib/utils/datetime_utility'; +import tooltip from '~/vue_shared/directives/tooltip'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { directives: { diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index da14bb2d308..b6eff2931d3 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -1,7 +1,7 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; -import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants'; +import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import createFlash from '~/flash'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue index dc43d94f4fd..dc43d94f4fd 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index 7b209c5fa12..64de6d2a053 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -1,7 +1,7 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; -import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants'; +import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; import createFlash from '~/flash'; import { debounce } from 'lodash'; diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index 4062a3b11bb..b5aeb3fe9e0 100644 --- a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -13,7 +13,7 @@ import { ANY_TRIGGER_AUTHOR, FETCH_AUTHOR_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY, -} from '../../constants'; +} from '../../../constants'; export default { anyTriggerAuthor: ANY_TRIGGER_AUTHOR, diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 06ab45adf80..8746784aa57 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,10 +1,9 @@ <script> -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; import TestSummaryTable from './test_summary_table.vue'; -import store from '~/pipelines/stores/test_reports'; export default { name: 'TestReports', @@ -14,24 +13,37 @@ export default { TestSummary, TestSummaryTable, }, - store, computed: { - ...mapState(['isLoading', 'selectedSuite', 'testReports']), + ...mapState(['hasFullReport', 'isLoading', 'selectedSuiteIndex', 'testReports']), + ...mapGetters(['getSelectedSuite']), showSuite() { - return this.selectedSuite.total_count > 0; + return this.selectedSuiteIndex !== null; }, showTests() { const { test_suites: testSuites = [] } = this.testReports; return testSuites.length > 0; }, }, + created() { + this.fetchSummary(); + }, methods: { - ...mapActions(['setSelectedSuite', 'removeSelectedSuite']), + ...mapActions([ + 'fetchFullReport', + 'fetchSummary', + 'setSelectedSuiteIndex', + 'removeSelectedSuiteIndex', + ]), summaryBackClick() { - this.removeSelectedSuite(); + this.removeSelectedSuiteIndex(); }, - summaryTableRowClick(suite) { - this.setSelectedSuite(suite); + summaryTableRowClick(index) { + this.setSelectedSuiteIndex(index); + + // Fetch the full report when the user clicks to see more details + if (!this.hasFullReport) { + this.fetchFullReport(); + } }, beforeEnterTransition() { document.documentElement.style.overflowX = 'hidden'; @@ -45,7 +57,7 @@ export default { <template> <div v-if="isLoading"> - <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" /> + <gl-loading-icon size="lg" class="gl-mt-3 js-loading-spinner" /> </div> <div @@ -59,7 +71,7 @@ export default { @after-leave="afterLeaveTransition" > <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element"> - <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" /> + <test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" /> <test-suite-table /> </div> @@ -73,7 +85,7 @@ export default { </div> <div v-else> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> </div> 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 be7f27f210d..d57b1466177 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,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import store from '~/pipelines/stores/test_reports'; import { __ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { @@ -11,7 +11,9 @@ export default { Icon, SmartVirtualList, }, - store, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { heading: { type: String, @@ -32,16 +34,16 @@ export default { <template> <div> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <h4>{{ heading }}</h4> </div> </div> - <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-cases-table"> + <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-cases-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> <div role="rowheader" class="table-section section-20"> - {{ __('Class') }} + {{ __('Suite') }} </div> <div role="rowheader" class="table-section section-20"> {{ __('Name') }} @@ -68,13 +70,25 @@ export default { class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row" > <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> - <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.classname }}</div> + <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> + <div + v-gl-tooltip + :title="testCase.classname" + class="table-mobile-content pr-md-1 text-truncate" + > + {{ 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 text-truncate">{{ testCase.name }}</div> + <div + v-gl-tooltip + :title="testCase.name" + class="table-mobile-content pr-md-1 text-truncate" + > + {{ testCase.name }} + </div> </div> <div class="table-section section-10 section-wrap"> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index 67646c537bd..712ac5eb0e5 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -72,7 +72,7 @@ export default { <gl-deprecated-button v-if="showBack" size="sm" - class="append-right-default js-back-button" + class="gl-mr-3 js-back-button" @click="onBackClick" > <icon name="angle-left" /> @@ -85,7 +85,7 @@ export default { <div class="row mt-2"> <div class="col-4 col-md"> <span class="js-total-tests">{{ - sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count }) + sprintf(s__('TestReports|%{count} tests'), { count: report.total_count }) }}</span> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 4dfb67dd8e8..6cfb795595d 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -2,7 +2,6 @@ import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import store from '~/pipelines/stores/test_reports'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { @@ -14,12 +13,11 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - store, props: { heading: { type: String, required: false, - default: s__('TestReports|Test suites'), + default: s__('TestReports|Jobs'), }, }, computed: { @@ -29,8 +27,8 @@ export default { }, }, methods: { - tableRowClick(suite) { - this.$emit('row-click', suite); + tableRowClick(index) { + this.$emit('row-click', index); }, }, maxShownRows: 20, @@ -40,16 +38,16 @@ export default { <template> <div> - <div class="row prepend-top-default"> + <div class="row gl-mt-3"> <div class="col-12"> <h4>{{ heading }}</h4> </div> </div> - <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-suites-table"> + <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-suites-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> <div role="rowheader" class="table-section section-25 pl-3"> - {{ __('Suite') }} + {{ __('Job') }} </div> <div role="rowheader" class="table-section section-25"> {{ __('Duration') }} @@ -84,7 +82,7 @@ export default { :class="{ 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error, }" - @click="tableRowClick(testSuite)" + @click="tableRowClick(index)" > <div class="table-section section-25"> <div role="rowheader" class="table-mobile-header font-weight-bold"> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index c709f329728..abe5e1060c8 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -7,6 +7,7 @@ export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const ANY_TRIGGER_AUTHOR = 'Any'; export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status']; export const FILTER_TAG_IDENTIFIER = 'tag'; +export const SCHEDULE_ORIGIN = 'schedule'; export const TestStatus = { FAILED: 'failed', diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 876b30299fb..7710a96e5fb 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -1,11 +1,11 @@ import Visibility from 'visibilityjs'; import { GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '../../locale'; -import createFlash from '../../flash'; -import Poll from '../../lib/utils/poll'; -import EmptyState from '../components/empty_state.vue'; -import SvgBlankState from '../components/blank_state.vue'; -import PipelinesTableComponent from '../components/pipelines_table.vue'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import EmptyState from '../components/pipelines_list/empty_state.vue'; +import SvgBlankState from '../components/pipelines_list/blank_state.vue'; +import PipelinesTableComponent from '../components/pipelines_list/pipelines_table.vue'; import eventHub from '../event_hub'; import { CANCEL_REQUEST } from '../constants'; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 90109598542..f1102a9bddf 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -10,8 +10,7 @@ import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; -import testReportsStore from './stores/test_reports'; -import axios from '~/lib/utils/axios_utils'; +import createTestReportsStore from './stores/test_reports'; Vue.use(Translate); @@ -93,15 +92,11 @@ const createPipelineHeaderApp = mediator => { }); }; -const createPipelinesTabs = dataset => { +const createPipelinesTabs = testReportsStore => { const tabsElement = document.querySelector('.pipelines-tabs'); - const testReportsEnabled = - window.gon && window.gon.features && window.gon.features.junitPipelineView; - - if (tabsElement && testReportsEnabled) { - const fetchReportsAction = 'fetchReports'; - testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); + if (tabsElement) { + const fetchReportsAction = 'fetchFullReport'; const isTestTabActive = Boolean( document.querySelector('.pipelines-tabs > li > a.test-tab.active'), ); @@ -121,28 +116,35 @@ const createPipelinesTabs = dataset => { } }; -const createTestDetails = detailsEndpoint => { +const createTestDetails = () => { + if (!window.gon?.features?.junitPipelineView) { + return; + } + + const el = document.querySelector('#js-pipeline-tests-detail'); + const { fullReportEndpoint, summaryEndpoint, countEndpoint } = el?.dataset || {}; + + const testReportsStore = createTestReportsStore({ + fullReportEndpoint, + summaryEndpoint: summaryEndpoint || countEndpoint, + useBuildSummaryReport: window.gon?.features?.buildReportSummary, + }); + + if (!window.gon?.features?.buildReportSummary) { + createPipelinesTabs(testReportsStore); + } + // eslint-disable-next-line no-new new Vue({ - el: '#js-pipeline-tests-detail', + el, components: { TestReports, }, + store: testReportsStore, render(createElement) { return createElement('test-reports'); }, }); - - axios - .get(detailsEndpoint) - .then(({ data }) => { - if (!data.total_count) { - return; - } - - document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count; - }) - .catch(() => {}); }; const createDagApp = () => { @@ -151,7 +153,8 @@ const createDagApp = () => { } const el = document.querySelector('#js-pipeline-dag-vue'); - const graphUrl = el?.dataset?.pipelineDataPath; + const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset; + // eslint-disable-next-line no-new new Vue({ el, @@ -161,7 +164,9 @@ const createDagApp = () => { render(createElement) { return createElement('dag', { props: { - graphUrl, + graphUrl: pipelineDataPath, + emptySvgPath, + dagDocPath, }, }); }, @@ -175,7 +180,6 @@ export default () => { createPipelinesDetailApp(mediator); createPipelineHeaderApp(mediator); - createPipelinesTabs(dataset); - createTestDetails(dataset.testReportsCountEndpoint); + createTestDetails(); createDagApp(); }; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index 71d875c1a83..ccacb9f7e97 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -3,17 +3,42 @@ import * as types from './mutation_types'; import createFlash from '~/flash'; import { s__ } from '~/locale'; -export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data); +export const fetchSummary = ({ state, commit, dispatch }) => { + // If we do this without the build_report_summary feature flag enabled + // it causes a race condition for toggleLoading and ruins the loading + // state in the application + if (state.useBuildSummaryReport) { + dispatch('toggleLoading'); + } -export const fetchReports = ({ state, commit, dispatch }) => { + return axios + .get(state.summaryEndpoint) + .then(({ data }) => { + commit(types.SET_SUMMARY, data); + + if (!state.useBuildSummaryReport) { + // Set the tab counter badge to total_count + // This is temporary until we can server-side render that count number + // (see https://gitlab.com/gitlab-org/gitlab/-/issues/223134) + document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count || 0; + } + }) + .catch(() => { + createFlash(s__('TestReports|There was an error fetching the summary.')); + }) + .finally(() => { + if (state.useBuildSummaryReport) { + dispatch('toggleLoading'); + } + }); +}; + +export const fetchFullReport = ({ state, commit, dispatch }) => { dispatch('toggleLoading'); return axios - .get(state.endpoint) - .then(response => { - const { data } = response; - commit(types.SET_REPORTS, data); - }) + .get(state.fullReportEndpoint) + .then(({ data }) => commit(types.SET_REPORTS, data)) .catch(() => { createFlash(s__('TestReports|There was an error fetching the test reports.')); }) @@ -22,8 +47,10 @@ export const fetchReports = ({ state, commit, dispatch }) => { }); }; -export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data); -export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {}); +export const setSelectedSuiteIndex = ({ commit }, data) => + commit(types.SET_SELECTED_SUITE_INDEX, data); +export const removeSelectedSuiteIndex = ({ commit }) => + commit(types.SET_SELECTED_SUITE_INDEX, null); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index 788c1d32987..877762b77c9 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -9,14 +9,12 @@ export const getTestSuites = state => { })); }; -export const getSuiteTests = state => { - const { selectedSuite } = state; - - if (selectedSuite.test_cases) { - return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus); - } +export const getSelectedSuite = state => + state.testReports?.test_suites?.[state.selectedSuiteIndex] || {}; - return []; +export const getSuiteTests = state => { + const { test_cases: testCases = [] } = getSelectedSuite(state); + return testCases.sort(sortTestCases).map(addIconStatus); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js index 318dff5bcb2..88f61b09025 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/index.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -7,9 +7,10 @@ import mutations from './mutations'; Vue.use(Vuex); -export default new Vuex.Store({ - actions, - getters, - mutations, - state, -}); +export default initialState => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(initialState), + }); 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 832e45cf7a1..76405557b51 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -1,4 +1,4 @@ -export const SET_ENDPOINT = 'SET_ENDPOINT'; export const SET_REPORTS = 'SET_REPORTS'; -export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE'; +export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; +export const SET_SUMMARY = 'SET_SUMMARY'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 349e6ec0469..2531ab1e87c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,16 +1,16 @@ import * as types from './mutation_types'; export default { - [types.SET_ENDPOINT](state, endpoint) { - Object.assign(state, { endpoint }); + [types.SET_REPORTS](state, testReports) { + Object.assign(state, { testReports, hasFullReport: true }); }, - [types.SET_REPORTS](state, testReports) { - Object.assign(state, { testReports }); + [types.SET_SELECTED_SUITE_INDEX](state, selectedSuiteIndex) { + Object.assign(state, { selectedSuiteIndex }); }, - [types.SET_SELECTED_SUITE](state, selectedSuite) { - Object.assign(state, { selectedSuite }); + [types.SET_SUMMARY](state, summary) { + Object.assign(state, { testReports: { ...state.testReports, ...summary } }); }, [types.TOGGLE_LOADING](state) { diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index 80a0c2a46a0..bcf5c147916 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -1,6 +1,13 @@ -export default () => ({ - endpoint: '', +export default ({ + fullReportEndpoint = '', + summaryEndpoint = '', + useBuildSummaryReport = false, +}) => ({ + summaryEndpoint, + fullReportEndpoint, testReports: {}, - selectedSuite: {}, + selectedSuiteIndex: null, + hasFullReport: false, isLoading: false, + useBuildSummaryReport, }); |