Commit 0a2c5b45 authored by Sean McGivern's avatar Sean McGivern

Merge branch '8688-recursive-pipelines' into 'master'

Recursively expanding upstream/downstream pipelines inline

Closes #8688

See merge request gitlab-org/gitlab-ee!9073
parents 32d37bc9 c11a3a78
...@@ -2,8 +2,8 @@ import Vue from 'vue'; ...@@ -2,8 +2,8 @@ import Vue from 'vue';
import Flash from '~/flash'; import Flash from '~/flash';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import { __ } from '~/locale'; import { __ } from '~/locale';
import PipelinesMediator from 'ee/pipelines/pipeline_details_mediator';
import pipelineGraph from 'ee/pipelines/components/graph/graph_component.vue'; import pipelineGraph from 'ee/pipelines/components/graph/graph_component.vue';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue'; import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub'; import eventHub from './event_hub';
import GraphEEMixin from 'ee/pipelines/mixins/graph_pipeline_bundle_mixin'; // eslint-disable-line import/order import GraphEEMixin from 'ee/pipelines/mixins/graph_pipeline_bundle_mixin'; // eslint-disable-line import/order
...@@ -29,35 +29,19 @@ export default () => { ...@@ -29,35 +29,19 @@ export default () => {
mediator, mediator,
}; };
}, },
methods: {
requestRefreshPipelineGraph() {
// When an action is clicked
// (wether in the dropdown or in the main nodes, we refresh the big graph)
this.mediator
.refreshPipeline()
.catch(() => Flash(__('An error occurred while making the request.')));
},
},
render(createElement) { render(createElement) {
return createElement('pipeline-graph', { return createElement('pipeline-graph', {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline, pipeline: this.mediator.store.state.pipeline,
// EE-only start mediator: this.mediator,
triggeredPipelines: this.mediator.store.state.triggeredPipelines,
triggered: this.mediator.store.state.triggered,
triggeredByPipelines: this.mediator.store.state.triggeredByPipelines,
triggeredBy: this.mediator.store.state.triggeredBy,
// EE-only end
}, },
on: { on: {
refreshPipelineGraph: this.requestRefreshPipelineGraph, refreshPipelineGraph: this.requestRefreshPipelineGraph,
// EE-only start onClickTriggeredBy: (parentPipeline, pipeline) =>
refreshTriggeredPipelineGraph: this.mediator.refreshTriggeredByPipelineGraph, this.clickTriggeredByPipeline(parentPipeline, pipeline),
refreshTriggeredByPipelineGraph: this.mediator.refreshTriggeredByPipelineGraph, onClickTriggered: (parentPipeline, pipeline) =>
onClickTriggeredBy: pipeline => this.clickTriggeredBy(pipeline), this.clickTriggeredPipeline(parentPipeline, pipeline),
onClickTriggered: pipeline => this.clickTriggered(pipeline),
// EE-only end
}, },
}); });
}, },
......
...@@ -2,8 +2,8 @@ import Visibility from 'visibilityjs'; ...@@ -2,8 +2,8 @@ import Visibility from 'visibilityjs';
import Flash from '../flash'; import Flash from '../flash';
import Poll from '../lib/utils/poll'; import Poll from '../lib/utils/poll';
import { __ } from '../locale'; import { __ } from '../locale';
import PipelineService from '~/pipelines/services/pipeline_service';
import PipelineStore from 'ee/pipelines/stores/pipeline_store'; // eslint-disable-line import/order import PipelineStore from 'ee/pipelines/stores/pipeline_store'; // eslint-disable-line import/order
import PipelineService from 'ee/pipelines/services/pipeline_service'; // eslint-disable-line import/order
export default class pipelinesMediator { export default class pipelinesMediator {
constructor(options = {}) { constructor(options = {}) {
......
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import LinkedPipelinesColumn from 'ee/pipelines/components/graph/linked_pipelines_column.vue';
import EEGraphMixin from 'ee/pipelines/mixins/graph_component_mixin';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import GraphMixin from '~/pipelines/mixins/graph_component_mixin';
import LinkedPipelinesColumn from 'ee/pipelines/components/graph/linked_pipelines_column.vue';
import GraphEEMixin from 'ee/pipelines/mixins/graph_pipeline_bundle_mixin';
export default { export default {
name: 'PipelineGraph',
components: { components: {
LinkedPipelinesColumn,
StageColumnComponent, StageColumnComponent,
GlLoadingIcon, GlLoadingIcon,
LinkedPipelinesColumn,
}, },
mixins: [EEGraphMixin], mixins: [GraphMixin, GraphEEMixin],
props: { props: {
isLoading: { isLoading: {
type: Boolean, type: Boolean,
...@@ -21,35 +23,72 @@ export default { ...@@ -21,35 +23,72 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
isLinkedPipeline: {
type: Boolean,
required: false,
default: false,
},
mediator: {
type: Object,
required: true,
},
type: {
type: String,
required: false,
default: 'main',
},
},
upstream: 'upstream',
downstream: 'downstream',
data() {
return {
triggeredTopIndex: 1,
};
}, },
computed: { computed: {
graph() { hasTriggeredBy() {
return this.pipeline.details && this.pipeline.details.stages; return (
this.type !== this.$options.downstream &&
this.triggeredByPipelines &&
this.pipeline.triggered_by !== null
);
}, },
triggeredByPipelines() {
return this.pipeline.triggered_by;
}, },
methods: { hasTriggered() {
capitalizeStageName(name) { return (
const escapedName = _.escape(name); this.type !== this.$options.upstream &&
return escapedName.charAt(0).toUpperCase() + escapedName.slice(1); this.triggeredPipelines &&
this.pipeline.triggered.length > 0
);
}, },
isFirstColumn(index) { triggeredPipelines() {
return index === 0; return this.pipeline.triggered;
},
expandedTriggeredBy() {
return (
this.pipeline.triggered_by &&
_.isArray(this.pipeline.triggered_by) &&
this.pipeline.triggered_by.find(el => el.isExpanded)
);
},
expandedTriggered() {
return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
}, },
stageConnectorClass(index, stage) {
let className;
// If it's the first stage column and only has one job
if (index === 0 && stage.groups.length === 1) {
className = 'no-margin';
} else if (index > 0) {
// If it is not the first column
className = 'left-margin';
}
return className; /**
* Calculates the margin top of the clicked downstream pipeline by
* adding the height of each linked pipeline and the margin
*/
marginTop() {
return `${this.triggeredTopIndex * 52}px`;
},
}, },
refreshPipelineGraph() { methods: {
this.$emit('refreshPipelineGraph'); handleClickedDownstream(pipeline, clickedIndex) {
this.triggeredTopIndex = clickedIndex;
this.$emit('onClickTriggered', this.pipeline, pipeline);
}, },
hasOnlyOneJob(stage) { hasOnlyOneJob(stage) {
return stage.groups.length === 1; return stage.groups.length === 1;
...@@ -59,30 +98,35 @@ export default { ...@@ -59,30 +98,35 @@ export default {
</script> </script>
<template> <template>
<div class="build-content middle-block js-pipeline-graph"> <div class="build-content middle-block js-pipeline-graph">
<div class="pipeline-visualization pipeline-graph pipeline-tab-content"> <div
<div class="text-center"><gl-loading-icon v-if="isLoading" :size="3" /></div> class="pipeline-visualization pipeline-graph"
:class="{ 'pipeline-tab-content': !isLinkedPipeline }"
>
<div v-if="isLoading" class="m-auto"><gl-loading-icon :size="3" /></div>
<ul v-if="shouldRenderTriggeredByPipeline" class="d-inline-block upstream-pipeline align-top"> <pipeline-graph
<stage-column-component v-if="type !== $options.downstream && expandedTriggeredBy"
v-for="(stage, indexUpstream) in triggeredByGraph" type="upstream"
:key="stage.name" class="d-inline-block upstream-pipeline"
:class="{ :class="`js-upstream-pipeline-${expandedTriggeredBy.id}`"
'has-only-one-job': hasOnlyOneJob(stage), :is-loading="false"
}" :pipeline="expandedTriggeredBy"
:title="capitalizeStageName(stage.name)" :is-linked-pipeline="true"
:groups="stage.groups" :mediator="mediator"
:stage-connector-class="stageConnectorClass(indexUpstream, stage)" @onClickTriggeredBy="
:is-first-column="isFirstColumn(indexUpstream)" (parentPipeline, pipeline) => clickTriggeredByPipeline(parentPipeline, pipeline)
@refreshPipelineGraph="refreshTriggeredByPipelineGraph" "
@refreshPipelineGraph="requestRefreshPipelineGraph"
/> />
</ul>
<linked-pipelines-column <linked-pipelines-column
v-if="hasTriggeredBy" v-if="hasTriggeredBy"
:linked-pipelines="triggeredByPipelines" :linked-pipelines="triggeredByPipelines"
:column-title="__('Upstream')" :column-title="__('Upstream')"
graph-position="left" graph-position="left"
@linkedPipelineClick="pipeline => $emit('onClickTriggeredBy', pipeline)" @linkedPipelineClick="
linkedPipeline => $emit('onClickTriggeredBy', pipeline, linkedPipeline)
"
/> />
<ul <ul
...@@ -117,24 +161,21 @@ export default { ...@@ -117,24 +161,21 @@ export default {
@linkedPipelineClick="handleClickedDownstream" @linkedPipelineClick="handleClickedDownstream"
/> />
<ul <pipeline-graph
v-if="shouldRenderTriggeredPipeline" v-if="type !== $options.upstream && expandedTriggered"
class="d-inline-block downstream-pipeline position-relative align-top" type="downstream"
class="d-inline-block"
:class="`js-downstream-pipeline-${expandedTriggered.id}`"
:is-loading="false"
:pipeline="expandedTriggered"
:is-linked-pipeline="true"
:style="{ 'margin-top': marginTop }" :style="{ 'margin-top': marginTop }"
> :mediator="mediator"
<stage-column-component @onClickTriggered="
v-for="(stage, indexDownstream) in triggeredGraph" (parentPipeline, pipeline) => clickTriggeredPipeline(parentPipeline, pipeline)
:key="stage.name" "
:class="{ @refreshPipelineGraph="requestRefreshPipelineGraph"
'has-only-one-job': hasOnlyOneJob(stage),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(indexDownstream, stage)"
:is-first-column="isFirstColumn(indexDownstream)"
@refreshPipelineGraph="refreshTriggeredPipelineGraph"
/> />
</ul>
</div> </div>
</div> </div>
</template> </template>
...@@ -12,30 +12,23 @@ export default { ...@@ -12,30 +12,23 @@ export default {
GlButton, GlButton,
}, },
props: { props: {
pipelineId: { pipeline: {
type: Number,
required: true,
},
pipelineStatus: {
type: Object, type: Object,
required: true, required: true,
}, },
projectName: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
tooltipText() { tooltipText() {
return `${this.projectName} - ${this.pipelineStatus.label}`; return `${this.projectName} - ${this.pipelineStatus.label}`;
}, },
buttonId() { buttonId() {
return `js-linked-pipeline-${this.pipelineId}`; return `js-linked-pipeline-${this.pipeline.id}`;
},
pipelineStatus() {
return this.pipeline.details.status;
},
projectName() {
return this.pipeline.project.name;
}, },
}, },
methods: { methods: {
...@@ -57,10 +50,9 @@ export default { ...@@ -57,10 +50,9 @@ export default {
class="js-linked-pipeline-content linked-pipeline-content" class="js-linked-pipeline-content linked-pipeline-content"
@click="onClickLinkedPipeline" @click="onClickLinkedPipeline"
> >
<gl-loading-icon v-if="isLoading" class="js-linked-pipeline-loading d-inline" /> <ci-status :status="pipelineStatus" class="js-linked-pipeline-status" />
<ci-status v-else :status="pipelineStatus" class="js-linked-pipeline-status" />
<span class="str-truncated align-bottom"> {{ projectName }} &#8226; #{{ pipelineId }} </span> <span class="str-truncated align-bottom"> {{ projectName }} &#8226; #{{ pipeline.id }} </span>
</gl-button> </gl-button>
</li> </li>
</template> </template>
...@@ -37,14 +37,10 @@ export default { ...@@ -37,14 +37,10 @@ export default {
:key="pipeline.id" :key="pipeline.id"
:class="{ :class="{
'flat-connector-before': index === 0 && graphPosition === 'right', 'flat-connector-before': index === 0 && graphPosition === 'right',
active: pipeline.isExpanded || pipeline.isLoading, active: pipeline.isExpanded,
'left-connector': pipeline.isExpanded && graphPosition === 'left', 'left-connector': pipeline.isExpanded && graphPosition === 'left',
}" }"
:pipeline-id="pipeline.id" :pipeline="pipeline"
:project-name="pipeline.project.name"
:pipeline-status="pipeline.details.status"
:pipeline-path="pipeline.path"
:is-loading="pipeline.isLoading"
@pipelineClicked="$emit('linkedPipelineClick', pipeline, index)" @pipelineClicked="$emit('linkedPipelineClick', pipeline, index)"
/> />
</ul> </ul>
......
export default {
triggeredPipelines: 'triggeredPipelines',
triggeredByPipelines: 'triggeredByPipelines',
triggeredBy: 'triggeredBy',
triggered: 'triggered',
};
import _ from 'underscore';
export default {
props: {
triggered: {
type: Object,
required: false,
default: () => ({}),
},
triggeredBy: {
type: Object,
required: false,
default: () => ({}),
},
triggeredByPipelines: {
type: Array,
required: false,
default: () => [],
},
triggeredPipelines: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
triggeredTopIndex: 1,
};
},
computed: {
triggeredGraph() {
return this.triggered && this.triggered.details && this.triggered.details.stages;
},
triggeredByGraph() {
return this.triggeredBy && this.triggeredBy.details && this.triggeredBy.details.stages;
},
hasTriggered() {
return this.triggeredPipelines.length > 0;
},
hasTriggeredBy() {
return this.triggeredByPipelines.length > 0;
},
shouldRenderTriggeredPipeline() {
return !this.isLoading && !_.isEmpty(this.triggered);
},
shouldRenderTriggeredByPipeline() {
return !this.isLoading && !_.isEmpty(this.triggeredBy);
},
/**
* Calculates the margin top of the clicked downstream pipeline by
* adding the height of each linked pipeline and the margin
*/
marginTop() {
return `${this.triggeredTopIndex * 52}px`;
},
},
methods: {
refreshTriggeredPipelineGraph() {
this.$emit('refreshTriggeredPipelineGraph');
},
refreshTriggeredByPipelineGraph() {
this.$emit('refreshTriggeredByPipelineGraph');
},
handleClickedDownstream(pipeline, clickedIndex) {
this.triggeredTopIndex = clickedIndex;
this.$emit('onClickTriggered', pipeline);
},
},
};
import pipelinesKeys from 'ee/pipelines/constants'; import flash from '~/flash';
import { __ } from '~/locale';
export default { export default {
methods: { methods: {
...@@ -13,30 +14,35 @@ export default { ...@@ -13,30 +14,35 @@ export default {
* @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset * @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset
* @param {Object} pipeline The clicked pipeline * @param {Object} pipeline The clicked pipeline
*/ */
clickPipeline(method, storeKey, resetStoreKey, pipeline, pollKey) { clickPipeline(parentPipeline, pipeline, openMethod, closeMethod) {
if (!pipeline.isExpanded) { if (!pipeline.isExpanded) {
this.mediator[method](pipeline); this.mediator.store[openMethod](parentPipeline, pipeline);
} else { } else {
this.mediator.resetPipeline(storeKey, pipeline, resetStoreKey, pollKey); this.mediator.store[closeMethod](pipeline);
} }
}, },
clickTriggered(triggered) { clickTriggeredByPipeline(parentPipeline, pipeline) {
this.clickPipeline( this.clickPipeline(
'fetchTriggeredPipeline', parentPipeline,
pipelinesKeys.triggeredPipelines, pipeline,
pipelinesKeys.triggered, 'openTriggeredByPipeline',
triggered, 'closeTriggeredByPipeline',
'pollTriggered',
); );
}, },
clickTriggeredBy(triggeredBy) { clickTriggeredPipeline(parentPipeline, pipeline) {
this.clickPipeline( this.clickPipeline(
'fetchTriggeredByPipeline', parentPipeline,
pipelinesKeys.triggeredByPipelines, pipeline,
pipelinesKeys.triggeredBy, 'openTriggeredPipeline',
triggeredBy, 'closeTriggeredPipeline',
'pollTriggeredBy',
); );
}, },
requestRefreshPipelineGraph() {
// When an action is clicked
// (wether in the dropdown or in the main nodes, we refresh the big graph)
this.mediator
.refreshPipeline()
.catch(() => flash(__('An error occurred while making the request.')));
},
}, },
}; };
import CePipelineMediator from '~/pipelines/pipeline_details_mediator';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import PipelineService from 'ee/pipelines/services/pipeline_service';
/**
* Extends CE mediator with the logic to handle the upstream/downstream pipelines
*/
export default class EePipelineMediator extends CePipelineMediator {
/**
* Requests the clicked downstream pipeline pipeline
*
* @param {Object} pipeline
*/
fetchTriggeredPipeline(pipeline) {
if (this.pollTriggered) {
this.pollTriggered.stop();
this.pollTriggered = null;
}
this.store.requestTriggeredPipeline(pipeline);
this.pollTriggered = new Poll({
resource: PipelineService,
method: 'getUpstreamDownstream',
data: pipeline.path,
successCallback: ({ data }) => this.store.receiveTriggeredPipelineSuccess(pipeline, data),
errorCallback: () => {
this.store.receiveTriggeredPipelineError(pipeline);
createFlash(
__('An error occured while fetching this downstream pipeline. Please try again'),
);
},
});
this.pollTriggered.makeRequest();
}
refreshTriggeredPipelineGraph() {
this.pollTriggered.stop();
this.pollTriggered.restart();
}
/**
* Requests the clicked upstream pipeline pipeline
* @param {*} pipeline
*/
fetchTriggeredByPipeline(pipeline) {
if (this.pollTriggeredBy) {
this.pollTriggeredBy.stop();
this.pollTriggeredBy = null;
}
this.store.requestTriggeredByPipeline(pipeline);
this.pollTriggeredBy = new Poll({
resource: PipelineService,
method: 'getUpstreamDownstream',
data: pipeline.path,
successCallback: ({ data }) => this.store.receiveTriggeredByPipelineSuccess(pipeline, data),
errorCallback: () => {
this.store.receiveTriggeredByPipelineError(pipeline);
createFlash(__('An error occured while fetching this upstream pipeline. Please try again'));
},
});
this.pollTriggeredBy.makeRequest();
}
refreshTriggeredByPipelineGraph() {
this.pollTriggeredBy.stop();
this.pollTriggeredBy.restart();
}
resetPipeline(storeKey, pipeline, resetStoreKey, pollKey) {
this[pollKey].stop();
this.store.closePipeline(storeKey, pipeline, resetStoreKey);
}
}
import axios from '~/lib/utils/axios_utils';
import CePipelineService from '~/pipelines/services/pipeline_service';
export default class PipelineStore extends CePipelineService {
static getUpstreamDownstream(endpoint) {
return axios.get(`${endpoint}.json`);
}
}
import Vue from 'vue';
import _ from 'underscore';
import CePipelineStore from '~/pipelines/stores/pipeline_store'; import CePipelineStore from '~/pipelines/stores/pipeline_store';
import pipelinesKeys from '../constants';
/** /**
* Extends CE store with the logic to handle the upstream/downstream pipelines * Extends CE store with the logic to handle the upstream/downstream pipelines
*/ */
export default class PipelineStore extends CePipelineStore { export default class PipelineStore extends CePipelineStore {
constructor() {
super();
// Stores the dowsntream collapsed pipelines
// with basic info sent in the main request
this.state.triggeredPipelines = [];
// Stores the upstream collapsed pipelines
// with basic info sent in the main request
this.state.triggeredByPipelines = [];
// Visible downstream pipeline
this.state.triggered = {};
// Visible upstream pipeline
this.state.triggeredBy = {};
}
/** /**
* For the triggered pipelines, parses them to add `isLoading` and `isExpanded` keys * For the triggered pipelines adds the `isExpanded` key
* *
* For the triggered_by pipeline, parsed the object to add `isLoading` and `isExpanded` keys * For the triggered_by pipeline adds the `isExpanded` key
* and saves it as an array * and saves it as an array
* *
* @param {Object} pipeline * @param {Object} pipeline
...@@ -32,216 +17,138 @@ export default class PipelineStore extends CePipelineStore { ...@@ -32,216 +17,138 @@ export default class PipelineStore extends CePipelineStore {
storePipeline(pipeline = {}) { storePipeline(pipeline = {}) {
super.storePipeline(pipeline); super.storePipeline(pipeline);
if (pipeline.triggered && pipeline.triggered.length) { if (pipeline.triggered_by) {
this.state.triggeredPipelines = pipeline.triggered.map(triggered => { this.state.pipeline.triggered_by = [pipeline.triggered_by];
// because we are polling we need to make sure we do not hijack user's clicks.
const oldPipeline = this.state.triggeredPipelines.find(
oldValue => oldValue.id === triggered.id,
);
return Object.assign({}, triggered, { this.parseTriggeredByPipelines(this.state.pipeline.triggered_by[0]);
isExpanded: oldPipeline ? oldPipeline.isExpanded : false,
isLoading: oldPipeline ? oldPipeline.isLoading : false,
});
});
} }
if (pipeline.triggered_by) { if (pipeline.triggered && pipeline.triggered.length) {
this.state.triggeredByPipelines = [ this.state.pipeline.triggered.forEach(el => this.parseTriggeredPipelines(el));
Object.assign({}, pipeline.triggered_by, {
isExpanded: this.state.triggeredByPipelines.length
? this.state.triggeredByPipelines[0].isExpanded
: false,
isLoading: this.state.triggeredByPipelines.length
? this.state.triggeredByPipelines[0].isLoading
: false,
}),
];
} }
} }
//
// Downstream pipeline's methods
//
/** /**
* Called when the user clicks on a pipeline that was triggered by the main one. * Recursiverly parses the triggered by pipelines.
* *
* Resets isExpanded and isLoading props for all triggered (downstream) pipelines * Sets triggered_by as an array, there is always only 1 triggered_by pipeline.
* Sets isLoading to true for the requested one. * Adds key `isExpanding`
* Keeps old isExpading value due to polling
* *
* @param {Array} parentPipeline
* @param {Object} pipeline * @param {Object} pipeline
*/ */
requestTriggeredPipeline(pipeline) { parseTriggeredByPipelines(pipeline) {
this.updateStoreOnRequest(pipelinesKeys.triggeredPipelines, pipeline); // keep old value in case it's opened because we're polling
Vue.set(pipeline, 'isExpanded', pipeline.isExpanded || false);
if (pipeline.triggered_by) {
if (!_.isArray(pipeline.triggered_by)) {
Object.assign(pipeline, { triggered_by: [pipeline.triggered_by] });
}
this.parseTriggeredByPipelines(pipeline.triggered_by[0]);
}
} }
/** /**
* Called when we receive success callback for the downstream pipeline requested. * Recursively parses the triggered pipelines
* * @param {Array} parentPipeline
* Updates loading state for the request pipeline
* Updates the visible pipeline with the response
*
* @param {Object} pipeline * @param {Object} pipeline
* @param {Object} response
*/ */
receiveTriggeredPipelineSuccess(pipeline, response) { parseTriggeredPipelines(pipeline) {
this.updatePipeline( // keep old value in case it's opened because we're polling
pipelinesKeys.triggeredPipelines, Vue.set(pipeline, 'isExpanded', pipeline.isExpanded || false);
pipeline,
{ isLoading: false, isExpanded: true }, if (pipeline.triggered && pipeline.triggered.length > 0) {
pipelinesKeys.triggered, pipeline.triggered.forEach(el => this.parseTriggeredPipelines(el));
response, }
);
} }
/** /**
* Called when we receive an error callback for the downstream pipeline requested * Recursively resets all triggered by pipelines
* Resets the loading state + collpased state
* Resets triggered pipeline
* *
* @param {Object} pipeline * @param {Object} pipeline
*/ */
receiveTriggeredPipelineError(pipeline) { resetTriggeredByPipeline(parentPipeline, pipeline) {
this.updatePipeline( parentPipeline.triggered_by.forEach(el => PipelineStore.closePipeline(el));
pipelinesKeys.triggeredPipelines,
pipeline,
{ isLoading: false, isExpanded: false },
pipelinesKeys.triggered,
{},
);
}
// if (pipeline.triggered_by && pipeline.triggered_by) {
// Upstream pipeline's methods this.resetTriggeredByPipeline(pipeline, pipeline.triggered_by);
// }
}
/** /**
* Called when the user clicks on the pipeline that triggered the main one. * Opens the clicked pipeline and closes all other ones.
*
* Handle the request for the upstream pipeline
* Updates the given pipeline with isLoading: true and isExpanded: true
*
* @param {Object} pipeline * @param {Object} pipeline
*/ */
requestTriggeredByPipeline(pipeline) { openTriggeredByPipeline(parentPipeline, pipeline) {
this.updateStoreOnRequest(pipelinesKeys.triggeredByPipelines, pipeline); // first we need to reset all triggeredBy pipelines
this.resetTriggeredByPipeline(parentPipeline, pipeline);
PipelineStore.openPipeline(pipeline);
} }
/** /**
* Success callback for the upstream pipeline received * On click, will close the given pipeline and all nested triggered by pipelines
* *
* @param {Object} pipeline * @param {Object} pipeline
* @param {Object} response
*/ */
receiveTriggeredByPipelineSuccess(pipeline, response) { closeTriggeredByPipeline(pipeline) {
this.updatePipeline( PipelineStore.closePipeline(pipeline);
pipelinesKeys.triggeredByPipelines,
pipeline,
{ isLoading: false, isExpanded: true },
pipelinesKeys.triggeredBy,
response,
);
}
/** if (pipeline.triggered_by && pipeline.triggered_by.length) {
* Error callback for the upstream callback pipeline.triggered_by.forEach(triggeredBy => this.closeTriggeredByPipeline(triggeredBy));
* @param {Object} pipeline }
*/
receiveTriggeredByPipelineError(pipeline) {
this.updatePipeline(
pipelinesKeys.triggeredByPipelines,
pipeline,
{ isLoading: false, isExpanded: false },
pipelinesKeys.triggeredBy,
{},
);
} }
//
// Common utils between upstream & dowsntream pipelines
//
/** /**
* Adds isLoading and isCollpased keys to the given pipeline * Recursively closes all triggered pipelines for the given one.
*
* Used to know when to render the spinning icon
* and the blue background when the pipeline is expanded.
* *
* @param {Object} pipeline * @param {Object} pipeline
* @returns {Object}
*/ */
static parsePipeline(pipeline) { resetTriggeredPipelines(parentPipeline, pipeline) {
return Object.assign({}, pipeline, { parentPipeline.triggered.forEach(el => this.closePipeline(el));
isExpanded: false,
isLoading: false, if (pipeline.triggered && pipeline.triggered.length) {
}); pipeline.triggered.forEach(el => this.resetTriggeredPipelines(pipeline, el));
}
} }
/** /**
* Returns the index of the upstream/downstream that matches the given ID * Opens the clicked triggered pipeline and closes all other ones.
* *
* @param {Object} pipeline * @param {Object} pipeline
* @returns {Number}
*/ */
getPipelineIndex(storeKey, pipelineId) { openTriggeredPipeline(parentPipeline, pipeline) {
return this.state[storeKey].findIndex(triggered => triggered.id === pipelineId); this.resetTriggeredPipelines(parentPipeline, pipeline);
PipelineStore.openPipeline(pipeline);
} }
/** /**
* Updates the pipelines to reflect which one was requested. * On click, will close the given pipeline and all the nested triggered ones
* It sets isLoading to true and isExpanded to false * @param {Object} pipeline
*
* @param {String} storeKey which property to update: `triggeredPipelines|triggeredByPipelines`
* @param {Object} pipeline the requested pipeline
*/ */
updateStoreOnRequest(storeKey, pipeline) { closeTriggeredPipeline(pipeline) {
this.state[storeKey] = this.state[storeKey].map(triggered => { PipelineStore.closePipeline(pipeline);
if (triggered.id === pipeline.id) {
return Object.assign({}, triggered, { isLoading: true, isExpanded: true }); if (pipeline.triggered && pipeline.triggered.length) {
pipeline.triggered.forEach(triggered => this.closeTriggeredPipeline(triggered));
} }
// reset the others, in case another was one opened
return PipelineStore.parsePipeline(triggered);
});
} }
/** /**
* Updates a single pipeline with the new props and the visible pipeline * Utility function, Closes the given pipeline
* Used for success and error callbacks for both upstream and downstream requests. * @param {Object} pipeline
*
* @param {String} storeKey Which array needs to be updated: `triggeredPipelines|triggeredByPipelines`
* @param {Object} pipeline Which pipeline should be updated
* @param {Object} props The new properties to be updated for the given pipeline
* @param {String} visiblePipelineKey Which visible pipeline needs to be updated: `triggered|triggeredBy`
* @param {Object} visiblePipeline The new visible pipeline value
*/ */
updatePipeline(storeKey, pipeline, props, visiblePipelineKey, visiblePipeline = {}) { static closePipeline(pipeline) {
this.state[storeKey].splice( Vue.set(pipeline, 'isExpanded', false);
this.getPipelineIndex(storeKey, pipeline.id),
1,
Object.assign({}, pipeline, props),
);
this.state[visiblePipelineKey] = visiblePipeline;
} }
/** /**
* When the user clicks on a non collapsed pipeline we need to close it * Utility function, Opens the given pipeline
* * @param {Object} pipeline
* @param {String} storeKey Which array needs to be updated: `triggeredPipelines|triggeredByPipelines`
* @param {Object} pipeline Which pipeline should be updated
* @param {String} visiblePipelineKey Which visible pipeline needs to be updated: `triggered|triggeredBy`
*/ */
closePipeline(storeKey, pipeline, visiblePipelineKey) { static openPipeline(pipeline) {
this.updatePipeline( Vue.set(pipeline, 'isExpanded', true);
storeKey,
pipeline,
{
isLoading: false,
isExpanded: false,
},
visiblePipelineKey,
{},
);
} }
} }
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module EE module EE
module Projects module Projects
module PipelinesController module PipelinesController
extend ActiveSupport::Concern extend ::Gitlab::Utils::Override
def security def security
if pipeline.expose_security_dashboard? if pipeline.expose_security_dashboard?
...@@ -20,6 +20,11 @@ module EE ...@@ -20,6 +20,11 @@ module EE
redirect_to pipeline_path(pipeline) redirect_to pipeline_path(pipeline)
end end
end end
override :show_represent_params
def show_represent_params
super.merge(expanded: params[:expanded].to_a.map(&:to_i))
end
end end
end end
end end
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
class TriggeredPipelineEntity < Grape::Entity class TriggeredPipelineEntity < Grape::Entity
include RequestAwareEntity include RequestAwareEntity
MAX_EXPAND_DEPTH = 3
expose :id expose :id
expose :user, using: UserEntity expose :user, using: UserEntity
expose :active?, as: :active expose :active?, as: :active
...@@ -15,15 +17,49 @@ class TriggeredPipelineEntity < Grape::Entity ...@@ -15,15 +17,49 @@ class TriggeredPipelineEntity < Grape::Entity
expose :details do expose :details do
expose :detailed_status, as: :status, with: DetailedStatusEntity expose :detailed_status, as: :status, with: DetailedStatusEntity
expose :ordered_stages,
as: :stages, using: StageEntity,
if: -> (_, opts) { can_read_details? && expand?(opts) }
end end
expose :triggered_by_pipeline,
as: :triggered_by, with: TriggeredPipelineEntity,
if: -> (_, opts) { can_read_details? && expand_for_path?(opts) }
expose :triggered_pipelines,
as: :triggered, using: TriggeredPipelineEntity,
if: -> (_, opts) { can_read_details? && expand_for_path?(opts) }
expose :project, using: ProjectEntity expose :project, using: ProjectEntity
private private
alias_method :pipeline, :object alias_method :pipeline, :object
def can_read_details?
can?(request.current_user, :read_pipeline, pipeline)
end
def detailed_status def detailed_status
pipeline.detailed_status(request.current_user) pipeline.detailed_status(request.current_user)
end end
def expand?(opts)
opts[:expanded].to_a.include?(pipeline.id)
end
def expand_for_path?(opts)
# The `opts[:attr_path]` holds a list of all `exposes` in path
# The check ensures that we always expand only `triggered_by`, `triggered_by`, ...
# but not the `triggered_by`, `triggered` which would result in dead loop
attr_path = opts[:attr_path]
current_expose = attr_path.last
# We expand at most to depth of MAX_DEPTH
# We ensure that we expand in one direction: triggered_by,... or triggered, ...
attr_path.length < MAX_EXPAND_DEPTH &&
attr_path.all?(current_expose) &&
expand?(opts)
end
end end
---
title: Recursively expands upstream and downstream pipelines
merge_request: 9073
author:
type: changed
...@@ -3,6 +3,7 @@ require 'spec_helper' ...@@ -3,6 +3,7 @@ require 'spec_helper'
describe Projects::PipelinesController do describe Projects::PipelinesController do
set(:user) { create(:user) } set(:user) { create(:user) }
set(:project) { create(:project, :repository) } set(:project) { create(:project, :repository) }
set(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do before do
project.add_developer(user) project.add_developer(user)
...@@ -10,9 +11,193 @@ describe Projects::PipelinesController do ...@@ -10,9 +11,193 @@ describe Projects::PipelinesController do
sign_in(user) sign_in(user)
end end
describe 'GET security' do describe 'GET show.json' do
set(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } set(:source_project) { create(:project) }
set(:target_project) { create(:project) }
set(:root_pipeline) { create_pipeline(project) }
set(:source_pipeline) { create_pipeline(source_project) }
set(:source_of_source_pipeline) { create_pipeline(source_project) }
set(:target_pipeline) { create_pipeline(target_project) }
set(:target_of_target_pipeline) { create_pipeline(target_project) }
before do
create_link(source_of_source_pipeline, source_pipeline)
create_link(source_pipeline, root_pipeline)
create_link(root_pipeline, target_pipeline)
create_link(target_pipeline, target_of_target_pipeline)
end
shared_examples 'not expanded' do
let(:expected_stages) { be_nil }
it 'does return base details' do
get_pipeline_json(root_pipeline)
expect(json_response['triggered_by']).to include('id' => source_pipeline.id)
expect(json_response['triggered']).to contain_exactly(
include('id' => target_pipeline.id))
end
it 'does not expand triggered_by pipeline' do
get_pipeline_json(root_pipeline)
triggered_by = json_response['triggered_by']
expect(triggered_by['triggered_by']).to be_nil
expect(triggered_by['triggered']).to be_nil
expect(triggered_by['details']['stages']).to expected_stages
end
it 'does not expand triggered pipelines' do
get_pipeline_json(root_pipeline)
first_triggered = json_response['triggered'].first
expect(first_triggered['triggered_by']).to be_nil
expect(first_triggered['triggered']).to be_nil
expect(first_triggered['details']['stages']).to expected_stages
end
end
shared_examples 'expanded' do
it 'does return base details' do
get_pipeline_json(root_pipeline)
expect(json_response['triggered_by']).to include('id' => source_pipeline.id)
expect(json_response['triggered']).to contain_exactly(
include('id' => target_pipeline.id))
end
it 'does expand triggered_by pipeline' do
get_pipeline_json(root_pipeline)
triggered_by = json_response['triggered_by']
expect(triggered_by['triggered_by']).to include(
'id' => source_of_source_pipeline.id)
expect(triggered_by['details']['stages']).not_to be_nil
end
it 'does not recursively expand triggered_by' do
get_pipeline_json(root_pipeline)
triggered_by = json_response['triggered_by']
expect(triggered_by['triggered']).to be_nil
end
it 'does expand triggered pipelines' do
get_pipeline_json(root_pipeline)
first_triggered = json_response['triggered'].first
expect(first_triggered['triggered']).to contain_exactly(
include('id' => target_of_target_pipeline.id))
expect(first_triggered['details']['stages']).not_to be_nil
end
it 'does not recursively expand triggered' do
get_pipeline_json(root_pipeline)
first_triggered = json_response['triggered'].first
expect(first_triggered['triggered_by']).to be_nil
end
end
context 'when it does have permission to read other projects' do
before do
source_project.add_developer(user)
target_project.add_developer(user)
end
context 'when not-expanding any pipelines' do
let(:expanded) { nil }
it_behaves_like 'not expanded'
end
context 'when expanding non-existing pipeline' do
let(:expanded) { [-1] }
it_behaves_like 'not expanded'
end
context 'when expanding pipeline that is not directly expandable' do
let(:expanded) { [source_of_source_pipeline.id, target_of_target_pipeline.id] }
it_behaves_like 'not expanded'
end
context 'when expanding self' do
let(:expanded) { [root_pipeline.id] }
context 'it does not recursively expand pipelines' do
it_behaves_like 'not expanded'
end
end
context 'when expanding source and target pipeline' do
let(:expanded) { [source_pipeline.id, target_pipeline.id] }
it_behaves_like 'expanded'
context 'when expand depth is limited to 1' do
before do
stub_const('TriggeredPipelineEntity::MAX_EXPAND_DEPTH', 1)
end
it_behaves_like 'not expanded' do
# We expect that triggered/triggered_by is not expanded,
# but we still return details.stages for that pipeline
let(:expected_stages) { be_a(Array) }
end
end
end
context 'when expanding all' do
let(:expanded) do
[
source_of_source_pipeline.id,
source_pipeline.id,
root_pipeline.id,
target_pipeline.id,
target_of_target_pipeline.id
]
end
it_behaves_like 'expanded'
end
end
context 'when does not have permission to read other projects' do
let(:expanded) { [source_pipeline.id, target_pipeline.id] }
it_behaves_like 'not expanded'
end
def create_pipeline(project)
create(:ci_empty_pipeline, project: project).tap do |pipeline|
create(:ci_build, pipeline: pipeline, stage: 'test', name: 'rspec')
end
end
def create_link(source_pipeline, pipeline)
source_pipeline.sourced_pipelines.create!(
source_job: source_pipeline.builds.all.sample,
source_project: source_pipeline.project,
project: pipeline.project,
pipeline: pipeline
)
end
def get_pipeline_json(pipeline)
params = {
namespace_id: pipeline.project.namespace,
project_id: pipeline.project,
id: pipeline,
expanded: expanded
}
get :show, params: params.compact, format: :json
end
end
describe 'GET security' do
context 'with a sast artifact' do context 'with a sast artifact' do
before do before do
create(:ee_ci_build, :legacy_sast, pipeline: pipeline) create(:ee_ci_build, :legacy_sast, pipeline: pipeline)
...@@ -68,8 +253,6 @@ describe Projects::PipelinesController do ...@@ -68,8 +253,6 @@ describe Projects::PipelinesController do
end end
describe 'GET licenses' do describe 'GET licenses' do
set(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
context 'with a license management artifact' do context 'with a license management artifact' do
before do before do
build = create(:ci_build, pipeline: pipeline) build = create(:ci_build, pipeline: pipeline)
......
import Vue from 'vue'; import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import PipelineStore from 'ee/pipelines/stores/pipeline_store';
import graphComponent from 'ee/pipelines/components/graph/graph_component.vue'; import graphComponent from 'ee/pipelines/components/graph/graph_component.vue';
import pipelineJSON from 'spec/pipelines/graph/mock_data'; import linkedPipelineJSON from 'ee_spec/pipelines/linked_pipelines_mock.json';
import linkedPipelineJSON from 'ee_spec/pipelines/graph/linked_pipelines_mock_data'; import PipelinesMediator from '~/pipelines/pipeline_details_mediator';
import graphJSON from 'spec/pipelines/graph/mock_data';
const graphJSON = Object.assign(pipelineJSON, {
triggered: linkedPipelineJSON.triggered,
triggered_by: linkedPipelineJSON.triggered_by,
});
describe('graph component', () => { describe('graph component', () => {
const GraphComponent = Vue.extend(graphComponent); const GraphComponent = Vue.extend(graphComponent);
const store = new PipelineStore();
store.storePipeline(linkedPipelineJSON);
const mediator = new PipelinesMediator({ endpoint: '' });
let component; let component;
afterEach(() => { afterEach(() => {
...@@ -22,6 +23,7 @@ describe('graph component', () => { ...@@ -22,6 +23,7 @@ describe('graph component', () => {
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: true, isLoading: true,
pipeline: {}, pipeline: {},
mediator,
}); });
expect(component.$el.querySelector('.loading-icon')).toBeDefined(); expect(component.$el.querySelector('.loading-icon')).toBeDefined();
...@@ -32,9 +34,8 @@ describe('graph component', () => { ...@@ -32,9 +34,8 @@ describe('graph component', () => {
beforeEach(() => { beforeEach(() => {
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: false, isLoading: false,
pipeline: graphJSON, pipeline: store.state.pipeline,
triggeredByPipelines: [linkedPipelineJSON.triggered_by], mediator,
triggeredPipelines: linkedPipelineJSON.triggered,
}); });
}); });
...@@ -95,6 +96,14 @@ describe('graph component', () => { ...@@ -95,6 +96,14 @@ describe('graph component', () => {
}); });
describe('linked pipelines components', () => { describe('linked pipelines components', () => {
beforeEach(() => {
component = mountComponent(GraphComponent, {
isLoading: false,
pipeline: store.state.pipeline,
mediator,
});
});
it('should render an upstream pipelines column', () => { it('should render an upstream pipelines column', () => {
expect(component.$el.querySelector('.linked-pipelines-column')).not.toBeNull(); expect(component.$el.querySelector('.linked-pipelines-column')).not.toBeNull();
expect(component.$el.innerHTML).toContain('Upstream'); expect(component.$el.innerHTML).toContain('Upstream');
...@@ -106,57 +115,63 @@ describe('graph component', () => { ...@@ -106,57 +115,63 @@ describe('graph component', () => {
}); });
describe('triggered by', () => { describe('triggered by', () => {
describe('on click', () => {
it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => { it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => {
spyOn(component, '$emit'); spyOn(component, '$emit');
component.$el.querySelector('#js-linked-pipeline-129').click();
component.$el.querySelector('#js-linked-pipeline-12').click();
expect(component.$emit).toHaveBeenCalledWith( expect(component.$emit).toHaveBeenCalledWith(
'onClickTriggeredBy', 'onClickTriggeredBy',
linkedPipelineJSON.triggered_by, component.pipeline,
component.pipeline.triggered_by[0],
); );
}); });
});
describe('with expanded pipeline', () => {
it('should render expanded pipeline', () => {
// expand the pipeline
store.state.pipeline.triggered_by[0].isExpanded = true;
describe('with expanded triggered by pipeline', () => {
it('should render expanded upstream pipeline', () => {
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: false, isLoading: false,
pipeline: graphJSON, pipeline: store.state.pipeline,
triggeredByPipelines: [ mediator,
Object.assign({}, linkedPipelineJSON.triggered_by, { isExpanded: true }),
],
triggeredPipelines: linkedPipelineJSON.triggered,
triggeredBy: linkedPipelineJSON.triggered_by,
}); });
expect(component.$el.querySelector('.upstream-pipeline')).not.toBeNull(); expect(component.$el.querySelector('.js-upstream-pipeline-12')).not.toBeNull();
}); });
}); });
}); });
describe('triggered ', () => { describe('triggered', () => {
it('should emit `onClickTriggered` when triggered linked pipeline is clicked', () => { describe('on click', () => {
it('should emit `onClickTriggered`', () => {
spyOn(component, '$emit'); spyOn(component, '$emit');
component.$el.querySelector('#js-linked-pipeline-132').click();
component.$el.querySelector('#js-linked-pipeline-34993051').click();
expect(component.$emit).toHaveBeenCalledWith( expect(component.$emit).toHaveBeenCalledWith(
'onClickTriggered', 'onClickTriggered',
linkedPipelineJSON.triggered[0], component.pipeline,
component.pipeline.triggered[0],
); );
}); });
});
describe('with expanded pipeline', () => {
it('should render expanded pipeline', () => {
// expand the pipeline
store.state.pipeline.triggered[0].isExpanded = true;
describe('with expanded triggered pipeline', () => {
it('should render expanded downstream pipeline', () => {
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: false, isLoading: false,
pipeline: graphJSON, pipeline: store.state.pipeline,
triggeredByPipelines: [linkedPipelineJSON.triggered_by], mediator,
triggeredPipelines: [
Object.assign({}, linkedPipelineJSON.triggered[0], { isExpanded: true }),
],
triggered: linkedPipelineJSON.triggered[0],
}); });
expect(component.$el.querySelector('.downstream-pipeline')).not.toBeNull(); expect(component.$el.querySelector('.js-downstream-pipeline-34993051')).not.toBeNull();
}); });
}); });
}); });
...@@ -165,10 +180,11 @@ describe('graph component', () => { ...@@ -165,10 +180,11 @@ describe('graph component', () => {
describe('when linked pipelines are not present', () => { describe('when linked pipelines are not present', () => {
beforeEach(() => { beforeEach(() => {
const pipeline = Object.assign(graphJSON, { triggered: null, triggered_by: null }); const pipeline = Object.assign(linkedPipelineJSON, { triggered: null, triggered_by: null });
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: false, isLoading: false,
pipeline, pipeline,
mediator,
}); });
}); });
...@@ -200,6 +216,7 @@ describe('graph component', () => { ...@@ -200,6 +216,7 @@ describe('graph component', () => {
component = mountComponent(GraphComponent, { component = mountComponent(GraphComponent, {
isLoading: false, isLoading: false,
pipeline: graphJSON, pipeline: graphJSON,
mediator,
}); });
expect( expect(
......
...@@ -13,31 +13,9 @@ describe('Linked pipeline', () => { ...@@ -13,31 +13,9 @@ describe('Linked pipeline', () => {
vm.$destroy(); vm.$destroy();
}); });
describe('while is loading', () => { describe('rendered output', () => {
const props = { const props = {
pipelineId: mockPipeline.id, pipeline: mockPipeline,
pipelinePath: mockPipeline.path,
pipelineStatus: mockPipeline.details.status,
projectName: mockPipeline.project.name,
isLoading: true,
};
beforeEach(() => {
vm = mountComponent(Component, props);
});
it('renders loading icon', () => {
expect(vm.$el.querySelector('.js-linked-pipeline-loading')).not.toBeNull();
});
});
describe('when it is not loading', () => {
const props = {
pipelineId: mockPipeline.id,
pipelinePath: mockPipeline.path,
pipelineStatus: mockPipeline.details.status,
projectName: mockPipeline.project.name,
isLoading: false,
}; };
beforeEach(() => { beforeEach(() => {
...@@ -55,7 +33,7 @@ describe('Linked pipeline', () => { ...@@ -55,7 +33,7 @@ describe('Linked pipeline', () => {
}); });
it('should render the project name', () => { it('should render the project name', () => {
expect(vm.$el.innerText).toContain(props.projectName); expect(vm.$el.innerText).toContain(props.pipeline.project.name);
}); });
it('should render an svg within the status container', () => { it('should render an svg within the status container', () => {
...@@ -74,7 +52,7 @@ describe('Linked pipeline', () => { ...@@ -74,7 +52,7 @@ describe('Linked pipeline', () => {
}); });
it('should render the pipeline id', () => { it('should render the pipeline id', () => {
expect(vm.$el.innerText).toContain(`#${props.pipelineId}`); expect(vm.$el.innerText).toContain(`#${props.pipeline.id}`);
}); });
it('should correctly compute the tooltip text', () => { it('should correctly compute the tooltip text', () => {
...@@ -93,11 +71,7 @@ describe('Linked pipeline', () => { ...@@ -93,11 +71,7 @@ describe('Linked pipeline', () => {
describe('on click', () => { describe('on click', () => {
const props = { const props = {
pipelineId: mockPipeline.id, pipeline: mockPipeline,
pipelinePath: mockPipeline.path,
pipelineStatus: mockPipeline.details.status,
projectName: mockPipeline.project.name,
isLoading: false,
}; };
beforeEach(() => { beforeEach(() => {
...@@ -115,10 +89,10 @@ describe('Linked pipeline', () => { ...@@ -115,10 +89,10 @@ describe('Linked pipeline', () => {
spyOn(vm.$root, '$emit'); spyOn(vm.$root, '$emit');
vm.$el.querySelector('button').click(); vm.$el.querySelector('button').click();
expect(vm.$root.$emit).toHaveBeenCalledWith( expect(vm.$root.$emit.calls.argsFor(0)).toEqual([
'bv::hide::tooltip', 'bv::hide::tooltip',
`js-linked-pipeline-${props.pipelineId}`, 'js-linked-pipeline-132',
); ]);
}); });
}); });
}); });
This source diff could not be displayed because it is too large. You can view the blob instead.
import PipelineStore from 'ee/pipelines/stores/pipeline_store'; import PipelineStore from 'ee/pipelines/stores/pipeline_store';
import pipelineWithTriggered from './pipeline_with_triggered.json'; import LinkedPipelines from '../linked_pipelines_mock.json';
import pipelineWithTriggeredBy from './pipeline_with_triggered_by.json';
import pipelineWithBoth from './pipeline_with_triggered_triggered_by.json';
import pipeline from './pipeline.json';
describe('EE Pipeline store', () => { describe('EE Pipeline store', () => {
let store; let store;
let data;
beforeEach(() => { beforeEach(() => {
store = new PipelineStore(); store = new PipelineStore();
data = Object.assign({}, LinkedPipelines);
}); });
describe('storePipeline', () => { describe('storePipeline', () => {
describe('triggeredPipelines ', () => { beforeAll(() => {
describe('with triggered pipelines', () => { store.storePipeline(data);
it('saves parsed pipelines', () => {
store.storePipeline(pipelineWithTriggered);
expect(store.triggeredPipelines.length).toEqual(pipelineWithTriggered.triggered.length);
expect(store.triggeredPipelines[0]).toEqual(
Object.assign({}, pipelineWithTriggered.triggered[0], {
isLoading: false,
isCollpased: true,
}),
);
});
}); });
describe('without triggered pipelines', () => { describe('triggered_by', () => {
it('triggeredPipelines should be an empty array', () => { it('sets triggered_by as an array', () => {
store.storePipeline({ triggered: [] }); expect(store.state.pipeline.triggered_by.length).toEqual(1);
expect(store.triggeredPipelines).toEqual([]);
});
});
}); });
describe('triggeredByPipelines', () => { it('adds isExpanding key set to false', () => {
describe('with triggered_by pipeline', () => { expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
store.storePipeline(pipelineWithTriggeredBy);
expect(store.pipelineWithTriggeredBy.length).toEqual(1);
expect(store.triggeredByPipelines[0]).toEqual(
Object.assign({}, pipelineWithTriggeredBy.triggered_by, {
isLoading: false,
isCollpased: true,
}),
);
}); });
describe('without triggered_by pipeline', () => { it('parses nested triggered_by', () => {
it('triggeredByPipelines should be an empty array', () => { expect(store.state.pipeline.triggered_by[0].triggered_by.length).toEqual(1);
store.storePipeline({ triggered_by: null }); expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
expect(store.triggeredByPipelines).toEqual([]);
});
});
}); });
}); });
describe('downstream', () => { describe('triggered', () => {
beforeAll(() => { it('adds isExpanding key set to false for each triggered pipeline', () => {
store.storePipeline(pipelineWithBoth); store.state.pipeline.triggered.forEach(pipeline => {
expect(pipeline.isExpanded).toEqual(false);
}); });
describe('requestTriggeredPipeline', () => {
beforeEach(() => {
store.requestTriggeredPipeline(store.triggeredPipelines[0]);
}); });
it('sets isLoading to true for the requested pipeline', () => { it('parses nested triggered pipelines', () => {
expect(store.triggeredPipelines[0].isLoading).toEqual(true); store.state.pipeline.triggered[1].triggered.forEach(pipeline => {
expect(pipeline.isExpanded).toEqual(false);
}); });
it('sets isExpanded to true for the requested pipeline', () => {
expect(store.triggeredPipelines[0].isExpanded).toEqual(true);
}); });
it('sets isLoading to false for the other pipelines', () => {
expect(store.triggeredPipelines[1].isLoading).toEqual(false);
});
it('sets isExpanded to false for the other pipelines', () => {
expect(store.triggeredPipelines[1].isExpanded).toEqual(false);
}); });
}); });
describe('receiveTriggeredPipelineSuccess', () => { describe('resetTriggeredByPipeline', () => {
it('updates the given pipeline and sets it as the visible one', () => { beforeEach(() => {
const receivedPipeline = store.triggeredPipelines[0]; store.storePipeline(data);
store.receiveTriggeredPipelineSuccess(receivedPipeline);
expect(store.triggeredPipelines[0].isLoading).toEqual(false);
expect(store.triggered).toEqual(receivedPipeline);
});
}); });
describe('receiveTriggeredPipelineError', () => { it('closes the pipeline & nested ones', () => {
it('resets the given pipeline and resets it as the visible one', () => { store.state.pipeline.triggered_by[0].isExpanded = true;
const receivedPipeline = store.triggeredPipelines[0]; store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded = true;
store.receiveTriggeredPipelineError(receivedPipeline);
expect(store.triggeredPipelines[0].isLoading).toEqual(false); store.resetTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
expect(store.triggeredPipelines[0].isExpanded).toEqual(false);
expect(store.triggered).toEqual({}); expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
}); expect(store.state.pipeline.triggered_by[0].triggered_by[0].isExpanded).toEqual(false);
}); });
}); });
describe('upstream', () => { describe('openTriggeredByPipeline', () => {
describe('requestTriggeredByPipeline', () => {
beforeEach(() => { beforeEach(() => {
store.requestTriggeredByPipeline(store.triggeredByPipelines[0]); store.storePipeline(data);
}); });
it('sets isLoading to true for the requested pipeline', () => { it('opens the given pipeline', () => {
expect(store.triggeredByPipelines[0].isLoading).toEqual(true); store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
});
it('sets isExpanded to true for the requested pipeline', () => { expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(true);
expect(store.triggeredByPipelines[0].isExpanded).toEqual(true);
}); });
}); });
describe('receiveTriggeredByPipelineSuccess', () => { describe('closeTriggeredByPipeline', () => {
it('updates the given pipeline and sets it as the visible one', () => { beforeEach(() => {
const receivedPipeline = store.triggeredByPipelines[0]; store.storePipeline(data);
store.receiveTriggeredByPipelineSuccess(receivedPipeline);
expect(store.triggeredByPipelines[0].isLoading).toEqual(false);
expect(store.triggeredBy).toEqual(receivedPipeline);
});
}); });
describe('receiveTriggeredByPipelineError', () => { it('closes the given pipeline', () => {
it('resets the given pipeline and resets it as the visible one', () => { // open it first
const receivedPipeline = store.triggeredByPipelines[0]; store.openTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
store.receiveTriggeredByPipelineError(receivedPipeline);
expect(store.triggeredByPipelines[0].isLoading).toEqual(false); store.closeTriggeredByPipeline(store.state.pipeline, store.state.pipeline.triggered_by[0]);
expect(store.triggeredByPipelines[0].isExpanded).toEqual(false);
expect(store.triggeredBy).toEqual({}); expect(store.state.pipeline.triggered_by[0].isExpanded).toEqual(false);
});
});
});
describe('utils', () => {
describe('parsePipeline', () => {
let parsed;
beforeAll(() => {
parsed = PipelineStore.parsePipeline(pipeline);
}); });
it('adds isLoading key set to false', () => {
expect(parsed.isLoading).toEqual(false);
}); });
it('adds isExpanded key set to false', () => { describe('resetTriggeredPipelines', () => {
expect(parsed.isExpanded).toEqual(false); beforeEach(() => {
}); store.storePipeline(data);
}); });
describe('getPipelineIndex', () => { it('closes the pipeline & nested ones', () => {
beforeAll(() => { store.state.pipeline.triggered[0].isExpanded = true;
store.storePipeline(pipelineWithBoth); store.state.pipeline.triggered[0].triggered[0].isExpanded = true;
});
it('returns the pipeline index for the provided pipeline and storeKey', () => { store.resetTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
store.getPipelineIndex('triggeredPipelines', store.triggeredPipelines[1]).toEqual(1);
});
});
describe('updateStoreOnRequest', () => { expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
beforeAll(() => { expect(store.state.pipeline.triggered[0].triggered[0].isExpanded).toEqual(false);
store.storePipeline(pipelineWithBoth);
}); });
it('sets clicked pipeline isLoading to true', () => {
store.updateStoreOnRequest('triggeredPipelines', store.triggeredPipelines[1]);
expect(store.triggeredPipelines[1].isLoading).isLoading(true);
}); });
it('sets clicked pipeline isExpanded to true', () => { describe('openTriggeredPipeline', () => {
store.updateStoreOnRequest('triggeredPipelines', store.triggeredPipelines[1]); beforeEach(() => {
store.storePipeline(data);
expect(store.triggeredPipelines[1].isExpanded).isLoading(true);
});
}); });
describe('updatePipeline', () => { it('opens the given pipeline', () => {
beforeAll(() => { store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
store.storePipeline(pipelineWithBoth);
store.updatePipeline(
'triggeredPipelines',
store.triggeredPipelines[1],
{ isLoading: true },
'triggered',
store.triggeredPipelines[1],
);
});
it('updates the given pipeline in the correct array', () => { expect(store.state.pipeline.triggered[0].isExpanded).toEqual(true);
expect(store.triggeredPipelines[1].isLoading).toEqual(true);
expect(store.triggered).toEqual(store.triggeredPipelines[1]);
}); });
it('updates the visible pipeline to the given value', () => {});
}); });
describe('closePipeline', () => { describe('closeTriggeredPipeline', () => {
beforeAll(() => { beforeEach(() => {
store.storePipeline(pipelineWithBoth); store.storePipeline(data);
}); });
it('closes the given pipeline', () => { it('closes the given pipeline', () => {
const clickedPipeline = store.triggeredPipelines[1];
// open it first // open it first
clickedPipeline.isExpanded = true; store.openTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
store.triggered = clickedPipeline;
store.closePipeline('triggeredPipelines', clickedPipeline, 'triggered'); store.closeTriggeredPipeline(store.state.pipeline, store.state.pipeline.triggered[0]);
expect(store.triggeredPipelines[1].isExpanded).toEqual(true); expect(store.state.pipeline.triggered[0].isExpanded).toEqual(false);
expect(store.triggered).toEqual({});
});
}); });
}); });
}); });
...@@ -685,12 +685,6 @@ msgstr "" ...@@ -685,12 +685,6 @@ msgstr ""
msgid "An error occured while fetching the releases. Please try again." msgid "An error occured while fetching the releases. Please try again."
msgstr "" msgstr ""
msgid "An error occured while fetching this downstream pipeline. Please try again"
msgstr ""
msgid "An error occured while fetching this upstream pipeline. Please try again"
msgstr ""
msgid "An error occurred adding a draft to the discussion." msgid "An error occurred adding a draft to the discussion."
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment