diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-21 02:50:22 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-21 02:50:22 +0300 |
commit | 9dc93a4519d9d5d7be48ff274127136236a3adb3 (patch) | |
tree | 70467ae3692a0e35e5ea56bcb803eb512a10bedb /app/assets/javascripts/pipelines | |
parent | 4b0f34b6d759d6299322b3a54453e930c6121ff0 (diff) |
Add latest changes from gitlab-org/gitlab@13-11-stable-eev13.11.0-rc43
Diffstat (limited to 'app/assets/javascripts/pipelines')
52 files changed, 1196 insertions, 721 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index e44dedfe2ee..16fb931ec2b 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -50,6 +50,10 @@ export default { }; }, update(data) { + if (!data?.project?.pipeline) { + return this.graphData; + } + const { stages: { nodes: stages }, } = data.project.pipeline; diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index caa269f5095..dd9cdae518f 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -10,3 +10,12 @@ export const ONE_COL_WIDTH = 180; export const REST = 'rest'; export const GRAPHQL = 'graphql'; + +export const STAGE_VIEW = 'stage'; +export const LAYER_VIEW = 'layer'; +export const VIEW_TYPE_KEY = 'pipeline_graph_view_type'; + +export const SINGLE_JOB = 'single_job'; +export const JOB_DROPDOWN = 'job_dropdown'; + +export const IID_FAILURE = 'missing_iid'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 363226a0d85..63048777724 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,10 +1,11 @@ <script> +import { reportToSentry } from '../../utils'; import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinksLayer from '../graph_shared/links_layer.vue'; -import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants'; +import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; -import { reportToSentry, validateConfigPaths } from './utils'; +import { validateConfigPaths } from './utils'; export default { name: 'PipelineGraph', @@ -24,11 +25,20 @@ export default { type: Object, required: true, }, + viewType: { + type: String, + required: true, + }, isLinkedPipeline: { type: Boolean, required: false, default: false, }, + pipelineLayers: { + type: Array, + required: false, + default: () => [], + }, type: { type: String, required: false, @@ -44,6 +54,7 @@ export default { data() { return { hoveredJobName: '', + hoveredSourceJobName: '', highlightedJobs: [], measurements: { width: 0, @@ -62,8 +73,8 @@ export default { downstreamPipelines() { return this.hasDownstreamPipelines ? this.pipeline.downstream : []; }, - graph() { - return this.pipeline.stages; + layout() { + return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList(); }, hasDownstreamPipelines() { return Boolean(this.pipeline?.downstream?.length > 0); @@ -71,12 +82,21 @@ export default { hasUpstreamPipelines() { return Boolean(this.pipeline?.upstream?.length > 0); }, + isStageView() { + return this.viewType === STAGE_VIEW; + }, metricsConfig() { return { path: this.configPaths.metricsPath, collectMetrics: true, }; }, + shouldHideLinks() { + return this.isStageView; + }, + shouldShowStageName() { + return !this.isStageView; + }, // The show downstream check prevents showing redundant linked columns showDownstreamPipelines() { return ( @@ -100,6 +120,26 @@ export default { this.getMeasurements(); }, methods: { + generateColumnsFromLayersList() { + return this.pipelineLayers.map((layers, idx) => { + /* + look up the groups in each layer, + then add each set of layer groups to a stage-like object + */ + + const groups = layers.map((id) => { + const { stageIdx, groupIdx } = this.pipeline.stagesLookup[id]; + return this.pipeline.stages?.[stageIdx]?.groups?.[groupIdx]; + }); + + return { + name: '', + id: `layer-${idx}`, + status: { action: null }, + groups: groups.filter(Boolean), + }; + }); + }, getMeasurements() { this.measurements = { width: this.$refs[this.containerId].scrollWidth, @@ -112,6 +152,9 @@ export default { setJob(jobName) { this.hoveredJobName = jobName; }, + setSourceJob(jobName) { + this.hoveredSourceJobName = jobName; + }, slidePipelineContainer() { this.$refs.mainPipelineContainer.scrollBy({ left: ONE_COL_WIDTH, @@ -146,31 +189,35 @@ export default { :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" :type="$options.pipelineTypeConstants.UPSTREAM" + :view-type="viewType" @error="onError" /> </template> <template #main> <div :id="containerId" :ref="containerId"> <links-layer - :pipeline-data="graph" + :pipeline-data="layout" :pipeline-id="pipeline.id" :container-id="containerId" :container-measurements="measurements" :highlighted-job="hoveredJobName" :metrics-config="metricsConfig" - :never-show-links="true" + :never-show-links="shouldHideLinks" + :view-type="viewType" default-link-color="gl-stroke-transparent" @error="onError" @highlightedJobsChange="updateHighlightedJobs" > <stage-column-component - v-for="stage in graph" - :key="stage.name" - :title="stage.name" - :groups="stage.groups" - :action="stage.status.action" + v-for="column in layout" + :key="column.id || column.name" + :name="column.name" + :groups="column.groups" + :action="column.status.action" :highlighted-jobs="highlightedJobs" + :show-stage-name="shouldShowStageName" :job-hovered="hoveredJobName" + :source-job-hovered="hoveredSourceJobName" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipeline.id" @refreshPipelineGraph="$emit('refreshPipelineGraph')" @@ -188,7 +235,8 @@ export default { :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" :type="$options.pipelineTypeConstants.DOWNSTREAM" - @downstreamHovered="setJob" + :view-type="viewType" + @downstreamHovered="setSourceJob" @pipelineExpandToggle="togglePipelineExpanded" @scrollContainer="slidePipelineContainer" @error="onError" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue index abbf8df6eed..39d0fa8a8ca 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue @@ -2,10 +2,10 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { escape, capitalize } from 'lodash'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; +import { reportToSentry } from '../../utils'; import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue'; import StageColumnComponentLegacy from './stage_column_component_legacy.vue'; -import { reportToSentry } from './utils'; export default { name: 'PipelineGraphLegacy', diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 962f2ca2a4c..0bc6d883245 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -2,11 +2,16 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { __ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; +import { reportToSentry } from '../../utils'; +import { listByLayers } from '../parsing_utils'; +import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; import PipelineGraph from './graph_component.vue'; +import GraphViewSelector from './graph_view_selector.vue'; import { getQueryHeaders, - reportToSentry, serializeLoadErrors, toggleQueryPollingByVisibility, unwrapPipelineData, @@ -17,8 +22,11 @@ export default { components: { GlAlert, GlLoadingIcon, + GraphViewSelector, + LocalStorageSync, PipelineGraph, }, + mixins: [glFeatureFlagMixin()], inject: { graphqlResourceEtag: { default: '', @@ -35,13 +43,18 @@ export default { }, data() { return { - pipeline: null, alertType: null, + currentViewType: STAGE_VIEW, + pipeline: null, + pipelineLayers: null, showAlert: false, }; }, errorTexts: { [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'), + [IID_FAILURE]: __( + 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', + ), [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, @@ -58,6 +71,9 @@ export default { iid: this.pipelineIid, }; }, + skip() { + return !(this.pipelineProjectPath && this.pipelineIid); + }, update(data) { /* This check prevents the pipeline from being overwritten @@ -98,6 +114,11 @@ export default { text: this.$options.errorTexts[DRAW_FAILURE], variant: 'danger', }; + case IID_FAILURE: + return { + text: this.$options.errorTexts[IID_FAILURE], + variant: 'info', + }; case LOAD_FAILURE: return { text: this.$options.errorTexts[LOAD_FAILURE], @@ -123,14 +144,28 @@ export default { */ return this.$apollo.queries.pipeline.loading && !this.pipeline; }, + showGraphViewSelector() { + return Boolean(this.glFeatures.pipelineGraphLayersView && this.pipeline?.usesNeeds); + }, }, mounted() { + if (!this.pipelineIid) { + this.reportFailure({ type: IID_FAILURE, skipSentry: true }); + } + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, methods: { + getPipelineLayers() { + if (this.currentViewType === LAYER_VIEW && !this.pipelineLayers) { + this.pipelineLayers = listByLayers(this.pipeline); + } + + return this.pipelineLayers; + }, hideAlert() { this.showAlert = false; this.alertType = null; @@ -147,7 +182,11 @@ export default { } }, /* eslint-enable @gitlab/require-i18n-strings */ + updateViewType(type) { + this.currentViewType = type; + }, }, + viewTypeKey: VIEW_TYPE_KEY, }; </script> <template> @@ -155,11 +194,24 @@ export default { <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert"> {{ alert.text }} </gl-alert> + <local-storage-sync + :storage-key="$options.viewTypeKey" + :value="currentViewType" + @input="updateViewType" + > + <graph-view-selector + v-if="showGraphViewSelector" + :type="currentViewType" + @updateViewType="updateViewType" + /> + </local-storage-sync> <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> <pipeline-graph v-if="pipeline" :config-paths="configPaths" :pipeline="pipeline" + :pipeline-layers="getPipelineLayers()" + :view-type="currentViewType" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue new file mode 100644 index 00000000000..f33e6290e37 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -0,0 +1,85 @@ +<script> +import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { STAGE_VIEW, LAYER_VIEW } from './constants'; + +export default { + name: 'GraphViewSelector', + components: { + GlDropdown, + GlDropdownItem, + GlIcon, + GlSprintf, + }, + props: { + type: { + type: String, + required: true, + }, + }, + data() { + return { + currentViewType: STAGE_VIEW, + }; + }, + i18n: { + labelText: __('Order jobs by'), + }, + views: { + [STAGE_VIEW]: { + type: STAGE_VIEW, + text: { + primary: __('Stage'), + secondary: __('View the jobs grouped into stages'), + }, + }, + [LAYER_VIEW]: { + type: LAYER_VIEW, + text: { + primary: __('%{codeStart}needs:%{codeEnd} relationships'), + secondary: __('View what jobs are needed for a job to run'), + }, + }, + }, + computed: { + currentDropdownText() { + return this.$options.views[this.type].text.primary; + }, + }, + methods: { + itemClick(type) { + this.$emit('updateViewType', type); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-my-4"> + <span>{{ $options.i18n.labelText }}</span> + <gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4"> + <template #button-content> + <gl-sprintf :message="currentDropdownText"> + <template #code="{ content }"> + <code> {{ content }} </code> + </template> + </gl-sprintf> + <gl-icon class="gl-px-2" name="angle-down" :size="16" /> + </template> + <gl-dropdown-item + v-for="view in $options.views" + :key="view.type" + :secondary-text="view.text.secondary" + @click="itemClick(view.type)" + > + <b> + <gl-sprintf :message="view.text.primary"> + <template #code="{ content }"> + <code> {{ content }} </code> + </template> + </gl-sprintf> + </b> + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index f6aee8c5fcf..6451605a222 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -1,8 +1,7 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { reportToSentry } from '../../utils'; +import { JOB_DROPDOWN, SINGLE_JOB } from './constants'; import JobItem from './job_item.vue'; -import { reportToSentry } from './utils'; /** * Renders the dropdown for the pipeline graph. @@ -11,12 +10,8 @@ import { reportToSentry } from './utils'; * */ export default { - directives: { - GlTooltip: GlTooltipDirective, - }, components: { JobItem, - CiIcon, }, props: { group: { @@ -28,6 +23,15 @@ export default { required: false, default: -1, }, + stageName: { + type: String, + required: false, + default: '', + }, + }, + jobItemTypes: { + jobDropdown: JOB_DROPDOWN, + singleJob: SINGLE_JOB, }, computed: { computedJobId() { @@ -51,22 +55,20 @@ export default { <template> <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <button - v-gl-tooltip.hover="{ boundary: 'viewport' }" - :title="tooltipText" type="button" data-toggle="dropdown" data-display="static" - class="dropdown-menu-toggle build-content gl-build-content" + class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!" > <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> - <span class="gl-display-flex gl-align-items-center gl-min-w-0"> - <ci-icon :status="group.status" :size="24" class="gl-line-height-0" /> - <span class="gl-text-truncate mw-70p gl-pl-3"> - {{ group.name }} - </span> - </span> + <job-item + :type="$options.jobItemTypes.jobDropdown" + :group-tooltip="tooltipText" + :job="group" + :stage-name="stageName" + /> - <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span> + <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div> </div> </button> @@ -77,6 +79,7 @@ export default { <job-item :dropdown-length="group.size" :job="job" + :type="$options.jobItemTypes.singleJob" css-class-job-name="mini-pipeline-graph-dropdown-item" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 46ef0457d40..6584d89d87c 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -3,11 +3,12 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { reportToSentry } from '../../utils'; +import ActionComponent from '../jobs_shared/action_component.vue'; +import JobNameComponent from '../jobs_shared/job_name_component.vue'; import { accessValue } from './accessors'; -import ActionComponent from './action_component.vue'; -import { REST } from './constants'; -import JobNameComponent from './job_name_component.vue'; -import { reportToSentry } from './utils'; +import { REST, SINGLE_JOB } from './constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -38,6 +39,7 @@ export default { hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, + CiIcon, JobNameComponent, GlLink, }, @@ -65,6 +67,11 @@ export default { required: false, default: Infinity, }, + groupTooltip: { + type: String, + required: false, + default: '', + }, jobHovered: { type: String, required: false, @@ -80,24 +87,55 @@ export default { required: false, default: -1, }, + sourceJobHovered: { + type: String, + required: false, + default: '', + }, + stageName: { + type: String, + required: false, + default: '', + }, + type: { + type: String, + required: false, + default: SINGLE_JOB, + }, }, computed: { boundary() { return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; }, + computedJobId() { + return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; + }, detailsPath() { return accessValue(this.dataMethod, 'detailsPath', this.status); }, hasDetails() { return accessValue(this.dataMethod, 'hasDetails', this.status); }, - computedJobId() { - return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; + isSingleItem() { + return this.type === SINGLE_JOB; + }, + nameComponent() { + return this.hasDetails ? 'gl-link' : 'div'; + }, + showStageName() { + return Boolean(this.stageName); }, status() { return this.job && this.job.status ? this.job.status : {}; }, + testId() { + return this.hasDetails ? 'job-with-link' : 'job-without-link'; + }, tooltipText() { + if (this.groupTooltip) { + return this.groupTooltip; + } + const textBuilder = []; const { name: jobName } = this.job; @@ -129,7 +167,7 @@ export default { return this.job.status && this.job.status.action && this.job.status.action.path; }, relatedDownstreamHovered() { - return this.job.name === this.jobHovered; + return this.job.name === this.sourceJobHovered; }, relatedDownstreamExpanded() { return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; @@ -147,6 +185,17 @@ export default { hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); }, + jobItemClick(evt) { + if (this.isSingleItem) { + /* + This is so the jobDropdown still toggles. Issue to refactor: + https://gitlab.com/gitlab-org/gitlab/-/issues/267117 + */ + evt.stopPropagation(); + } + + this.hideTooltips(); + }, pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); }, @@ -156,40 +205,45 @@ export default { <template> <div :id="computedJobId" - class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" + class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full" data-qa-selector="job_item_container" > - <gl-link - v-if="hasDetails" - v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" - :href="detailsPath" - :title="tooltipText" - :class="jobClasses" - class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" - data-testid="job-with-link" - @click.stop="hideTooltips" - @mouseout="hideTooltips" - > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> - </gl-link> - - <div - v-else - v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" + <component + :is="nameComponent" + v-gl-tooltip="{ + boundary: 'viewport', + placement: 'bottom', + customClass: 'gl-pointer-events-none', + }" :title="tooltipText" :class="jobClasses" - class="js-job-component-tooltip non-details-job-component menu-item" - data-testid="job-without-link" + :href="detailsPath" + class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full" + :data-testid="testId" + @click="jobItemClick" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> - </div> + <div class="ci-job-name-component gl-display-flex gl-align-items-center"> + <ci-icon :size="24" :status="job.status" class="gl-line-height-0" /> + <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full"> + <div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div> + <div + v-if="showStageName" + data-testid="stage-name-in-job" + class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal" + > + {{ stageName }} + </div> + </div> + </div> + </component> <action-component v-if="hasAction" :tooltip-text="status.action.title" :link="status.action.path" :action-icon="status.action.icon" + class="gl-mr-1" data-qa-selector="action_button" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index add7b3445f7..3f746731e34 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -3,9 +3,9 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@g import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; +import { reportToSentry } from '../../utils'; import { accessValue } from './accessors'; import { DOWNSTREAM, REST, UPSTREAM } from './constants'; -import { reportToSentry } from './utils'; export default { directives: { @@ -183,6 +183,7 @@ export default { class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!" :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`" :icon="expandedIcon" + :aria-label="__('Expand pipeline')" data-testid="expand-pipeline-button" data-qa-selector="expand_pipeline_button" @click="onClickLinkedPipeline" 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 b55a77a3c4f..7f772e35e55 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,11 +1,12 @@ <script> import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { LOAD_FAILURE } from '../../constants'; -import { ONE_COL_WIDTH, UPSTREAM } from './constants'; +import { reportToSentry } from '../../utils'; +import { listByLayers } from '../parsing_utils'; +import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; import { getQueryHeaders, - reportToSentry, serializeLoadErrors, toggleQueryPollingByVisibility, unwrapPipelineData, @@ -35,11 +36,16 @@ export default { type: String, required: true, }, + viewType: { + type: String, + required: true, + }, }, data() { return { currentPipeline: null, loadingPipelineId: null, + pipelineLayers: {}, pipelineExpanded: false, }; }, @@ -123,6 +129,13 @@ export default { toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline); }, + getPipelineLayers(id) { + if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) { + this.pipelineLayers[id] = listByLayers(this.currentPipeline); + } + + return this.pipelineLayers[id]; + }, isExpanded(id) { return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id); }, @@ -203,7 +216,9 @@ export default { class="d-inline-block gl-mt-n2" :config-paths="configPaths" :pipeline="currentPipeline" + :pipeline-layers="getPipelineLayers(pipeline.id)" :is-linked-pipeline="true" + :view-type="viewType" /> </div> </li> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue index 0d1ff94c275..39baeb6e1c3 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue @@ -1,7 +1,7 @@ <script> +import { reportToSentry } from '../../utils'; import { UPSTREAM } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; -import { reportToSentry } from './utils'; export default { components: { 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 0a762563114..fa2f381c8a4 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,12 +1,13 @@ <script> import { capitalize, escape, isEmpty } from 'lodash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { reportToSentry } from '../../utils'; import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue'; +import ActionComponent from '../jobs_shared/action_component.vue'; import { accessValue } from './accessors'; -import ActionComponent from './action_component.vue'; import { GRAPHQL } from './constants'; import JobGroupDropdown from './job_group_dropdown.vue'; import JobItem from './job_item.vue'; -import { reportToSentry } from './utils'; export default { components: { @@ -15,17 +16,18 @@ export default { JobItem, MainGraphWrapper, }, + mixins: [glFeatureFlagMixin()], props: { groups: { type: Array, required: true, }, - pipelineId: { - type: Number, + name: { + type: String, required: true, }, - title: { - type: String, + pipelineId: { + type: Number, required: true, }, action: { @@ -48,6 +50,16 @@ export default { required: false, default: () => ({}), }, + showStageName: { + type: Boolean, + required: false, + default: false, + }, + sourceJobHovered: { + type: String, + required: false, + default: '', + }, }, titleClasses: [ 'gl-font-weight-bold', @@ -57,8 +69,23 @@ export default { 'gl-pl-3', ], computed: { + /* + currentGroups and filteredGroups are part of + a test to hunt down a bug + (see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57142). + + They should be removed when the bug is rectified. + */ + currentGroups() { + return this.glFeatures.pipelineFilterJobs ? this.filteredGroups : this.groups; + }, + filteredGroups() { + return this.groups.map((group) => { + return { ...group, jobs: group.jobs.filter(Boolean) }; + }); + }, formattedTitle() { - return capitalize(escape(this.title)); + return capitalize(escape(this.name)); }, hasAction() { return !isEmpty(this.action); @@ -80,6 +107,18 @@ export default { isFadedOut(jobName) { return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName); }, + isParallel(group) { + return group.size > 1 && group.jobs.length > 1; + }, + singleJobExists(group) { + const firstJobDefined = Boolean(group.jobs?.[0]); + + if (!firstJobDefined) { + reportToSentry('stage_column_component', 'undefined_job_hunt'); + } + + return group.size === 1 && firstJobDefined; + }, }, }; </script> @@ -104,7 +143,7 @@ export default { </template> <template #jobs> <div - v-for="group in groups" + v-for="group in currentGroups" :id="groupId(group)" :key="getGroupId(group)" data-testid="stage-column-group" @@ -113,17 +152,23 @@ export default { @mouseleave="$emit('jobHover', '')" > <job-item - v-if="group.size === 1" + v-if="singleJobExists(group)" :job="group.jobs[0]" :job-hovered="jobHovered" + :source-job-hovered="sourceJobHovered" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipelineId" + :stage-name="showStageName ? group.stageName : ''" css-class-job-name="gl-build-content" :class="{ 'gl-opacity-3': isFadedOut(group.name) }" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" /> - <div v-else :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> - <job-group-dropdown :group="group" :pipeline-id="pipelineId" /> + <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> + <job-group-dropdown + :group="group" + :stage-name="showStageName ? group.stageName : ''" + :pipeline-id="pipelineId" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue index 2cee2fbbd8f..cbaf07c05cf 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue @@ -1,10 +1,10 @@ <script> import { isEmpty, escape } from 'lodash'; import stageColumnMixin from '../../mixins/stage_column_mixin'; -import ActionComponent from './action_component.vue'; +import { reportToSentry } from '../../utils'; +import ActionComponent from '../jobs_shared/action_component.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; import JobItem from './job_item.vue'; -import { reportToSentry } from './utils'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index b9a8e2638bc..373aa6bf9a1 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,7 +1,6 @@ -import * as Sentry from '@sentry/browser'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { unwrapStagesWithNeeds } from '../unwrapping_utils'; +import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils'; const addMulti = (mainPipelineProjectPath, linkedPipeline) => { return { @@ -24,13 +23,6 @@ const getQueryHeaders = (etagResource) => { }; }; -const reportToSentry = (component, failureType) => { - Sentry.withScope((scope) => { - scope.setTag('component', component); - Sentry.captureException(failureType); - }); -}; - const serializeGqlErr = (gqlError) => { const { locations = [], message = '', path = [] } = gqlError; @@ -94,12 +86,13 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => { stages: { nodes: stages }, } = pipeline; - const nodes = unwrapStagesWithNeeds(stages); + const { stages: updatedStages, lookup } = unwrapStagesWithNeedsAndLookup(stages); return { ...pipeline, id: getIdFromGraphQLId(pipeline.id), - stages: nodes, + stages: updatedStages, + stagesLookup: lookup, upstream: upstream ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId) : [], @@ -113,7 +106,6 @@ const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0; export { getQueryHeaders, - reportToSentry, serializeGqlErr, serializeLoadErrors, toggleQueryPollingByVisibility, diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js index 04ac15ae24c..49cd04d11e9 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/api.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js @@ -1,5 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import { reportToSentry } from '../graph/utils'; +import { reportToSentry } from '../../utils'; export const reportPerformance = (path, stats) => { axios.post(path, stats).catch((err) => { diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index fad57084992..0ed5b8a5f09 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -10,8 +10,8 @@ import { } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { DRAW_FAILURE } from '../../constants'; -import { createJobsHash, generateJobNeedsDict } from '../../utils'; -import { reportToSentry } from '../graph/utils'; +import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils'; +import { STAGE_VIEW } from '../graph/constants'; import { parseData } from '../parsing_utils'; import { reportPerformance } from './api'; import { generateLinksData } from './drawing_utils'; @@ -55,11 +55,17 @@ export default { required: false, default: '', }, + viewType: { + type: String, + required: false, + default: STAGE_VIEW, + }, }, data() { return { links: [], needsObject: null, + parsedData: {}, }; }, computed: { @@ -109,6 +115,15 @@ export default { highlightedJobs(jobs) { this.$emit('highlightedJobsChange', jobs); }, + viewType() { + /* + We need to wait a tick so that the layout reflows + before the links refresh. + */ + this.$nextTick(() => { + this.refreshLinks(); + }); + }, }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); @@ -167,14 +182,17 @@ export default { this.beginPerfMeasure(); try { const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - const parsedData = parseData(arrayOfJobs); - this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`); + this.parsedData = parseData(arrayOfJobs); + this.refreshLinks(); } catch (err) { this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false }); reportToSentry(this.$options.name, err); } this.finishPerfMeasureAndSend(); }, + refreshLinks() { + this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); + }, getLinkClasses(link) { return [ this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor, diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 42eab13b0bd..8dbab245f44 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -11,7 +11,7 @@ import { PIPELINES_DETAIL_LINKS_JOB_RATIO, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import { reportToSentry } from '../graph/utils'; +import { reportToSentry } from '../../utils'; import { parseData } from '../parsing_utils'; import { reportPerformance } from './api'; import LinksInner from './links_inner.vue'; diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 4ce43b92c93..d8e7b83a8c1 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -8,6 +8,7 @@ import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutatio import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; +import { getQueryHeaders } from './graph/utils'; const DELETE_MODAL_ID = 'pipeline-delete-modal'; const POLL_INTERVAL = 10000; @@ -34,7 +35,9 @@ export default { [DEFAULT]: __('An unknown error occurred.'), }, inject: { - // Receive `fullProject` and `pipelinesPath` + graphqlResourceEtag: { + default: '', + }, paths: { default: {}, }, @@ -47,6 +50,9 @@ export default { }, apollo: { pipeline: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, query: getPipelineQuery, variables() { return { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index 1df693704d4..3972c126673 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -5,7 +5,7 @@ import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { dasherize } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; -import { reportToSentry } from './utils'; +import { reportToSentry } from '../../utils'; /** * Renders either a cancel, retry or play icon button and handles the post request diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue index fffd8e1818a..fffd8e1818a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue new file mode 100644 index 00000000000..6982586ab12 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue @@ -0,0 +1,90 @@ +<script> +import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; + +const featureName = 'pipeline_needs_banner'; +const enumFeatureName = featureName.toUpperCase(); + +export default { + i18n: { + title: __('View job dependencies in the pipeline graph!'), + description: __( + 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}', + ), + buttonText: __('Provide feedback'), + }, + components: { + GlBanner, + GlLink, + GlSprintf, + }, + apollo: { + callouts: { + query: getUserCallouts, + update(data) { + return data?.currentUser?.callouts?.nodes.map((c) => c.featureName); + }, + error() { + this.hasError = true; + }, + }, + }, + inject: ['dagDocPath'], + data() { + return { + callouts: [], + dismissedAlert: false, + hasError: false, + }; + }, + computed: { + showBanner() { + return ( + !this.$apollo.queries.callouts?.loading && + !this.hasError && + !this.dismissedAlert && + !this.callouts.includes(enumFeatureName) + ); + }, + }, + methods: { + handleClose() { + this.dismissedAlert = true; + try { + this.$apollo.mutate({ + mutation: DismissPipelineNotification, + variables: { + featureName, + }, + }); + } catch { + createFlash(__('There was a problem dismissing this notification.')); + } + }, + }, +}; +</script> +<template> + <gl-banner + v-if="showBanner" + :title="$options.i18n.title" + :button-text="$options.i18n.buttonText" + button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688" + variant="introduction" + @close="handleClose" + > + <p> + <gl-sprintf :message="$options.i18n.description"> + <template #link="{ content }"> + <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link> + </template> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-banner> +</template> diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index 9c97fa832d0..f5ab869633b 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -1,4 +1,5 @@ import { uniqWith, isEqual } from 'lodash'; +import { createSankey } from './dag/drawing_utils'; /* The following functions are the main engine in transforming the data as @@ -144,3 +145,28 @@ export const getMaxNodes = (nodes) => { export const removeOrphanNodes = (sankeyfiedNodes) => { return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length); }; + +/* + This utility accepts unwrapped pipeline data in the format returned from + our standard pipeline GraphQL query and returns a list of names by layer + for the layer view. It can be combined with the stageLookup on the pipeline + to generate columns by layer. +*/ + +export const listByLayers = ({ stages }) => { + const arrayOfJobs = stages.flatMap(({ groups }) => groups); + const parsedData = parseData(arrayOfJobs); + const dataWithLayers = createSankey()(parsedData); + + return dataWithLayers.nodes.reduce((acc, { layer, name }) => { + /* sort groups by layer */ + + if (!acc[layer]) { + acc[layer] = []; + } + + acc[layer].push(name); + + return acc; + }, []); +}; 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 51a95612d3f..01baf0a42d5 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -10,6 +10,10 @@ export default { type: String, required: true, }, + pipelineId: { + type: Number, + required: true, + }, isHighlighted: { type: Boolean, required: false, @@ -32,6 +36,9 @@ export default { }, }, computed: { + id() { + return `${this.jobName}-${this.pipelineId}`; + }, jobPillClasses() { return [ { 'gl-opacity-3': this.isFadedOut }, @@ -52,7 +59,7 @@ export default { <template> <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <div - :id="jobName" + :id="id" class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" :class="jobPillClasses" @mouseover="onMouseEnter" diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 707d6966e77..3ba0d7d0120 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -1,11 +1,8 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; -import { createJobsHash, generateJobNeedsDict } from '../../utils'; -import { generateLinksData } from '../graph_shared/drawing_utils'; -import { parseData } from '../parsing_utils'; +import { DRAW_FAILURE, DEFAULT } from '../../constants'; +import LinksLayer from '../graph_shared/links_layer.vue'; import JobPill from './job_pill.vue'; import StagePill from './stage_pill.vue'; @@ -13,18 +10,16 @@ export default { components: { GlAlert, JobPill, + LinksLayer, StagePill, }, CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF', - CONTAINER_ID: 'pipeline-graph-container', + BASE_CONTAINER_ID: 'pipeline-graph-container', + PIPELINE_ID: 0, STROKE_WIDTH: 2, errorTexts: { [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DEFAULT]: __('An unknown error occurred.'), - [EMPTY_PIPELINE_DATA]: __( - 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', - ), - [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'), }, props: { pipelineData: { @@ -36,33 +31,16 @@ export default { return { failureType: null, highlightedJob: null, - links: [], - needsObject: null, - height: 0, - width: 0, + highlightedJobs: [], + measurements: { + height: 0, + width: 0, + }, }; }, computed: { - hideGraph() { - // We won't even try to render the graph with these condition - // because it would cause additional errors down the line for the user - // which is confusing. - return this.isPipelineDataEmpty || this.isInvalidCiConfig; - }, - pipelineStages() { - return this.pipelineData?.stages || []; - }, - isPipelineDataEmpty() { - return !this.isInvalidCiConfig && this.pipelineStages.length === 0; - }, - isInvalidCiConfig() { - return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID; - }, - hasError() { - return this.failureType; - }, - hasHighlightedJob() { - return Boolean(this.highlightedJob); + containerId() { + return `${this.$options.BASE_CONTAINER_ID}-${this.$options.PIPELINE_ID}`; }, failure() { switch (this.failureType) { @@ -72,18 +50,6 @@ export default { variant: 'danger', dismissible: true, }; - case EMPTY_PIPELINE_DATA: - return { - text: this.$options.errorTexts[EMPTY_PIPELINE_DATA], - variant: 'tip', - dismissible: false, - }; - case INVALID_CI_CONFIG: - return { - text: this.$options.errorTexts[INVALID_CI_CONFIG], - variant: 'danger', - dismissible: false, - }; default: return { text: this.$options.errorTexts[DEFAULT], @@ -92,56 +58,32 @@ export default { }; } }, - viewBox() { - return [0, 0, this.width, this.height]; + hasError() { + return this.failureType; }, - 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.highlightedJob, ...this.needsObject[this.highlightedJob]]; + hasHighlightedJob() { + return Boolean(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 []; + pipelineStages() { + return this.pipelineData?.stages || []; }, }, watch: { pipelineData: { immediate: true, handler() { - if (this.isPipelineDataEmpty) { - this.reportFailure(EMPTY_PIPELINE_DATA); - } else if (this.isInvalidCiConfig) { - this.reportFailure(INVALID_CI_CONFIG); - } else { - this.$nextTick(() => { - this.computeGraphDimensions(); - this.prepareLinkData(); - }); - } + this.$nextTick(() => { + this.computeGraphDimensions(); + }); }, }, }, methods: { - prepareLinkData() { - try { - const arrayOfJobs = this.pipelineStages.flatMap(({ groups }) => groups); - const parsedData = parseData(arrayOfJobs); - this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID); - } catch { - this.reportFailure(DRAW_FAILURE); - } + computeGraphDimensions() { + this.measurements = { + width: this.$refs[this.$options.CONTAINER_REF].scrollWidth, + height: this.$refs[this.$options.CONTAINER_REF].scrollHeight, + }; }, getStageBackgroundClasses(index) { const { length } = this.pipelineStages; @@ -161,22 +103,14 @@ export default { 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) { - const jobs = createJobsHash(this.pipelineStages); - this.needsObject = generateJobNeedsDict(jobs) ?? {}; - } - - this.highlightedJob = uniqueJobId; + isJobHighlighted(jobName) { + return this.highlightedJobs.includes(jobName); }, - removeHighlightNeeds() { - this.highlightedJob = null; + onError(error) { + this.reportFailure(error.type); }, - computeGraphDimensions() { - this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`; - this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`; + removeHoveredJob() { + this.highlightedJob = null; }, reportFailure(errorType) { this.failureType = errorType; @@ -184,17 +118,11 @@ export default { resetFailure() { this.failureType = null; }, - isJobHighlighted(jobName) { - return this.highlightedJobs.includes(jobName); + setHoveredJob(jobName) { + this.highlightedJob = 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) }, - ]; + updateHighlightedJobs(jobs) { + this.highlightedJobs = jobs; }, }, }; @@ -209,50 +137,44 @@ export default { > {{ failure.text }} </gl-alert> - <div - v-if="!hideGraph" - :id="$options.CONTAINER_ID" - :ref="$options.CONTAINER_REF" - class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7" - data-testid="graph-container" - > - <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> - <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" - /> - </svg> - <div - v-for="(stage, index) in pipelineStages" - :key="`${stage.name}-${index}`" - class="gl-flex-direction-column" + <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container"> + <links-layer + :pipeline-data="pipelineStages" + :pipeline-id="$options.PIPELINE_ID" + :container-id="containerId" + :container-measurements="measurements" + :highlighted-job="highlightedJob" + @highlightedJobsChange="updateHighlightedJobs" + @error="onError" > <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="getStageBackgroundClasses(index)" - data-testid="stage-background" - > - <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" + v-for="(stage, index) in pipelineStages" + :key="`${stage.name}-${index}`" + class="gl-flex-direction-column" > - <job-pill - v-for="group in stage.groups" - :key="group.name" - :job-name="group.name" - :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)" - :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)" - @on-mouse-enter="highlightNeeds" - @on-mouse-leave="removeHighlightNeeds" - /> + <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="getStageBackgroundClasses(index)" + data-testid="stage-background" + > + <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" + :pipeline-id="$options.PIPELINE_ID" + :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)" + :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)" + @on-mouse-enter="setHoveredJob" + @on-mouse-leave="removeHoveredJob" + /> + </div> </div> - </div> + </links-layer> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue deleted file mode 100644 index 6c3a4a27606..00000000000 --- a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue +++ /dev/null @@ -1,30 +0,0 @@ -<script> -export default { - name: 'PipelinesSvgState', - props: { - svgPath: { - type: String, - required: true, - }, - - message: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div class="row empty-state"> - <div class="col-12"> - <div class="svg-content"><img :src="svgPath" /></div> - </div> - - <div class="col-12 text-center"> - <div class="text-content"> - <h4>{{ message }}</h4> - </div> - </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 f8107d288d9..c3bcfcb18fb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,7 +1,9 @@ <script> import { GlEmptyState } from '@gitlab/ui'; +import Experiment from '~/experimentation/components/experiment.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import PipelinesCiTemplates from './pipelines_ci_templates.vue'; export default { i18n: { @@ -15,6 +17,8 @@ export default { name: 'PipelinesEmptyState', components: { GlEmptyState, + Experiment, + PipelinesCiTemplates, }, props: { emptyStateSvgPath: { @@ -35,19 +39,26 @@ export default { </script> <template> <div> - <gl-empty-state - v-if="canSetCi" - :title="$options.i18n.title" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.description" - :primary-button-text="$options.i18n.btnText" - :primary-button-link="ciHelpPagePath" - /> - <gl-empty-state - v-else - title="" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.noCiDescription" - /> + <experiment name="pipeline_empty_state_templates"> + <template #control> + <gl-empty-state + v-if="canSetCi" + :title="$options.i18n.title" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.description" + :primary-button-text="$options.i18n.btnText" + :primary-button-link="ciHelpPagePath" + /> + <gl-empty-state + v-else + title="" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.noCiDescription" + /> + </template> + <template #candidate> + <pipelines-ci-templates /> + </template> + </experiment> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue new file mode 100644 index 00000000000..670fa398536 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue @@ -0,0 +1,190 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { sprintf } from '~/locale'; +import { reportToSentry } from '../../utils'; +import ActionComponent from '../jobs_shared/action_component.vue'; +import JobNameComponent from '../jobs_shared/job_name_component.vue'; + +/** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "tooltip": "passed", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + +export default { + hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', + components: { + ActionComponent, + JobNameComponent, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + cssClassJobName: { + type: String, + required: false, + default: '', + }, + dropdownLength: { + type: Number, + required: false, + default: Infinity, + }, + jobHovered: { + type: String, + required: false, + default: '', + }, + pipelineExpanded: { + type: Object, + required: false, + default: () => ({}), + }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, + }, + computed: { + boundary() { + return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; + }, + detailsPath() { + return this.status.details_path; + }, + hasDetails() { + return this.status.has_details; + }, + status() { + return this.job && this.job.status ? this.job.status : {}; + }, + tooltipText() { + const textBuilder = []; + const { name: jobName } = this.job; + + if (jobName) { + textBuilder.push(jobName); + } + + const { tooltip: statusTooltip } = this.status; + if (jobName && statusTooltip) { + textBuilder.push('-'); + } + + if (statusTooltip) { + if (this.isDelayedJob) { + textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); + } else { + textBuilder.push(statusTooltip); + } + } + + return textBuilder.join(' '); + }, + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; + }, + relatedDownstreamHovered() { + return this.job.name === this.jobHovered; + }, + relatedDownstreamExpanded() { + return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; + }, + jobClasses() { + return this.relatedDownstreamHovered || this.relatedDownstreamExpanded + ? `${this.$options.hoverClass} ${this.cssClassJobName}` + : this.cssClassJobName; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`); + }, + methods: { + hideTooltips() { + this.$root.$emit(BV_HIDE_TOOLTIP); + }, + pipelineActionRequestComplete() { + this.$emit('pipelineActionRequestComplete'); + }, + }, +}; +</script> +<template> + <div + class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-qa-selector="job_item_container" + > + <gl-link + v-if="hasDetails" + v-gl-tooltip="{ + boundary: 'viewport', + placement: 'bottom', + customClass: 'gl-pointer-events-none', + }" + :href="detailsPath" + :title="tooltipText" + :class="jobClasses" + class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" + data-testid="job-with-link" + @click.stop="hideTooltips" + @mouseout="hideTooltips" + > + <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + </gl-link> + + <div + v-else + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" + :title="tooltipText" + :class="jobClasses" + class="js-job-component-tooltip non-details-job-component menu-item" + data-testid="job-without-link" + @mouseout="hideTooltips" + > + <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + </div> + + <action-component + v-if="hasAction" + :tooltip-text="status.action.title" + :link="status.action.path" + :action-icon="status.action.icon" + data-qa-selector="action_button" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </div> +</template> 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 cf0849751df..235126fea0c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue @@ -41,29 +41,29 @@ export default { <template> <div class="nav-controls"> <gl-button - v-if="newPipelinePath" - :href="newPipelinePath" - 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> - - <gl-button v-if="resetCachePath" :loading="isResetCacheButtonLoading" class="js-clear-cache" data-testid="clear-cache-button" @click="onClickResetCache" > - {{ s__('Pipelines|Clear Runner Caches') }} + {{ s__('Pipelines|Clear runner caches') }} </gl-button> <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button"> - {{ s__('Pipelines|CI Lint') }} + {{ s__('Pipelines|CI lint') }} + </gl-button> + + <gl-button + v-if="newPipelinePath" + :href="newPipelinePath" + variant="confirm" + category="primary" + class="js-run-pipeline" + data-testid="run-pipeline-button" + data-qa-selector="run_pipeline_button" + > + {{ s__('Pipeline|Run pipeline') }} </gl-button> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue index 05372010d0f..2b33467e948 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue @@ -36,7 +36,7 @@ export default { }; </script> <template> - <div data-testid="widget-mini-pipeline-graph"> + <div data-testid="pipeline-mini-graph"> <div v-for="stage in stages" :key="stage.name" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue index bdb7dd06620..bf992b84387 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -17,7 +17,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import eventHub from '../../event_hub'; -import JobItem from '../graph/job_item.vue'; +import JobItem from './job_item.vue'; export default { components: { @@ -103,7 +103,7 @@ export default { <template> <gl-dropdown ref="dropdown" - v-gl-tooltip.hover + v-gl-tooltip.hover.ds0 data-testid="mini-pipeline-graph-dropdown" :title="stage.title" variant="link" 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 c707b395192..0528e4c147c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -17,19 +17,11 @@ export default { user() { return this.pipeline.user; }, - classes() { - const triggererClass = 'pipeline-triggerer'; - - if (this.glFeatures.newPipelinesTable) { - return triggererClass; - } - return `table-section section-10 d-none d-md-block ${triggererClass}`; - }, }, }; </script> <template> - <div :class="classes" data-testid="pipeline-triggerer"> + <div class="pipeline-triggerer" data-testid="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 0de520a2ca7..d39e120dc6c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -49,19 +49,11 @@ export default { autoDevopsHelpPath() { return helpPagePath('topics/autodevops/index.md'); }, - classes() { - const tagsClass = 'pipeline-tags'; - - if (this.glFeatures.newPipelinesTable) { - return tagsClass; - } - return `table-section section-10 d-none d-md-block ${tagsClass}`; - }, }, }; </script> <template> - <div :class="classes" data-testid="pipeline-url-table-cell"> + <div class="pipeline-tags" data-testid="pipeline-url-table-cell"> <gl-link :href="pipeline.path" data-testid="pipeline-url-link" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 19d93e7d083..f14a582d731 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { isEqual } from 'lodash'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getParameterByName } from '~/lib/utils/common_utils'; @@ -10,7 +10,6 @@ import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../ import PipelinesMixin from '../../mixins/pipelines_mixin'; import PipelinesService from '../../services/pipelines_service'; import { validateParams } from '../../utils'; -import SvgBlankState from './blank_state.vue'; import EmptyState from './empty_state.vue'; import NavigationControls from './nav_controls.vue'; import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; @@ -19,13 +18,13 @@ import PipelinesTableComponent from './pipelines_table.vue'; export default { components: { EmptyState, + GlEmptyState, GlIcon, GlLoadingIcon, NavigationTabs, NavigationControls, PipelinesFilteredSearch, PipelinesTableComponent, - SvgBlankState, TablePagination, }, mixins: [PipelinesMixin], @@ -314,6 +313,7 @@ export default { </div> <pipelines-filtered-search + v-if="stateToRender !== $options.stateMap.emptyState" :project-id="projectId" :params="validatedParams" @filterPipelines="filterPipelines" @@ -333,19 +333,19 @@ export default { :can-set-ci="canCreatePipeline" /> - <svg-blank-state + <gl-empty-state v-else-if="stateToRender === $options.stateMap.error" :svg-path="errorStateSvgPath" - :message=" + :title=" s__(`Pipelines|There was an error fetching the pipelines. Try again in a few moments or contact your support team.`) " /> - <svg-blank-state + <gl-empty-state v-else-if="stateToRender === $options.stateMap.emptyTab" :svg-path="noPipelinesSvgPath" - :message="emptyTabMessage" + :title="emptyTabMessage" /> <div v-else-if="stateToRender === $options.stateMap.tableList"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue new file mode 100644 index 00000000000..c2ec8c57fd7 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue @@ -0,0 +1,143 @@ +<script> +import { GlButton, GlCard, GlSprintf } from '@gitlab/ui'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { s__, sprintf } from '~/locale'; +import { HELLO_WORLD_TEMPLATE_KEY } from '../../constants'; + +export default { + components: { + GlButton, + GlCard, + GlSprintf, + }, + HELLO_WORLD_TEMPLATE_KEY, + i18n: { + cta: s__('Pipelines|Use template'), + testTemplates: { + title: s__('Pipelines|Use a sample CI/CD template'), + subtitle: s__( + 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.', + ), + helloWorld: { + title: s__('Pipelines|“Hello world” with GitLab CI/CD'), + description: s__( + 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a simple pipeline that runs a “Hello world” script.', + ), + }, + }, + templates: { + title: s__('Pipelines|Use a CI/CD template'), + subtitle: s__( + "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.", + ), + description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), + }, + }, + inject: ['addCiYmlPath', 'suggestedCiTemplates'], + data() { + const templates = this.suggestedCiTemplates.map(({ name, logo }) => { + return { + name, + logo, + link: mergeUrlParams({ template: name }, this.addCiYmlPath), + description: sprintf(this.$options.i18n.templates.description, { name }), + }; + }); + + return { + templates, + helloWorldTemplateUrl: mergeUrlParams( + { template: HELLO_WORLD_TEMPLATE_KEY }, + this.addCiYmlPath, + ), + }; + }, + methods: { + trackEvent(template) { + const tracking = new ExperimentTracking('pipeline_empty_state_templates', { + label: template, + }); + tracking.event('template_clicked'); + }, + }, +}; +</script> +<template> + <div> + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6"> + <gl-sprintf :message="$options.i18n.testTemplates.subtitle"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <div class="row gl-mb-8"> + <div class="col-lg-3"> + <gl-card> + <div class="gl-flex-direction-row"> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800 gl-mb-2">{{ + $options.i18n.testTemplates.helloWorld.title + }}</strong> + </div> + <p class="gl-font-sm">{{ $options.i18n.testTemplates.helloWorld.description }}</p> + </div> + + <gl-button + category="primary" + variant="confirm" + :href="helloWorldTemplateUrl" + data-testid="test-template-link" + @click="trackEvent($options.HELLO_WORLD_TEMPLATE_KEY)" + > + {{ $options.i18n.cta }} + </gl-button> + </gl-card> + </div> + </div> + + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p> + + <ul class="gl-list-style-none gl-pl-0"> + <li v-for="template in templates" :key="template.name"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3" + > + <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> + <img + width="64" + height="64" + :src="template.logo" + class="gl-mr-6" + data-testid="template-logo" + /> + <div class="gl-flex-direction-row"> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800" data-testid="template-name">{{ + template.name + }}</strong> + </div> + <p class="gl-mb-0 gl-font-sm" data-testid="template-description"> + {{ template.description }} + </p> + </div> + </div> + <gl-button + category="primary" + variant="confirm" + :href="template.link" + data-testid="template-link" + @click="trackEvent(template.name)" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + </li> + </ul> + </div> +</template> 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 aa27aa7e50d..47fc7023222 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,7 +1,6 @@ <script> import { GlTable, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import PipelineMiniGraph from './pipeline_mini_graph.vue'; import PipelineOperations from './pipeline_operations.vue'; @@ -10,7 +9,6 @@ import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelineUrl from './pipeline_url.vue'; import PipelinesCommit from './pipelines_commit.vue'; import PipelinesStatusBadge from './pipelines_status_badge.vue'; -import PipelinesTableRowComponent from './pipelines_table_row.vue'; import PipelinesTimeago from './time_ago.vue'; const DEFAULT_TD_CLASS = 'gl-p-5!'; @@ -83,7 +81,6 @@ export default { PipelineOperations, PipelinesStatusBadge, PipelineStopModal, - PipelinesTableRowComponent, PipelinesTimeago, PipelineTriggerer, PipelineUrl, @@ -91,7 +88,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { pipelines: { type: Array, @@ -149,41 +145,7 @@ export default { </script> <template> <div class="ci-table"> - <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table"> - <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-10 js-pipeline-status" role="rowheader"> - {{ s__('Pipeline|Status') }} - </div> - <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader"> - {{ s__('Pipeline|Pipeline') }} - </div> - <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader"> - {{ s__('Pipeline|Triggerer') }} - </div> - <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader"> - {{ s__('Pipeline|Commit') }} - </div> - <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" - :key="model.id" - :pipeline="model" - :pipeline-schedule-url="pipelineScheduleUrl" - :update-graph-dropdown="updateGraphDropdown" - :view-type="viewType" - :canceling-pipeline="cancelingPipeline" - /> - </div> - <gl-table - v-else :fields="$options.fields" :items="pipelines" tbody-tr-class="commit" 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 deleted file mode 100644 index f684a0b0fcd..00000000000 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ /dev/null @@ -1,269 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; -import CommitComponent from '~/vue_shared/components/commit.vue'; -import eventHub from '../../event_hub'; -import PipelineMiniGraph from './pipeline_mini_graph.vue'; -import PipelineTriggerer from './pipeline_triggerer.vue'; -import PipelineUrl from './pipeline_url.vue'; -import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; -import PipelinesManualActionsComponent from './pipelines_manual_actions.vue'; -import PipelinesTimeago from './time_ago.vue'; - -export default { - i18n: { - cancelTitle: __('Cancel'), - redeployTitle: __('Retry'), - }, - directives: { - GlTooltip: GlTooltipDirective, - GlModalDirective, - }, - components: { - PipelinesManualActionsComponent, - PipelinesArtifactsComponent, - CommitComponent, - PipelineMiniGraph, - PipelineUrl, - PipelineTriggerer, - CiBadge, - PipelinesTimeago, - GlButton, - }, - props: { - pipeline: { - type: Object, - required: true, - }, - pipelineScheduleUrl: { - type: String, - required: false, - default: '', - }, - updateGraphDropdown: { - type: Boolean, - required: false, - default: false, - }, - viewType: { - type: String, - required: true, - }, - cancelingPipeline: { - type: Number, - required: false, - default: null, - }, - }, - data() { - return { - isRetrying: false, - }; - }, - computed: { - actions() { - if (!this.pipeline || !this.pipeline.details) { - return []; - } - const { details } = this.pipeline; - return [...(details.manual_actions || []), ...(details.scheduled_actions || [])]; - }, - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * This field needs a lot of verification, because of different possible cases: - * - * 1. person who is an author of a commit might be a GitLab user - * 2. if person who is an author of a commit is a GitLab user, they can have a GitLab avatar - * 3. If GitLab user does not have avatar they might have a Gravatar - * 4. If committer is not a GitLab User they can have a Gravatar - * 5. We do not have consistent API object in this case - * 6. We should improve API and the code - * - * @returns {Object|Undefined} - */ - commitAuthor() { - let commitAuthorInformation; - - if (!this.pipeline || !this.pipeline.commit) { - return null; - } - - // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline.commit.author) { - // 2. if person who is an author of a commit is a GitLab user - // they can have a GitLab avatar - if (this.pipeline.commit.author.avatar_url) { - commitAuthorInformation = this.pipeline.commit.author; - - // 3. If GitLab user does not have avatar, they might have a Gravatar - } else if (this.pipeline.commit.author_gravatar_url) { - commitAuthorInformation = { - ...this.pipeline.commit.author, - avatar_url: this.pipeline.commit.author_gravatar_url, - }; - } - // 4. If committer is not a GitLab User, they can have a Gravatar - } else { - commitAuthorInformation = { - avatar_url: this.pipeline.commit.author_gravatar_url, - path: `mailto:${this.pipeline.commit.author_email}`, - username: this.pipeline.commit.author_name, - }; - } - - return commitAuthorInformation; - }, - commitTag() { - return this.pipeline?.ref?.tag; - }, - commitRef() { - return this.pipeline?.ref; - }, - commitUrl() { - return this.pipeline?.commit?.commit_path; - }, - commitShortSha() { - return this.pipeline?.commit?.short_id; - }, - commitTitle() { - return this.pipeline?.commit?.title; - }, - pipelineStatus() { - return this.pipeline?.details?.status ?? {}; - }, - hasStages() { - return this.pipeline?.details?.stages?.length > 0; - }, - displayPipelineActions() { - return ( - this.pipeline.flags.retryable || - this.pipeline.flags.cancelable || - this.pipeline.details.manual_actions.length || - this.pipeline.details.artifacts.length - ); - }, - isChildView() { - return this.viewType === 'child'; - }, - isCancelling() { - return this.cancelingPipeline === this.pipeline.id; - }, - }, - watch: { - pipeline() { - this.isRetrying = false; - }, - }, - methods: { - handleCancelClick() { - eventHub.$emit('openConfirmationModal', { - pipeline: this.pipeline, - endpoint: this.pipeline.cancel_path, - }); - }, - handleRetryClick() { - this.isRetrying = true; - eventHub.$emit('retryPipeline', this.pipeline.retry_path); - }, - handlePipelineActionRequestComplete() { - // warn the pipelines table to update - eventHub.$emit('refreshPipelinesTable'); - }, - }, -}; -</script> -<template> - <div class="commit gl-responsive-table-row"> - <div class="table-section section-10 commit-link"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div> - <div class="table-mobile-content"> - <ci-badge - :status="pipelineStatus" - :show-text="!isChildView" - :icon-classes="'gl-vertical-align-middle!'" - data-qa-selector="pipeline_commit_status" - /> - </div> - </div> - - <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" /> - <pipeline-triggerer :pipeline="pipeline" /> - - <div class="table-section section-wrap section-20"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div> - <div class="table-mobile-content"> - <commit-component - :tag="commitTag" - :commit-ref="commitRef" - :commit-url="commitUrl" - :merge-request-ref="pipeline.merge_request" - :short-sha="commitShortSha" - :title="commitTitle" - :author="commitAuthor" - :show-ref-info="!isChildView" - /> - </div> - </div> - - <div class="table-section section-wrap section-15 stage-cell"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div> - <div class="table-mobile-content"> - <pipeline-mini-graph - v-if="hasStages" - :stages="pipeline.details.stages" - :update-dropdown="updateGraphDropdown" - @pipelineActionRequestComplete="handlePipelineActionRequestComplete" - /> - </div> - </div> - - <pipelines-timeago class="gl-text-right" :pipeline="pipeline" /> - - <div - v-if="displayPipelineActions" - class="table-section section-20 table-button-footer pipeline-actions" - > - <div class="btn-group table-action-buttons"> - <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" /> - - <pipelines-artifacts-component - v-if="pipeline.details.artifacts.length" - :artifacts="pipeline.details.artifacts" - /> - - <gl-button - v-if="pipeline.flags.retryable" - v-gl-tooltip.hover - :aria-label="$options.i18n.redeployTitle" - :title="$options.i18n.redeployTitle" - :disabled="isRetrying" - :loading="isRetrying" - class="js-pipelines-retry-button" - data-qa-selector="pipeline_retry_button" - icon="repeat" - variant="default" - category="secondary" - @click="handleRetryClick" - /> - - <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" - icon="close" - variant="danger" - category="primary" - class="js-pipelines-cancel-button" - @click="handleCancelClick" - /> - </div> - </div> - </div> -</template> 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 543bdf94307..e6b03751350 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -22,6 +22,12 @@ export default { finishedTime() { return this.pipeline?.details?.finished_at; }, + skipped() { + return this.pipeline?.details?.status?.label === 'skipped'; + }, + stuck() { + return this.pipeline.flags.stuck; + }, durationFormatted() { const date = new Date(this.duration * 1000); @@ -42,46 +48,50 @@ export default { return `${hh}:${mm}:${ss}`; }, - legacySectionClass() { - return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : ''; - }, - legacyTableMobileClass() { - return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : ''; - }, showInProgress() { - return !this.duration && !this.finishedTime; + return !this.duration && !this.finishedTime && !this.skipped; + }, + showSkipped() { + return !this.duration && !this.finishedTime && this.skipped; }, }, }; </script> <template> - <div :class="legacySectionClass"> - <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader"> - {{ s__('Pipeline|Duration') }} - </div> - <div :class="legacyTableMobileClass"> - <span v-if="showInProgress" data-testid="pipeline-in-progress"> - <gl-icon name="hourglass" class="gl-vertical-align-baseline! gl-mr-2" :size="12" /> - {{ s__('Pipeline|In progress') }} - </span> + <div> + <span v-if="showInProgress" data-testid="pipeline-in-progress"> + <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" /> + <gl-icon + v-else + name="hourglass" + class="gl-vertical-align-baseline! gl-mr-2" + :size="12" + data-testid="hourglass-icon" + /> + {{ s__('Pipeline|In progress') }} + </span> + + <span v-if="showSkipped" data-testid="pipeline-skipped"> + <gl-icon name="status_skipped_borderless" class="gl-mr-2" :size="16" /> + {{ s__('Pipeline|Skipped') }} + </span> - <p v-if="duration" class="duration"> - <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" /> - {{ durationFormatted }} - </p> + <p v-if="duration" class="duration"> + <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" /> + {{ durationFormatted }} + </p> - <p v-if="finishedTime" class="finished-at d-none d-md-block"> - <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" /> + <p v-if="finishedTime" class="finished-at d-none d-md-block"> + <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" /> - <time - v-gl-tooltip - :title="tooltipTitle(finishedTime)" - data-placement="top" - data-container="body" - > - {{ timeFormatted(finishedTime) }} - </time> - </p> - </div> + <time + v-gl-tooltip + :title="tooltipTitle(finishedTime)" + data-placement="top" + data-container="body" + > + {{ timeFormatted(finishedTime) }} + </time> + </p> </div> </template> 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 d33d4e7dfd0..79b1b6af38b 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -72,6 +72,7 @@ export default { size="small" class="gl-mr-3 js-back-button" icon="angle-left" + :aria-label="__('Go back')" @click="onBackClick" /> diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js index 15073079c0a..2d24beb8323 100644 --- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js +++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js @@ -1,15 +1,33 @@ +import { reportToSentry } from '../utils'; + const unwrapGroups = (stages) => { - return stages.map((stage) => { + return stages.map((stage, idx) => { const { groups: { nodes: groups }, } = stage; - return { ...stage, groups }; + + /* + Being peformance conscious here means we don't want to spread and copy the + group value just to add one parameter. + */ + /* eslint-disable no-param-reassign */ + const groupsWithStageName = groups.map((group) => { + group.stageName = stage.name; + return group; + }); + /* eslint-enable no-param-reassign */ + + return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } }; }); }; const unwrapNodesWithName = (jobArray, prop, field = 'name') => { + if (jobArray.length < 1) { + reportToSentry('unwrapping_utils', 'undefined_job_hunt, array empty from backend'); + } + return jobArray.map((job) => { - return { ...job, [prop]: job[prop].nodes.map((item) => item[field]) }; + return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') }; }); }; @@ -17,20 +35,34 @@ const unwrapJobWithNeeds = (denodedJobArray) => { return unwrapNodesWithName(denodedJobArray, 'needs'); }; -const unwrapStagesWithNeeds = (denodedStages) => { +const unwrapStagesWithNeedsAndLookup = (denodedStages) => { const unwrappedNestedGroups = unwrapGroups(denodedStages); - const nodes = unwrappedNestedGroups.map((node) => { + const lookupMap = {}; + + const nodes = unwrappedNestedGroups.map(({ node, lookup }) => { const { groups } = node; - const groupsWithJobs = groups.map((group) => { + const groupsWithJobs = groups.map((group, idx) => { const jobs = unwrapJobWithNeeds(group.jobs.nodes); + + lookupMap[group.name] = { ...lookup, groupIdx: idx }; return { ...group, jobs }; }); return { ...node, groups: groupsWithJobs }; }); - return nodes; + return { stages: nodes, lookup: lookupMap }; +}; + +const unwrapStagesWithNeeds = (denodedStages) => { + return unwrapStagesWithNeedsAndLookup(denodedStages).stages; }; -export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds }; +export { + unwrapGroups, + unwrapJobWithNeeds, + unwrapNodesWithName, + unwrapStagesWithNeeds, + unwrapStagesWithNeedsAndLookup, +}; diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 21b114825a6..01705e7726f 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -35,3 +35,6 @@ export const POST_FAILURE = 'post_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; export const CHILD_VIEW = 'child'; + +// The key of the template is the same as the filename +export const HELLO_WORLD_TEMPLATE_KEY = 'Hello-World'; diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql new file mode 100644 index 00000000000..e4fd55a28be --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql @@ -0,0 +1,5 @@ +mutation DismissPipelineNotification($featureName: String!) { + userCalloutCreate(input: { featureName: $featureName }) { + errors + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql index c73b186739e..887c217da41 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql @@ -1,6 +1,7 @@ query getDagVisData($projectPath: ID!, $iid: ID!) { project(fullPath: $projectPath) { pipeline(iid: $iid) { + id stages { nodes { name diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql new file mode 100644 index 00000000000..12b391e41ac --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql @@ -0,0 +1,13 @@ +query getUser { + currentUser { + id + __typename + callouts { + __typename + nodes { + __typename + featureName + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index 2321728e30c..d9c9289f66e 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -190,7 +190,7 @@ export default { .then(() => this.updateTable()) .catch(() => { createFlash( - __('An error occurred while trying to run a new pipeline for this Merge Request.'), + __('An error occurred while trying to run a new pipeline for this merge request.'), ); }) .finally(() => this.store.toggleIsRunningPipeline(false)); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index c3444f38ea0..a2bc049c3c7 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,11 +3,15 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; -import { reportToSentry } from './components/graph/utils'; import TestReports from './components/test_reports/test_reports.vue'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import createDagApp from './pipeline_details_dag'; +import { createPipelinesDetailApp } from './pipeline_details_graph'; +import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineNotificationApp } from './pipeline_details_notification'; +import { apolloProvider } from './pipeline_shared_client'; import createTestReportsStore from './stores/test_reports'; +import { reportToSentry } from './utils'; Vue.use(Translate); @@ -15,6 +19,7 @@ const SELECTORS = { PIPELINE_DETAILS: '.js-pipeline-details-vue', PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', + PIPELINE_NOTIFICATION: '#js-pipeline-notification', PIPELINE_TESTS: '#js-pipeline-tests-detail', }; @@ -79,21 +84,28 @@ const createTestDetails = () => { }; export default async function initPipelineDetailsBundle() { - createTestDetails(); - createDagApp(); - const canShowNewPipelineDetails = gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers; const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); - if (canShowNewPipelineDetails) { + try { + createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); + } catch { + Flash(__('An error occurred while loading a section of this page.')); + } + + if (gon.features.pipelineGraphLayersView) { try { - const { createPipelinesDetailApp } = await import( - /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' - ); + createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider); + } catch { + Flash(__('An error occurred while loading a section of this page.')); + } + } - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset); + if (canShowNewPipelineDetails) { + try { + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); } catch { Flash(__('An error occurred while loading the pipeline.')); } @@ -107,12 +119,6 @@ export default async function initPipelineDetailsBundle() { createLegacyPipelinesDetailApp(mediator); } - try { - const { createPipelineHeaderApp } = await import( - /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header' - ); - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); - } catch { - Flash(__('An error occurred while loading a section of this page.')); - } + createDagApp(apolloProvider); + createTestDetails(); } diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js index 4ee0ad462d2..e2835ecc4d1 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js @@ -1,15 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import Dag from './components/dag/dag.vue'; Vue.use(VueApollo); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -const createDagApp = () => { +const createDagApp = (apolloProvider) => { const el = document.querySelector('#js-pipeline-dag-vue'); if (!el) { diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js index 9eba39738dc..39c3c2ea5c5 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -1,23 +1,14 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { GRAPHQL } from './components/graph/constants'; import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; -import { reportToSentry } from './components/graph/utils'; +import { reportToSentry } from './utils'; Vue.use(VueApollo); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - useGet: true, - }, - ), -}); - const createPipelinesDetailApp = ( selector, + apolloProvider, { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {}, ) => { // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index cba29acdb32..1c619768764 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -1,15 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import pipelineHeader from './components/header_component.vue'; Vue.use(VueApollo); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineHeaderApp = (elSelector) => { +export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => { const el = document.querySelector(elSelector); if (!el) { @@ -27,6 +22,7 @@ export const createPipelineHeaderApp = (elSelector) => { provide: { paths: { fullProject: fullPath, + graphqlResourceEtag, pipelinesPath, }, pipelineId, diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js new file mode 100644 index 00000000000..be234e8972d --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import PipelineNotification from './components/notification/pipeline_notification.vue'; + +Vue.use(VueApollo); + +export const createPipelineNotificationApp = (elSelector, apolloProvider) => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { dagDocPath } = el?.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + PipelineNotification, + }, + provide: { + dagDocPath, + }, + apolloProvider, + render(createElement) { + return createElement('pipeline-notification'); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js new file mode 100644 index 00000000000..c3be487caae --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js @@ -0,0 +1,11 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + useGet: true, + }, + ), +}); diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 0e2e9785956..9ed4365ad75 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -27,6 +27,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { errorStateSvgPath, noPipelinesSvgPath, newPipelinePath, + addCiYmlPath, + suggestedCiTemplates, canCreatePipeline, hasGitlabCi, ciLintPath, @@ -37,6 +39,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { return new Vue({ el, + provide: { + addCiYmlPath, + suggestedCiTemplates: JSON.parse(suggestedCiTemplates), + }, data() { return { store: new PipelinesStore(), diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 22820fca43e..0a6c326fa3d 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import { pickBy } from 'lodash'; import { createNodeDict } from './components/parsing_utils'; import { SUPPORTED_FILTER_PARAMETERS } from './constants'; @@ -65,3 +66,10 @@ export const generateJobNeedsDict = (jobs = {}) => { return { ...acc, [value]: uniqueValues }; }, {}); }; + +export const reportToSentry = (component, failureType) => { + Sentry.withScope((scope) => { + scope.setTag('component', component); + Sentry.captureException(failureType); + }); +}; |