Merge branch 'auto-pipelines-vue' into 'master'
Pipelines Vue See merge request !7196
This commit is contained in:
commit
2f1701c56a
43 changed files with 2111 additions and 262 deletions
|
@ -184,11 +184,6 @@
|
|||
new TreeView();
|
||||
}
|
||||
break;
|
||||
case 'projects:pipelines:index':
|
||||
new gl.MiniPipelineGraph({
|
||||
container: '.js-pipeline-table',
|
||||
});
|
||||
break;
|
||||
case 'projects:pipelines:builds':
|
||||
case 'projects:pipelines:show':
|
||||
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
|
||||
|
|
|
@ -139,6 +139,21 @@
|
|||
}, 200);
|
||||
};
|
||||
|
||||
/**
|
||||
this will take in the `name` of the param you want to parse in the url
|
||||
if the name does not exist this function will return `null`
|
||||
otherwise it will return the value of the param key provided
|
||||
*/
|
||||
w.gl.utils.getParameterByName = (name) => {
|
||||
const url = window.location.href;
|
||||
name = name.replace(/[[\]]/g, '\\$&');
|
||||
const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`);
|
||||
const results = regex.exec(url);
|
||||
if (!results) return null;
|
||||
if (!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
};
|
||||
|
||||
})(window);
|
||||
|
||||
}).call(this);
|
148
app/assets/javascripts/vue_pagination/index.js.es6
Normal file
148
app/assets/javascripts/vue_pagination/index.js.es6
Normal file
|
@ -0,0 +1,148 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign, no-plusplus */
|
||||
|
||||
((gl) => {
|
||||
const PAGINATION_UI_BUTTON_LIMIT = 4;
|
||||
const UI_LIMIT = 6;
|
||||
const SPREAD = '...';
|
||||
const PREV = 'Prev';
|
||||
const NEXT = 'Next';
|
||||
const FIRST = '<< First';
|
||||
const LAST = 'Last >>';
|
||||
|
||||
gl.VueGlPagination = Vue.extend({
|
||||
props: {
|
||||
|
||||
/**
|
||||
This function will take the information given by the pagination component
|
||||
And make a new Turbolinks call
|
||||
|
||||
Here is an example `change` method:
|
||||
|
||||
change(pagenum, apiScope) {
|
||||
Turbolinks.visit(`?scope=${apiScope}&p=${pagenum}`);
|
||||
},
|
||||
*/
|
||||
|
||||
change: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
pageInfo will come from the headers of the API call
|
||||
in the `.then` clause of the VueResource API call
|
||||
there should be a function that contructs the pageInfo for this component
|
||||
|
||||
This is an example:
|
||||
|
||||
const pageInfo = headers => ({
|
||||
perPage: +headers['X-Per-Page'],
|
||||
page: +headers['X-Page'],
|
||||
total: +headers['X-Total'],
|
||||
totalPages: +headers['X-Total-Pages'],
|
||||
nextPage: +headers['X-Next-Page'],
|
||||
previousPage: +headers['X-Prev-Page'],
|
||||
});
|
||||
*/
|
||||
|
||||
pageInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changePage(e) {
|
||||
let apiScope = gl.utils.getParameterByName('scope');
|
||||
|
||||
if (!apiScope) apiScope = 'all';
|
||||
|
||||
const text = e.target.innerText;
|
||||
const { totalPages, nextPage, previousPage } = this.pageInfo;
|
||||
|
||||
switch (text) {
|
||||
case SPREAD:
|
||||
break;
|
||||
case LAST:
|
||||
this.change(totalPages, apiScope);
|
||||
break;
|
||||
case NEXT:
|
||||
this.change(nextPage, apiScope);
|
||||
break;
|
||||
case PREV:
|
||||
this.change(previousPage, apiScope);
|
||||
break;
|
||||
case FIRST:
|
||||
this.change(1, apiScope);
|
||||
break;
|
||||
default:
|
||||
this.change(+text, apiScope);
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
prev() {
|
||||
return this.pageInfo.previousPage;
|
||||
},
|
||||
next() {
|
||||
return this.pageInfo.nextPage;
|
||||
},
|
||||
getItems() {
|
||||
const total = this.pageInfo.totalPages;
|
||||
const page = this.pageInfo.page;
|
||||
const items = [];
|
||||
|
||||
if (page > 1) items.push({ title: FIRST });
|
||||
|
||||
if (page > 1) {
|
||||
items.push({ title: PREV, prev: true });
|
||||
} else {
|
||||
items.push({ title: PREV, disabled: true, prev: true });
|
||||
}
|
||||
|
||||
if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
|
||||
|
||||
const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
|
||||
const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const isActive = i === page;
|
||||
items.push({ title: i, active: isActive, page: true });
|
||||
}
|
||||
|
||||
if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
|
||||
items.push({ title: SPREAD, separator: true, page: true });
|
||||
}
|
||||
|
||||
if (page === total) {
|
||||
items.push({ title: NEXT, disabled: true, next: true });
|
||||
} else if (total - page >= 1) {
|
||||
items.push({ title: NEXT, next: true });
|
||||
}
|
||||
|
||||
if (total - page >= 1) items.push({ title: LAST, last: true });
|
||||
|
||||
return items;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class="gl-pagination">
|
||||
<ul class="pagination clearfix">
|
||||
<li v-for='item in getItems'
|
||||
:class='{
|
||||
page: item.page,
|
||||
prev: item.prev,
|
||||
next: item.next,
|
||||
separator: item.separator,
|
||||
active: item.active,
|
||||
disabled: item.disabled
|
||||
}'
|
||||
>
|
||||
<a @click="changePage($event)">{{item.title}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
41
app/assets/javascripts/vue_pipelines_index/index.js.es6
Normal file
41
app/assets/javascripts/vue_pipelines_index/index.js.es6
Normal file
|
@ -0,0 +1,41 @@
|
|||
/* global Vue, VueResource, gl */
|
||||
/*= require vue_common_component/commit */
|
||||
/*= require vue-resource
|
||||
/*= require boards/vue_resource_interceptor */
|
||||
/*= require ./status.js.es6 */
|
||||
/*= require ./store.js.es6 */
|
||||
/*= require ./pipeline_url.js.es6 */
|
||||
/*= require ./stage.js.es6 */
|
||||
/*= require ./stages.js.es6 */
|
||||
/*= require ./pipeline_actions.js.es6 */
|
||||
/*= require ./time_ago.js.es6 */
|
||||
/*= require ./pipelines.js.es6 */
|
||||
|
||||
(() => {
|
||||
const project = document.querySelector('.pipelines');
|
||||
const entry = document.querySelector('.vue-pipelines-index');
|
||||
const svgs = document.querySelector('.pipeline-svgs');
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
if (!entry) return null;
|
||||
return new Vue({
|
||||
el: entry,
|
||||
data: {
|
||||
scope: project.dataset.url,
|
||||
store: new gl.PipelineStore(),
|
||||
svgs: svgs.dataset,
|
||||
},
|
||||
components: {
|
||||
'vue-pipelines': gl.VuePipelines,
|
||||
},
|
||||
template: `
|
||||
<vue-pipelines
|
||||
:scope='scope'
|
||||
:store='store'
|
||||
:svgs='svgs'
|
||||
>
|
||||
</vue-pipelines>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,99 @@
|
|||
/* global Vue, Flash, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VuePipelineActions = Vue.extend({
|
||||
props: ['pipeline', 'svgs'],
|
||||
computed: {
|
||||
actions() {
|
||||
return this.pipeline.details.manual_actions.length > 0;
|
||||
},
|
||||
artifacts() {
|
||||
return this.pipeline.details.artifacts.length > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
download(name) {
|
||||
return `Download ${name} artifacts`;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<td class="pipeline-actions hidden-xs">
|
||||
<div class="controls pull-right">
|
||||
<div class="btn-group inline">
|
||||
<div class="btn-group">
|
||||
<a
|
||||
v-if='actions'
|
||||
class="dropdown-toggle btn btn-default js-pipeline-dropdown-manual-actions"
|
||||
data-toggle="dropdown"
|
||||
title="Manual build"
|
||||
alt="Manual Build"
|
||||
>
|
||||
<span v-html='svgs.iconPlay'></span>
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-align-right">
|
||||
<li v-for='action in pipeline.details.manual_actions'>
|
||||
<a
|
||||
rel="nofollow"
|
||||
data-method="post"
|
||||
:href='action.path'
|
||||
title="Manual build"
|
||||
>
|
||||
<span v-html='svgs.iconPlay'></span>
|
||||
<span title="Manual build">{{action.name}}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a
|
||||
v-if='artifacts'
|
||||
class="dropdown-toggle btn btn-default build-artifacts js-pipeline-dropdown-download"
|
||||
data-toggle="dropdown"
|
||||
type="button"
|
||||
>
|
||||
<i class="fa fa-download"></i>
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-align-right">
|
||||
<li v-for='artifact in pipeline.details.artifacts'>
|
||||
<a
|
||||
rel="nofollow"
|
||||
:href='artifact.path'
|
||||
>
|
||||
<i class="fa fa-download"></i>
|
||||
<span>{{download(artifact.name)}}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cancel-retry-btns inline">
|
||||
<a
|
||||
v-if='pipeline.flags.retryable'
|
||||
class="btn has-tooltip"
|
||||
title="Retry"
|
||||
rel="nofollow"
|
||||
data-method="post"
|
||||
:href='pipeline.retry_path'
|
||||
>
|
||||
<i class="fa fa-repeat"></i>
|
||||
</a>
|
||||
<a
|
||||
v-if='pipeline.flags.cancelable'
|
||||
class="btn btn-remove has-tooltip"
|
||||
title="Cancel"
|
||||
rel="nofollow"
|
||||
data-method="post"
|
||||
:href='pipeline.cancel_path'
|
||||
data-original-title="Cancel"
|
||||
>
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -0,0 +1,63 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VuePipelineUrl = Vue.extend({
|
||||
props: [
|
||||
'pipeline',
|
||||
],
|
||||
computed: {
|
||||
user() {
|
||||
return !!this.pipeline.user;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<td>
|
||||
<a :href='pipeline.path'>
|
||||
<span class="pipeline-id">#{{pipeline.id}}</span>
|
||||
</a>
|
||||
<span>by</span>
|
||||
<a
|
||||
v-if='user'
|
||||
:href='pipeline.user.web_url'
|
||||
>
|
||||
<img
|
||||
v-if='user'
|
||||
class="avatar has-tooltip s20 "
|
||||
:title='pipeline.user.name'
|
||||
data-container="body"
|
||||
:src='pipeline.user.avatar_url'
|
||||
>
|
||||
</a>
|
||||
<span
|
||||
v-if='!user'
|
||||
class="api monospace"
|
||||
>
|
||||
API
|
||||
</span>
|
||||
<span
|
||||
v-if='pipeline.flags.latest'
|
||||
class="label label-success has-tooltip"
|
||||
title="Latest pipeline for this branch"
|
||||
data-original-title="Latest pipeline for this branch"
|
||||
>
|
||||
latest
|
||||
</span>
|
||||
<span
|
||||
v-if='pipeline.flags.yaml_errors'
|
||||
class="label label-danger has-tooltip"
|
||||
:title='pipeline.yaml_errors'
|
||||
:data-original-title='pipeline.yaml_errors'
|
||||
>
|
||||
yaml invalid
|
||||
</span>
|
||||
<span
|
||||
v-if='pipeline.flags.stuck'
|
||||
class="label label-warning"
|
||||
>
|
||||
stuck
|
||||
</span>
|
||||
</td>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
131
app/assets/javascripts/vue_pipelines_index/pipelines.js.es6
Normal file
131
app/assets/javascripts/vue_pipelines_index/pipelines.js.es6
Normal file
|
@ -0,0 +1,131 @@
|
|||
/* global Vue, Turbolinks, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VuePipelines = Vue.extend({
|
||||
components: {
|
||||
runningPipeline: gl.VueRunningPipeline,
|
||||
pipelineActions: gl.VuePipelineActions,
|
||||
stages: gl.VueStages,
|
||||
commit: gl.CommitComponent,
|
||||
pipelineUrl: gl.VuePipelineUrl,
|
||||
pipelineHead: gl.VuePipelineHead,
|
||||
glPagination: gl.VueGlPagination,
|
||||
statusScope: gl.VueStatusScope,
|
||||
timeAgo: gl.VueTimeAgo,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pipelines: [],
|
||||
timeLoopInterval: '',
|
||||
intervalId: '',
|
||||
apiScope: 'all',
|
||||
pageInfo: {},
|
||||
pagenum: 1,
|
||||
count: { all: 0, running_or_pending: 0 },
|
||||
pageRequest: false,
|
||||
};
|
||||
},
|
||||
props: ['scope', 'store', 'svgs'],
|
||||
created() {
|
||||
const pagenum = gl.utils.getParameterByName('p');
|
||||
const scope = gl.utils.getParameterByName('scope');
|
||||
if (pagenum) this.pagenum = pagenum;
|
||||
if (scope) this.apiScope = scope;
|
||||
this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope);
|
||||
},
|
||||
methods: {
|
||||
change(pagenum, apiScope) {
|
||||
Turbolinks.visit(`?scope=${apiScope}&p=${pagenum}`);
|
||||
},
|
||||
author(pipeline) {
|
||||
if (!pipeline.commit) return { avatar_url: '', web_url: '', username: '' };
|
||||
if (pipeline.commit.author) return pipeline.commit.author;
|
||||
return {
|
||||
avatar_url: pipeline.commit.author_gravatar_url,
|
||||
web_url: `mailto:${pipeline.commit.author_email}`,
|
||||
username: pipeline.commit.author_name,
|
||||
};
|
||||
},
|
||||
ref(pipeline) {
|
||||
const { ref } = pipeline;
|
||||
return { name: ref.name, tag: ref.tag, ref_url: ref.path };
|
||||
},
|
||||
commitTitle(pipeline) {
|
||||
return pipeline.commit ? pipeline.commit.title : '';
|
||||
},
|
||||
commitSha(pipeline) {
|
||||
return pipeline.commit ? pipeline.commit.short_id : '';
|
||||
},
|
||||
commitUrl(pipeline) {
|
||||
return pipeline.commit ? pipeline.commit.commit_path : '';
|
||||
},
|
||||
match(string) {
|
||||
return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase());
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div class="pipelines realtime-loading" v-if='pipelines.length < 1'>
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
<div class="table-holder" v-if='pipelines.length'>
|
||||
<table class="table ci-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Pipeline</th>
|
||||
<th>Commit</th>
|
||||
<th>Stages</th>
|
||||
<th></th>
|
||||
<th class="hidden-xs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="commit" v-for='pipeline in pipelines'>
|
||||
<status-scope
|
||||
:pipeline='pipeline'
|
||||
:match='match'
|
||||
:svgs='svgs'
|
||||
>
|
||||
</status-scope>
|
||||
<pipeline-url :pipeline='pipeline'></pipeline-url>
|
||||
<td>
|
||||
<commit
|
||||
:commit-icon-svg='svgs.commitIconSvg'
|
||||
:author='author(pipeline)'
|
||||
:tag="pipeline.ref.tag"
|
||||
:title='commitTitle(pipeline)'
|
||||
:commit-ref='ref(pipeline)'
|
||||
:short-sha='commitSha(pipeline)'
|
||||
:commit-url='commitUrl(pipeline)'
|
||||
>
|
||||
</commit>
|
||||
</td>
|
||||
<stages
|
||||
:pipeline='pipeline'
|
||||
:svgs='svgs'
|
||||
:match='match'
|
||||
>
|
||||
</stages>
|
||||
<time-ago :pipeline='pipeline' :svgs='svgs'></time-ago>
|
||||
<pipeline-actions :pipeline='pipeline' :svgs='svgs'></pipeline-actions>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pipelines realtime-loading" v-if='pageRequest'>
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
<gl-pagination
|
||||
v-if='pageInfo.total > pageInfo.perPage'
|
||||
:pagenum='pagenum'
|
||||
:change='change'
|
||||
:count='count.all'
|
||||
:pageInfo='pageInfo'
|
||||
>
|
||||
</gl-pagination>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
76
app/assets/javascripts/vue_pipelines_index/stage.js.es6
Normal file
76
app/assets/javascripts/vue_pipelines_index/stage.js.es6
Normal file
|
@ -0,0 +1,76 @@
|
|||
/* global Vue, Flash, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VueStage = Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
request: false,
|
||||
builds: '',
|
||||
spinner: '<span class="fa fa-spinner fa-spin"></span>',
|
||||
};
|
||||
},
|
||||
props: ['stage', 'svgs', 'match'],
|
||||
methods: {
|
||||
fetchBuilds() {
|
||||
if (this.request) return this.clearBuilds();
|
||||
|
||||
return this.$http.get(this.stage.dropdown_path)
|
||||
.then((response) => {
|
||||
this.request = true;
|
||||
this.builds = JSON.parse(response.body).html;
|
||||
}, () => {
|
||||
const flash = new Flash('Something went wrong on our end.');
|
||||
this.request = false;
|
||||
return flash;
|
||||
});
|
||||
},
|
||||
clearBuilds() {
|
||||
this.builds = '';
|
||||
this.request = false;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
buildsOrSpinner() {
|
||||
return this.request ? this.builds : this.spinner;
|
||||
},
|
||||
dropdownClass() {
|
||||
if (this.request) return 'js-builds-dropdown-container';
|
||||
return 'js-builds-dropdown-loading builds-dropdown-loading';
|
||||
},
|
||||
buildStatus() {
|
||||
return `Build: ${this.stage.status.label}`;
|
||||
},
|
||||
tooltip() {
|
||||
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
|
||||
},
|
||||
svg() {
|
||||
const icon = this.stage.status.icon;
|
||||
const stageIcon = icon.replace(/icon/i, 'stage_icon');
|
||||
return this.svgs[this.match(stageIcon)];
|
||||
},
|
||||
triggerButtonClass() {
|
||||
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button
|
||||
@click='fetchBuilds'
|
||||
@blur='fetchBuilds'
|
||||
:class="triggerButtonClass"
|
||||
:title='stage.title'
|
||||
data-placement="top"
|
||||
data-toggle="dropdown"
|
||||
type="button">
|
||||
<span v-html="svg"></span>
|
||||
<i class="fa fa-caret-down "></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
|
||||
<div class="arrow-up"></div>
|
||||
<div :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu" v-html="buildsOrSpinner"></div>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
21
app/assets/javascripts/vue_pipelines_index/stages.js.es6
Normal file
21
app/assets/javascripts/vue_pipelines_index/stages.js.es6
Normal file
|
@ -0,0 +1,21 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VueStages = Vue.extend({
|
||||
components: {
|
||||
'vue-stage': gl.VueStage,
|
||||
},
|
||||
props: ['pipeline', 'svgs', 'match'],
|
||||
template: `
|
||||
<td class="stage-cell">
|
||||
<div
|
||||
class="stage-container dropdown js-mini-pipeline-graph"
|
||||
v-for='stage in pipeline.details.stages'
|
||||
>
|
||||
<vue-stage :stage='stage' :svgs='svgs' :match='match'></vue-stage>
|
||||
</div>
|
||||
</td>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
34
app/assets/javascripts/vue_pipelines_index/status.js.es6
Normal file
34
app/assets/javascripts/vue_pipelines_index/status.js.es6
Normal file
|
@ -0,0 +1,34 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VueStatusScope = Vue.extend({
|
||||
props: [
|
||||
'pipeline', 'svgs', 'match',
|
||||
],
|
||||
computed: {
|
||||
cssClasses() {
|
||||
const cssObject = { 'ci-status': true };
|
||||
cssObject[`ci-${this.pipeline.details.status.group}`] = true;
|
||||
return cssObject;
|
||||
},
|
||||
svg() {
|
||||
return this.svgs[this.match(this.pipeline.details.status.icon)];
|
||||
},
|
||||
detailsPath() {
|
||||
const { status } = this.pipeline.details;
|
||||
return status.has_details ? status.details_path : false;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<td class="commit-link">
|
||||
<a
|
||||
:class='cssClasses'
|
||||
:href='detailsPath'
|
||||
v-html='svg + pipeline.details.status.text'
|
||||
>
|
||||
</a>
|
||||
</td>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
59
app/assets/javascripts/vue_pipelines_index/store.js.es6
Normal file
59
app/assets/javascripts/vue_pipelines_index/store.js.es6
Normal file
|
@ -0,0 +1,59 @@
|
|||
/* global gl, Flash */
|
||||
/* eslint-disable no-param-reassign, no-underscore-dangle */
|
||||
/*= require vue_realtime_listener/index.js */
|
||||
|
||||
((gl) => {
|
||||
const pageValues = headers => ({
|
||||
perPage: +headers['X-Per-Page'],
|
||||
page: +headers['X-Page'],
|
||||
total: +headers['X-Total'],
|
||||
totalPages: +headers['X-Total-Pages'],
|
||||
nextPage: +headers['X-Next-Page'],
|
||||
previousPage: +headers['X-Prev-Page'],
|
||||
});
|
||||
|
||||
gl.PipelineStore = class {
|
||||
fetchDataLoop(Vue, pageNum, url, apiScope) {
|
||||
const updatePipelineNums = (count) => {
|
||||
const { all } = count;
|
||||
const running = count.running_or_pending;
|
||||
document.querySelector('.js-totalbuilds-count').innerHTML = all;
|
||||
document.querySelector('.js-running-count').innerHTML = running;
|
||||
};
|
||||
|
||||
const goFetch = () =>
|
||||
this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`)
|
||||
.then((response) => {
|
||||
const pageInfo = pageValues(response.headers);
|
||||
this.pageInfo = Object.assign({}, this.pageInfo, pageInfo);
|
||||
|
||||
const res = JSON.parse(response.body);
|
||||
this.count = Object.assign({}, this.count, res.count);
|
||||
this.pipelines = Object.assign([], this.pipelines, res.pipelines);
|
||||
|
||||
updatePipelineNums(this.count);
|
||||
this.pageRequest = false;
|
||||
}, () => {
|
||||
this.pageRequest = false;
|
||||
return new Flash('Something went wrong on our end.');
|
||||
});
|
||||
|
||||
goFetch();
|
||||
|
||||
const startTimeLoops = () => {
|
||||
this.timeLoopInterval = setInterval(() => {
|
||||
this.$children
|
||||
.filter(e => e.$options._componentTag === 'time-ago')
|
||||
.forEach(e => e.changeTime());
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
startTimeLoops();
|
||||
|
||||
const removeIntervals = () => clearInterval(this.timeLoopInterval);
|
||||
const startIntervals = () => startTimeLoops();
|
||||
|
||||
gl.VueRealtimeListener(removeIntervals, startIntervals);
|
||||
}
|
||||
};
|
||||
})(window.gl || (window.gl = {}));
|
73
app/assets/javascripts/vue_pipelines_index/time_ago.js.es6
Normal file
73
app/assets/javascripts/vue_pipelines_index/time_ago.js.es6
Normal file
|
@ -0,0 +1,73 @@
|
|||
/* global Vue, gl */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VueTimeAgo = Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
currentTime: new Date(),
|
||||
};
|
||||
},
|
||||
props: ['pipeline', 'svgs'],
|
||||
computed: {
|
||||
timeAgo() {
|
||||
return gl.utils.getTimeago();
|
||||
},
|
||||
localTimeFinished() {
|
||||
return gl.utils.formatDate(this.pipeline.details.finished_at);
|
||||
},
|
||||
timeStopped() {
|
||||
const changeTime = this.currentTime;
|
||||
const options = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
};
|
||||
options.timeZoneName = 'short';
|
||||
const finished = this.pipeline.details.finished_at;
|
||||
if (!finished && changeTime) return false;
|
||||
return ({ words: this.timeAgo.format(finished) });
|
||||
},
|
||||
duration() {
|
||||
const { duration } = this.pipeline.details;
|
||||
const date = new Date(duration * 1000);
|
||||
|
||||
let hh = date.getUTCHours();
|
||||
let mm = date.getUTCMinutes();
|
||||
let ss = date.getSeconds();
|
||||
|
||||
if (hh < 10) hh = `0${hh}`;
|
||||
if (mm < 10) mm = `0${mm}`;
|
||||
if (ss < 10) ss = `0${ss}`;
|
||||
|
||||
if (duration !== null) return `${hh}:${mm}:${ss}`;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changeTime() {
|
||||
this.currentTime = new Date();
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<td>
|
||||
<p class="duration" v-if='duration'>
|
||||
<span v-html='svgs.iconTimer'></span>
|
||||
{{duration}}
|
||||
</p>
|
||||
<p class="finished-at" v-if='timeStopped'>
|
||||
<i class="fa fa-calendar"></i>
|
||||
<time
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
data-container="body"
|
||||
:data-original-title='localTimeFinished'
|
||||
>
|
||||
{{timeStopped.words}}
|
||||
</time>
|
||||
</p>
|
||||
</td>
|
||||
`,
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
18
app/assets/javascripts/vue_realtime_listener/index.js.es6
Normal file
18
app/assets/javascripts/vue_realtime_listener/index.js.es6
Normal file
|
@ -0,0 +1,18 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
|
||||
((gl) => {
|
||||
gl.VueRealtimeListener = (removeIntervals, startIntervals) => {
|
||||
const removeAll = () => {
|
||||
removeIntervals();
|
||||
window.removeEventListener('beforeunload', removeIntervals);
|
||||
window.removeEventListener('focus', startIntervals);
|
||||
window.removeEventListener('blur', removeIntervals);
|
||||
document.removeEventListener('page:fetch', removeAll);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', removeIntervals);
|
||||
window.addEventListener('focus', startIntervals);
|
||||
window.addEventListener('blur', removeIntervals);
|
||||
document.addEventListener('page:fetch', removeAll);
|
||||
};
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -1,4 +1,9 @@
|
|||
.pipelines {
|
||||
.realtime-loading {
|
||||
font-size: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stage {
|
||||
max-width: 90px;
|
||||
width: 90px;
|
||||
|
@ -24,6 +29,10 @@
|
|||
min-width: 1200px;
|
||||
table-layout: fixed;
|
||||
|
||||
.label {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.pipeline-id {
|
||||
color: $black;
|
||||
}
|
||||
|
@ -177,6 +186,7 @@
|
|||
.stage-cell {
|
||||
font-size: 0;
|
||||
|
||||
> .stage-container > div > button > span > svg,
|
||||
> .stage-container > button > svg {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
|
|
|
@ -7,11 +7,33 @@ class Projects::PipelinesController < Projects::ApplicationController
|
|||
|
||||
def index
|
||||
@scope = params[:scope]
|
||||
@pipelines = PipelinesFinder.new(project).execute(scope: @scope).page(params[:page]).per(30)
|
||||
@pipelines = @pipelines.includes(project: :namespace)
|
||||
@pipelines = PipelinesFinder
|
||||
.new(project)
|
||||
.execute(scope: @scope)
|
||||
.page(params[:page])
|
||||
.per(30)
|
||||
|
||||
@running_or_pending_count = PipelinesFinder.new(project).execute(scope: 'running').count
|
||||
@pipelines_count = PipelinesFinder.new(project).execute.count
|
||||
@running_or_pending_count = PipelinesFinder
|
||||
.new(project).execute(scope: 'running').count
|
||||
|
||||
@pipelines_count = PipelinesFinder
|
||||
.new(project).execute.count
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
render json: {
|
||||
pipelines: PipelineSerializer
|
||||
.new(project: @project, user: @current_user)
|
||||
.with_pagination(request, response)
|
||||
.represent(@pipelines),
|
||||
count: {
|
||||
all: @pipelines_count,
|
||||
running_or_pending: @running_or_pending_count
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
|
|
|
@ -142,7 +142,7 @@ module Ci
|
|||
end
|
||||
|
||||
def artifacts
|
||||
builds.latest.with_artifacts_not_expired
|
||||
builds.latest.with_artifacts_not_expired.includes(project: [:namespace])
|
||||
end
|
||||
|
||||
def project_id
|
||||
|
@ -191,7 +191,11 @@ module Ci
|
|||
end
|
||||
|
||||
def manual_actions
|
||||
builds.latest.manual_actions
|
||||
builds.latest.manual_actions.includes(project: [:namespace])
|
||||
end
|
||||
|
||||
def stuck?
|
||||
builds.pending.any?(&:stuck?)
|
||||
end
|
||||
|
||||
def retryable?
|
||||
|
@ -283,6 +287,10 @@ module Ci
|
|||
end
|
||||
end
|
||||
|
||||
def has_yaml_errors?
|
||||
yaml_errors.present?
|
||||
end
|
||||
|
||||
def environments
|
||||
builds.where.not(environment: nil).success.pluck(:environment).uniq
|
||||
end
|
||||
|
|
14
app/serializers/build_action_entity.rb
Normal file
14
app/serializers/build_action_entity.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
class BuildActionEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :name do |build|
|
||||
build.name.humanize
|
||||
end
|
||||
|
||||
expose :path do |build|
|
||||
play_namespace_project_build_path(
|
||||
build.project.namespace,
|
||||
build.project,
|
||||
build)
|
||||
end
|
||||
end
|
14
app/serializers/build_artifact_entity.rb
Normal file
14
app/serializers/build_artifact_entity.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
class BuildArtifactEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :name do |build|
|
||||
build.name
|
||||
end
|
||||
|
||||
expose :path do |build|
|
||||
download_namespace_project_build_artifacts_path(
|
||||
build.project.namespace,
|
||||
build.project,
|
||||
build)
|
||||
end
|
||||
end
|
|
@ -3,6 +3,10 @@ class CommitEntity < API::Entities::RepoCommit
|
|||
|
||||
expose :author, using: UserEntity
|
||||
|
||||
expose :author_gravatar_url do |commit|
|
||||
GravatarService.new.execute(commit.author_email)
|
||||
end
|
||||
|
||||
expose :commit_url do |commit|
|
||||
namespace_project_tree_url(
|
||||
request.project.namespace,
|
||||
|
|
83
app/serializers/pipeline_entity.rb
Normal file
83
app/serializers/pipeline_entity.rb
Normal file
|
@ -0,0 +1,83 @@
|
|||
class PipelineEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :id
|
||||
expose :user, using: UserEntity
|
||||
|
||||
expose :path do |pipeline|
|
||||
namespace_project_pipeline_path(
|
||||
pipeline.project.namespace,
|
||||
pipeline.project,
|
||||
pipeline)
|
||||
end
|
||||
|
||||
expose :details do
|
||||
expose :status do |pipeline, options|
|
||||
StatusEntity.represent(
|
||||
pipeline.detailed_status(request.user),
|
||||
options)
|
||||
end
|
||||
|
||||
expose :duration
|
||||
expose :finished_at
|
||||
expose :stages, using: StageEntity
|
||||
expose :artifacts, using: BuildArtifactEntity
|
||||
expose :manual_actions, using: BuildActionEntity
|
||||
end
|
||||
|
||||
expose :flags do
|
||||
expose :latest?, as: :latest
|
||||
expose :triggered?, as: :triggered
|
||||
expose :stuck?, as: :stuck
|
||||
expose :has_yaml_errors?, as: :yaml_errors
|
||||
expose :can_retry?, as: :retryable
|
||||
expose :can_cancel?, as: :cancelable
|
||||
end
|
||||
|
||||
expose :ref do
|
||||
expose :name do |pipeline|
|
||||
pipeline.ref
|
||||
end
|
||||
|
||||
expose :path do |pipeline|
|
||||
namespace_project_tree_path(
|
||||
pipeline.project.namespace,
|
||||
pipeline.project,
|
||||
id: pipeline.ref)
|
||||
end
|
||||
|
||||
expose :tag?, as: :tag
|
||||
expose :branch?, as: :branch
|
||||
end
|
||||
|
||||
expose :commit, using: CommitEntity
|
||||
expose :yaml_errors, if: ->(pipeline, _) { pipeline.has_yaml_errors? }
|
||||
|
||||
expose :retry_path, if: proc { can_retry? } do |pipeline|
|
||||
retry_namespace_project_pipeline_path(pipeline.project.namespace,
|
||||
pipeline.project,
|
||||
pipeline.id)
|
||||
end
|
||||
|
||||
expose :cancel_path, if: proc { can_cancel? } do |pipeline|
|
||||
cancel_namespace_project_pipeline_path(pipeline.project.namespace,
|
||||
pipeline.project,
|
||||
pipeline.id)
|
||||
end
|
||||
|
||||
expose :created_at, :updated_at
|
||||
|
||||
private
|
||||
|
||||
alias_method :pipeline, :object
|
||||
|
||||
def can_retry?
|
||||
pipeline.retryable? &&
|
||||
can?(request.user, :update_pipeline, pipeline)
|
||||
end
|
||||
|
||||
def can_cancel?
|
||||
pipeline.cancelable? &&
|
||||
can?(request.user, :update_pipeline, pipeline)
|
||||
end
|
||||
end
|
40
app/serializers/pipeline_serializer.rb
Normal file
40
app/serializers/pipeline_serializer.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
class PipelineSerializer < BaseSerializer
|
||||
entity PipelineEntity
|
||||
class InvalidResourceError < StandardError; end
|
||||
include API::Helpers::Pagination
|
||||
Struct.new('Pagination', :request, :response)
|
||||
|
||||
def represent(resource, opts = {})
|
||||
if paginated?
|
||||
raise InvalidResourceError unless resource.respond_to?(:page)
|
||||
|
||||
super(paginate(resource.includes(project: :namespace)), opts)
|
||||
else
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
|
||||
def paginated?
|
||||
defined?(@pagination)
|
||||
end
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { @pagination = Struct::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Methods needed by `API::Helpers::Pagination`
|
||||
#
|
||||
def params
|
||||
@pagination.request.query_parameters
|
||||
end
|
||||
|
||||
def request
|
||||
@pagination.request
|
||||
end
|
||||
|
||||
def header(header, value)
|
||||
@pagination.response.headers[header] = value
|
||||
end
|
||||
end
|
|
@ -2,14 +2,11 @@ module RequestAwareEntity
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include Gitlab::Routing.url_helpers
|
||||
include Gitlab::Routing
|
||||
include Gitlab::Allowable
|
||||
end
|
||||
|
||||
def request
|
||||
@options.fetch(:request)
|
||||
end
|
||||
|
||||
def can?(object, action, subject)
|
||||
Ability.allowed?(object, action, subject)
|
||||
options.fetch(:request)
|
||||
end
|
||||
end
|
||||
|
|
38
app/serializers/stage_entity.rb
Normal file
38
app/serializers/stage_entity.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
class StageEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :name
|
||||
|
||||
expose :title do |stage|
|
||||
"#{stage.name}: #{detailed_status.label}"
|
||||
end
|
||||
|
||||
expose :detailed_status,
|
||||
as: :status,
|
||||
with: StatusEntity
|
||||
|
||||
expose :path do |stage|
|
||||
namespace_project_pipeline_path(
|
||||
stage.pipeline.project.namespace,
|
||||
stage.pipeline.project,
|
||||
stage.pipeline,
|
||||
anchor: stage.name)
|
||||
end
|
||||
|
||||
expose :dropdown_path do |stage|
|
||||
stage_namespace_project_pipeline_path(
|
||||
stage.pipeline.project.namespace,
|
||||
stage.pipeline.project,
|
||||
stage.pipeline,
|
||||
stage: stage.name,
|
||||
format: :json)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
alias_method :stage, :object
|
||||
|
||||
def detailed_status
|
||||
stage.detailed_status(request.user)
|
||||
end
|
||||
end
|
8
app/serializers/status_entity.rb
Normal file
8
app/serializers/status_entity.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
class StatusEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :icon, :text, :label, :group
|
||||
|
||||
expose :has_details?, as: :has_details
|
||||
expose :details_path
|
||||
end
|
|
@ -35,21 +35,34 @@
|
|||
|
||||
= link_to ci_lint_path, class: 'btn btn-default' do
|
||||
%span CI Lint
|
||||
|
||||
.content-list.pipelines
|
||||
.content-list.pipelines{ data: { url: namespace_project_pipelines_path(@project.namespace, @project, format: :json) } }
|
||||
- if @pipelines.blank?
|
||||
%div
|
||||
.nothing-here-block No pipelines to show
|
||||
- else
|
||||
.table-holder
|
||||
%table.table.ci-table.js-pipeline-table
|
||||
%thead
|
||||
%th.pipeline-status Status
|
||||
%th.pipeline-info Pipeline
|
||||
%th.pipeline-commit Commit
|
||||
%th.pipeline-stages Stages
|
||||
%th.pipeline-date
|
||||
%th.pipeline-actions.hidden-xs
|
||||
= render @pipelines, commit_sha: true, stage: true, allow_retry: true
|
||||
.pipeline-svgs{ "data" => {"commit_icon_svg" => custom_icon("icon_commit"),
|
||||
"icon_status_canceled" => custom_icon("icon_status_canceled"),
|
||||
"icon_status_running" => custom_icon("icon_status_running"),
|
||||
"icon_status_skipped" => custom_icon("icon_status_skipped"),
|
||||
"icon_status_created" => custom_icon("icon_status_created"),
|
||||
"icon_status_pending" => custom_icon("icon_status_pending"),
|
||||
"icon_status_success" => custom_icon("icon_status_success"),
|
||||
"icon_status_failed" => custom_icon("icon_status_failed"),
|
||||
"icon_status_warning" => custom_icon("icon_status_warning"),
|
||||
"stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"),
|
||||
"stage_icon_status_running" => custom_icon("icon_status_running_borderless"),
|
||||
"stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"),
|
||||
"stage_icon_status_created" => custom_icon("icon_status_created_borderless"),
|
||||
"stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"),
|
||||
"stage_icon_status_success" => custom_icon("icon_status_success_borderless"),
|
||||
"stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"),
|
||||
"stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"),
|
||||
"icon_play" => custom_icon("icon_play"),
|
||||
"icon_timer" => custom_icon("icon_timer"),
|
||||
"icon_status_manual" => custom_icon("icon_status_manual"),
|
||||
} }
|
||||
|
||||
= paginate @pipelines, theme: 'gitlab'
|
||||
.vue-pipelines-index
|
||||
|
||||
= page_specific_javascript_tag('vue_pagination/index.js')
|
||||
= page_specific_javascript_tag('vue_pipelines_index/index.js')
|
||||
|
|
|
@ -109,6 +109,8 @@ module Gitlab
|
|||
config.assets.precompile << "lib/utils/*.js"
|
||||
config.assets.precompile << "lib/*.js"
|
||||
config.assets.precompile << "u2f.js"
|
||||
config.assets.precompile << "vue_pipelines_index/index.js"
|
||||
config.assets.precompile << "vue_pagination/index.js"
|
||||
config.assets.precompile << "vendor/assets/fonts/*"
|
||||
|
||||
# Version of your assets, change this if you want to expire all your assets
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
module API
|
||||
module Helpers
|
||||
include Gitlab::Utils
|
||||
include Helpers::Pagination
|
||||
|
||||
SUDO_HEADER = "HTTP_SUDO"
|
||||
SUDO_PARAM = :sudo
|
||||
|
@ -85,12 +86,6 @@ module API
|
|||
IssuesFinder.new(current_user, project_id: user_project.id).find(id)
|
||||
end
|
||||
|
||||
def paginate(relation)
|
||||
relation.page(params[:page]).per(params[:per_page].to_i).tap do |data|
|
||||
add_pagination_headers(data)
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate!
|
||||
unauthorized! unless current_user
|
||||
end
|
||||
|
@ -361,38 +356,6 @@ module API
|
|||
@sudo_identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER]
|
||||
end
|
||||
|
||||
def add_pagination_headers(paginated_data)
|
||||
header 'X-Total', paginated_data.total_count.to_s
|
||||
header 'X-Total-Pages', paginated_data.total_pages.to_s
|
||||
header 'X-Per-Page', paginated_data.limit_value.to_s
|
||||
header 'X-Page', paginated_data.current_page.to_s
|
||||
header 'X-Next-Page', paginated_data.next_page.to_s
|
||||
header 'X-Prev-Page', paginated_data.prev_page.to_s
|
||||
header 'Link', pagination_links(paginated_data)
|
||||
end
|
||||
|
||||
def pagination_links(paginated_data)
|
||||
request_url = request.url.split('?').first
|
||||
request_params = params.clone
|
||||
request_params[:per_page] = paginated_data.limit_value
|
||||
|
||||
links = []
|
||||
|
||||
request_params[:page] = paginated_data.current_page - 1
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page?
|
||||
|
||||
request_params[:page] = paginated_data.current_page + 1
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page?
|
||||
|
||||
request_params[:page] = 1
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="first")
|
||||
|
||||
request_params[:page] = paginated_data.total_pages
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="last")
|
||||
|
||||
links.join(', ')
|
||||
end
|
||||
|
||||
def secret_token
|
||||
Gitlab::Shell.secret_token
|
||||
end
|
||||
|
|
45
lib/api/helpers/pagination.rb
Normal file
45
lib/api/helpers/pagination.rb
Normal file
|
@ -0,0 +1,45 @@
|
|||
module API
|
||||
module Helpers
|
||||
module Pagination
|
||||
def paginate(relation)
|
||||
relation.page(params[:page]).per(params[:per_page].to_i).tap do |data|
|
||||
add_pagination_headers(data)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_pagination_headers(paginated_data)
|
||||
header 'X-Total', paginated_data.total_count.to_s
|
||||
header 'X-Total-Pages', paginated_data.total_pages.to_s
|
||||
header 'X-Per-Page', paginated_data.limit_value.to_s
|
||||
header 'X-Page', paginated_data.current_page.to_s
|
||||
header 'X-Next-Page', paginated_data.next_page.to_s
|
||||
header 'X-Prev-Page', paginated_data.prev_page.to_s
|
||||
header 'Link', pagination_links(paginated_data)
|
||||
end
|
||||
|
||||
def pagination_links(paginated_data)
|
||||
request_url = request.url.split('?').first
|
||||
request_params = params.clone
|
||||
request_params[:per_page] = paginated_data.limit_value
|
||||
|
||||
links = []
|
||||
|
||||
request_params[:page] = paginated_data.current_page - 1
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page?
|
||||
|
||||
request_params[:page] = paginated_data.current_page + 1
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page?
|
||||
|
||||
request_params[:page] = 1
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="first")
|
||||
|
||||
request_params[:page] = paginated_data.total_pages
|
||||
links << %(<#{request_url}?#{request_params.to_query}>; rel="last")
|
||||
|
||||
links.join(', ')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5,13 +5,33 @@ describe Projects::PipelinesController do
|
|||
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:empty_project, :public) }
|
||||
let(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
describe 'GET index.json' do
|
||||
before do
|
||||
create_list(:ci_empty_pipeline, 2, project: project)
|
||||
|
||||
get :index, namespace_id: project.namespace.path,
|
||||
project_id: project.path,
|
||||
format: :json
|
||||
end
|
||||
|
||||
it 'returns JSON with serialized pipelines' do
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(json_response).to include('pipelines')
|
||||
expect(json_response['pipelines'].count).to eq 2
|
||||
expect(json_response['count']['all']).to eq 2
|
||||
expect(json_response['count']['running_or_pending']).to eq 2
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET stages.json' do
|
||||
let(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
|
||||
context 'when accessing existing stage' do
|
||||
before do
|
||||
create(:ci_build, pipeline: pipeline, stage: 'build')
|
||||
|
|
|
@ -31,6 +31,14 @@ FactoryGirl.define do
|
|||
File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
|
||||
end
|
||||
end
|
||||
|
||||
# Populates pipeline with errors
|
||||
#
|
||||
pipeline.config_processor if evaluator.config
|
||||
end
|
||||
|
||||
trait :invalid do
|
||||
config(rspec: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,6 +8,10 @@ FactoryGirl.define do
|
|||
is_shared false
|
||||
active true
|
||||
|
||||
trait :online do
|
||||
contacted_at Time.now
|
||||
end
|
||||
|
||||
trait :shared do
|
||||
is_shared true
|
||||
end
|
||||
|
|
|
@ -1,267 +1,364 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe 'Pipelines', :feature, :js do
|
||||
include GitlabRoutingHelper
|
||||
include WaitForAjax
|
||||
include WaitForVueResource
|
||||
|
||||
let(:project) { create(:empty_project) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
login_as(user)
|
||||
project.team << [user, :developer]
|
||||
end
|
||||
context 'when user is logged in' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'GET /:project/pipelines' do
|
||||
let!(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running') }
|
||||
|
||||
[:all, :running, :branches].each do |scope|
|
||||
context "displaying #{scope}" do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
before { visit namespace_project_pipelines_path(project.namespace, project, scope: scope) }
|
||||
|
||||
it { expect(page).to have_content(pipeline.short_sha) }
|
||||
end
|
||||
before do
|
||||
login_as(user)
|
||||
project.team << [user, :developer]
|
||||
end
|
||||
|
||||
context 'anonymous access' do
|
||||
before { visit namespace_project_pipelines_path(project.namespace, project) }
|
||||
describe 'GET /:project/pipelines' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
it { expect(page).to have_http_status(:success) }
|
||||
end
|
||||
|
||||
context 'cancelable pipeline' do
|
||||
let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') }
|
||||
|
||||
before do
|
||||
build.run
|
||||
visit namespace_project_pipelines_path(project.namespace, project)
|
||||
let!(:pipeline) do
|
||||
create(
|
||||
:ci_empty_pipeline,
|
||||
project: project,
|
||||
ref: 'master',
|
||||
status: 'running',
|
||||
sha: project.commit.id,
|
||||
)
|
||||
end
|
||||
|
||||
it { expect(page).to have_link('Cancel') }
|
||||
it { expect(page).to have_selector('.ci-running') }
|
||||
[:all, :running, :branches].each do |scope|
|
||||
context "when displaying #{scope}" do
|
||||
before do
|
||||
visit_project_pipelines(scope: scope)
|
||||
end
|
||||
|
||||
context 'when canceling' do
|
||||
before { click_link('Cancel') }
|
||||
|
||||
it { expect(page).not_to have_link('Cancel') }
|
||||
it { expect(page).to have_selector('.ci-canceled') }
|
||||
end
|
||||
end
|
||||
|
||||
context 'retryable pipelines' do
|
||||
let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') }
|
||||
|
||||
before do
|
||||
build.drop
|
||||
visit namespace_project_pipelines_path(project.namespace, project)
|
||||
end
|
||||
|
||||
it { expect(page).to have_link('Retry') }
|
||||
it { expect(page).to have_selector('.ci-failed') }
|
||||
|
||||
context 'when retrying' do
|
||||
before { click_link('Retry') }
|
||||
|
||||
it { expect(page).not_to have_link('Retry') }
|
||||
it { expect(page).to have_selector('.ci-running') }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with manual actions' do
|
||||
let!(:manual) do
|
||||
create(:ci_build, :manual, pipeline: pipeline,
|
||||
name: 'manual build',
|
||||
stage: 'test',
|
||||
commands: 'test')
|
||||
end
|
||||
|
||||
before do
|
||||
visit namespace_project_pipelines_path(project.namespace, project)
|
||||
end
|
||||
|
||||
it 'has link to the manual action' do
|
||||
find('.js-pipeline-dropdown-manual-actions').click
|
||||
|
||||
expect(page).to have_link('Manual build')
|
||||
end
|
||||
|
||||
context 'when manual action was played' do
|
||||
before do
|
||||
find('.js-pipeline-dropdown-manual-actions').click
|
||||
click_link('Manual build')
|
||||
end
|
||||
|
||||
it 'enqueues manual action job' do
|
||||
expect(manual.reload).to be_pending
|
||||
it 'contains pipeline commit short SHA' do
|
||||
expect(page).to have_content(pipeline.short_sha)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for generic statuses' do
|
||||
context 'when running' do
|
||||
let!(:running) { create(:generic_commit_status, status: 'running', pipeline: pipeline, stage: 'test') }
|
||||
context 'when pipeline is cancelable' do
|
||||
let!(:build) do
|
||||
create(:ci_build, pipeline: pipeline,
|
||||
stage: 'test',
|
||||
commands: 'test')
|
||||
end
|
||||
|
||||
before do
|
||||
visit namespace_project_pipelines_path(project.namespace, project)
|
||||
build.run
|
||||
visit_project_pipelines
|
||||
end
|
||||
|
||||
it 'is cancelable' do
|
||||
it 'indicates that pipeline can be canceled' do
|
||||
expect(page).to have_link('Cancel')
|
||||
end
|
||||
|
||||
it 'has pipeline running' do
|
||||
expect(page).to have_selector('.ci-running')
|
||||
end
|
||||
|
||||
context 'when canceling' do
|
||||
before { click_link('Cancel') }
|
||||
|
||||
it { expect(page).not_to have_link('Cancel') }
|
||||
it { expect(page).to have_selector('.ci-canceled') }
|
||||
it 'indicated that pipelines was canceled' do
|
||||
expect(page).not_to have_link('Cancel')
|
||||
expect(page).to have_selector('.ci-canceled')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
let!(:status) { create(:generic_commit_status, :pending, pipeline: pipeline, stage: 'test') }
|
||||
context 'when pipeline is retryable' do
|
||||
let!(:build) do
|
||||
create(:ci_build, pipeline: pipeline,
|
||||
stage: 'test',
|
||||
commands: 'test')
|
||||
end
|
||||
|
||||
before do
|
||||
status.drop
|
||||
visit namespace_project_pipelines_path(project.namespace, project)
|
||||
build.drop
|
||||
visit_project_pipelines
|
||||
end
|
||||
|
||||
it 'is not retryable' do
|
||||
expect(page).not_to have_link('Retry')
|
||||
end
|
||||
|
||||
it 'has failed pipeline' do
|
||||
it 'indicates that pipeline can be retried' do
|
||||
expect(page).to have_link('Retry')
|
||||
expect(page).to have_selector('.ci-failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'downloadable pipelines' do
|
||||
context 'with artifacts' do
|
||||
let!(:with_artifacts) { create(:ci_build, :artifacts, :success, pipeline: pipeline, name: 'rspec tests', stage: 'test') }
|
||||
context 'when retrying' do
|
||||
before { click_link('Retry') }
|
||||
|
||||
before { visit namespace_project_pipelines_path(project.namespace, project) }
|
||||
|
||||
it { expect(page).to have_selector('.build-artifacts') }
|
||||
it do
|
||||
find('.js-pipeline-dropdown-download').click
|
||||
expect(page).to have_link(with_artifacts.name)
|
||||
it 'shows running pipeline that is not retryable' do
|
||||
expect(page).not_to have_link('Retry')
|
||||
expect(page).to have_selector('.ci-running')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with artifacts expired' do
|
||||
let!(:with_artifacts_expired) { create(:ci_build, :artifacts_expired, :success, pipeline: pipeline, name: 'rspec', stage: 'test') }
|
||||
context 'when pipeline has configuration errors' do
|
||||
let(:pipeline) do
|
||||
create(:ci_pipeline, :invalid, project: project)
|
||||
end
|
||||
|
||||
before { visit namespace_project_pipelines_path(project.namespace, project) }
|
||||
before { visit_project_pipelines }
|
||||
|
||||
it { expect(page).not_to have_selector('.build-artifacts') }
|
||||
it 'contains badge that indicates errors' do
|
||||
expect(page).to have_content 'yaml invalid'
|
||||
end
|
||||
|
||||
it 'contains badge with tooltip which contains error' do
|
||||
expect(pipeline).to have_yaml_errors
|
||||
expect(page).to have_selector(
|
||||
%Q{span[data-original-title="#{pipeline.yaml_errors}"]})
|
||||
end
|
||||
end
|
||||
|
||||
context 'without artifacts' do
|
||||
let!(:without_artifacts) { create(:ci_build, :success, pipeline: pipeline, name: 'rspec', stage: 'test') }
|
||||
context 'with manual actions' do
|
||||
let!(:manual) do
|
||||
create(:ci_build, :manual,
|
||||
pipeline: pipeline,
|
||||
name: 'manual build',
|
||||
stage: 'test',
|
||||
commands: 'test')
|
||||
end
|
||||
|
||||
before { visit namespace_project_pipelines_path(project.namespace, project) }
|
||||
before { visit_project_pipelines }
|
||||
|
||||
it { expect(page).not_to have_selector('.build-artifacts') }
|
||||
it 'has a dropdown with play button' do
|
||||
expect(page).to have_selector('.dropdown-toggle.btn.btn-default .icon-play')
|
||||
end
|
||||
|
||||
it 'has link to the manual action' do
|
||||
find('.js-pipeline-dropdown-manual-actions').click
|
||||
|
||||
expect(page).to have_link('Manual build')
|
||||
end
|
||||
|
||||
context 'when manual action was played' do
|
||||
before do
|
||||
find('.js-pipeline-dropdown-manual-actions').click
|
||||
click_link('Manual build')
|
||||
end
|
||||
|
||||
it 'enqueues manual action job' do
|
||||
expect(manual.reload).to be_pending
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'for generic statuses' do
|
||||
context 'when running' do
|
||||
let!(:running) do
|
||||
create(:generic_commit_status,
|
||||
status: 'running',
|
||||
pipeline: pipeline,
|
||||
stage: 'test')
|
||||
end
|
||||
|
||||
before { visit_project_pipelines }
|
||||
|
||||
it 'is cancelable' do
|
||||
expect(page).to have_link('Cancel')
|
||||
end
|
||||
|
||||
it 'has pipeline running' do
|
||||
expect(page).to have_selector('.ci-running')
|
||||
end
|
||||
|
||||
context 'when canceling' do
|
||||
before { click_link('Cancel') }
|
||||
|
||||
it 'indicates that pipeline was canceled' do
|
||||
expect(page).not_to have_link('Cancel')
|
||||
expect(page).to have_selector('.ci-canceled')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
let!(:status) do
|
||||
create(:generic_commit_status, :pending,
|
||||
pipeline: pipeline,
|
||||
stage: 'test')
|
||||
end
|
||||
|
||||
before do
|
||||
status.drop
|
||||
visit_project_pipelines
|
||||
end
|
||||
|
||||
it 'is not retryable' do
|
||||
expect(page).not_to have_link('Retry')
|
||||
end
|
||||
|
||||
it 'has failed pipeline' do
|
||||
expect(page).to have_selector('.ci-failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'downloadable pipelines' do
|
||||
context 'with artifacts' do
|
||||
let!(:with_artifacts) do
|
||||
create(:ci_build, :artifacts, :success,
|
||||
pipeline: pipeline,
|
||||
name: 'rspec tests',
|
||||
stage: 'test')
|
||||
end
|
||||
|
||||
before { visit_project_pipelines }
|
||||
|
||||
it 'has artifats' do
|
||||
expect(page).to have_selector('.build-artifacts')
|
||||
end
|
||||
|
||||
it 'has artifacts download dropdown' do
|
||||
find('.js-pipeline-dropdown-download').click
|
||||
|
||||
expect(page).to have_link(with_artifacts.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with artifacts expired' do
|
||||
let!(:with_artifacts_expired) do
|
||||
create(:ci_build, :artifacts_expired, :success,
|
||||
pipeline: pipeline,
|
||||
name: 'rspec',
|
||||
stage: 'test')
|
||||
end
|
||||
|
||||
before { visit_project_pipelines }
|
||||
|
||||
it { expect(page).not_to have_selector('.build-artifacts') }
|
||||
end
|
||||
|
||||
context 'without artifacts' do
|
||||
let!(:without_artifacts) do
|
||||
create(:ci_build, :success,
|
||||
pipeline: pipeline,
|
||||
name: 'rspec',
|
||||
stage: 'test')
|
||||
end
|
||||
|
||||
before { visit_project_pipelines }
|
||||
|
||||
it { expect(page).not_to have_selector('.build-artifacts') }
|
||||
end
|
||||
end
|
||||
|
||||
context 'mini pipeline graph' do
|
||||
let!(:build) do
|
||||
create(:ci_build, :pending, pipeline: pipeline,
|
||||
stage: 'build',
|
||||
name: 'build')
|
||||
end
|
||||
|
||||
before { visit_project_pipelines }
|
||||
|
||||
it 'should render a mini pipeline graph' do
|
||||
expect(page).to have_selector('.js-mini-pipeline-graph')
|
||||
expect(page).to have_selector('.js-builds-dropdown-button')
|
||||
end
|
||||
|
||||
context 'when clicking a stage badge' do
|
||||
it 'should open a dropdown' do
|
||||
find('.js-builds-dropdown-button').trigger('click')
|
||||
|
||||
expect(page).to have_link build.name
|
||||
end
|
||||
|
||||
it 'should be possible to cancel pending build' do
|
||||
find('.js-builds-dropdown-button').trigger('click')
|
||||
find('a.js-ci-action-icon').trigger('click')
|
||||
|
||||
expect(page).to have_content('canceled')
|
||||
expect(build.reload).to be_canceled
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'mini pipleine graph' do
|
||||
let!(:build) do
|
||||
create(:ci_build, pipeline: pipeline, stage: 'build', name: 'build')
|
||||
end
|
||||
describe 'POST /:project/pipelines' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
before do
|
||||
visit namespace_project_pipelines_path(project.namespace, project)
|
||||
visit new_namespace_project_pipeline_path(project.namespace, project)
|
||||
end
|
||||
|
||||
it 'should render a mini pipeline graph' do
|
||||
endpoint = stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: build.name)
|
||||
context 'for valid commit' do
|
||||
before { fill_in('pipeline[ref]', with: 'master') }
|
||||
|
||||
expect(page).to have_selector('.js-mini-pipeline-graph')
|
||||
expect(page).to have_selector(".js-builds-dropdown-button[data-stage-endpoint='#{endpoint}']")
|
||||
end
|
||||
context 'with gitlab-ci.yml' do
|
||||
before { stub_ci_pipeline_to_return_yaml_file }
|
||||
|
||||
context 'when clicking a graph stage' do
|
||||
it 'should open a dropdown' do
|
||||
find('.js-builds-dropdown-button').trigger('click')
|
||||
|
||||
wait_for_ajax
|
||||
|
||||
expect(page).to have_link build.name
|
||||
it 'creates a new pipeline' do
|
||||
expect { click_on 'Create pipeline' }
|
||||
.to change { Ci::Pipeline.count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
it 'should be possible to retry the failed build' do
|
||||
find('.js-builds-dropdown-button').trigger('click')
|
||||
context 'without gitlab-ci.yml' do
|
||||
before { click_on 'Create pipeline' }
|
||||
|
||||
wait_for_ajax
|
||||
it { expect(page).to have_content('Missing .gitlab-ci.yml file') }
|
||||
end
|
||||
end
|
||||
|
||||
find('a.js-ci-action-icon').trigger('click')
|
||||
expect(page).not_to have_content('Cancel running')
|
||||
context 'for invalid commit' do
|
||||
before do
|
||||
fill_in('pipeline[ref]', with: 'invalid-reference')
|
||||
click_on 'Create pipeline'
|
||||
end
|
||||
|
||||
it { expect(page).to have_content('Reference not found') }
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Create pipelines' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
before do
|
||||
visit new_namespace_project_pipeline_path(project.namespace, project)
|
||||
end
|
||||
|
||||
describe 'new pipeline page' do
|
||||
it 'has field to add a new pipeline' do
|
||||
expect(page).to have_field('pipeline[ref]')
|
||||
expect(page).to have_content('Create for')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'find pipelines' do
|
||||
it 'shows filtered pipelines', js: true do
|
||||
fill_in('pipeline[ref]', with: 'fix')
|
||||
find('input#ref').native.send_keys(:keydown)
|
||||
|
||||
within('.ui-autocomplete') do
|
||||
expect(page).to have_selector('li', text: 'fix')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /:project/pipelines' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
before { visit new_namespace_project_pipeline_path(project.namespace, project) }
|
||||
|
||||
context 'for valid commit' do
|
||||
before { fill_in('pipeline[ref]', with: 'master') }
|
||||
|
||||
context 'with gitlab-ci.yml' do
|
||||
before { stub_ci_pipeline_to_return_yaml_file }
|
||||
|
||||
it { expect{ click_on 'Create pipeline' }.to change{ Ci::Pipeline.count }.by(1) }
|
||||
end
|
||||
|
||||
context 'without gitlab-ci.yml' do
|
||||
before { click_on 'Create pipeline' }
|
||||
|
||||
it { expect(page).to have_content('Missing .gitlab-ci.yml file') }
|
||||
end
|
||||
end
|
||||
|
||||
context 'for invalid commit' do
|
||||
before do
|
||||
fill_in('pipeline[ref]', with: 'invalid-reference')
|
||||
click_on 'Create pipeline'
|
||||
end
|
||||
|
||||
it { expect(page).to have_content('Reference not found') }
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Create pipelines', feature: true do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
context 'when user is not logged in' do
|
||||
before do
|
||||
visit new_namespace_project_pipeline_path(project.namespace, project)
|
||||
visit namespace_project_pipelines_path(project.namespace, project)
|
||||
end
|
||||
|
||||
describe 'new pipeline page' do
|
||||
it 'has field to add a new pipeline' do
|
||||
expect(page).to have_field('pipeline[ref]')
|
||||
expect(page).to have_content('Create for')
|
||||
end
|
||||
context 'when project is public' do
|
||||
let(:project) { create(:project, :public) }
|
||||
|
||||
it { expect(page).to have_content 'No pipelines to show' }
|
||||
it { expect(page).to have_http_status(:success) }
|
||||
end
|
||||
|
||||
describe 'find pipelines' do
|
||||
it 'shows filtered pipelines', js: true do
|
||||
fill_in('pipeline[ref]', with: 'fix')
|
||||
find('input#ref').native.send_keys(:keydown)
|
||||
context 'when project is private' do
|
||||
let(:project) { create(:project, :private) }
|
||||
|
||||
within('.ui-autocomplete') do
|
||||
expect(page).to have_selector('li', text: 'fix')
|
||||
end
|
||||
end
|
||||
it { expect(page).to have_content 'You need to sign in' }
|
||||
end
|
||||
end
|
||||
|
||||
def visit_project_pipelines(**query)
|
||||
visit namespace_project_pipelines_path(project.namespace, project, query)
|
||||
wait_for_vue_resource
|
||||
end
|
||||
end
|
||||
|
|
168
spec/javascripts/vue_pagination/pagination_spec.js.es6
Normal file
168
spec/javascripts/vue_pagination/pagination_spec.js.es6
Normal file
|
@ -0,0 +1,168 @@
|
|||
//= require vue
|
||||
//= require lib/utils/common_utils
|
||||
//= require vue_pagination/index
|
||||
/* global fixture, gl */
|
||||
|
||||
describe('Pagination component', () => {
|
||||
let component;
|
||||
|
||||
const changeChanges = {
|
||||
one: '',
|
||||
two: '',
|
||||
};
|
||||
|
||||
const change = (one, two) => {
|
||||
changeChanges.one = one;
|
||||
changeChanges.two = two;
|
||||
};
|
||||
|
||||
it('should render and start at page 1', () => {
|
||||
fixture.set('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
nextPage: 2,
|
||||
previousPage: '',
|
||||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.$el.classList).toContain('gl-pagination');
|
||||
|
||||
component.changePage({ target: { innerText: '1' } });
|
||||
|
||||
expect(changeChanges.one).toEqual(1);
|
||||
expect(changeChanges.two).toEqual('all');
|
||||
});
|
||||
|
||||
it('should go to the previous page', () => {
|
||||
fixture.set('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
nextPage: 3,
|
||||
previousPage: 1,
|
||||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
|
||||
component.changePage({ target: { innerText: 'Prev' } });
|
||||
|
||||
expect(changeChanges.one).toEqual(1);
|
||||
expect(changeChanges.two).toEqual('all');
|
||||
});
|
||||
|
||||
it('should go to the next page', () => {
|
||||
fixture.set('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
nextPage: 5,
|
||||
previousPage: 3,
|
||||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
|
||||
component.changePage({ target: { innerText: 'Next' } });
|
||||
|
||||
expect(changeChanges.one).toEqual(5);
|
||||
expect(changeChanges.two).toEqual('all');
|
||||
});
|
||||
|
||||
it('should go to the last page', () => {
|
||||
fixture.set('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
nextPage: 5,
|
||||
previousPage: 3,
|
||||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
|
||||
component.changePage({ target: { innerText: 'Last >>' } });
|
||||
|
||||
expect(changeChanges.one).toEqual(10);
|
||||
expect(changeChanges.two).toEqual('all');
|
||||
});
|
||||
|
||||
it('should go to the first page', () => {
|
||||
fixture.set('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
nextPage: 5,
|
||||
previousPage: 3,
|
||||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
|
||||
component.changePage({ target: { innerText: '<< First' } });
|
||||
|
||||
expect(changeChanges.one).toEqual(1);
|
||||
expect(changeChanges.two).toEqual('all');
|
||||
});
|
||||
|
||||
it('should do nothing', () => {
|
||||
fixture.set('<div class="test-pagination-container"></div>');
|
||||
|
||||
component = new window.gl.VueGlPagination({
|
||||
el: document.querySelector('.test-pagination-container'),
|
||||
propsData: {
|
||||
pageInfo: {
|
||||
totalPages: 10,
|
||||
nextPage: 2,
|
||||
previousPage: '',
|
||||
},
|
||||
change,
|
||||
},
|
||||
});
|
||||
|
||||
component.changePage({ target: { innerText: '...' } });
|
||||
|
||||
expect(changeChanges.one).toEqual(1);
|
||||
expect(changeChanges.two).toEqual('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paramHelper', () => {
|
||||
it('can parse url parameters correctly', () => {
|
||||
window.history.pushState({}, null, '?scope=all&p=2');
|
||||
|
||||
const scope = gl.utils.getParameterByName('scope');
|
||||
const p = gl.utils.getParameterByName('p');
|
||||
|
||||
expect(scope).toEqual('all');
|
||||
expect(p).toEqual('2');
|
||||
});
|
||||
|
||||
it('returns null if param not in url', () => {
|
||||
window.history.pushState({}, null, '?p=2');
|
||||
|
||||
const scope = gl.utils.getParameterByName('scope');
|
||||
const p = gl.utils.getParameterByName('p');
|
||||
|
||||
expect(scope).toEqual(null);
|
||||
expect(p).toEqual('2');
|
||||
});
|
||||
});
|
94
spec/lib/api/helpers/pagination_spec.rb
Normal file
94
spec/lib/api/helpers/pagination_spec.rb
Normal file
|
@ -0,0 +1,94 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe API::Helpers::Pagination do
|
||||
let(:resource) { Project.all }
|
||||
|
||||
subject do
|
||||
Class.new.include(described_class).new
|
||||
end
|
||||
|
||||
describe '#paginate' do
|
||||
let(:value) { spy('return value') }
|
||||
|
||||
before do
|
||||
allow(value).to receive(:to_query).and_return(value)
|
||||
|
||||
allow(subject).to receive(:header).and_return(value)
|
||||
allow(subject).to receive(:params).and_return(value)
|
||||
allow(subject).to receive(:request).and_return(value)
|
||||
end
|
||||
|
||||
describe 'required instance methods' do
|
||||
let(:return_spy) { spy }
|
||||
|
||||
it 'requires some instance methods' do
|
||||
expect_message(:header)
|
||||
expect_message(:params)
|
||||
expect_message(:request)
|
||||
|
||||
subject.paginate(resource)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when resource can be paginated' do
|
||||
before do
|
||||
create_list(:empty_project, 3)
|
||||
end
|
||||
|
||||
describe 'first page' do
|
||||
before do
|
||||
allow(subject).to receive(:params)
|
||||
.and_return({ page: 1, per_page: 2 })
|
||||
end
|
||||
|
||||
it 'returns appropriate amount of resources' do
|
||||
expect(subject.paginate(resource).count).to eq 2
|
||||
end
|
||||
|
||||
it 'adds appropriate headers' do
|
||||
expect_header('X-Total', '3')
|
||||
expect_header('X-Total-Pages', '2')
|
||||
expect_header('X-Per-Page', '2')
|
||||
expect_header('X-Page', '1')
|
||||
expect_header('X-Next-Page', '2')
|
||||
expect_header('X-Prev-Page', '')
|
||||
expect_header('Link', any_args)
|
||||
|
||||
subject.paginate(resource)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'second page' do
|
||||
before do
|
||||
allow(subject).to receive(:params)
|
||||
.and_return({ page: 2, per_page: 2 })
|
||||
end
|
||||
|
||||
it 'returns appropriate amount of resources' do
|
||||
expect(subject.paginate(resource).count).to eq 1
|
||||
end
|
||||
|
||||
it 'adds appropriate headers' do
|
||||
expect_header('X-Total', '3')
|
||||
expect_header('X-Total-Pages', '2')
|
||||
expect_header('X-Per-Page', '2')
|
||||
expect_header('X-Page', '2')
|
||||
expect_header('X-Next-Page', '')
|
||||
expect_header('X-Prev-Page', '1')
|
||||
expect_header('Link', any_args)
|
||||
|
||||
subject.paginate(resource)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def expect_header(name, value)
|
||||
expect(subject).to receive(:header).with(name, value)
|
||||
end
|
||||
|
||||
def expect_message(method)
|
||||
expect(subject).to receive(method)
|
||||
.at_least(:once).and_return(value)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -888,6 +888,48 @@ describe Ci::Pipeline, models: true do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#stuck?' do
|
||||
before do
|
||||
create(:ci_build, :pending, pipeline: pipeline)
|
||||
end
|
||||
|
||||
context 'when pipeline is stuck' do
|
||||
it 'is stuck' do
|
||||
expect(pipeline).to be_stuck
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline is not stuck' do
|
||||
before { create(:ci_runner, :shared, :online) }
|
||||
|
||||
it 'is not stuck' do
|
||||
expect(pipeline).not_to be_stuck
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#has_yaml_errors?' do
|
||||
context 'when pipeline has errors' do
|
||||
let(:pipeline) do
|
||||
create(:ci_pipeline, config: { rspec: nil })
|
||||
end
|
||||
|
||||
it 'contains yaml errors' do
|
||||
expect(pipeline).to have_yaml_errors
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline does not have errors' do
|
||||
let(:pipeline) do
|
||||
create(:ci_pipeline, config: { rspec: { script: 'rake test' } })
|
||||
end
|
||||
|
||||
it 'does not containyaml errors' do
|
||||
expect(pipeline).not_to have_yaml_errors
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'notifications when pipeline success or failed' do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
|
|
21
spec/serializers/build_action_entity_spec.rb
Normal file
21
spec/serializers/build_action_entity_spec.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe BuildActionEntity do
|
||||
let(:build) { create(:ci_build, name: 'test_build') }
|
||||
|
||||
let(:entity) do
|
||||
described_class.new(build, request: double)
|
||||
end
|
||||
|
||||
describe '#as_json' do
|
||||
subject { entity.as_json }
|
||||
|
||||
it 'contains humanized build name' do
|
||||
expect(subject[:name]).to eq 'Test build'
|
||||
end
|
||||
|
||||
it 'contains path to the action play' do
|
||||
expect(subject[:path]).to include "builds/#{build.id}/play"
|
||||
end
|
||||
end
|
||||
end
|
22
spec/serializers/build_artifact_entity_spec.rb
Normal file
22
spec/serializers/build_artifact_entity_spec.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe BuildArtifactEntity do
|
||||
let(:build) { create(:ci_build, name: 'test:build') }
|
||||
|
||||
let(:entity) do
|
||||
described_class.new(build, request: double)
|
||||
end
|
||||
|
||||
describe '#as_json' do
|
||||
subject { entity.as_json }
|
||||
|
||||
it 'contains build name' do
|
||||
expect(subject[:name]).to eq 'test:build'
|
||||
end
|
||||
|
||||
it 'contains path to the artifacts' do
|
||||
expect(subject[:path])
|
||||
.to include "builds/#{build.id}/artifacts/download"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -45,4 +45,8 @@ describe CommitEntity do
|
|||
|
||||
subject
|
||||
end
|
||||
|
||||
it 'exposes gravatar url that belongs to author' do
|
||||
expect(subject.fetch(:author_gravatar_url)).to match /gravatar/
|
||||
end
|
||||
end
|
||||
|
|
138
spec/serializers/pipeline_entity_spec.rb
Normal file
138
spec/serializers/pipeline_entity_spec.rb
Normal file
|
@ -0,0 +1,138 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe PipelineEntity do
|
||||
let(:user) { create(:user) }
|
||||
let(:request) { double('request') }
|
||||
|
||||
before do
|
||||
allow(request).to receive(:user).and_return(user)
|
||||
end
|
||||
|
||||
let(:entity) do
|
||||
described_class.represent(pipeline, request: request)
|
||||
end
|
||||
|
||||
describe '#as_json' do
|
||||
subject { entity.as_json }
|
||||
|
||||
context 'when pipeline is empty' do
|
||||
let(:pipeline) { create(:ci_empty_pipeline) }
|
||||
|
||||
it 'contains required fields' do
|
||||
expect(subject).to include :id, :user, :path
|
||||
expect(subject).to include :ref, :commit
|
||||
expect(subject).to include :updated_at, :created_at
|
||||
end
|
||||
|
||||
it 'contains details' do
|
||||
expect(subject).to include :details
|
||||
expect(subject[:details])
|
||||
.to include :duration, :finished_at
|
||||
expect(subject[:details])
|
||||
.to include :stages, :artifacts, :manual_actions
|
||||
expect(subject[:details][:status]).to include :icon, :text, :label
|
||||
end
|
||||
|
||||
it 'contains flags' do
|
||||
expect(subject).to include :flags
|
||||
expect(subject[:flags])
|
||||
.to include :latest, :triggered, :stuck,
|
||||
:yaml_errors, :retryable, :cancelable
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline is retryable' do
|
||||
let(:project) { create(:empty_project) }
|
||||
|
||||
let(:pipeline) do
|
||||
create(:ci_pipeline, status: :success, project: project)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:ci_build, :failed, pipeline: pipeline)
|
||||
end
|
||||
|
||||
context 'user has ability to retry pipeline' do
|
||||
before { project.team << [user, :developer] }
|
||||
|
||||
it 'retryable flag is true' do
|
||||
expect(subject[:flags][:retryable]).to eq true
|
||||
end
|
||||
|
||||
it 'contains retry path' do
|
||||
expect(subject[:retry_path]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'user does not have ability to retry pipeline' do
|
||||
it 'retryable flag is false' do
|
||||
expect(subject[:flags][:retryable]).to eq false
|
||||
end
|
||||
|
||||
it 'does not contain retry path' do
|
||||
expect(subject).not_to have_key(:retry_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline is cancelable' do
|
||||
let(:project) { create(:empty_project) }
|
||||
|
||||
let(:pipeline) do
|
||||
create(:ci_pipeline, status: :running, project: project)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:ci_build, :pending, pipeline: pipeline)
|
||||
end
|
||||
|
||||
context 'user has ability to cancel pipeline' do
|
||||
before { project.team << [user, :developer] }
|
||||
|
||||
it 'cancelable flag is true' do
|
||||
expect(subject[:flags][:cancelable]).to eq true
|
||||
end
|
||||
|
||||
it 'contains cancel path' do
|
||||
expect(subject[:cancel_path]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'user does not have ability to cancel pipeline' do
|
||||
it 'cancelable flag is false' do
|
||||
expect(subject[:flags][:cancelable]).to eq false
|
||||
end
|
||||
|
||||
it 'does not contain cancel path' do
|
||||
expect(subject).not_to have_key(:cancel_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline has YAML errors' do
|
||||
let(:pipeline) do
|
||||
create(:ci_pipeline, config: { rspec: { invalid: :value } })
|
||||
end
|
||||
|
||||
it 'contains flag that indicates there are errors' do
|
||||
expect(subject[:flags][:yaml_errors]).to be true
|
||||
end
|
||||
|
||||
it 'contains information about error' do
|
||||
expect(subject[:yaml_errors]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pipeline does not have YAML errors' do
|
||||
let(:pipeline) { create(:ci_empty_pipeline) }
|
||||
|
||||
it 'contains flag that indicates there are no errors' do
|
||||
expect(subject[:flags][:yaml_errors]).to be false
|
||||
end
|
||||
|
||||
it 'does not contain field that normally holds an error' do
|
||||
expect(subject).not_to have_key(:yaml_errors)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
101
spec/serializers/pipeline_serializer_spec.rb
Normal file
101
spec/serializers/pipeline_serializer_spec.rb
Normal file
|
@ -0,0 +1,101 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe PipelineSerializer do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
let(:serializer) do
|
||||
described_class.new(user: user)
|
||||
end
|
||||
|
||||
let(:entity) do
|
||||
serializer.represent(resource)
|
||||
end
|
||||
|
||||
subject { entity.as_json }
|
||||
|
||||
describe '#represent' do
|
||||
context 'when used without pagination' do
|
||||
it 'created a not paginated serializer' do
|
||||
expect(serializer).not_to be_paginated
|
||||
end
|
||||
|
||||
context 'when a single object is being serialized' do
|
||||
let(:resource) { create(:ci_empty_pipeline) }
|
||||
|
||||
it 'serializers the pipeline object' do
|
||||
expect(subject[:id]).to eq resource.id
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple objects are being serialized' do
|
||||
let(:resource) { create_list(:ci_pipeline, 2) }
|
||||
|
||||
it 'serializers the array of pipelines' do
|
||||
expect(subject).not_to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when used with pagination' do
|
||||
let(:request) { spy('request') }
|
||||
let(:response) { spy('response') }
|
||||
let(:pagination) { {} }
|
||||
|
||||
before do
|
||||
allow(request)
|
||||
.to receive(:query_parameters)
|
||||
.and_return(pagination)
|
||||
end
|
||||
|
||||
let(:serializer) do
|
||||
described_class.new(user: user)
|
||||
.with_pagination(request, response)
|
||||
end
|
||||
|
||||
it 'created a paginated serializer' do
|
||||
expect(serializer).to be_paginated
|
||||
end
|
||||
|
||||
context 'when resource does is not paginatable' do
|
||||
context 'when a single pipeline object is being serialized' do
|
||||
let(:resource) { create(:ci_empty_pipeline) }
|
||||
let(:pagination) { { page: 1, per_page: 1 } }
|
||||
|
||||
it 'raises error' do
|
||||
expect { subject }
|
||||
.to raise_error(PipelineSerializer::InvalidResourceError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when resource is paginatable relation' do
|
||||
let(:resource) { Ci::Pipeline.all }
|
||||
let(:pagination) { { page: 1, per_page: 2 } }
|
||||
|
||||
context 'when a single pipeline object is present in relation' do
|
||||
before { create(:ci_empty_pipeline) }
|
||||
|
||||
it 'serializes pipeline relation' do
|
||||
expect(subject.first).to have_key :id
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a multiple pipeline objects are being serialized' do
|
||||
before { create_list(:ci_empty_pipeline, 3) }
|
||||
|
||||
it 'serializes appropriate number of objects' do
|
||||
expect(subject.count).to be 2
|
||||
end
|
||||
|
||||
it 'appends relevant headers' do
|
||||
expect(response).to receive(:[]=).with('X-Total', '3')
|
||||
expect(response).to receive(:[]=).with('X-Total-Pages', '2')
|
||||
expect(response).to receive(:[]=).with('X-Per-Page', '2')
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
22
spec/serializers/request_aware_entity_spec.rb
Normal file
22
spec/serializers/request_aware_entity_spec.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe RequestAwareEntity do
|
||||
subject do
|
||||
Class.new.include(described_class).new
|
||||
end
|
||||
|
||||
it 'includes URL helpers' do
|
||||
expect(subject).to respond_to(:namespace_project_path)
|
||||
end
|
||||
|
||||
it 'includes method for checking abilities' do
|
||||
expect(subject).to respond_to(:can?)
|
||||
end
|
||||
|
||||
it 'fetches request from options' do
|
||||
expect(subject).to receive(:options)
|
||||
.and_return({ request: 'some value' })
|
||||
|
||||
expect(subject.request).to eq 'some value'
|
||||
end
|
||||
end
|
51
spec/serializers/stage_entity_spec.rb
Normal file
51
spec/serializers/stage_entity_spec.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe StageEntity do
|
||||
let(:pipeline) { create(:ci_pipeline) }
|
||||
let(:request) { double('request') }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
let(:entity) do
|
||||
described_class.new(stage, request: request)
|
||||
end
|
||||
|
||||
let(:stage) do
|
||||
build(:ci_stage, pipeline: pipeline, name: 'test')
|
||||
end
|
||||
|
||||
before do
|
||||
allow(request).to receive(:user).and_return(user)
|
||||
create(:ci_build, :success, pipeline: pipeline)
|
||||
end
|
||||
|
||||
describe '#as_json' do
|
||||
subject { entity.as_json }
|
||||
|
||||
it 'contains relevant fields' do
|
||||
expect(subject).to include :name, :status, :path
|
||||
end
|
||||
|
||||
it 'contains detailed status' do
|
||||
expect(subject[:status]).to include :text, :label, :group, :icon
|
||||
expect(subject[:status][:label]).to eq 'passed'
|
||||
end
|
||||
|
||||
it 'contains valid name' do
|
||||
expect(subject[:name]).to eq 'test'
|
||||
end
|
||||
|
||||
it 'contains path to the stage' do
|
||||
expect(subject[:path])
|
||||
.to include "pipelines/#{pipeline.id}##{stage.name}"
|
||||
end
|
||||
|
||||
it 'contains path to the stage dropdown' do
|
||||
expect(subject[:dropdown_path])
|
||||
.to include "pipelines/#{pipeline.id}/stage.json?stage=test"
|
||||
end
|
||||
|
||||
it 'contains stage title' do
|
||||
expect(subject[:title]).to eq 'test: passed'
|
||||
end
|
||||
end
|
||||
end
|
23
spec/serializers/status_entity_spec.rb
Normal file
23
spec/serializers/status_entity_spec.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe StatusEntity do
|
||||
let(:entity) { described_class.new(status) }
|
||||
|
||||
let(:status) do
|
||||
Gitlab::Ci::Status::Success.new(double('object'), double('user'))
|
||||
end
|
||||
|
||||
before do
|
||||
allow(status).to receive(:has_details?).and_return(true)
|
||||
allow(status).to receive(:details_path).and_return('some/path')
|
||||
end
|
||||
|
||||
describe '#as_json' do
|
||||
subject { entity.as_json }
|
||||
|
||||
it 'contains status details' do
|
||||
expect(subject).to include :text, :icon, :label, :group
|
||||
expect(subject).to include :has_details, :details_path
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue